Merge branch 'main' into iptc

This commit is contained in:
Andrew Murray 2023-08-22 10:14:48 +10:00
commit dcfce9487e
15 changed files with 206 additions and 154 deletions

View File

@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 23.3.0 rev: 23.7.0
hooks: hooks:
- id: black - id: black
args: [--target-version=py38] args: [--target-version=py38]
@ -23,13 +23,13 @@ repos:
- id: yesqa - id: yesqa
- repo: https://github.com/Lucas-C/pre-commit-hooks - repo: https://github.com/Lucas-C/pre-commit-hooks
rev: v1.5.1 rev: v1.5.3
hooks: hooks:
- id: remove-tabs - id: remove-tabs
exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$) exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$)
- repo: https://github.com/PyCQA/flake8 - repo: https://github.com/PyCQA/flake8
rev: 6.0.0 rev: 6.1.0
hooks: hooks:
- id: flake8 - id: flake8
additional_dependencies: additional_dependencies:
@ -55,7 +55,7 @@ repos:
- id: sphinx-lint - id: sphinx-lint
- repo: https://github.com/tox-dev/pyproject-fmt - repo: https://github.com/tox-dev/pyproject-fmt
rev: 0.12.1 rev: 0.13.0
hooks: hooks:
- id: pyproject-fmt - id: pyproject-fmt

View File

@ -5,6 +5,21 @@ Changelog (Pillow)
10.1.0 (unreleased) 10.1.0 (unreleased)
------------------- -------------------
- Read WebP duration after opening #7311
[k128, radarhere]
- Allow "loop=None" when saving GIF images #7329
[radarhere]
- Fixed transparency when saving P mode images to PDF #7323
[radarhere]
- Added saving LA images as PDFs #7299
[radarhere]
- Set SMaskInData to 1 for PDFs with alpha #7316, #7317
[radarhere]
- Changed Image mode property to be read-only by default #7307 - Changed Image mode property to be read-only by default #7307
[radarhere] [radarhere]

View File

@ -875,6 +875,14 @@ def test_identical_frames_to_single_frame(duration, tmp_path):
assert reread.info["duration"] == 8500 assert reread.info["duration"] == 8500
def test_loop_none(tmp_path):
out = str(tmp_path / "temp.gif")
im = Image.new("L", (100, 100), "#000")
im.save(out, loop=None)
with Image.open(out) as reread:
assert "loop" not in reread.info
def test_number_of_loops(tmp_path): def test_number_of_loops(tmp_path):
number_of_loops = 2 number_of_loops = 2

View File

@ -48,6 +48,22 @@ def test_save_alpha(tmp_path, mode):
helper_save_as_pdf(tmp_path, mode) helper_save_as_pdf(tmp_path, mode)
def test_p_alpha(tmp_path):
# Arrange
outfile = str(tmp_path / "temp.pdf")
with Image.open("Tests/images/pil123p.png") as im:
assert im.mode == "P"
assert isinstance(im.info["transparency"], bytes)
# Act
im.save(outfile)
# Assert
with open(outfile, "rb") as fp:
contents = fp.read()
assert b"\n/SMask " in contents
def test_monochrome(tmp_path): def test_monochrome(tmp_path):
# Arrange # Arrange
mode = "1" mode = "1"

View File

@ -79,7 +79,7 @@ class TestFilePng:
def test_sanity(self, tmp_path): def test_sanity(self, tmp_path):
# internal version number # internal version number
assert re.search(r"\d+\.\d+\.\d+(\.\d+)?$", features.version_codec("zlib")) assert re.search(r"\d+(\.\d+){1,3}$", features.version_codec("zlib"))
test_file = str(tmp_path / "temp.png") test_file = str(tmp_path / "temp.png")

View File

@ -233,5 +233,4 @@ class TestFileWebp:
im.save(out_webp, save_all=True) im.save(out_webp, save_all=True)
with Image.open(out_webp) as reloaded: with Image.open(out_webp) as reloaded:
reloaded.load()
assert reloaded.info["duration"] == 1000 assert reloaded.info["duration"] == 1000

View File

