Compare commits

...

12 commits

3 changed files with 541 additions and 343 deletions

View file

@ -80,7 +80,17 @@ Bugs
- Input images with alpha channels are not allowed. PDF only supports - Input images with alpha channels are not allowed. PDF only supports
transparency using binary masks but is unable to store 8-bit transparency transparency using binary masks but is unable to store 8-bit transparency
information as part of the image itself. But img2pdf will always be lossless information as part of the image itself. But img2pdf will always be lossless
and thus, input images must not carry transparency information. and thus, input images must not carry transparency information. You can
remove the alpha channel for example with imagemagick:
convert input.png -background white -alpha remove -alpha off output.png
- An error is produced if the input image is broken. This commonly happens if
the input image has an invalid EXIF Orientation value of zero. Even though
only nine different values from 1 to 9 are permitted, Anroid phones and
Canon DSLR cameras produce JPEG images with the invalid value of zero.
Either fix your input images with `exiftool` or similar software before
passing the JPEG to `img2pdf` or run `img2pdf` with `--rotation=ifvalid`.
- img2pdf uses PIL (or Pillow) to obtain image meta data and to convert the - img2pdf uses PIL (or Pillow) to obtain image meta data and to convert the
input if necessary. To prevent decompression bomb denial of service attacks, input if necessary. To prevent decompression bomb denial of service attacks,

View file

@ -85,9 +85,9 @@ FitMode = Enum("FitMode", "into fill exact shrink enlarge")
PageOrientation = Enum("PageOrientation", "portrait landscape") PageOrientation = Enum("PageOrientation", "portrait landscape")
Colorspace = Enum("Colorspace", "RGB L 1 CMYK CMYK;I RGBA P other") Colorspace = Enum("Colorspace", "RGB RGBA L LA 1 CMYK CMYK;I P other")
ImageFormat = Enum("ImageFormat", "JPEG JPEG2000 CCITTGroup4 PNG TIFF MPO other") ImageFormat = Enum("ImageFormat", "JPEG JPEG2000 CCITTGroup4 PNG GIF TIFF MPO other")
PageMode = Enum("PageMode", "none outlines thumbs") PageMode = Enum("PageMode", "none outlines thumbs")
@ -742,6 +742,7 @@ class pdfdoc(object):
imgheightpx, imgheightpx,
imgformat, imgformat,
imgdata, imgdata,
smaskdata,
imgwidthpdf, imgwidthpdf,
imgheightpdf, imgheightpdf,
imgxpdf, imgxpdf,
@ -759,6 +760,11 @@ class pdfdoc(object):
artborder=None, artborder=None,
iccp=None, iccp=None,
): ):
assert (
(color != Colorspace.RGBA and color != Colorspace.LA)
or (imgformat == ImageFormat.PNG and smaskdata is not None)
)
if self.engine == Engine.pikepdf: if self.engine == Engine.pikepdf:
PdfArray = pikepdf.Array PdfArray = pikepdf.Array
PdfDict = pikepdf.Dictionary PdfDict = pikepdf.Dictionary
@ -777,9 +783,9 @@ class pdfdoc(object):
TrueObject = True if self.engine == Engine.pikepdf else PdfObject("true") TrueObject = True if self.engine == Engine.pikepdf else PdfObject("true")
FalseObject = False if self.engine == Engine.pikepdf else PdfObject("false") FalseObject = False if self.engine == Engine.pikepdf else PdfObject("false")
if color == Colorspace["1"] or color == Colorspace.L: if color == Colorspace["1"] or color == Colorspace.L or color == Colorspace.LA:
colorspace = PdfName.DeviceGray colorspace = PdfName.DeviceGray
elif color == Colorspace.RGB: elif color == Colorspace.RGB or color == Colorspace.RGBA:
colorspace = PdfName.DeviceRGB colorspace = PdfName.DeviceRGB
elif color == Colorspace.CMYK or color == Colorspace["CMYK;I"]: elif color == Colorspace.CMYK or color == Colorspace["CMYK;I"]:
colorspace = PdfName.DeviceCMYK colorspace = PdfName.DeviceCMYK
@ -816,9 +822,9 @@ class pdfdoc(object):
else: else:
iccpdict = PdfDict(stream=convert_load(iccp)) iccpdict = PdfDict(stream=convert_load(iccp))
iccpdict[PdfName.Alternate] = colorspace iccpdict[PdfName.Alternate] = colorspace
if color == Colorspace["1"] or color == Colorspace.L: if color == Colorspace["1"] or color == Colorspace.L or color == Colorspace.LA:
iccpdict[PdfName.N] = 1 iccpdict[PdfName.N] = 1
elif color == Colorspace.RGB: elif color == Colorspace.RGB or color == Colorspace.RGBA:
iccpdict[PdfName.N] = 3 iccpdict[PdfName.N] = 3
elif color == Colorspace.CMYK or color == Colorspace["CMYK;I"]: elif color == Colorspace.CMYK or color == Colorspace["CMYK;I"]:
iccpdict[PdfName.N] = 4 iccpdict[PdfName.N] = 4
@ -852,6 +858,8 @@ class pdfdoc(object):
image[PdfName.ColorSpace] = colorspace image[PdfName.ColorSpace] = colorspace
image[PdfName.BitsPerComponent] = depth image[PdfName.BitsPerComponent] = depth
smask = None
if color == Colorspace["CMYK;I"]: if color == Colorspace["CMYK;I"]:
# Inverts all four channels # Inverts all four channels
image[PdfName.Decode] = [1, 0, 1, 0, 1, 0, 1, 0] image[PdfName.Decode] = [1, 0, 1, 0, 1, 0, 1, 0]
@ -869,9 +877,35 @@ class pdfdoc(object):
decodeparms[PdfName.Rows] = imgheightpx decodeparms[PdfName.Rows] = imgheightpx
image[PdfName.DecodeParms] = [decodeparms] image[PdfName.DecodeParms] = [decodeparms]
elif imgformat is ImageFormat.PNG: elif imgformat is ImageFormat.PNG:
if smaskdata is not None:
if self.engine == Engine.pikepdf:
smask = self.writer.make_stream(smaskdata)
else:
smask = PdfDict(stream=convert_load(smaskdata))
smask[PdfName.Type] = PdfName.XObject
smask[PdfName.Subtype] = PdfName.Image
smask[PdfName.Filter] = PdfName.FlateDecode
smask[PdfName.Width] = imgwidthpx
smask[PdfName.Height] = imgheightpx
smask[PdfName.ColorSpace] = PdfName.DeviceGray
smask[PdfName.BitsPerComponent] = depth
decodeparms = PdfDict() decodeparms = PdfDict()
decodeparms[PdfName.Predictor] = 15 decodeparms[PdfName.Predictor] = 15
if color in [Colorspace.P, Colorspace["1"], Colorspace.L]: decodeparms[PdfName.Colors] = 1
decodeparms[PdfName.Columns] = imgwidthpx
decodeparms[PdfName.BitsPerComponent] = depth
smask[PdfName.DecodeParms] = decodeparms
image[PdfName.SMask] = smask
# /SMask requires PDF 1.4
if self.output_version < "1.4":
self.output_version = "1.4"
decodeparms = PdfDict()
decodeparms[PdfName.Predictor] = 15
if color in [Colorspace.P, Colorspace["1"], Colorspace.L, Colorspace.LA]:
decodeparms[PdfName.Colors] = 1 decodeparms[PdfName.Colors] = 1
else: else:
decodeparms[PdfName.Colors] = 3 decodeparms[PdfName.Colors] = 3
@ -954,6 +988,8 @@ class pdfdoc(object):
if self.engine == Engine.internal: if self.engine == Engine.internal:
self.writer.addobj(content) self.writer.addobj(content)
self.writer.addobj(image) self.writer.addobj(image)
if smask is not None:
self.writer.addobj(smask)
if iccp is not None: if iccp is not None:
self.writer.addobj(iccpdict) self.writer.addobj(iccpdict)
@ -1184,7 +1220,20 @@ def get_imgmetadata(
ndpi = (int(round(ndpi[0])), int(round(ndpi[1]))) ndpi = (int(round(ndpi[0])), int(round(ndpi[1])))
ics = imgdata.mode ics = imgdata.mode
if ics in ["LA", "PA", "RGBA"] or "transparency" in imgdata.info: # GIF and PNG files with transparency are supported
if (
(imgformat == ImageFormat.PNG or imgformat == ImageFormat.GIF)
and (ics in ["RGBA", "LA"] or "transparency" in imgdata.info)
):
# Must check the IHDR chunk for the bit depth, because PIL would lossily
# convert 16-bit RGBA/LA images to 8-bit.
if imgformat == ImageFormat.PNG and rawdata is not None:
depth = rawdata[24]
if depth > 8:
logger.warning("Image with transparency and a bit depth of %d." % depth)
logger.warning("This is unsupported due to PIL limitations.")
raise AlphaChannelError("Refusing to work with multiple >8bit channels.")
elif (ics in ["LA", "PA", "RGBA"] or "transparency" in imgdata.info):
logger.warning("Image contains transparency which cannot be retained in PDF.") logger.warning("Image contains transparency which cannot be retained in PDF.")
logger.warning("img2pdf will not perform a lossy operation.") logger.warning("img2pdf will not perform a lossy operation.")
logger.warning("You can remove the alpha channel using imagemagick:") logger.warning("You can remove the alpha channel using imagemagick:")
@ -1427,6 +1476,7 @@ def read_images(rawdata, colorspace, first_frame_only=False, rot=None):
ndpi, ndpi,
imgformat, imgformat,
rawdata, rawdata,
None,
imgwidthpx, imgwidthpx,
imgheightpx, imgheightpx,
[], [],
@ -1483,6 +1533,7 @@ def read_images(rawdata, colorspace, first_frame_only=False, rot=None):
ndpi, ndpi,
ImageFormat.JPEG, ImageFormat.JPEG,
rawdata[offset : offset + mpent["Size"]], rawdata[offset : offset + mpent["Size"]],
None,
imgwidthpx, imgwidthpx,
imgheightpx, imgheightpx,
[], [],
@ -1507,6 +1558,11 @@ def read_images(rawdata, colorspace, first_frame_only=False, rot=None):
color, ndpi, imgwidthpx, imgheightpx, rotation, iccp = get_imgmetadata( color, ndpi, imgwidthpx, imgheightpx, rotation, iccp = get_imgmetadata(
imgdata, imgformat, default_dpi, colorspace, rawdata, rot imgdata, imgformat, default_dpi, colorspace, rawdata, rot
) )
if (
color != Colorspace.RGBA
and color != Colorspace.LA
and "transparency" not in imgdata.info
):
pngidat, palette = parse_png(rawdata) pngidat, palette = parse_png(rawdata)
# PIL does not provide the information about the original bits per # PIL does not provide the information about the original bits per
# sample. Thus, we retrieve that info manually by looking at byte 9 in # sample. Thus, we retrieve that info manually by looking at byte 9 in
@ -1523,6 +1579,7 @@ def read_images(rawdata, colorspace, first_frame_only=False, rot=None):
ndpi, ndpi,
imgformat, imgformat,
pngidat, pngidat,
None,
imgwidthpx, imgwidthpx,
imgheightpx, imgheightpx,
palette, palette,
@ -1615,6 +1672,7 @@ def read_images(rawdata, colorspace, first_frame_only=False, rot=None):
ndpi, ndpi,
ImageFormat.CCITTGroup4, ImageFormat.CCITTGroup4,
rawdata, rawdata,
None,
imgwidthpx, imgwidthpx,
imgheightpx, imgheightpx,
[], [],
@ -1644,6 +1702,7 @@ def read_images(rawdata, colorspace, first_frame_only=False, rot=None):
ndpi, ndpi,
ImageFormat.CCITTGroup4, ImageFormat.CCITTGroup4,
ccittdata, ccittdata,
None,
imgwidthpx, imgwidthpx,
imgheightpx, imgheightpx,
[], [],
@ -1662,7 +1721,9 @@ def read_images(rawdata, colorspace, first_frame_only=False, rot=None):
color = Colorspace.L color = Colorspace.L
elif color in [ elif color in [
Colorspace.RGB, Colorspace.RGB,
Colorspace.RGBA,
Colorspace.L, Colorspace.L,
Colorspace.LA,
Colorspace.CMYK, Colorspace.CMYK,
Colorspace["CMYK;I"], Colorspace["CMYK;I"],
Colorspace.P, Colorspace.P,
@ -1682,6 +1743,7 @@ def read_images(rawdata, colorspace, first_frame_only=False, rot=None):
ndpi, ndpi,
imgformat, imgformat,
imggz, imggz,
None,
imgwidthpx, imgwidthpx,
imgheightpx, imgheightpx,
[], [],
@ -1692,27 +1754,42 @@ def read_images(rawdata, colorspace, first_frame_only=False, rot=None):
) )
) )
else: else:
# cheapo version to retrieve a PNG encoding of the payload is to if (
# just save it with PIL. In the future this could be replaced by color == Colorspace.RGBA
# dedicated function applying the Paeth PNG filter to the raw pixel or color == Colorspace.LA
pngbuffer = BytesIO() or "transparency" in newimg.info
newimg.save(pngbuffer, format="png") ):
pngidat, palette = parse_png(pngbuffer.getvalue()) if color == Colorspace.RGBA:
# PIL does not provide the information about the original bits per newcolor = color
# sample. Thus, we retrieve that info manually by looking at byte 9 in r, g, b, a = newimg.split()
# the IHDR chunk. We know where to find that in the file because the newimg = Image.merge("RGB", (r, g, b))
# IHDR chunk must be the first chunk elif color == Colorspace.LA:
pngbuffer.seek(24) newcolor = color
depth = ord(pngbuffer.read(1)) l, a = newimg.split()
if depth not in [1, 2, 4, 8, 16]: newimg = l
raise ValueError("invalid bit depth: %d" % depth) else:
newcolor = Colorspace.RGBA
r, g, b, a = newimg.convert(mode="RGBA").split()
newimg = Image.merge("RGB", (r, g, b))
smaskidat, _, _ = to_png_data(a)
logger.warning(
"Image contains an alpha channel which will be stored "
"as a separate soft mask (/SMask) image in PDF."
)
else:
newcolor = color
smaskidat = None
pngidat, palette, depth = to_png_data(newimg)
logger.debug("read_images() encoded an image as PNG") logger.debug("read_images() encoded an image as PNG")
result.append( result.append(
( (
color, newcolor,
ndpi, ndpi,
ImageFormat.PNG, ImageFormat.PNG,
pngidat, pngidat,
smaskidat,
imgwidthpx, imgwidthpx,
imgheightpx, imgheightpx,
palette, palette,
@ -1726,6 +1803,23 @@ def read_images(rawdata, colorspace, first_frame_only=False, rot=None):
cleanup() cleanup()
return result return result
def to_png_data(img):
# cheapo version to retrieve a PNG encoding of the payload is to
# just save it with PIL. In the future this could be replaced by
# dedicated function applying the Paeth PNG filter to the raw pixel
pngbuffer = BytesIO()
img.save(pngbuffer, format="png")
pngidat, palette = parse_png(pngbuffer.getvalue())
# PIL does not provide the information about the original bits per
# sample. Thus, we retrieve that info manually by looking at byte 9 in
# the IHDR chunk. We know where to find that in the file because the
# IHDR chunk must be the first chunk
pngbuffer.seek(24)
depth = ord(pngbuffer.read(1))
if depth not in [1, 2, 4, 8, 16]:
raise ValueError("invalid bit depth: %d" % depth)
return pngidat, palette, depth
# converts a length in pixels to a length in PDF units (1/72 of an inch) # converts a length in pixels to a length in PDF units (1/72 of an inch)
def px_to_pt(length, dpi): def px_to_pt(length, dpi):
@ -2118,6 +2212,7 @@ def convert(*images, **kwargs):
ndpi, ndpi,
imgformat, imgformat,
imgdata, imgdata,
smaskdata,
imgwidthpx, imgwidthpx,
imgheightpx, imgheightpx,
palette, palette,
@ -2171,6 +2266,7 @@ def convert(*images, **kwargs):
imgheightpx, imgheightpx,
imgformat, imgformat,
imgdata, imgdata,
smaskdata,
imgwidthpdf, imgwidthpdf,
imgheightpdf, imgheightpdf,
imgxpdf, imgxpdf,
@ -2651,7 +2747,6 @@ def gui():
args = { args = {
"engine": tkinter.StringVar(), "engine": tkinter.StringVar(),
"first_frame_only": tkinter.BooleanVar(),
"auto_orient": tkinter.BooleanVar(), "auto_orient": tkinter.BooleanVar(),
"fit": tkinter.StringVar(), "fit": tkinter.StringVar(),
"title": tkinter.StringVar(), "title": tkinter.StringVar(),
@ -3611,8 +3706,9 @@ ifvalid, 0, 90, 180 and 270. The default value is auto and indicates that input
images are rotated according to their EXIF Orientation tag. The values none and images are rotated according to their EXIF Orientation tag. The values none and
0 ignore the EXIF Orientation values of the input images. The value ifvalid 0 ignore the EXIF Orientation values of the input images. The value ifvalid
acts like auto but ignores invalid EXIF rotation values and only issues a acts like auto but ignores invalid EXIF rotation values and only issues a
warning instead of throwing an error. The values 90, 180 and 270 perform a warning instead of throwing an error. This is useful because many devices like
clockwise rotation of the image. Android phones, Canon cameras or scanners emit an invalid Orientation tag value
of zero. The values 90, 180 and 270 perform a clockwise rotation of the image.
""", """,
) )
sizeargs.add_argument( sizeargs.add_argument(

File diff suppressed because it is too large Load diff