Compare commits

...

9 commits

Author SHA1 Message Date
6fbb6cd60d Merge branch 'main' into png-alpha-to-smask 2021-07-11 18:56:16 +00:00
Tamás Zahola
8dac6242fc Test cases for transparency 2021-06-19 18:54:32 +02:00
Tamás Zahola
9cd5121477 Test support on macOS 2021-06-19 18:53:45 +02:00
Tamás Zahola
6516339ae3 Always initialize smask 2021-06-19 18:49:21 +02:00
Tamás Zahola
7e3b5ef0fb Formatting 2021-06-19 01:28:57 +02:00
Tamás Zahola
0ff4925909 Use PNG predictor for /SMask too 2021-06-19 01:20:09 +02:00
e2aa5e5aca Merge branch 'main' into png-alpha-to-smask 2021-06-18 23:09:43 +00:00
Tamás Zahola
8839129d6b Added transparency support for GIFs, palette-based PNGs and grayscale PNGs 2021-06-19 01:08:18 +02:00
Tamás Zahola
1821befff4 Convert 8-bit PNG alpha channels to /SMasks in PDF 2021-06-12 22:03:56 +02:00
2 changed files with 568 additions and 216 deletions

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,

File diff suppressed because it is too large Load diff