forked from josch/img2pdf
Compare commits
12 commits
6e6987c982
...
8cbe03d486
Author | SHA1 | Date | |
---|---|---|---|
|
8cbe03d486 | ||
|
968fc0c27a | ||
|
cfbb40b0f6 | ||
|
ff03d9c1cd | ||
|
e6613d3244 | ||
|
219dbd2856 | ||
|
bf51768fb4 | ||
4c5b72dab0 | |||
853a1ec363 | |||
55d589a548 | |||
5c617965f5 | |||
0067edf965 |
3 changed files with 541 additions and 343 deletions
12
README.md
12
README.md
|
@ -80,7 +80,17 @@ Bugs
|
|||
- Input images with alpha channels are not allowed. PDF only supports
|
||||
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
|
||||
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
|
||||
input if necessary. To prevent decompression bomb denial of service attacks,
|
||||
|
|
200
src/img2pdf.py
200
src/img2pdf.py
|
@ -85,9 +85,9 @@ FitMode = Enum("FitMode", "into fill exact shrink enlarge")
|
|||
|
||||
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")
|
||||
|
||||
|
@ -742,6 +742,7 @@ class pdfdoc(object):
|
|||
imgheightpx,
|
||||
imgformat,
|
||||
imgdata,
|
||||
smaskdata,
|
||||
imgwidthpdf,
|
||||
imgheightpdf,
|
||||
imgxpdf,
|
||||
|
@ -759,6 +760,11 @@ class pdfdoc(object):
|
|||
artborder=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:
|
||||
PdfArray = pikepdf.Array
|
||||
PdfDict = pikepdf.Dictionary
|
||||
|
@ -777,9 +783,9 @@ class pdfdoc(object):
|
|||
TrueObject = True if self.engine == Engine.pikepdf else PdfObject("true")
|
||||
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
|
||||
elif color == Colorspace.RGB:
|
||||
elif color == Colorspace.RGB or color == Colorspace.RGBA:
|
||||
colorspace = PdfName.DeviceRGB
|
||||
elif color == Colorspace.CMYK or color == Colorspace["CMYK;I"]:
|
||||
colorspace = PdfName.DeviceCMYK
|
||||
|
@ -816,9 +822,9 @@ class pdfdoc(object):
|
|||
else:
|
||||
iccpdict = PdfDict(stream=convert_load(iccp))
|
||||
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
|
||||
elif color == Colorspace.RGB:
|
||||
elif color == Colorspace.RGB or color == Colorspace.RGBA:
|
||||
iccpdict[PdfName.N] = 3
|
||||
elif color == Colorspace.CMYK or color == Colorspace["CMYK;I"]:
|
||||
iccpdict[PdfName.N] = 4
|
||||
|
@ -852,6 +858,8 @@ class pdfdoc(object):
|
|||
image[PdfName.ColorSpace] = colorspace
|
||||
image[PdfName.BitsPerComponent] = depth
|
||||
|
||||
smask = None
|
||||
|
||||
if color == Colorspace["CMYK;I"]:
|
||||
# Inverts all four channels
|
||||
image[PdfName.Decode] = [1, 0, 1, 0, 1, 0, 1, 0]
|
||||
|
@ -869,9 +877,35 @@ class pdfdoc(object):
|
|||
decodeparms[PdfName.Rows] = imgheightpx
|
||||
image[PdfName.DecodeParms] = [decodeparms]
|
||||
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[PdfName.Predictor] = 15
|
||||
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]:
|
||||
if color in [Colorspace.P, Colorspace["1"], Colorspace.L, Colorspace.LA]:
|
||||
decodeparms[PdfName.Colors] = 1
|
||||
else:
|
||||
decodeparms[PdfName.Colors] = 3
|
||||
|
@ -954,6 +988,8 @@ class pdfdoc(object):
|
|||
if self.engine == Engine.internal:
|
||||
self.writer.addobj(content)
|
||||
self.writer.addobj(image)
|
||||
if smask is not None:
|
||||
self.writer.addobj(smask)
|
||||
if iccp is not None:
|
||||
self.writer.addobj(iccpdict)
|
||||
|
||||
|
@ -1183,8 +1219,21 @@ def get_imgmetadata(
|
|||
# Search online for the 72.009 dpi problem for more info.
|
||||
ndpi = (int(round(ndpi[0])), int(round(ndpi[1])))
|
||||
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("img2pdf will not perform a lossy operation.")
|
||||
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,
|
||||
imgformat,
|
||||
rawdata,
|
||||
None,
|
||||
imgwidthpx,
|
||||
imgheightpx,
|
||||
[],
|
||||
|
@ -1483,6 +1533,7 @@ def read_images(rawdata, colorspace, first_frame_only=False, rot=None):
|
|||
ndpi,
|
||||
ImageFormat.JPEG,
|
||||
rawdata[offset : offset + mpent["Size"]],
|
||||
None,
|
||||
imgwidthpx,
|
||||
imgheightpx,
|
||||
[],
|
||||
|
@ -1507,31 +1558,37 @@ def read_images(rawdata, colorspace, first_frame_only=False, rot=None):
|
|||
color, ndpi, imgwidthpx, imgheightpx, rotation, iccp = get_imgmetadata(
|
||||
imgdata, imgformat, default_dpi, colorspace, rawdata, rot
|
||||
)
|
||||
pngidat, palette = parse_png(rawdata)
|
||||
# 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
|
||||
depth = rawdata[24]
|
||||
if depth not in [1, 2, 4, 8, 16]:
|
||||
raise ValueError("invalid bit depth: %d" % depth)
|
||||
logger.debug("read_images() embeds a PNG")
|
||||
cleanup()
|
||||
return [
|
||||
(
|
||||
color,
|
||||
ndpi,
|
||||
imgformat,
|
||||
pngidat,
|
||||
imgwidthpx,
|
||||
imgheightpx,
|
||||
palette,
|
||||
False,
|
||||
depth,
|
||||
rotation,
|
||||
iccp,
|
||||
)
|
||||
]
|
||||
if (
|
||||
color != Colorspace.RGBA
|
||||
and color != Colorspace.LA
|
||||
and "transparency" not in imgdata.info
|
||||
):
|
||||
pngidat, palette = parse_png(rawdata)
|
||||
# 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
|
||||
depth = rawdata[24]
|
||||
if depth not in [1, 2, 4, 8, 16]:
|
||||
raise ValueError("invalid bit depth: %d" % depth)
|
||||
logger.debug("read_images() embeds a PNG")
|
||||
cleanup()
|
||||
return [
|
||||
(
|
||||
color,
|
||||
ndpi,
|
||||
imgformat,
|
||||
pngidat,
|
||||
None,
|
||||
imgwidthpx,
|
||||
imgheightpx,
|
||||
palette,
|
||||
False,
|
||||
depth,
|
||||
rotation,
|
||||
iccp,
|
||||
)
|
||||
]
|
||||
|
||||
# If our input is not JPEG or PNG, then we might have a format that
|
||||
# supports multiple frames (like TIFF or GIF), so we need a loop to
|
||||
|
@ -1615,6 +1672,7 @@ def read_images(rawdata, colorspace, first_frame_only=False, rot=None):
|
|||
ndpi,
|
||||
ImageFormat.CCITTGroup4,
|
||||
rawdata,
|
||||
None,
|
||||
imgwidthpx,
|
||||
imgheightpx,
|
||||
[],
|
||||
|
@ -1644,6 +1702,7 @@ def read_images(rawdata, colorspace, first_frame_only=False, rot=None):
|
|||
ndpi,
|
||||
ImageFormat.CCITTGroup4,
|
||||
ccittdata,
|
||||
None,
|
||||
imgwidthpx,
|
||||
imgheightpx,
|
||||
[],
|
||||
|
@ -1662,7 +1721,9 @@ def read_images(rawdata, colorspace, first_frame_only=False, rot=None):
|
|||
color = Colorspace.L
|
||||
elif color in [
|
||||
Colorspace.RGB,
|
||||
Colorspace.RGBA,
|
||||
Colorspace.L,
|
||||
Colorspace.LA,
|
||||
Colorspace.CMYK,
|
||||
Colorspace["CMYK;I"],
|
||||
Colorspace.P,
|
||||
|
@ -1682,6 +1743,7 @@ def read_images(rawdata, colorspace, first_frame_only=False, rot=None):
|
|||
ndpi,
|
||||
imgformat,
|
||||
imggz,
|
||||
None,
|
||||
imgwidthpx,
|
||||
imgheightpx,
|
||||
[],
|
||||
|
@ -1692,27 +1754,42 @@ def read_images(rawdata, colorspace, first_frame_only=False, rot=None):
|
|||
)
|
||||
)
|
||||
else:
|
||||
# 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()
|
||||
newimg.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)
|
||||
if (
|
||||
color == Colorspace.RGBA
|
||||
or color == Colorspace.LA
|
||||
or "transparency" in newimg.info
|
||||
):
|
||||
if color == Colorspace.RGBA:
|
||||
newcolor = color
|
||||
r, g, b, a = newimg.split()
|
||||
newimg = Image.merge("RGB", (r, g, b))
|
||||
elif color == Colorspace.LA:
|
||||
newcolor = color
|
||||
l, a = newimg.split()
|
||||
newimg = l
|
||||
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")
|
||||
result.append(
|
||||
(
|
||||
color,
|
||||
newcolor,
|
||||
ndpi,
|
||||
ImageFormat.PNG,
|
||||
pngidat,
|
||||
smaskidat,
|
||||
imgwidthpx,
|
||||
imgheightpx,
|
||||
palette,
|
||||
|
@ -1726,6 +1803,23 @@ def read_images(rawdata, colorspace, first_frame_only=False, rot=None):
|
|||
cleanup()
|
||||
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)
|
||||
def px_to_pt(length, dpi):
|
||||
|
@ -2118,6 +2212,7 @@ def convert(*images, **kwargs):
|
|||
ndpi,
|
||||
imgformat,
|
||||
imgdata,
|
||||
smaskdata,
|
||||
imgwidthpx,
|
||||
imgheightpx,
|
||||
palette,
|
||||
|
@ -2171,6 +2266,7 @@ def convert(*images, **kwargs):
|
|||
imgheightpx,
|
||||
imgformat,
|
||||
imgdata,
|
||||
smaskdata,
|
||||
imgwidthpdf,
|
||||
imgheightpdf,
|
||||
imgxpdf,
|
||||
|
@ -2651,7 +2747,6 @@ def gui():
|
|||
|
||||
args = {
|
||||
"engine": tkinter.StringVar(),
|
||||
"first_frame_only": tkinter.BooleanVar(),
|
||||
"auto_orient": tkinter.BooleanVar(),
|
||||
"fit": 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
|
||||
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
|
||||
warning instead of throwing an error. The values 90, 180 and 270 perform a
|
||||
clockwise rotation of the image.
|
||||
warning instead of throwing an error. This is useful because many devices like
|
||||
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(
|
||||
|
|
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue