summaryrefslogtreecommitdiff
path: root/src/img2pdf.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/img2pdf.py')
-rwxr-xr-xsrc/img2pdf.py641
1 files changed, 488 insertions, 153 deletions
diff --git a/src/img2pdf.py b/src/img2pdf.py
index 78d7639..d6bb54f 100755
--- a/src/img2pdf.py
+++ b/src/img2pdf.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
-# Copyright (C) 2012-2014 Johannes 'josch' Schauer <j.schauer at email.de>
+# Copyright (C) 2012-2021 Johannes Schauer Marin Rodrigues <josch@mister-muffin.de>
#
# This program is free software: you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
@@ -34,6 +34,9 @@ import logging
import struct
import platform
import hashlib
+from itertools import chain
+
+logger = logging.getLogger(__name__)
have_pdfrw = True
try:
@@ -47,7 +50,7 @@ try:
except ImportError:
have_pikepdf = False
-__version__ = "0.4.0"
+__version__ = "0.4.2"
default_dpi = 96.0
papersizes = {
"letter": "8.5inx11in",
@@ -76,13 +79,15 @@ papernames = {
Engine = Enum("Engine", "internal pdfrw pikepdf")
+Rotation = Enum("Rotation", "auto none ifvalid 0 90 180 270")
+
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 PA other")
-ImageFormat = Enum("ImageFormat", "JPEG JPEG2000 CCITTGroup4 PNG TIFF other")
+ImageFormat = Enum("ImageFormat", "JPEG JPEG2000 CCITTGroup4 PNG GIF TIFF MPO other")
PageMode = Enum("PageMode", "none outlines thumbs")
@@ -737,6 +742,7 @@ class pdfdoc(object):
imgheightpx,
imgformat,
imgdata,
+ smaskdata,
imgwidthpdf,
imgheightpdf,
imgxpdf,
@@ -754,6 +760,10 @@ 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
@@ -772,9 +782,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
@@ -811,9 +821,13 @@ 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
@@ -845,6 +859,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]
@@ -862,9 +878,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
@@ -898,8 +940,8 @@ class pdfdoc(object):
page[PdfName.CropBox] = [
cropborder[1],
cropborder[0],
- pagewidth - 2 * cropborder[1],
- pageheight - 2 * cropborder[0],
+ pagewidth - cropborder[1],
+ pageheight - cropborder[0],
]
if bleedborder is None:
if PdfName.CropBox in page:
@@ -908,8 +950,8 @@ class pdfdoc(object):
page[PdfName.BleedBox] = [
bleedborder[1],
bleedborder[0],
- pagewidth - 2 * bleedborder[1],
- pageheight - 2 * bleedborder[0],
+ pagewidth - bleedborder[1],
+ pageheight - bleedborder[0],
]
if trimborder is None:
if PdfName.CropBox in page:
@@ -918,8 +960,8 @@ class pdfdoc(object):
page[PdfName.TrimBox] = [
trimborder[1],
trimborder[0],
- pagewidth - 2 * trimborder[1],
- pageheight - 2 * trimborder[0],
+ pagewidth - trimborder[1],
+ pageheight - trimborder[0],
]
if artborder is None:
if PdfName.CropBox in page:
@@ -928,8 +970,8 @@ class pdfdoc(object):
page[PdfName.ArtBox] = [
artborder[1],
artborder[0],
- pagewidth - 2 * artborder[1],
- pageheight - 2 * artborder[0],
+ pagewidth - artborder[1],
+ pageheight - artborder[0],
]
page[PdfName.Resources] = resources
page[PdfName.Contents] = content
@@ -947,6 +989,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)
@@ -1152,7 +1196,9 @@ class pdfdoc(object):
raise ValueError("unknown engine: %s" % self.engine)
-def get_imgmetadata(imgdata, imgformat, default_dpi, colorspace, rawdata=None):
+def get_imgmetadata(
+ imgdata, imgformat, default_dpi, colorspace, rawdata=None, rotreq=None
+):
if imgformat == ImageFormat.JPEG2000 and rawdata is not None and imgdata is None:
# this codepath gets called if the PIL installation is not able to
# handle JPEG2000 files
@@ -1175,15 +1221,22 @@ def get_imgmetadata(imgdata, imgformat, default_dpi, colorspace, rawdata=None):
ndpi = (int(round(ndpi[0])), int(round(ndpi[1])))
ics = imgdata.mode
- if ics in ["LA", "PA", "RGBA"] or "transparency" in imgdata.info:
- logging.warning("Image contains transparency which cannot be retained in PDF.")
- logging.warning("img2pdf will not perform a lossy operation.")
- logging.warning("You can remove the alpha channel using imagemagick:")
- logging.warning(
- " $ convert input.png -background white -alpha "
- "remove -alpha off output.png"
- )
- raise AlphaChannelError("Refusing to work on images with alpha channel")
+ # 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:
+ raise AlphaChannelError("This function must not be called on images with alpha")
# Since commit 07a96209597c5e8dfe785c757d7051ce67a980fb or release 4.1.0
# Pillow retrieves the DPI from EXIF if it cannot find the DPI in the JPEG
@@ -1200,34 +1253,53 @@ def get_imgmetadata(imgdata, imgformat, default_dpi, colorspace, rawdata=None):
imgdata.tag_v2.get(TiffImagePlugin.Y_RESOLUTION, default_dpi),
)
- logging.debug("input dpi = %d x %d", *ndpi)
+ logger.debug("input dpi = %d x %d", *ndpi)
rotation = 0
- if hasattr(imgdata, "_getexif") and imgdata._getexif() is not None:
- for tag, value in imgdata._getexif().items():
- if TAGS.get(tag, tag) == "Orientation":
- # Detailed information on EXIF rotation tags:
- # http://impulseadventure.com/photo/exif-orientation.html
- if value == 1:
- rotation = 0
- elif value == 6:
- rotation = 90
- elif value == 3:
- rotation = 180
- elif value == 8:
- rotation = 270
- elif value in (2, 4, 5, 7):
- raise ExifOrientationError(
- "Unsupported flipped rotation mode (%d)" % value
- )
- else:
- raise ExifOrientationError("Invalid rotation (%d)" % value)
+ if rotreq in (None, Rotation.auto, Rotation.ifvalid):
+ if hasattr(imgdata, "_getexif") and imgdata._getexif() is not None:
+ for tag, value in imgdata._getexif().items():
+ if TAGS.get(tag, tag) == "Orientation":
+ # Detailed information on EXIF rotation tags:
+ # http://impulseadventure.com/photo/exif-orientation.html
+ if value == 1:
+ rotation = 0
+ elif value == 6:
+ rotation = 90
+ elif value == 3:
+ rotation = 180
+ elif value == 8:
+ rotation = 270
+ elif value in (2, 4, 5, 7):
+ if rotreq == Rotation.ifvalid:
+ logger.warning(
+ "Unsupported flipped rotation mode (%d)", value
+ )
+ else:
+ raise ExifOrientationError(
+ "Unsupported flipped rotation mode (%d)" % value
+ )
+ else:
+ if rotreq == Rotation.ifvalid:
+ logger.warning("Invalid rotation (%d)", value)
+ else:
+ raise ExifOrientationError("Invalid rotation (%d)" % value)
+ elif rotreq in (Rotation.none, Rotation["0"]):
+ rotation = 0
+ elif rotreq == Rotation["90"]:
+ rotation = 90
+ elif rotreq == Rotation["180"]:
+ rotation = 180
+ elif rotreq == Rotation["270"]:
+ rotation = 270
+ else:
+ raise Exception("invalid rotreq")
- logging.debug("rotation = %d°", rotation)
+ logger.debug("rotation = %d°", rotation)
if colorspace:
color = colorspace
- logging.debug("input colorspace (forced) = %s", color)
+ logger.debug("input colorspace (forced) = %s", color)
else:
color = None
for c in Colorspace:
@@ -1257,13 +1329,13 @@ def get_imgmetadata(imgdata, imgformat, default_dpi, colorspace, rawdata=None):
# with the first approach for now.
if "adobe" in imgdata.info:
color = Colorspace["CMYK;I"]
- logging.debug("input colorspace = %s", color.name)
+ logger.debug("input colorspace = %s", color.name)
iccp = None
if "icc_profile" in imgdata.info:
iccp = imgdata.info.get("icc_profile")
- logging.debug("width x height = %dpx x %dpx", imgwidthpx, imgheightpx)
+ logger.debug("width x height = %dpx x %dpx", imgwidthpx, imgheightpx)
return (color, ndpi, imgwidthpx, imgheightpx, rotation, iccp)
@@ -1280,19 +1352,20 @@ def ccitt_payload_location_from_pil(img):
# Read the TIFF tags to find the offset(s) of the compressed data strips.
strip_offsets = img.tag_v2[TiffImagePlugin.STRIPOFFSETS]
strip_bytes = img.tag_v2[TiffImagePlugin.STRIPBYTECOUNTS]
- rows_per_strip = img.tag_v2.get(TiffImagePlugin.ROWSPERSTRIP, 2 ** 32 - 1)
# PIL always seems to create a single strip even for very large TIFFs when
# it saves images, so assume we only have to read a single strip.
# A test ~10 GPixel image was still encoded as a single strip. Just to be
# safe check throw an error if there is more than one offset.
if len(strip_offsets) != 1 or len(strip_bytes) != 1:
- raise NotImplementedError("Transcoding multiple strips not supported")
+ raise NotImplementedError(
+ "Transcoding multiple strips not supported by the PDF format"
+ )
(offset,), (length,) = strip_offsets, strip_bytes
- logging.debug("TIFF strip_offsets: %d" % offset)
- logging.debug("TIFF strip_bytes: %d" % length)
+ logger.debug("TIFF strip_offsets: %d" % offset)
+ logger.debug("TIFF strip_bytes: %d" % length)
return offset, length
@@ -1300,7 +1373,7 @@ def ccitt_payload_location_from_pil(img):
def transcode_monochrome(imgdata):
"""Convert the open PIL.Image imgdata to compressed CCITT Group4 data"""
- logging.debug("Converting monochrome to CCITT Group4")
+ logger.debug("Converting monochrome to CCITT Group4")
# Convert the image to Group 4 in memory. If libtiff is not installed and
# Pillow is not compiled against it, .save() will raise an exception.
@@ -1311,7 +1384,33 @@ def transcode_monochrome(imgdata):
# killed by a SIGABRT:
# https://gitlab.mister-muffin.de/josch/img2pdf/issues/46
im = Image.frombytes(imgdata.mode, imgdata.size, imgdata.tobytes())
- im.save(newimgio, format="TIFF", compression="group4")
+
+ # Since version 8.3.0 Pillow limits strips to 64 KB. Since PDF only
+ # supports single strip CCITT Group4 payloads, we have to coerce it back
+ # into putting everything into a single strip. Thanks to Andrew Murray for
+ # the hack.
+ #
+ # This can be dropped once this gets merged:
+ # https://github.com/python-pillow/Pillow/pull/5744
+ pillow__getitem__ = TiffImagePlugin.ImageFileDirectory_v2.__getitem__
+
+ def __getitem__(self, tag):
+ overrides = {
+ TiffImagePlugin.ROWSPERSTRIP: imgdata.size[1],
+ TiffImagePlugin.STRIPBYTECOUNTS: [
+ (imgdata.size[0] + 7) // 8 * imgdata.size[1]
+ ],
+ TiffImagePlugin.STRIPOFFSETS: [0],
+ }
+ return overrides.get(tag, pillow__getitem__(self, tag))
+
+ # use try/finally to make sure that __getitem__ is reset even if save()
+ # raises an exception
+ try:
+ TiffImagePlugin.ImageFileDirectory_v2.__getitem__ = __getitem__
+ im.save(newimgio, format="TIFF", compression="group4")
+ finally:
+ TiffImagePlugin.ImageFileDirectory_v2.__getitem__ = pillow__getitem__
# Open new image in memory
newimgio.seek(0)
@@ -1341,7 +1440,7 @@ def parse_png(rawdata):
return pngidat, palette
-def read_images(rawdata, colorspace, first_frame_only=False):
+def read_images(rawdata, colorspace, first_frame_only=False, rot=None):
im = BytesIO(rawdata)
im.seek(0)
imgdata = None
@@ -1357,6 +1456,7 @@ def read_images(rawdata, colorspace, first_frame_only=False):
# image is jpeg2000
imgformat = ImageFormat.JPEG2000
else:
+ logger.debug("PIL format = %s", imgdata.format)
imgformat = None
for f in ImageFormat:
if f.name == imgdata.format:
@@ -1364,7 +1464,17 @@ def read_images(rawdata, colorspace, first_frame_only=False):
if imgformat is None:
imgformat = ImageFormat.other
- logging.debug("imgformat = %s", imgformat.name)
+ def cleanup():
+ if imgdata is not None:
+ # the python-pil version 2.3.0-1ubuntu3 in Ubuntu does not have the
+ # close() method
+ try:
+ imgdata.close()
+ except AttributeError:
+ pass
+ im.close()
+
+ logger.debug("imgformat = %s", imgformat.name)
# depending on the input format, determine whether to pass the raw
# image or the zlib compressed color information
@@ -1372,7 +1482,7 @@ def read_images(rawdata, colorspace, first_frame_only=False):
# JPEG and JPEG2000 can be embedded into the PDF as-is
if imgformat == ImageFormat.JPEG or imgformat == ImageFormat.JPEG2000:
color, ndpi, imgwidthpx, imgheightpx, rotation, iccp = get_imgmetadata(
- imgdata, imgformat, default_dpi, colorspace, rawdata
+ imgdata, imgformat, default_dpi, colorspace, rawdata, rot
)
if color == Colorspace["1"]:
raise JpegColorspaceError("jpeg can't be monochrome")
@@ -1380,14 +1490,15 @@ def read_images(rawdata, colorspace, first_frame_only=False):
raise JpegColorspaceError("jpeg can't have a color palette")
if color == Colorspace["RGBA"]:
raise JpegColorspaceError("jpeg can't have an alpha channel")
- im.close()
- logging.debug("read_images() embeds a JPEG")
+ logger.debug("read_images() embeds a JPEG")
+ cleanup()
return [
(
color,
ndpi,
imgformat,
rawdata,
+ None,
imgwidthpx,
imgheightpx,
[],
@@ -1398,6 +1509,66 @@ def read_images(rawdata, colorspace, first_frame_only=False):
)
]
+ # The MPO format is multiple JPEG images concatenated together
+ # we use the offset and size information to dissect the MPO into its
+ # individual JPEG images and then embed those into the PDF individually.
+ #
+ # The downside is, that this truncates the first JPEG as the MPO metadata
+ # will still be in it but the referenced images are chopped off. We still
+ # do it that way instead of adding the full MPO as the first image to not
+ # store duplicate image data.
+ if imgformat == ImageFormat.MPO:
+ result = []
+ img_page_count = 0
+ for offset, mpent in zip(
+ imgdata._MpoImageFile__mpoffsets, imgdata.mpinfo[0xB002]
+ ):
+ if first_frame_only and img_page_count > 0:
+ break
+ with BytesIO(rawdata[offset : offset + mpent["Size"]]) as rawframe:
+ with Image.open(rawframe) as imframe:
+ # The first frame contains the data that makes the JPEG a MPO
+ # Could we thus embed an MPO into another MPO? Lets not support
+ # such madness ;)
+ if img_page_count > 0 and imframe.format != "JPEG":
+ raise Exception("MPO payload must be a JPEG %s", imframe.format)
+ (
+ color,
+ ndpi,
+ imgwidthpx,
+ imgheightpx,
+ rotation,
+ iccp,
+ ) = get_imgmetadata(
+ imframe, ImageFormat.JPEG, default_dpi, colorspace, rotreq=rot
+ )
+ if color == Colorspace["1"]:
+ raise JpegColorspaceError("jpeg can't be monochrome")
+ if color == Colorspace["P"]:
+ raise JpegColorspaceError("jpeg can't have a color palette")
+ if color == Colorspace["RGBA"]:
+ raise JpegColorspaceError("jpeg can't have an alpha channel")
+ logger.debug("read_images() embeds a JPEG from MPO")
+ result.append(
+ (
+ color,
+ ndpi,
+ ImageFormat.JPEG,
+ rawdata[offset : offset + mpent["Size"]],
+ None,
+ imgwidthpx,
+ imgheightpx,
+ [],
+ False,
+ 8,
+ rotation,
+ iccp,
+ )
+ )
+ img_page_count += 1
+ cleanup()
+ return result
+
# We can directly embed the IDAT chunk of PNG images if the PNG is not
# interlaced
#
@@ -1407,33 +1578,44 @@ def read_images(rawdata, colorspace, first_frame_only=False):
# must be the first chunk.
if imgformat == ImageFormat.PNG and rawdata[28] == 0:
color, ndpi, imgwidthpx, imgheightpx, rotation, iccp = get_imgmetadata(
- imgdata, imgformat, default_dpi, colorspace, rawdata
+ imgdata, imgformat, default_dpi, colorspace, rawdata, rot
)
- pngidat, palette = parse_png(rawdata)
- im.close()
- # 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)
- logging.debug("read_images() embeds a PNG")
- return [
- (
- color,
- ndpi,
- imgformat,
- pngidat,
- imgwidthpx,
- imgheightpx,
- palette,
- False,
- depth,
- rotation,
- iccp,
- )
- ]
+ if (
+ color != Colorspace.RGBA
+ and color != Colorspace.LA
+ and color != Colorspace.PA
+ 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)
+ # we embed the PNG only if it is not at the same time palette based
+ # and has an icc profile because PDF doesn't support icc profiles
+ # on palette images
+ if palette == b"" or iccp is None:
+ 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
@@ -1478,6 +1660,7 @@ def read_images(rawdata, colorspace, first_frame_only=False):
imgformat == ImageFormat.TIFF
and imgdata.info["compression"] == "group4"
and len(imgdata.tag_v2[TiffImagePlugin.STRIPOFFSETS]) == 1
+ and len(imgdata.tag_v2[TiffImagePlugin.STRIPBYTECOUNTS]) == 1
):
photo = imgdata.tag_v2[TiffImagePlugin.PHOTOMETRIC_INTERPRETATION]
inverted = False
@@ -1489,7 +1672,7 @@ def read_images(rawdata, colorspace, first_frame_only=False):
"group4 tiff: %d" % photo
)
color, ndpi, imgwidthpx, imgheightpx, rotation, iccp = get_imgmetadata(
- imgdata, imgformat, default_dpi, colorspace, rawdata
+ imgdata, imgformat, default_dpi, colorspace, rawdata, rot
)
offset, length = ccitt_payload_location_from_pil(imgdata)
im.seek(offset)
@@ -1502,7 +1685,7 @@ def read_images(rawdata, colorspace, first_frame_only=False):
# msb-to-lsb: nothing to do
pass
elif fillorder == 2:
- logging.debug("fillorder is lsb-to-msb => reverse bits")
+ logger.debug("fillorder is lsb-to-msb => reverse bits")
# lsb-to-msb: reverse bits of each byte
rawdata = bytearray(rawdata)
for i in range(len(rawdata)):
@@ -1510,13 +1693,14 @@ def read_images(rawdata, colorspace, first_frame_only=False):
rawdata = bytes(rawdata)
else:
raise ValueError("unsupported FillOrder: %d" % fillorder)
- logging.debug("read_images() embeds Group4 from TIFF")
+ logger.debug("read_images() embeds Group4 from TIFF")
result.append(
(
color,
ndpi,
ImageFormat.CCITTGroup4,
rawdata,
+ None,
imgwidthpx,
imgheightpx,
[],
@@ -1529,23 +1713,24 @@ def read_images(rawdata, colorspace, first_frame_only=False):
img_page_count += 1
continue
- logging.debug("Converting frame: %d" % img_page_count)
+ logger.debug("Converting frame: %d" % img_page_count)
color, ndpi, imgwidthpx, imgheightpx, rotation, iccp = get_imgmetadata(
- imgdata, imgformat, default_dpi, colorspace
+ imgdata, imgformat, default_dpi, colorspace, rotreq=rot
)
newimg = None
if color == Colorspace["1"]:
try:
ccittdata = transcode_monochrome(imgdata)
- logging.debug("read_images() encoded a B/W image as CCITT group 4")
+ logger.debug("read_images() encoded a B/W image as CCITT group 4")
result.append(
(
color,
ndpi,
ImageFormat.CCITTGroup4,
ccittdata,
+ None,
imgwidthpx,
imgheightpx,
[],
@@ -1558,18 +1743,20 @@ def read_images(rawdata, colorspace, first_frame_only=False):
img_page_count += 1
continue
except Exception as e:
- logging.debug(e)
- logging.debug("Converting colorspace 1 to L")
+ logger.debug(e)
+ logger.debug("Converting colorspace 1 to L")
newimg = imgdata.convert("L")
color = Colorspace.L
elif color in [
Colorspace.RGB,
+ Colorspace.RGBA,
Colorspace.L,
+ Colorspace.LA,
Colorspace.CMYK,
Colorspace["CMYK;I"],
Colorspace.P,
]:
- logging.debug("Colorspace is OK: %s", color)
+ logger.debug("Colorspace is OK: %s", color)
newimg = imgdata
else:
raise ValueError("unknown or unsupported colorspace: %s" % color.name)
@@ -1577,13 +1764,14 @@ def read_images(rawdata, colorspace, first_frame_only=False):
# compression
if color in [Colorspace.CMYK, Colorspace["CMYK;I"]]:
imggz = zlib.compress(newimg.tobytes())
- logging.debug("read_images() encoded CMYK with flate compression")
+ logger.debug("read_images() encoded CMYK with flate compression")
result.append(
(
color,
ndpi,
imgformat,
imggz,
+ None,
imgwidthpx,
imgheightpx,
[],
@@ -1594,27 +1782,52 @@ def read_images(rawdata, colorspace, first_frame_only=False):
)
)
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)
- logging.debug("read_images() encoded an image as PNG")
+ if (
+ color == Colorspace.RGBA
+ or color == Colorspace.LA
+ or color == Colorspace.PA
+ 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."
+ )
+ elif color in [Colorspace.P, Colorspace.PA] and iccp is not None:
+ # PDF does not support palette images with icc profile
+ if color == Colorspace.P:
+ newcolor = Colorspace.RGB
+ newimg = newimg.convert(mode="RGB")
+ elif color == Colorspace.PA:
+ newcolor = Colorspace.RGBA
+ newimg = newimg.convert(mode="RGBA")
+ smaskidat = None
+ 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,
@@ -1625,16 +1838,29 @@ def read_images(rawdata, colorspace, first_frame_only=False):
)
)
img_page_count += 1
- # the python-pil version 2.3.0-1ubuntu3 in Ubuntu does not have the
- # close() method
- try:
- imgdata.close()
- except AttributeError:
- pass
- im.close()
+ 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):
return 72.0 * length / dpi
@@ -1955,6 +2181,7 @@ def convert(*images, **kwargs):
trimborder=None,
artborder=None,
pdfa=None,
+ rotation=None,
)
for kwname, default in _default_kwargs.items():
if kwname not in kwargs:
@@ -1991,6 +2218,9 @@ def convert(*images, **kwargs):
if not isinstance(images, (list, tuple)):
images = [images]
+ else:
+ if len(images) == 0:
+ raise ValueError("Unable to process empty list")
for img in images:
# img is allowed to be a path, a binary string representing image data
@@ -2022,6 +2252,7 @@ def convert(*images, **kwargs):
ndpi,
imgformat,
imgdata,
+ smaskdata,
imgwidthpx,
imgheightpx,
palette,
@@ -2029,14 +2260,19 @@ def convert(*images, **kwargs):
depth,
rotation,
iccp,
- ) in read_images(rawdata, kwargs["colorspace"], kwargs["first_frame_only"]):
+ ) in read_images(
+ rawdata,
+ kwargs["colorspace"],
+ kwargs["first_frame_only"],
+ kwargs["rotation"],
+ ):
pagewidth, pageheight, imgwidthpdf, imgheightpdf = kwargs["layout_fun"](
imgwidthpx, imgheightpx, ndpi
)
userunit = None
if pagewidth < 3.00 or pageheight < 3.00:
- logging.warning(
+ logger.warning(
"pdf width or height is below 3.00 - too small for some viewers!"
)
elif pagewidth > 14400.0 or pageheight > 14400.0:
@@ -2050,6 +2286,17 @@ def convert(*images, **kwargs):
raise PdfTooLargeError(
"pdf width or height must not exceed 200 inches."
)
+ for border in ["crop", "bleed", "trim", "art"]:
+ if kwargs[border + "border"] is None:
+ continue
+ if pagewidth < 2 * kwargs[border + "border"][1]:
+ raise ValueError(
+ "horizontal %s border larger than page width" % border
+ )
+ if pageheight < 2 * kwargs[border + "border"][0]:
+ raise ValueError(
+ "vertical %s border larger than page height" % border
+ )
# the image is always centered on the page
imgxpdf = (pagewidth - imgwidthpdf) / 2.0
imgypdf = (pageheight - imgheightpdf) / 2.0
@@ -2059,6 +2306,7 @@ def convert(*images, **kwargs):
imgheightpx,
imgformat,
imgdata,
+ smaskdata,
imgwidthpdf,
imgheightpdf,
imgxpdf,
@@ -2262,16 +2510,17 @@ def parse_borderarg(string):
return h, v
-def input_images(path):
+def from_file(path):
+ result = []
if path == "-":
- # we slurp in all data from stdin because we need to seek in it later
- result = sys.stdin.buffer.read()
- if len(result) == 0:
- raise argparse.ArgumentTypeError('"%s" is empty' % path)
+ content = sys.stdin.buffer.read()
else:
+ with open(path, "rb") as f:
+ content = f.read()
+ for path in content.split(b"\0"):
+ if path == b"":
+ continue
try:
- if os.path.getsize(path) == 0:
- raise argparse.ArgumentTypeError('"%s" is empty' % path)
# test-read a byte from it so that we can abort early in case
# we cannot read data from the file
with open(path, "rb") as im:
@@ -2282,10 +2531,51 @@ def input_images(path):
raise argparse.ArgumentTypeError('"%s" permission denied' % path)
except FileNotFoundError:
raise argparse.ArgumentTypeError('"%s" does not exist' % path)
- result = path
+ result.append(path)
return result
+def input_images(path_expr):
+ if path_expr == "-":
+ # we slurp in all data from stdin because we need to seek in it later
+ result = [sys.stdin.buffer.read()]
+ if len(result) == 0:
+ raise argparse.ArgumentTypeError('"%s" is empty' % path_expr)
+ else:
+ result = []
+ paths = [path_expr]
+ if sys.platform == "win32" and ("*" in path_expr or "?" in path_expr):
+ # on windows, program is responsible for expanding wildcards such as *.jpg
+ # glob won't return files that don't exist so we only use it for wildcards
+ # paths without wildcards that do not exist will trigger "does not exist"
+ from glob import glob
+
+ paths = sorted(glob(path_expr))
+ for path in paths:
+ try:
+ if os.path.getsize(path) == 0:
+ raise argparse.ArgumentTypeError('"%s" is empty' % path)
+ # test-read a byte from it so that we can abort early in case
+ # we cannot read data from the file
+ with open(path, "rb") as im:
+ im.read(1)
+ except IsADirectoryError:
+ raise argparse.ArgumentTypeError('"%s" is a directory' % path)
+ except PermissionError:
+ raise argparse.ArgumentTypeError('"%s" permission denied' % path)
+ except FileNotFoundError:
+ raise argparse.ArgumentTypeError('"%s" does not exist' % path)
+ result.append(path)
+ return result
+
+
+def parse_rotationarg(string):
+ for m in Rotation:
+ if m.name == string.lower():
+ return m
+ raise argparse.ArgumentTypeError("unknown rotation value: %s" % string)
+
+
def parse_fitarg(string):
for m in FitMode:
if m.name == string.lower():
@@ -2497,7 +2787,6 @@ def gui():
args = {
"engine": tkinter.StringVar(),
- "first_frame_only": tkinter.BooleanVar(),
"auto_orient": tkinter.BooleanVar(),
"fit": tkinter.StringVar(),
"title": tkinter.StringVar(),
@@ -3078,9 +3367,9 @@ Losslessly convert raster images to PDF without re-encoding PNG, JPEG, and
JPEG2000 images. This leads to a lossless conversion of PNG, JPEG and JPEG2000
images with the only added file size coming from the PDF container itself.
Other raster graphics formats are losslessly stored using the same encoding
-that PNG uses. Since PDF does not support images with transparency and since
-img2pdf aims to never be lossy, input images with an alpha channel are not
-supported.
+that PNG uses.
+For images with transparency, the alpha channel will be stored as a separate
+soft mask. This is lossless, too.
The output is sent to standard output so that it can be redirected into a file
or to another program as part of a shell pipe. To directly write the output
@@ -3208,8 +3497,10 @@ Report bugs at https://gitlab.mister-muffin.de/josch/img2pdf/issues
"the Python Imaging Library (PIL). If no input images are given, then "
'a single image is read from standard input. The special filename "-" '
"can be used once to read an image from standard input. To read a "
- 'file in the current directory with the filename "-", pass it to '
- 'img2pdf by explicitly stating its relative path like "./-".',
+ 'file in the current directory with the filename "-" (or with a '
+ 'filename starting with "-"), pass it to img2pdf by explicitly '
+ 'stating its relative path like "./-". Cannot be used together with '
+ "--from-file.",
)
parser.add_argument(
"-v",
@@ -3228,6 +3519,19 @@ Report bugs at https://gitlab.mister-muffin.de/josch/img2pdf/issues
parser.add_argument(
"--gui", dest="gui", action="store_true", help="run experimental tkinter gui"
)
+ parser.add_argument(
+ "--from-file",
+ metavar="FILE",
+ type=from_file,
+ default=[],
+ help="Read the list of images from FILE instead of passing them as "
+ "positional arguments. If this option is used, then the list of "
+ "positional arguments must be empty. The paths to the input images "
+ 'in FILE are separated by NUL bytes. If FILE is "-" then the paths '
+ "are expected on standard input. This option is useful if you want "
+ "to pass more images than the maximum command length of your shell "
+ "permits. This option can be used with commands like `find -print0`.",
+ )
outargs = parser.add_argument_group(
title="General output arguments",
@@ -3310,7 +3614,7 @@ RGB.""",
nargs="?",
const="/usr/share/color/icc/sRGB.icc",
default=None,
- help="Output a PDF/A-1b complient document. By default, this will "
+ help="Output a PDF/A-1b compliant document. By default, this will "
"embed /usr/share/color/icc/sRGB.icc as the color profile.",
)
@@ -3430,6 +3734,24 @@ values set via the --border option.
""",
)
sizeargs.add_argument(
+ "-r",
+ "--rotation",
+ "--orientation",
+ metavar="ROT",
+ type=parse_rotationarg,
+ default=Rotation.auto,
+ help="""
+Specifies how input images should be rotated. ROT can be one of auto, none,
+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. 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(
"--crop-border",
metavar="L[:L]",
type=parse_borderarg,
@@ -3598,36 +3920,48 @@ and left/right, respectively. It is not possible to specify asymmetric borders.
args.pagesize, args.imgsize, args.border, args.fit, args.auto_orient
)
- # if no positional arguments were supplied, read a single image from
- # standard input
- if len(args.images) == 0:
- logging.info("reading image from standard input")
+ if len(args.images) > 0 and len(args.from_file) > 0:
+ logger.error(
+ "%s: error: cannot use --from-file with positional arguments" % parser.prog
+ )
+ sys.exit(2)
+ elif len(args.images) == 0 and len(args.from_file) == 0:
+ # if no positional arguments were supplied, read a single image from
+ # standard input
+ logger.info("reading image from standard input")
try:
- args.images = [sys.stdin.buffer.read()]
+ images = [sys.stdin.buffer.read()]
except KeyboardInterrupt:
- exit(0)
+ sys.exit(0)
+ elif len(args.images) > 0 and len(args.from_file) == 0:
+ # On windows, each positional argument can expand into multiple paths
+ # because we do globbing ourselves. Here we flatten the list of lists
+ # again.
+ images = chain.from_iterable(args.images)
+ elif len(args.images) == 0 and len(args.from_file) > 0:
+ images = args.from_file
# with the number of pages being equal to the number of images, the
# value passed to --viewer-initial-page must be between 1 and that number
if args.viewer_initial_page is not None:
if args.viewer_initial_page < 1:
parser.print_usage(file=sys.stderr)
- logging.error(
+ logger.error(
"%s: error: argument --viewer-initial-page: must be "
"greater than zero" % parser.prog
)
- exit(2)
- if args.viewer_initial_page > len(args.images):
+ sys.exit(2)
+ if args.viewer_initial_page > len(images):
parser.print_usage(file=sys.stderr)
- logging.error(
+ logger.error(
"%s: error: argument --viewer-initial-page: must be "
"less than or equal to the total number of pages" % parser.prog
)
- exit(2)
+ sys.exit(2)
try:
convert(
- *args.images,
+ *images,
engine=args.engine,
title=args.title,
author=args.author,
@@ -3654,14 +3988,15 @@ and left/right, respectively. It is not possible to specify asymmetric borders.
trimborder=args.trim_border,
artborder=args.art_border,
pdfa=args.pdfa,
+ rotation=args.rotation,
)
except Exception as e:
- logging.error("error: " + str(e))
- if logging.getLogger().isEnabledFor(logging.DEBUG):
+ logger.error("error: " + str(e))
+ if logger.isEnabledFor(logging.DEBUG):
import traceback
traceback.print_exc(file=sys.stderr)
- exit(1)
+ sys.exit(1)
if __name__ == "__main__":