@ -661,15 +661,15 @@ class TestImage:
blank_p.palette = None blank_p.palette = None
blank_pa.palette = None blank_pa.palette = None
def _make_new(base_image, im, palette_result=None): def _make_new(base_image, image, palette_result=None):
new_im = base_image._new(im) new_image = base_image._new(image.im)
assert new_im.mode == im.mode assert new_image.mode == image.mode
assert new_im.size == im.size assert new_image.size == image.size
assert new_im.info == base_image.info assert new_image.info == base_image.info
if palette_result is not None: if palette_result is not None:
assert new_im.palette.tobytes() == palette_result.tobytes() assert new_image.palette.tobytes() == palette_result.tobytes()
else: else:
assert new_im.palette is None assert new_image.palette is None
_make_new(im, im_p, ImagePalette.ImagePalette(list(range(256)) * 3)) _make_new(im, im_p, ImagePalette.ImagePalette(list(range(256)) * 3))
_make_new(im_p, im, None) _make_new(im_p, im, None)

View File

@ -253,7 +253,7 @@ their :py:attr:`~PIL.Image.Image.info` values.
**loop** **loop**
Integer number of times the GIF should loop. 0 means that it will loop Integer number of times the GIF should loop. 0 means that it will loop
forever. By default, the image will not loop. forever. If omitted or ``None``, the image will not loop.
**comment** **comment**
A comment about the image. A comment about the image.

View File

@ -83,10 +83,9 @@ Install Pillow with :command:`pip`::
.. tab:: Windows .. tab:: Windows
We provide Pillow binaries for Windows compiled for the matrix of We provide Pillow binaries for Windows compiled for the matrix of
supported Pythons in both 32 and 64-bit versions in the wheel format. supported Pythons in 64-bit versions in the wheel format. These binaries include
These binaries include support for all optional libraries except support for all optional libraries except libimagequant and libxcb. Raqm support
libimagequant and libxcb. Raqm support requires requires FriBiDi to be installed separately::
FriBiDi to be installed separately::
python3 -m pip install --upgrade pip python3 -m pip install --upgrade pip
python3 -m pip install --upgrade Pillow python3 -m pip install --upgrade Pillow

View File

@ -206,4 +206,4 @@ Support reading signed 8-bit TIFF images
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TIFF images with signed integer data, 8 bits per sample and a photometric TIFF images with signed integer data, 8 bits per sample and a photometric
interpretaton of BlackIsZero can now be read. interpretation of BlackIsZero can now be read.

View File

@ -912,7 +912,7 @@ def _get_global_header(im, info):
info info
and ( and (
"transparency" in info "transparency" in info
or "loop" in info or info.get("loop") is not None
or info.get("duration") or info.get("duration")
or info.get("comment") or info.get("comment")
) )
@ -937,7 +937,7 @@ def _get_global_header(im, info):
# Global Color Table # Global Color Table
_get_header_palette(palette_bytes), _get_header_palette(palette_bytes),
] ]
if "loop" in info: if info.get("loop") is not None:
header.append( header.append(
b"!" b"!"
+ o8(255) # extension intro + o8(255) # extension intro

View File

@ -247,7 +247,7 @@ def eval(expression, _dict={}, **kw):
def scan(code): def scan(code):
for const in code.co_consts: for const in code.co_consts:
if type(const) == type(compiled_code): if type(const) is type(compiled_code):
scan(const) scan(const)
for name in code.co_names: for name in code.co_names:

View File

@ -46,6 +46,132 @@ def _save_all(im, fp, filename):
# (Internal) Image save plugin for the PDF format. # (Internal) Image save plugin for the PDF format.
def _write_image(im, filename, existing_pdf, image_refs):
# FIXME: Should replace ASCIIHexDecode with RunLengthDecode
# (packbits) or LZWDecode (tiff/lzw compression). Note that
# PDF 1.2 also supports Flatedecode (zip compression).
params = None
decode = None
#
# Get image characteristics
width, height = im.size
dict_obj = {"BitsPerComponent": 8}
if im.mode == "1":
if features.check("libtiff"):
filter = "CCITTFaxDecode"
dict_obj["BitsPerComponent"] = 1
params = PdfParser.PdfArray(
[
PdfParser.PdfDict(
{
"K": -1,
"BlackIs1": True,
"Columns": width,
"Rows": height,
}
)
]
)
else:
filter = "DCTDecode"
dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceGray")
procset = "ImageB" # grayscale
elif im.mode == "L":
filter = "DCTDecode"
# params = f"<< /Predictor 15 /Columns {width-2} >>"
dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceGray")
procset = "ImageB" # grayscale
elif im.mode == "LA":
filter = "JPXDecode"
# params = f"<< /Predictor 15 /Columns {width-2} >>"
procset = "ImageB" # grayscale
dict_obj["SMaskInData"] = 1
elif im.mode == "P":
filter = "ASCIIHexDecode"
palette = im.getpalette()
dict_obj["ColorSpace"] = [
PdfParser.PdfName("Indexed"),
PdfParser.PdfName("DeviceRGB"),
255,
PdfParser.PdfBinary(palette),
]
procset = "ImageI" # indexed color
if "transparency" in im.info:
smask = im.convert("LA").getchannel("A")
smask.encoderinfo = {}
image_ref = _write_image(smask, filename, existing_pdf, image_refs)[0]
dict_obj["SMask"] = image_ref
elif im.mode == "RGB":
filter = "DCTDecode"
dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceRGB")
procset = "ImageC" # color images
elif im.mode == "RGBA":
filter = "JPXDecode"
procset = "ImageC" # color images
dict_obj["SMaskInData"] = 1
elif im.mode == "CMYK":
filter = "DCTDecode"
dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceCMYK")
procset = "ImageC" # color images
decode = [1, 0, 1, 0, 1, 0, 1, 0]
else:
msg = f"cannot save mode {im.mode}"
raise ValueError(msg)
#
# image
op = io.BytesIO()
if filter == "ASCIIHexDecode":
ImageFile._save(im, op, [("hex", (0, 0) + im.size, 0, im.mode)])
elif filter == "CCITTFaxDecode":
im.save(
op,
"TIFF",
compression="group4",
# use a single strip
strip_size=math.ceil(width / 8) * height,
)
elif filter == "DCTDecode":
Image.SAVE["JPEG"](im, op, filename)
elif filter == "JPXDecode":
del dict_obj["BitsPerComponent"]
Image.SAVE["JPEG2000"](im, op, filename)
else:
msg = f"unsupported PDF filter ({filter})"
raise ValueError(msg)
stream = op.getvalue()
if filter == "CCITTFaxDecode":
stream = stream[8:]
filter = PdfParser.PdfArray([PdfParser.PdfName(filter)])
else:
filter = PdfParser.PdfName(filter)
image_ref = image_refs.pop(0)
existing_pdf.write_obj(
image_ref,
stream=stream,
Type=PdfParser.PdfName("XObject"),
Subtype=PdfParser.PdfName("Image"),
Width=width, # * 72.0 / x_resolution,
Height=height, # * 72.0 / y_resolution,
Filter=filter,
Decode=decode,
DecodeParms=params,
**dict_obj,
)
return image_ref, procset
def _save(im, fp, filename, save_all=False): def _save(im, fp, filename, save_all=False):
is_appending = im.encoderinfo.get("append", False) is_appending = im.encoderinfo.get("append", False)
if is_appending: if is_appending:
@ -109,6 +235,9 @@ def _save(im, fp, filename, save_all=False):
number_of_pages += im_number_of_pages number_of_pages += im_number_of_pages
for i in range(im_number_of_pages): for i in range(im_number_of_pages):
image_refs.append(existing_pdf.next_object_id(0)) image_refs.append(existing_pdf.next_object_id(0))
if im.mode == "P" and "transparency" in im.info:
image_refs.append(existing_pdf.next_object_id(0))
page_refs.append(existing_pdf.next_object_id(0)) page_refs.append(existing_pdf.next_object_id(0))
contents_refs.append(existing_pdf.next_object_id(0)) contents_refs.append(existing_pdf.next_object_id(0))
existing_pdf.pages.append(page_refs[-1]) existing_pdf.pages.append(page_refs[-1])
@ -121,123 +250,7 @@ def _save(im, fp, filename, save_all=False):
for im_sequence in ims: for im_sequence in ims:
im_pages = ImageSequence.Iterator(im_sequence) if save_all else [im_sequence] im_pages = ImageSequence.Iterator(im_sequence) if save_all else [im_sequence]
for im in im_pages: for im in im_pages:
# FIXME: Should replace ASCIIHexDecode with RunLengthDecode image_ref, procset = _write_image(im, filename, existing_pdf, image_refs)
# (packbits) or LZWDecode (tiff/lzw compression). Note that
# PDF 1.2 also supports Flatedecode (zip compression).
params = None
decode = None
#
# Get image characteristics
width, height = im.size
dict_obj = {"BitsPerComponent": 8}
if im.mode == "1":
if features.check("libtiff"):
filter = "CCITTFaxDecode"
dict_obj["BitsPerComponent"] = 1
params = PdfParser.PdfArray(
[
PdfParser.PdfDict(
{
"K": -1,
"BlackIs1": True,
"Columns": width,
"Rows": height,
}
)
]
)
else:
filter = "DCTDecode"
dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceGray")
procset = "ImageB" # grayscale
elif im.mode == "L":
filter = "DCTDecode"
# params = f"<< /Predictor 15 /Columns {width-2} >>"
dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceGray")
procset = "ImageB" # grayscale
elif im.mode == "LA":
filter = "JPXDecode"
# params = f"<< /Predictor 15 /Columns {width-2} >>"
procset = "ImageB" # grayscale
dict_obj["SMaskInData"] = 1
elif im.mode == "P":
filter = "ASCIIHexDecode"
palette = im.getpalette()
dict_obj["ColorSpace"] = [
PdfParser.PdfName("Indexed"),
PdfParser.PdfName("DeviceRGB"),
255,
PdfParser.PdfBinary(palette),
]
procset = "ImageI" # indexed color
elif im.mode == "RGB":
filter = "DCTDecode"
dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceRGB")
procset = "ImageC" # color images
elif im.mode == "RGBA":
filter = "JPXDecode"
procset = "ImageC" # color images
dict_obj["SMaskInData"] = 1
elif im.mode == "CMYK":
filter = "DCTDecode"
dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceCMYK")
procset = "ImageC" # color images
decode = [1, 0, 1, 0, 1, 0, 1, 0]
else:
msg = f"cannot save mode {im.mode}"
raise ValueError(msg)
#
# image
op = io.BytesIO()
if filter == "ASCIIHexDecode":
ImageFile._save(im, op, [("hex", (0, 0) + im.size, 0, im.mode)])
elif filter == "CCITTFaxDecode":
im.save(
op,
"TIFF",
compression="group4",
# use a single strip
strip_size=math.ceil(im.width / 8) * im.height,
)
elif filter == "DCTDecode":
Image.SAVE["JPEG"](im, op, filename)
elif filter == "JPXDecode":
del dict_obj["BitsPerComponent"]
Image.SAVE["JPEG2000"](im, op, filename)
elif filter == "FlateDecode":
ImageFile._save(im, op, [("zip", (0, 0) + im.size, 0, im.mode)])
elif filter == "RunLengthDecode":
ImageFile._save(im, op, [("packbits", (0, 0) + im.size, 0, im.mode)])
else:
msg = f"unsupported PDF filter ({filter})"
raise ValueError(msg)
stream = op.getvalue()
if filter == "CCITTFaxDecode":
stream = stream[8:]
filter = PdfParser.PdfArray([PdfParser.PdfName(filter)])
else:
filter = PdfParser.PdfName(filter)
existing_pdf.write_obj(
image_refs[page_number],
stream=stream,
Type=PdfParser.PdfName("XObject"),
Subtype=PdfParser.PdfName("Image"),
Width=width, # * 72.0 / x_resolution,
Height=height, # * 72.0 / y_resolution,
Filter=filter,
Decode=decode,
DecodeParms=params,
**dict_obj,
)
# #
# page # page
@ -246,13 +259,13 @@ def _save(im, fp, filename, save_all=False):
page_refs[page_number], page_refs[page_number],
Resources=PdfParser.PdfDict( Resources=PdfParser.PdfDict(
ProcSet=[PdfParser.PdfName("PDF"), PdfParser.PdfName(procset)], ProcSet=[PdfParser.PdfName("PDF"), PdfParser.PdfName(procset)],
XObject=PdfParser.PdfDict(image=image_refs[page_number]), XObject=PdfParser.PdfDict(image=image_ref),
), ),
MediaBox=[ MediaBox=[
0, 0,
0, 0,
width * 72.0 / x_resolution, im.width * 72.0 / x_resolution,
height * 72.0 / y_resolution, im.height * 72.0 / y_resolution,
], ],
Contents=contents_refs[page_number], Contents=contents_refs[page_number],
) )
@ -261,8 +274,8 @@ def _save(im, fp, filename, save_all=False):
# page contents # page contents
page_contents = b"q %f 0 0 %f 0 0 cm /image Do Q\n" % ( page_contents = b"q %f 0 0 %f 0 0 cm /image Do Q\n" % (
width * 72.0 / x_resolution, im.width * 72.0 / x_resolution,
height * 72.0 / y_resolution, im.height * 72.0 / y_resolution,
) )
existing_pdf.write_obj(contents_refs[page_number], stream=page_contents) existing_pdf.write_obj(contents_refs[page_number], stream=page_contents)

View File

@ -74,6 +74,9 @@ class WebPImageFile(ImageFile.ImageFile):
self.info["background"] = (bg_r, bg_g, bg_b, bg_a) self.info["background"] = (bg_r, bg_g, bg_b, bg_a)
self.n_frames = frame_count self.n_frames = frame_count
self.is_animated = self.n_frames > 1 self.is_animated = self.n_frames > 1
ret = self._decoder.get_next()
if ret is not None:
self.info["duration"] = ret[1]
self._mode = "RGB" if mode == "RGBX" else mode self._mode = "RGB" if mode == "RGBX" else mode
self.rawmode = mode self.rawmode = mode
self.tile = [] self.tile = []
@ -90,7 +93,7 @@ class WebPImageFile(ImageFile.ImageFile):
self.info["xmp"] = xmp self.info["xmp"] = xmp
# Initialize seek state # Initialize seek state
self._reset(reset=False) self._reset()
def _getexif(self): def _getexif(self):
if "exif" not in self.info: if "exif" not in self.info:
@ -113,9 +116,8 @@ class WebPImageFile(ImageFile.ImageFile):
# Set logical frame to requested position # Set logical frame to requested position
self.__logical_frame = frame self.__logical_frame = frame
def _reset(self, reset=True): def _reset(self):
if reset: self._decoder.reset()
self._decoder.reset()
self.__physical_frame = 0 self.__physical_frame = 0
self.__loaded = -1 self.__loaded = -1
self.__timestamp = 0 self.__timestamp = 0

View File

@ -130,9 +130,9 @@ deps = {
"bins": ["cjpeg.exe", "djpeg.exe"], "bins": ["cjpeg.exe", "djpeg.exe"],
}, },
"zlib": { "zlib": {
"url": "https://zlib.net/zlib1213.zip", "url": "https://zlib.net/zlib13.zip",
"filename": "zlib1213.zip", "filename": "zlib13.zip",
"dir": "zlib-1.2.13", "dir": "zlib-1.3",
"license": "README", "license": "README",
"license_pattern": "Copyright notice:\n\n(.+)$", "license_pattern": "Copyright notice:\n\n(.+)$",
"build": [ "build": [
@ -335,9 +335,9 @@ deps = {
"libs": [r"imagequant.lib"], "libs": [r"imagequant.lib"],
}, },
"harfbuzz": { "harfbuzz": {
"url": "https://github.com/harfbuzz/harfbuzz/archive/8.0.0.zip", "url": "https://github.com/harfbuzz/harfbuzz/archive/8.1.1.zip",
"filename": "harfbuzz-8.0.0.zip", "filename": "harfbuzz-8.1.1.zip",
"dir": "harfbuzz-8.0.0", "dir": "harfbuzz-8.1.1",
"license": "COPYING", "license": "COPYING",
"build": [ "build": [
*cmds_cmake( *cmds_cmake(