From 67e3ccffeb09903e22c29d737e74070415890ed8 Mon Sep 17 00:00:00 2001 From: Peter Rowlands Date: Sun, 1 Dec 2019 12:50:21 +0900 Subject: [PATCH 01/19] Add APNG support See #3483 Adds support for reading APNG files and seeking through frames, and adds basic support for writing APNG files. --- src/PIL/PngImagePlugin.py | 401 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 390 insertions(+), 11 deletions(-) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index ee1400d67..5bb0e2fff 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -31,13 +31,15 @@ # See the README file for information on usage and redistribution. # +import itertools import logging import re import struct +import warnings import zlib -from . import Image, ImageFile, ImagePalette -from ._binary import i8, i16be as i16, i32be as i32, o16be as o16, o32be as o32 +from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence +from ._binary import i8, i16be as i16, i32be as i32, o8, o16be as o16, o32be as o32 logger = logging.getLogger(__name__) @@ -81,6 +83,16 @@ MAX_TEXT_CHUNK = ImageFile.SAFEBLOCK MAX_TEXT_MEMORY = 64 * MAX_TEXT_CHUNK +# APNG frame disposal modes +APNG_DISPOSE_OP_NONE = 0 +APNG_DISPOSE_OP_BACKGROUND = 1 +APNG_DISPOSE_OP_PREVIOUS = 2 + +# APNG frame blend modes +APNG_BLEND_OP_SOURCE = 0 +APNG_BLEND_OP_OVER = 1 + + def _safe_zlib_decompress(s): dobj = zlib.decompressobj() plaintext = dobj.decompress(s, MAX_TEXT_CHUNK) @@ -298,6 +310,9 @@ class PngStream(ChunkStream): self.im_tile = None self.im_palette = None self.im_custom_mimetype = None + self.im_n_frames = None + self._seq_num = None + self.rewind_state = None self.text_memory = 0 @@ -309,6 +324,18 @@ class PngStream(ChunkStream): % self.text_memory ) + def save_rewind(self): + self.rewind_state = { + "info": self.im_info.copy(), + "tile": self.im_tile, + "seq_num": self._seq_num, + } + + def rewind(self): + self.im_info = self.rewind_state["info"] + self.im_tile = self.rewind_state["tile"] + self._seq_num = self.rewind_state["seq_num"] + def chunk_iCCP(self, pos, length): # ICC profile @@ -356,7 +383,15 @@ class PngStream(ChunkStream): def chunk_IDAT(self, pos, length): # image data - self.im_tile = [("zip", (0, 0) + self.im_size, pos, self.im_rawmode)] + if "bbox" in self.im_info: + tile = [( + "zip", self.im_info["bbox"], pos, self.im_rawmode + )] + else: + if self.im_n_frames is not None: + self.im_info["default_image"] = True + tile = [("zip", (0, 0) + self.im_size, pos, self.im_rawmode)] + self.im_tile = tile self.im_idat = length raise EOFError @@ -537,9 +572,48 @@ class PngStream(ChunkStream): # APNG chunks def chunk_acTL(self, pos, length): s = ImageFile._safe_read(self.fp, length) + if self.im_n_frames is not None: + self.im_n_frames = None + warnings.warn("Invalid APNG, will use default PNG image if possible") + return s + n_frames = i32(s) + if n_frames == 0 or n_frames > 0x80000000: + warnings.warn("Invalid APNG, will use default PNG image if possible") + return s + self.im_n_frames = n_frames + self.im_info["loop"] = i32(s[4:]) self.im_custom_mimetype = "image/apng" return s + def chunk_fcTL(self, pos, length): + s = ImageFile._safe_read(self.fp, length) + seq = i32(s) + if (self._seq_num is None and seq != 0) or \ + (self._seq_num is not None and self._seq_num != seq - 1): + raise SyntaxError("APNG contains frame sequence errors") + self._seq_num = seq + width, height = i32(s[4:]), i32(s[8:]) + px, py = i32(s[12:]), i32(s[16:]) + im_w, im_h = self.im_size + if px + width > im_w or py + height > im_h: + raise SyntaxError("APNG contains invalid frames") + self.im_info["bbox"] = (px, py, px + width, py + height) + delay_num, delay_den = i16(s[20:]), i16(s[22:]) + if delay_den == 0: + delay_den = 100 + self.im_info["duration"] = float(delay_num) / float(delay_den) * 1000 + self.im_info["disposal"] = i8(s[24]) + self.im_info["blend"] = i8(s[25]) + return s + + def chunk_fdAT(self, pos, length): + s = ImageFile._safe_read(self.fp, 4) + seq = i32(s) + if self._seq_num != seq - 1: + raise SyntaxError("APNG contains frame sequence errors") + self._seq_num = seq + return self.chunk_IDAT(pos + 4, length - 4) + # -------------------------------------------------------------------- # PNG reader @@ -562,9 +636,11 @@ class PngImageFile(ImageFile.ImageFile): if self.fp.read(8) != _MAGIC: raise SyntaxError("not a PNG file") + self.__fp = self.fp # - # Parse headers up to the first IDAT chunk + # Parse headers up to the first IDAT chunk (or last fdAT chunk for + # APNG) self.png = PngStream(self.fp) @@ -598,12 +674,27 @@ class PngImageFile(ImageFile.ImageFile): self._text = None self.tile = self.png.im_tile self.custom_mimetype = self.png.im_custom_mimetype + self._n_frames = self.png.im_n_frames + self.default_image = self.info.get("default_image", False) if self.png.im_palette: rawmode, data = self.png.im_palette self.palette = ImagePalette.raw(rawmode, data) - self.__prepare_idat = length # used by load_prepare() + if cid == b"fdAT": + self.__prepare_idat = length - 4 + else: + self.__prepare_idat = length # used by load_prepare() + + if self._n_frames is not None: + self._close_exclusive_fp_after_loading = False + self.png.save_rewind() + self.__rewind_idat = self.__prepare_idat + self.__rewind = self.__fp.tell() + if self.default_image: + # IDAT chunk contains default image and not first animation frame + self._n_frames += 1 + self._seek(0) @property def text(self): @@ -611,9 +702,25 @@ class PngImageFile(ImageFile.ImageFile): if self._text is None: # iTxt, tEXt and zTXt chunks may appear at the end of the file # So load the file to ensure that they are read + if self.is_animated: + frame = self.__frame + # for APNG, seek to the final frame before loading + self.seek(self.n_frames - 1) self.load() + if self.is_animated: + self.seek(frame) return self._text + @property + def n_frames(self): + if self._n_frames is None: + return 1 + return self._n_frames + + @property + def is_animated(self): + return self._n_frames is not None + def verify(self): """Verify PNG file""" @@ -630,6 +737,95 @@ class PngImageFile(ImageFile.ImageFile): self.fp.close() self.fp = None + def seek(self, frame): + if not self._seek_check(frame): + return + if frame < self.__frame: + self._seek(0, True) + + last_frame = self.__frame + for f in range(self.__frame + 1, frame + 1): + try: + self._seek(f) + except EOFError: + self.seek(last_frame) + raise EOFError("no more images in APNG file") + + def _seek(self, frame, rewind=False): + if frame == 0: + if rewind: + self.__fp.seek(self.__rewind) + self.png.rewind() + self.__prepare_idat = self.__rewind_idat + self.im = None + self.info = self.png.im_info + self.tile = self.png.im_tile + self.fp = self.__fp + self._prev_im = None + self.dispose = None + self.default_image = self.info.get("default_image", False) + self.dispose_op = self.info.get("disposal") + self.blend_op = self.info.get("blend") + self.dispose_extent = self.info.get("bbox") + self.__frame = 0 + return + else: + if frame != self.__frame + 1: + raise ValueError("cannot seek to frame %d" % frame) + + # ensure previous frame was loaded + self.load() + + self.fp = self.__fp + + # advance to the next frame + if self.__prepare_idat: + ImageFile._safe_read(self.fp, self.__prepare_idat) + self.__prepare_idat = 0 + frame_start = False + while True: + self.fp.read(4) # CRC + + try: + cid, pos, length = self.png.read() + except (struct.error, SyntaxError): + break + + if cid == b"IEND": + raise EOFError("No more images in APNG file") + if cid == b"fcTL": + if frame_start: + # there must be at least one fdAT chunk between fcTL chunks + raise SyntaxError("APNG missing frame data") + frame_start = True + + try: + self.png.call(cid, pos, length) + except UnicodeDecodeError: + break + except EOFError: + if cid == b"fdAT": + length -= 4 + if frame_start: + self.__prepare_idat = length + break + ImageFile._safe_read(self.fp, length) + except AttributeError: + logger.debug("%r %s %s (unknown)", cid, pos, length) + ImageFile._safe_read(self.fp, length) + + self.__frame = frame + self.tile = self.png.im_tile + self.dispose_op = self.info.get("disposal") + self.blend_op = self.info.get("blend") + self.dispose_extent = self.info.get("bbox") + + if not self.tile: + raise EOFError + + def tell(self): + return self.__frame + def load_prepare(self): """internal: prepare to read PNG file""" @@ -649,11 +845,18 @@ class PngImageFile(ImageFile.ImageFile): cid, pos, length = self.png.read() - if cid not in [b"IDAT", b"DDAT"]: + if cid not in [b"IDAT", b"DDAT", b"fdAT"]: self.png.push(cid, pos, length) return b"" - self.__idat = length # empty chunks are allowed + if cid == b"fdAT": + try: + self.png.call(cid, pos, length) + except EOFError: + pass + self.__idat = length - 4 # sequence_num has already been read + else: + self.__idat = length # empty chunks are allowed # read more data from this chunk if read_bytes <= 0: @@ -677,19 +880,50 @@ class PngImageFile(ImageFile.ImageFile): if cid == b"IEND": break + elif cid == b"fcTL" and self.is_animated: + # start of the next frame, stop reading + self.__prepare_idat = 0 + self.png.push(cid, pos, length) + break try: self.png.call(cid, pos, length) except UnicodeDecodeError: break except EOFError: + if cid == b"fdAT": + length -= 4 ImageFile._safe_read(self.fp, length) except AttributeError: logger.debug("%r %s %s (unknown)", cid, pos, length) ImageFile._safe_read(self.fp, length) self._text = self.png.im_text - self.png.close() - self.png = None + if not self.is_animated: + self.png.close() + self.png = None + else: + # setup frame disposal (actual disposal done when needed in _seek()) + if self._prev_im is None and self.dispose_op == APNG_DISPOSE_OP_PREVIOUS: + self.dispose_op = APNG_DISPOSE_OP_BACKGROUND + + if self.dispose_op == APNG_DISPOSE_OP_PREVIOUS: + dispose = self._prev_im.copy() + dispose = self._crop(dispose, self.dispose_extent) + elif self.dispose_op == APNG_DISPOSE_OP_BACKGROUND: + dispose = Image.core.fill("RGBA", self.size, (0, 0, 0, 0)) + dispose = self._crop(dispose, self.dispose_extent) + else: + dispose = None + + if self._prev_im and self.blend_op == APNG_BLEND_OP_OVER: + updated = self._crop(self.im, self.dispose_extent) + self._prev_im.paste( + updated, self.dispose_extent, updated.convert("RGBA")) + self.im = self._prev_im + self._prev_im = self.im.copy() + + if dispose: + self._prev_im.paste(dispose, self.dispose_extent) def _getexif(self): if "exif" not in self.info: @@ -703,6 +937,15 @@ class PngImageFile(ImageFile.ImageFile): self.load() return ImageFile.ImageFile.getexif(self) + def _close__fp(self): + try: + if self.__fp != self.fp: + self.__fp.close() + except AttributeError: + pass + finally: + self.__fp = None + # -------------------------------------------------------------------- # PNG writer @@ -748,7 +991,139 @@ class _idat: self.chunk(self.fp, b"IDAT", data) -def _save(im, fp, filename, chunk=putchunk): +class _fdat: + # wrap encoder output in fdAT chunks + + def __init__(self, fp, chunk, seq_num): + self.fp = fp + self.chunk = chunk + self.seq_num = seq_num + + def write(self, data): + self.chunk(self.fp, b"fdAT", o32(self.seq_num), data) + + +def _write_multiple_frames(im, fp, chunk, rawmode): + default_image = im.encoderinfo.get("default_image", im.info.get("default_image")) + duration = im.encoderinfo.get("duration", im.info.get("duration", 0)) + loop = im.encoderinfo.get("loop", im.info.get("loop", 0)) + disposal = im.encoderinfo.get("disposal", im.info.get("disposal")) + blend = im.encoderinfo.get("blend", im.info.get("blend")) + + if default_image: + chain = itertools.chain(im.encoderinfo.get("append_images", [])) + else: + chain = itertools.chain([im], im.encoderinfo.get("append_images", [])) + + im_frames = [] + frame_count = 0 + for im_seq in chain: + for im_frame in ImageSequence.Iterator(im_seq): + im_frame = im_frame.copy() + if im_frame.mode != im.mode: + if im.mode == "P": + im_frame = im_frame.convert(im.mode, palette=im.palette) + else: + im_frame = im_frame.convert(im.mode) + encoderinfo = im.encoderinfo.copy() + if isinstance(duration, (list, tuple)): + encoderinfo["duration"] = duration[frame_count] + if isinstance(disposal, (list, tuple)): + encoderinfo["disposal"] = disposal[frame_count] + if isinstance(blend, (list, tuple)): + encoderinfo["blend"] = blend[frame_count] + frame_count += 1 + + if im_frames: + previous = im_frames[-1] + prev_disposal = previous["encoderinfo"].get("disposal") + prev_blend = previous["encoderinfo"].get("blend") + if prev_disposal == APNG_DISPOSE_OP_PREVIOUS and len(im_frames) < 2: + prev_disposal == APNG_DISPOSE_OP_BACKGROUND + + if prev_disposal == APNG_DISPOSE_OP_BACKGROUND: + base_im = previous["im"] + dispose = Image.core.fill("RGBA", im.size, (0, 0, 0, 0)) + bbox = previous["bbox"] + if bbox: + dispose = dispose.crop(bbox) + else: + bbox = (0, 0) + im.size + base_im.paste(dispose, bbox) + elif prev_disposal == APNG_DISPOSE_OP_PREVIOUS: + base_im = im_frames[-2]["im"] + else: + base_im = previous["im"] + delta = ImageChops.subtract_modulo(im_frame, base_im) + bbox = delta.getbbox() + if (not bbox and prev_disposal == encoderinfo.get("disposal") + and prev_blend == encoderinfo.get("blend")): + duration = encoderinfo.get("duration", 0) + if duration: + if "duration" in previous["encoderinfo"]: + previous["encoderinfo"]["duration"] += duration + else: + previous["encoderinfo"]["duration"] = duration + continue + else: + bbox = None + im_frames.append({"im": im_frame, "bbox": bbox, "encoderinfo": encoderinfo}) + + # animation control + chunk( + fp, + b"acTL", + o32(len(im_frames)), # 0: num_frames + o32(loop), # 4: num_plays + ) + + # default image IDAT (if it exists) + if default_image: + ImageFile._save(im, _idat(fp, chunk), [("zip", (0, 0) + im.size, 0, rawmode)]) + + seq_num = 0 + for frame, frame_data in enumerate(im_frames): + im_frame = frame_data["im"] + if not frame_data["bbox"]: + bbox = (0, 0) + im_frame.size + else: + bbox = frame_data["bbox"] + im_frame = im_frame.crop(bbox) + size = im_frame.size + duration = int(round(frame_data["encoderinfo"].get("duration", 0))) + disposal = frame_data["encoderinfo"].get("disposal", APNG_DISPOSE_OP_NONE) + blend = frame_data["encoderinfo"].get("blend", APNG_BLEND_OP_SOURCE) + # frame control + chunk( + fp, + b"fcTL", + o32(seq_num), # sequence_number + o32(size[0]), # width + o32(size[1]), # height + o32(bbox[0]), # x_offset + o32(bbox[1]), # y_offset + o16(duration), # delay_numerator + o16(1000), # delay_denominator + o8(disposal), # dispose_op + o8(blend), # blend_op + ) + seq_num += 1 + # frame data + if frame == 0 and not default_image: + # first frame must be in IDAT chunks for backwards compatibility + ImageFile._save(im_frame, _idat(fp, chunk), + [("zip", (0, 0) + im_frame.size, 0, rawmode)]) + else: + ImageFile._save(im_frame, _fdat(fp, chunk, seq_num), + [("zip", (0, 0) + im_frame.size, 0, rawmode)]) + seq_num += 1 + + +def _save_all(im, fp, filename): + _save(im, fp, filename, save_all=True) + + +def _save(im, fp, filename, chunk=putchunk, save_all=False): # save an image to disk (called by the save method) mode = im.mode @@ -897,7 +1272,10 @@ def _save(im, fp, filename, chunk=putchunk): exif = exif[6:] chunk(fp, b"eXIf", exif) - ImageFile._save(im, _idat(fp, chunk), [("zip", (0, 0) + im.size, 0, rawmode)]) + if save_all: + _write_multiple_frames(im, fp, chunk, rawmode) + else: + ImageFile._save(im, _idat(fp, chunk), [("zip", (0, 0) + im.size, 0, rawmode)]) chunk(fp, b"IEND", b"") @@ -942,6 +1320,7 @@ def getchunks(im, **params): Image.register_open(PngImageFile.format, PngImageFile, _accept) Image.register_save(PngImageFile.format, _save) +Image.register_save_all(PngImageFile.format, _save_all) Image.register_extensions(PngImageFile.format, [".png", ".apng"]) From 7c0df1034f16b59ff0565871d7140c959761c11c Mon Sep 17 00:00:00 2001 From: Peter Rowlands Date: Sun, 1 Dec 2019 12:50:37 +0900 Subject: [PATCH 02/19] Add APNG test cases Includes tests for reading and writing APNG files. The tests for reading files are based on the APNG browser compatibility tests from https://philip.html5.org/tests/apng/tests.html (which is linked in the Tests section of https://wiki.mozilla.org/APNG_Specification) --- Tests/images/apng/blend_op_over.png | Bin 0 -> 579 bytes .../apng/blend_op_over_near_transparent.png | Bin 0 -> 28791 bytes .../apng/blend_op_source_near_transparent.png | Bin 0 -> 614 bytes Tests/images/apng/blend_op_source_solid.png | Bin 0 -> 671 bytes .../apng/blend_op_source_transparent.png | Bin 0 -> 534 bytes Tests/images/apng/chunk_actl_after_idat.png | Bin 0 -> 470 bytes Tests/images/apng/chunk_multi_actl.png | Bin 0 -> 490 bytes Tests/images/apng/chunk_no_actl.png | Bin 0 -> 450 bytes Tests/images/apng/chunk_no_fctl.png | Bin 0 -> 432 bytes Tests/images/apng/chunk_no_fdat.png | Bin 0 -> 508 bytes Tests/images/apng/chunk_repeat_fctl.png | Bin 0 -> 508 bytes Tests/images/apng/delay.png | Bin 0 -> 1068 bytes Tests/images/apng/delay_round.png | Bin 0 -> 646 bytes Tests/images/apng/delay_short_max.png | Bin 0 -> 646 bytes Tests/images/apng/delay_zero_denom.png | Bin 0 -> 646 bytes Tests/images/apng/delay_zero_numer.png | Bin 0 -> 1067 bytes Tests/images/apng/dispose_op_background.png | Bin 0 -> 572 bytes .../dispose_op_background_before_region.png | Bin 0 -> 327 bytes .../apng/dispose_op_background_final.png | Bin 0 -> 508 bytes .../apng/dispose_op_background_region.png | Bin 0 -> 492 bytes Tests/images/apng/dispose_op_none.png | Bin 0 -> 617 bytes Tests/images/apng/dispose_op_none_region.png | Bin 0 -> 613 bytes Tests/images/apng/dispose_op_previous.png | Bin 0 -> 780 bytes .../images/apng/dispose_op_previous_final.png | Bin 0 -> 508 bytes .../images/apng/dispose_op_previous_first.png | Bin 0 -> 371 bytes .../apng/dispose_op_previous_region.png | Bin 0 -> 677 bytes Tests/images/apng/fctl_actl.png | Bin 0 -> 508 bytes Tests/images/apng/mode_16bit.png | Bin 0 -> 915 bytes Tests/images/apng/mode_greyscale.png | Bin 0 -> 331 bytes Tests/images/apng/mode_greyscale_alpha.png | Bin 0 -> 668 bytes Tests/images/apng/mode_palette.png | Bin 0 -> 262 bytes Tests/images/apng/mode_palette_1bit_alpha.png | Bin 0 -> 276 bytes Tests/images/apng/mode_palette_alpha.png | Bin 0 -> 308 bytes Tests/images/apng/num_plays.png | Bin 0 -> 646 bytes Tests/images/apng/num_plays_1.png | Bin 0 -> 646 bytes Tests/images/apng/sequence_fdat_fctl.png | Bin 0 -> 671 bytes Tests/images/apng/sequence_gap.png | Bin 0 -> 671 bytes Tests/images/apng/sequence_reorder.png | Bin 0 -> 687 bytes Tests/images/apng/sequence_reorder_chunk.png | Bin 0 -> 687 bytes Tests/images/apng/sequence_repeat.png | Bin 0 -> 671 bytes Tests/images/apng/sequence_repeat_chunk.png | Bin 0 -> 834 bytes Tests/images/apng/sequence_start.png | Bin 0 -> 671 bytes Tests/images/apng/single_frame.png | Bin 0 -> 307 bytes Tests/images/apng/single_frame_default.png | Bin 0 -> 470 bytes Tests/images/apng/split_fdat.png | Bin 0 -> 486 bytes Tests/images/apng/split_fdat_zero_chunk.png | Bin 0 -> 502 bytes Tests/images/apng/syntax_num_frames_high.png | Bin 0 -> 671 bytes .../images/apng/syntax_num_frames_invalid.png | Bin 0 -> 470 bytes Tests/images/apng/syntax_num_frames_low.png | Bin 0 -> 671 bytes Tests/images/apng/syntax_num_frames_zero.png | Bin 0 -> 65 bytes .../apng/syntax_num_frames_zero_default.png | Bin 0 -> 269 bytes Tests/test_file_png.py | 518 +++++++++++++++++- 52 files changed, 512 insertions(+), 6 deletions(-) create mode 100644 Tests/images/apng/blend_op_over.png create mode 100644 Tests/images/apng/blend_op_over_near_transparent.png create mode 100644 Tests/images/apng/blend_op_source_near_transparent.png create mode 100644 Tests/images/apng/blend_op_source_solid.png create mode 100644 Tests/images/apng/blend_op_source_transparent.png create mode 100644 Tests/images/apng/chunk_actl_after_idat.png create mode 100644 Tests/images/apng/chunk_multi_actl.png create mode 100644 Tests/images/apng/chunk_no_actl.png create mode 100644 Tests/images/apng/chunk_no_fctl.png create mode 100644 Tests/images/apng/chunk_no_fdat.png create mode 100644 Tests/images/apng/chunk_repeat_fctl.png create mode 100644 Tests/images/apng/delay.png create mode 100644 Tests/images/apng/delay_round.png create mode 100644 Tests/images/apng/delay_short_max.png create mode 100644 Tests/images/apng/delay_zero_denom.png create mode 100644 Tests/images/apng/delay_zero_numer.png create mode 100644 Tests/images/apng/dispose_op_background.png create mode 100644 Tests/images/apng/dispose_op_background_before_region.png create mode 100644 Tests/images/apng/dispose_op_background_final.png create mode 100644 Tests/images/apng/dispose_op_background_region.png create mode 100644 Tests/images/apng/dispose_op_none.png create mode 100644 Tests/images/apng/dispose_op_none_region.png create mode 100644 Tests/images/apng/dispose_op_previous.png create mode 100644 Tests/images/apng/dispose_op_previous_final.png create mode 100644 Tests/images/apng/dispose_op_previous_first.png create mode 100644 Tests/images/apng/dispose_op_previous_region.png create mode 100644 Tests/images/apng/fctl_actl.png create mode 100644 Tests/images/apng/mode_16bit.png create mode 100644 Tests/images/apng/mode_greyscale.png create mode 100644 Tests/images/apng/mode_greyscale_alpha.png create mode 100644 Tests/images/apng/mode_palette.png create mode 100644 Tests/images/apng/mode_palette_1bit_alpha.png create mode 100644 Tests/images/apng/mode_palette_alpha.png create mode 100644 Tests/images/apng/num_plays.png create mode 100644 Tests/images/apng/num_plays_1.png create mode 100644 Tests/images/apng/sequence_fdat_fctl.png create mode 100644 Tests/images/apng/sequence_gap.png create mode 100644 Tests/images/apng/sequence_reorder.png create mode 100644 Tests/images/apng/sequence_reorder_chunk.png create mode 100644 Tests/images/apng/sequence_repeat.png create mode 100644 Tests/images/apng/sequence_repeat_chunk.png create mode 100644 Tests/images/apng/sequence_start.png create mode 100644 Tests/images/apng/single_frame.png create mode 100644 Tests/images/apng/single_frame_default.png create mode 100644 Tests/images/apng/split_fdat.png create mode 100644 Tests/images/apng/split_fdat_zero_chunk.png create mode 100644 Tests/images/apng/syntax_num_frames_high.png create mode 100644 Tests/images/apng/syntax_num_frames_invalid.png create mode 100644 Tests/images/apng/syntax_num_frames_low.png create mode 100644 Tests/images/apng/syntax_num_frames_zero.png create mode 100644 Tests/images/apng/syntax_num_frames_zero_default.png diff --git a/Tests/images/apng/blend_op_over.png b/Tests/images/apng/blend_op_over.png new file mode 100644 index 0000000000000000000000000000000000000000..3fe0f4ca789063c338ed8d3de74cddd44de7ffc3 GIT binary patch literal 579 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEEw35Xe6x)whJ(vv-1 z978JRyuGxLk-WO`Nq62i1_lPHG>DUdOo)F#3^3qgNMT@96ViVG{j6jc^GZX~+ltJ@~ literal 0 HcmV?d00001 diff --git a/Tests/images/apng/blend_op_over_near_transparent.png b/Tests/images/apng/blend_op_over_near_transparent.png new file mode 100644 index 0000000000000000000000000000000000000000..3ee5fe3bf27103ec8d8101e44f83677b9999fa65 GIT binary patch literal 28791 zcmdU$X`IjH8pi)amaJo}+2tTf$QI3*o0O$lQCa9I2*DMrq0)x2rDVRNr!EDQ_P4o_WsGWpWA zQpKC6SI+F;;@GG>384ZhVYNfKw}(oFa{k{RoGTFCGv)D|=?_#q7#fs&d|jCv_CNkr zRPoH6>tCJp^36~vRJwh1+$iX^AAPrTzJNsy< z0)IZ6^6r7Yk>Qi_Bwd$B%8%cbbKS6tB?_mX8{P45_AgT#*S)oKefY$BTj5aZ9CUFe zP?(|a8+$u}zS>de95(lYqh1(&cPQ9}E+1*I=6cHRoTUr*id^t_C<%FOr;9lv{o;fhy?F3tq9*IQQRJG$Z(_6F}z*UfS|i zyXcBn#2dWHn;Pb4xZ*|7#hE|>UZ3b>b7RFT>J8q=-y+^;xZ;`jUE)k2d%diJ&(RgH zxHotkvcW7jjnhly}@gq|D`(&SG-bmaVC(xUih{1bj5qX8@zkX zSAWHD#WQcc#F;?$dgCI^E283+@dmF@X#DdGSG=-xaVC(xUQFCTy5g1d253=ba5t-yAWn4D_(87I1?zq8ymBr3tjQ*c!M{mV5epbSG>pQ;!L0buh)sepU@Srt~YqS zTP0^OT=DAB#hF0%df{OQ>55n18@z=#N>*mL;x(X)GlA^&#${BaD_%oy@Y<|>Yy-m; z&%FN>X9C&lWt|^RSG-2v;QexMdkVu9uQ6Sm31qL=we}3U;yvLFUg^&-n5SckXP#z? zGlA^&mN&adSG*{1@G^2w`GMhz*Mu(41hUtQek+Wwc+uYARc^Ly5W^Ku=xQno@ZKy^ zcQak_ntFrRtV(7%hAUn(x;PUkz@GY`T-KcrCoan>c6fWrizWOS(7{$X;*Rzz^t(*UB5b zI>(xwVYuS8ri(Ly?Dd)~evPhp&v=8keP+*ihAUnSU7QJIuXp2c0lMPFdV`nu(ZXLc zT=C-Q;!GfWy`&r$=!zHb4c_fO51Vgc70-P67iR(mcw@S5G~X2}-m~7|E!lp>JbzZa zwsdhOP=GfetA}|!uXz9T2CrS}-uoG@c#e3cxyrYi|?aXk+>qHl40@>@u zR59NPDqd%A@T$Gmc0a=v?*+Oz6Ubg~TnC{mUKelhuI+DZ?xJ`v(hZ2QmAzhg>T*6* zyqCPeD?hEm3Wh6Q0$rR56yS}%5nYL{c!}QNJvV=&c`;DDB)T{gD8TENba^jb@w$40 zxAesHbcQQlH@Y|z$X;*R{PlFj>+TKSfS$MNGhFe^i?=uv$X>5Wtoiz?cs;$rn_v0V z5{4_Dc?}e20@>@uj>tn-yx!j6Rq0pj9fm7jAG$ab$X@T{nyYlh>+22Pk%#i-X1L<@ zql+_v?DdMAdYi6z{k_2}J1Z%h;fnV%U7QJIua{P6Bwg_ac!PK4aEqP{SG<9AaVAiJ zmojd1f4brg@&<3+@RUOgSG-r~;!L0buYY)-RJ!5~_6AR)mk7fZ?^U|_*x1Tm??kCT z`B3qOc!M{7%za1uNSkwBwg`_dV}}+?u+K#q~Z;ui!*`j^`h@QLsz`l zy}{ctp>zbp74HqYI1|WTZ+Z1abj2I)4PM;?(PJ2{c;54bW8@$i+=Dp2u#hXkQX9C<+->JfGXY;Z}0|hOvuY{#hXeOX9C&lMONQP zSG;N7;ElQd)EtH@-gLS+6DYu&95vrOkx{(2yumx&c}y*aE8YycI1?zqOI#Uiek`qc zGrhs9Rbi?5t80p9o~Vd3f$a4XqZ`l_Zf$a6NFPq<=P`ugR z;Fb9C=hF;VJoD#JoC##FmwM_=y5ha-4c_YIrLHhs@!q40GlA^&A_|!&po;gtH+U~x zi87D)6mKqFoC##FmpLMwu6Xmj!F#(+d-GDRc=PGvOdxx`xHaa*U-3Th2CsXufn^!4 zcnj#_OrQX7+KOW4w>}kbp*MInbDcQHaK&3h7iR(mcuA3a%oiQSTkH+q_f7AaN4JXi zAzhpaWUqH~qxqkpcuTy&EB2q+=1acfeMA>$0@>>&^vF+FyrtgYUHEK$QHCqtGP*bu z$X>5#hYobbTkZ|si`i0<;fl9{F3tq9*BhJqC0+4WdV{xSL-(}|SG-kpaVC(xUi{5; zy5g<&2CrwKf=wB&cpuZnnLze>$E(z&E8ZG!@ZM?t&|!uvp1Gtr6DYu&nmHqiu6XOb z!P|HCS`UUR-g>$?6DYvz8rSp)UGX+}gZIRT`))E^@jju8GlA^&QtO&eTgBVx4c_k$ zEHb~+p?I6<;!GfWy@<0d=!*BLH+VU^q|Iiy;(bOJX9C&lB_1wASG>*M;BAf=V%~@; z-WIwz6UbgKJI8Ny#rxbFyq3?ksmE}|`+_dc1hUtQ8fczhE8bRb@KSzxd>6wN&%8Gg zX9C<z4W)SG;ZB;0;<+u06vQZ#!L_2^8Rs9~*X@u6Y0Q1}}VX|3(Z~yd89LCQyLa zy=X>ly5jBh2JeT-EzPeHE1r30Db574*E_zTHC^#`d4pHIPQ{K4SG?VHaVC(xUi{$2 zbj91_4PNGuTzeU=c&3Xpf$a4no86-;-dEn>b#0t|m*I-Hk1oyxve#SjRtdV|?e_+6 z#;AEcWvd%cA7{pgB!z#F_yJGYo8(~5VHF3tq9*SlGJEM4)w@dj^Y+6Va< zu6T#&;!L0bZ$ft3EV|+y_69GzX2-1zSG)|mI1?zq>z3GV1YPmI^#(6@?~&#)gW{QQ zTE&?__IfL;=B6v&ci!L~JW~97hAZAtx;PWaUN0)^8eQ>@d4m@{y><-46)%%6&IGd8 zi%Z`?SG?oi;H93~k<4(#J3$v`0@>?jmh41Vyp!JG4SD}3^J%Ae<`Y$%31qJq(Y+#F z@lJVzx3%iD@(fqJ({ynrkiA~&rmyIVcg7pMn4}d`8LoI|>EcYF0B>SK@UU0$e((nG zzQLh^Kq%fhx;PUk!0UPQwE5wR;+^*fFRNjdvkX_ff78X8K=yi(tIP|&;$83tFKS|J z5r!+?k92V+kiFiDWb;d{iuaQ@cwZmvHJagycabj61hUsV9%ufhk>XwQ25;!-_sq`~ z6z^xcI1|WTFMgi+0;G7qc!M`?*R}^4u6UQ};!GfWy|LGB&=v2BH+XTwj(y8;#k)!u zX9C&l6^$%MSG;T9;BAZi?Ma3!-mi3VCQyJkDPmGVy5e2;2Jiek_06Z9;+dZ$h%%~1hpRRaW-r$`WFw8t=P(1UaOmQZVy)N|_6Dy;jV`$uu6VcT;!GfWy_I`w&=v2tH+a*#t!cn;#k)fnX9C&lMU~k>SG>F4 z;3Za^_z}Yu?>}^LCXl^ec5m}@TgCgs8@%Q7&Y54QQoKLu;!GfWy~Hig(iQKXH+UV7 S@7ZH0T0Irl{NrW`WBvWO`Nq62i1_lPHG>DUdOo)F#3^3qgNMT@96ViVG{jDyf4D~bf`fKG#X1QhlSK8Ouig-|u<6 zCn&5~VPELEyyA@w12ft2fQZO*%m_Bt7|xM6Q=<5=?cYsikSjf1{an^LB{Ts5MtzdM literal 0 HcmV?d00001 diff --git a/Tests/images/apng/blend_op_source_solid.png b/Tests/images/apng/blend_op_source_solid.png new file mode 100644 index 0000000000000000000000000000000000000000..d90c54967b6899c395d1d749401ba909e58c6d34 GIT binary patch literal 671 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEEw35Xe6x)whJ(vv-1 z978JRyuGxLk-WO`Nq62i1_lPHG>DUdOo)F#3^3qgNMT@96ViVG{jDyf4D~bf`fKG#X1QhlSK(PJJ#x-a5Ex|)iod5{ zli>Lm*f8^Kwv9kHd(uY-9l4I9%o{5d#Q2XqYK$0Uf-P3YKMrsDXV%jN@}8%wpUXO@ GgeCx%x4t(3 literal 0 HcmV?d00001 diff --git a/Tests/images/apng/blend_op_source_transparent.png b/Tests/images/apng/blend_op_source_transparent.png new file mode 100644 index 0000000000000000000000000000000000000000..0f290fd7fdbab8db7e78ae0d2a1183048e04cb0b GIT binary patch literal 534 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEEw35Xe6x)whJ(vv-1 z978JRyuGxLk-WO`Nq62i1_lPHG>DUdOo)F#3^3qgNMT@96ViVG{jDyf4D~bf`fKG#X1QhlSKzopr0AAF5p8x;= literal 0 HcmV?d00001 diff --git a/Tests/images/apng/chunk_actl_after_idat.png b/Tests/images/apng/chunk_actl_after_idat.png new file mode 100644 index 0000000000000000000000000000000000000000..296a29d4c117a67b5d81e62f85460e965f9e8567 GIT binary patch literal 470 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQU^R;978JRygg?q$iTqCp!j?G zH3^=7fekayX4?pKvnPFY(2?sn%Dk~cL5%;%qsE9qCfH(S{NwPZgKs|^2Kt2~F*(Es zNHYR4!xr6_3xJeV8bk~v)BwZ|KnwvX3@JdB0?*C>xzp2997BLKBQPW`Eo5Xc5NO$O zyZ+^S2~n3L$~SV|uh+3BFz_@m8ZfXOV31&7CY$q!MDMH9{slJ7JezGJ(9NFo(LqP9<0$jS3I#F#Baa#*2AN=s zmGO_mn-0GHa2V)6sWgb6flNq%fEZwq!jJ+C34v#4fV}BxDUKmPni1&hOA8qp3 z+^&E5UP9F6i1LkG_v>}+2@E_9j0OyB2N)z6n91fm;t@!1uwXtSsc?>=kgdbmVGbi6 ZAzRTKhc~S#60BnY0#8>zmvv4FO#rqikzD`) literal 0 HcmV?d00001 diff --git a/Tests/images/apng/chunk_no_actl.png b/Tests/images/apng/chunk_no_actl.png new file mode 100644 index 0000000000000000000000000000000000000000..5b68c7b44091bfd9103f419e1a8256abf56a2f97 GIT binary patch literal 450 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQU^R;978JRygg?q$iTqCp!j?G zH3^=7fekayX4?pKvnPFY(2?sn%Dk~cL5%;%qsE9qCfH(S{NwPZgKs|^2Kq%REjh#o zNP|EF5IX=d1f(#eFfcL*JUavAPESj53<1)NKwn>4$jD$I(6Zrn{mb_fqAo|2Z{)gP zuVYVO;AvnqU|>7IAi=;)Hs=wKKzf4(^ASmfa}0%S9mWoG81V?%irzT9X+@D>9Rm<} My85}Sb4q9e0JHXquK)l5 literal 0 HcmV?d00001 diff --git a/Tests/images/apng/chunk_no_fctl.png b/Tests/images/apng/chunk_no_fctl.png new file mode 100644 index 0000000000000000000000000000000000000000..58ca904abdc9f9cce4983e8916d69b89e6652145 GIT binary patch literal 432 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEEw5r`SK=)PP4qz-tx zIEGZrd3(-K5U7Ab@%Qv=5eYzrG<vil23_J~t1`KQm7$g{&$>u!b s5lC;aU_K(LaE_snt;5)14kI2RThSYbH`%l7xxfGfp00i_>zopr0Oh`s_5c6? literal 0 HcmV?d00001 diff --git a/Tests/images/apng/chunk_repeat_fctl.png b/Tests/images/apng/chunk_repeat_fctl.png new file mode 100644 index 0000000000000000000000000000000000000000..a5779855fc6344d6362d2c94a8988c963a71288e GIT binary patch literal 508 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEEw5r`SK=)PP4qz-tx zIEGZrd3(-K5U7Ab@%Qv=5GfthU1BOZbD1`FmRk_zV-3fVf09p*6N a5waD%ad^{;BEdQaAnWO`Nq62i1_lPHG>DUdOo)F#3@|WaNMT^y?tEuCkk_4- z;ur#?8NnXmWnkbryy4&flJ7^BG)m53SMfZZ{+y8#_rn69K#o9QBphT2k~qBS*6zOb zKrQLqH11@o(EqRe{{?P{%VvV}XIA!Qg*=)Omvq8~hT=(qGPc&%kp) znb^Q1E{G{tb%*g{>(WUT(_i)hy#VtlC}A}Ku>%l;fx3X70t2Jed%H70E-aQ<2T3fc z@7H$+It%7_P)2D0Vh13G07eEN{3Md<0OZ1AiG2vh(v}Uq1t1@Jy85}Sb4q9e0CA}q A!2kdN literal 0 HcmV?d00001 diff --git a/Tests/images/apng/delay_round.png b/Tests/images/apng/delay_round.png new file mode 100644 index 0000000000000000000000000000000000000000..3f082665c99b80844f299d8f2f6a0eb7870e6e54 GIT binary patch literal 646 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEEw35Xd!_f9SVQjWO`Nq62i1_lPHG>DUdOo)F#3@~5>dV^7R#>!+MuRATp zF$736f<3~^z`%2O!@vI}-;XS5l$^n?;(0p#IU^;S|Nz{miEpFECS26DGU9nTDn1qO}=ga7qW=M6S&@JlF5e>v+t1J40v hVgrx3Af{Z^9mb2TOD9!Kf7u7}k*BMl%Q~loCIA}msxbfn literal 0 HcmV?d00001 diff --git a/Tests/images/apng/delay_short_max.png b/Tests/images/apng/delay_short_max.png new file mode 100644 index 0000000000000000000000000000000000000000..99d53b71812981b822516b5836860d2e6626e917 GIT binary patch literal 646 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEEw35Xd!_f9SVQjWO`Nq62i1_lPHG>DUdOo)F#3=pXQ|NkEYW07$QACS|X zmf{!!q#3~;;bmapIlSTD|B~-VmNZJvU{~=xo&KDW68FObpg@j5U?d!52$DFw>DKPP z^+4yq91jZb1|W6-Vlem*1PqJ~*KcwFx!a+RXNJZC14o0w|N5x&1{*f`C6uMVob{f8 k=YTS?fk#{rQ?BX`;w78)78&qol`;+0E1_+Z~y=R literal 0 HcmV?d00001 diff --git a/Tests/images/apng/delay_zero_denom.png b/Tests/images/apng/delay_zero_denom.png new file mode 100644 index 0000000000000000000000000000000000000000..bad60c767fbec09b0e188724a2f9bafc3c83035d GIT binary patch literal 646 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEEw35Xd!_f9SVQjWO`Nq62i1_lPHG>DUdOo)F#3@|VPl8is*?vDo2-DxR~ zAs{~3BfJa@Jcl>@`(N_?$dX3M8SE;ar_-M^QsRDC02Ig(2#kb-3_%iyH{IIZw;t#m znBzg=-2lW6Knw=VFPL92Fg~3ZE(GLmhdQ1a8Vd{@4F><~qs|*_*x;8?mi}_qdj_5Z i%ESg9aY0PEsymDqTbE9%nEtX4WO`Nq62i1_lPHG>DUdOo)F#3@AupV9Z%$?g->{r=>WC z0BJ_BM|c?+cn)v)`@iJ-ktI!%GuTtqIQy?MQ|t+#0Sn{^1jd6yOuSc?PO{r;(gJi2 z%<)V>b^{PQ05JkEo_o~}a`ZN+j=%%3bkb^{PQ05KRaeqnyW!1$o&-vb~Q7D=o~k@OE7NsYir zQt>>U{+yALL;S}I!2AN__@{@yH3GTY wp^?OnHImZ7k#s;ZpcrZZVh13G0B~#sd>6Y5 yIhq!GVEMUy-j8q{h?L&t;ucLK6VQmViD0 literal 0 HcmV?d00001 diff --git a/Tests/images/apng/dispose_op_background_before_region.png b/Tests/images/apng/dispose_op_background_before_region.png new file mode 100644 index 0000000000000000000000000000000000000000..427b829a025ea8c02ef23ba4c2fd6b080841c331 GIT binary patch literal 327 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEEw35Xe6x)whJ(o$&< zF(4D70mJ|UE`}6FMte@Vg+Sh9PZ!6KiaBpDEo5Xc5NO$OyZ+^S2~n3L$~SV|uh+3B zFz_@m8ZfXOV31&7CY$q!M2F2gguSxLy3v8HqHrqy^n?32H rgN|IsQRa;m3S#_69yLY`GQk!r;~$4NrCbQ!2=bn%tDnm{r-UW|EYy*$ literal 0 HcmV?d00001 diff --git a/Tests/images/apng/dispose_op_background_region.png b/Tests/images/apng/dispose_op_background_region.png new file mode 100644 index 0000000000000000000000000000000000000000..05948d44aeddd75798f11b596ff11c6020588957 GIT binary patch literal 492 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEEw8HgEozUm4C(o$&< zF(4D70mJ|UE`}5aMl~V*2SDB`PZ!6KiaBpD9pq#%;9)j=zrSRc%fW?LAOAb1ak8gw zot*;%2Lq!514{#g00R?(bD^0*%qC#1JtG4(!Iu5M7#J}v1KIrD+L`0bP0l+XkKa!_Og literal 0 HcmV?d00001 diff --git a/Tests/images/apng/dispose_op_none.png b/Tests/images/apng/dispose_op_none.png new file mode 100644 index 0000000000000000000000000000000000000000..3094c1d23d6004e4c93fa6ffd4064d5cb023d9a4 GIT binary patch literal 617 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEEw8HgEozUm4C(o$&< zF(4D70mJ|UE`}5aMl~V*2SDCrPZ!6KiaBpDEo5Xc5NO$OyZ+^S2~n3L$~SV|uh+3B zFz_@m8ZfXOV31&7CY$q!M7#>= zT*p!7jTH)F{6`)&Mhr5+7AxZ)hc~5M2;KgrOewNe6o;Tz<=R!NrRn3|7FHs47PskFPOg!F2O$bBaaw2RygR$ z33L-;zGBuXIK1gc+u1r`sKG*$5y*A`Vg)D`U;u~aNtWf?fFj9hDUKmPnyF&W({qNr zAeO@K>DMG=`j}K~k26d0XV@(G*l>&jMgy-#)rUvE4uvOxhQk~Mj&>j)1||juRt84lLx(|944$rjF6*2UngH^+!bJc8 literal 0 HcmV?d00001 diff --git a/Tests/images/apng/dispose_op_previous.png b/Tests/images/apng/dispose_op_previous.png new file mode 100644 index 0000000000000000000000000000000000000000..1c15f132fe7da93954e03fa54f7da6987463a751 GIT binary patch literal 780 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEEw8HgEozUm4C(vv-1 z978JRyuGxLk-WO`Nq62i1_lPHG>DUdOo)F#3^3qgNMT@96ViVG{j6jc^GZX~+ltJ-ubCKsZkpE(`~rwtoMgjwh6V=)Mx!mV>p*He MUHx3vIVCg!0N<_7&j0`b literal 0 HcmV?d00001 diff --git a/Tests/images/apng/dispose_op_previous_final.png b/Tests/images/apng/dispose_op_previous_final.png new file mode 100644 index 0000000000000000000000000000000000000000..858f6f0382b3256d970c2359b689153d39bb491c GIT binary patch literal 508 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEEw35Xe6x)whJ(o$&< zF(4D70mJ|UE`}5aMl~V*2SDCrPZ!6KiaBpDEo5Xc5NO$OyZ+^S2~n3L$~SV|uh+3B zFz_@m8ZfXOV31&7CY$q!M2F2gguSxLy3v8HqHrqy^n?32H rgN|IsQRa;m3S#_69yLY`GQk!r;~$4NrCbQ!2=bn%tDnm{r-UW|@*$CG literal 0 HcmV?d00001 diff --git a/Tests/images/apng/dispose_op_previous_first.png b/Tests/images/apng/dispose_op_previous_first.png new file mode 100644 index 0000000000000000000000000000000000000000..3f9b3cfae76a32b65fb8683d105fb989463d079b GIT binary patch literal 371 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEEw35Xe6x)whJ(o$&< zF(4D70mJ|UE`}5)MiCX?Bp`3Hr;B4q#hka77BVsz2()atUH|gEgs96Aklg)X=Baq%;!F)tg;T%IDTZgg397a4swxTxU`&cn Q>jo+FboFyt=akR{05Nn<@Bjb+ literal 0 HcmV?d00001 diff --git a/Tests/images/apng/dispose_op_previous_region.png b/Tests/images/apng/dispose_op_previous_region.png new file mode 100644 index 0000000000000000000000000000000000000000..f326afa5c279daccc38bf482596db2e1f88a8577 GIT binary patch literal 677 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEEw8HgEozUm4C(vv-1 z978JRyuGxLk-WO`Nq62i1_lPHG>DUdOo)F#3^3qgNMT@96ViVG{j6jc^GZX~+ltJCG<`kINc_xc}BphyhV*UUg)UjX{rK;ZC(59)tg z7cP|B@_Wzicj=cIqnJw?t})0iFm+(h;JrWsmtW!2her<<`yBw<19J@vkj)6iKne)J paUij`>>ZHJ4|NS|#hl~>2B6DX85ld#`}INUJYD@<);T3K0RZKi!1@3H literal 0 HcmV?d00001 diff --git a/Tests/images/apng/fctl_actl.png b/Tests/images/apng/fctl_actl.png new file mode 100644 index 0000000000000000000000000000000000000000..d0418ddd75f560ed4759fcb1819908d0b13e2094 GIT binary patch literal 508 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQc`KjAwEDF1R8+Y0f-@hiy?)9 zQB6qy0g%g)2vNlZWHYvOEq(^1Cwsa$hE&XXdubsfgMmQHhTHWo-%E(P98tcJ>wdkC zJ%NFzfzg10?Er%W12fs2M?3=Q4HnEtBo)pv6tZ;~JIrCkBV;RjWP&YL#y<{kO1Tic5#&8jS3j3^P66n+!osaDUQVC5Fa4T1jLLjU5lRq>93wH zjv*Cu-dn(fk&GmC4y1m@KBPnjrhBC(ymQrCx8JUl?DkEAQKW|AO;w4F{Cgs zstM^o0P?=2r8tHFX+}^`ZR7<8)!_|){+Dn);pAN^zHXLH@om+gt1B5i*&U1w_!V9< zc(6TVRFP#kdBCP&3A2La4E6~n3@Xf@87A>Hq$J2WTwax76)N7pgyTIh{-@E#99yo47eCl z7#P)r^dA6uwrMGjAwZfDWS=4J?r;ABq&O0jLwtZV6A&}DbS-`cq*r*l zIEGZrd3sKfk%57SW#RkyUt-LLjp?i79y9i`o5)?La2O1X1ANg(E*;8W+3^GDW~nrY zKY&b#CqWD_;9^K&U{n**e*omIPD^nN0n&^h-x=}(SxgGQr(cth>0?r{JGz&wOqDZfXLl-|m`OIb=8RU65M9yoK!r!&*i#N1N= literal 0 HcmV?d00001 diff --git a/Tests/images/apng/mode_palette.png b/Tests/images/apng/mode_palette.png new file mode 100644 index 0000000000000000000000000000000000000000..11ccfb6cba02b18bf51522560f32ee14e258124e GIT binary patch literal 262 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`$P6T3NW8THQfvV}A+G;{jQzopr0DPA>zyJUM literal 0 HcmV?d00001 diff --git a/Tests/images/apng/mode_palette_alpha.png b/Tests/images/apng/mode_palette_alpha.png new file mode 100644 index 0000000000000000000000000000000000000000..f3c4c9f9e6d66d585f229af36308dd81a0af5941 GIT binary patch literal 308 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`#0(_Y@0k7uNO1=Egt-1^U;rW@-g{=VDUiWj z666>BpW%Pz8~J@eE=OW=h!2ov0%FFNuEo!Qw4|qtV@SoE9$hw%zO>_YhLm+E_&$KhMK@Nu*3UW;Y5IX=d1f+lrecNWL3gp5JWd<6` ip~@iPCW>Zk03*Yi*(>Cu{0{8~>GpK>b6Mw<&;$TsI!8PJ literal 0 HcmV?d00001 diff --git a/Tests/images/apng/num_plays.png b/Tests/images/apng/num_plays.png new file mode 100644 index 0000000000000000000000000000000000000000..4d76802e4ccd94f1d508743cf161798ca9670428 GIT binary patch literal 646 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEEw35Xd!_f9SVQjWO`Nq62i1_lPHG>DUdOo)F#3@}JxNMT@P5O{V5$lIQl z;ur#?8KE9w;Ak-TUmtbeV8aH#gtGLPv)(iC98f0Ke&Sq7xvD#i7h9K3l52_n2y{Bk zqoBBH0AdFqh5)cf-?o{m0=eB#k1~Tj%FDpOb9lqQ|0Um#ENPUS!LH(YI{i5#B@qRS g=>jWO`Nq62i1_lPHG>DUdOo)F#3@}JxNMT@P5O{V5$lIQl z;ur#?8KE9w;Ak-TUmtbeV8aH#gtGLPv)(iC98f0Ke&Sq7xvD#i7h9K3l52_n2y{Bk zqoBBH0AdFqh5)cf-?o{m0=eB#k1~Tj%FDpOb9lqQ|0Um#ENPUS!LH(YI{i5#B@qRS g=>jvil23_J~t1`KQm7$g{&$>u!b5lC;aU_K(LaE_snt;5)14kI2R zThSYbH{DuL`h)@D5k?@p0f-%d7y`f^nVrld0p!9w!Z-*$vZ6?^4&-7_S3j3^P6V!Z literal 0 HcmV?d00001 diff --git a/Tests/images/apng/sequence_reorder_chunk.png b/Tests/images/apng/sequence_reorder_chunk.png new file mode 100644 index 0000000000000000000000000000000000000000..5d951ffe2a589325f39da5f6fbd855be2983400d GIT binary patch literal 687 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEEw35Xd!_f9SVQU^R; z978JRygg?q2voqJ__GJlp00i_>zopr0QtDWJ^%m! literal 0 HcmV?d00001 diff --git a/Tests/images/apng/sequence_repeat.png b/Tests/images/apng/sequence_repeat.png new file mode 100644 index 0000000000000000000000000000000000000000..d5cf83f9f98e4c25db142354b3ac9e6ec24807b5 GIT binary patch literal 671 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEEw35Xd!_f9SVQU^R; z978JRygg?q2voqJ_|p=^ literal 0 HcmV?d00001 diff --git a/Tests/images/apng/sequence_start.png b/Tests/images/apng/sequence_start.png new file mode 100644 index 0000000000000000000000000000000000000000..5e040743a1d8d9e7ebba15fe25e11880089edc09 GIT binary patch literal 671 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEEw35Xd!_f9SVQU^R; z978JRygg?q2voqJ_iyb;Spvay8(zDfEWV69x3~J|09qK^9aiz^vJ0h56^;J?CI*~vd$@? F2>@j$!7%^; literal 0 HcmV?d00001 diff --git a/Tests/images/apng/single_frame.png b/Tests/images/apng/single_frame.png new file mode 100644 index 0000000000000000000000000000000000000000..0cd5bea856b6ed89d5a57d2bef5daaa02e59f3df GIT binary patch literal 307 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEEw5r`SK=)PP4q@>ay zVj!UgAa(#^2uNW_0jd;ub_U2j;OXKRQZeW4IYU99F${{or(cuc`4`wQ^K7<_KsS5R tM+Y6bj-$*QD-^`|k34FO7-WJiR>nULZ#ww)!(ouuJYD@<);T3K0RX?tVKD#z literal 0 HcmV?d00001 diff --git a/Tests/images/apng/single_frame_default.png b/Tests/images/apng/single_frame_default.png new file mode 100644 index 0000000000000000000000000000000000000000..db7581fbdfcbebdcd3d442261782f8e1b3966395 GIT binary patch literal 470 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEEw5r`SK=)PP4q$Yd1 zIEGZrd3$LgBZGlJ%ZA(aFW*aux*Soyk?VfFjy-{ar-9Lcf$ac;1OqeKoJTwY=?xal zMlJ2~33=9lXX%Hs^nGpYg7+{dXkOK6Gz_T+z-jTEv z#}FXR2=vG~LqVWV85Dm{zb3)+FR)?e*=!quZuX>)4mxrjN0~QPD2VYNdDIv&$OK!g XjDH;7B+zBD7vwunS3j3^P6lJ2~33=9lXX%Hs^nGpYg7+{dXkOK6Gz_T+zo=RGZ zV+fFD1bXD0p&-zw42r*}Uz6bZ7uYcKY_^R+H+#}Y2OYVNWbO+wKy^!?>Y0u*Z>&%d j<3IAKF#@4zPzbhI8UHxEsV{7@FUYr^u6{1-oD!MlJ2~33=9lXX%Hs^nGpYg7+{dXkOK6Gz_T+zo=RGZ zV+fFD1bXD0p&-zw42r*}Uz6bZ7uYcKY_^R+H+#}Y2OYVNWbO+wKy@ro^-Qmio%aRu umq7WHq1PmZ6nalp7hZ{N3P>2^TrAVG5#Zu8Y2doV2hRU zkHeb|zWs0*=ohIpi2s00i045JFi2rYVPIqscy4D z`j_t|L|u+3-^g{pUdNumz|+8Jz`%BZL4tvqY|bMdf%FCo<|C2{=NJmvI*c9WFyaxi z6}@qI(~2U&ItGMCn1JjCAa(#^2mpKJZJVhokPGt&^C0wy@3k9VAQyYO`njxgN@xNA D)~>)I literal 0 HcmV?d00001 diff --git a/Tests/images/apng/syntax_num_frames_invalid.png b/Tests/images/apng/syntax_num_frames_invalid.png new file mode 100644 index 0000000000000000000000000000000000000000..ca7b13ab8ab4b98440130010f01e9396b44ea923 GIT binary patch literal 470 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwED+Mj)HvxmZpWkUHS$ z;uumf=j}N|L7)N##oyDfN$~s&Y?ygA+eV<9J?W!^j$Fr4=8Y8!V*E!QHAW0F!4@my zABQ&`eEZ=r&@WPHK>vaK1_TX2>;S|Nkiw7x^tHgVGeGY2v=qk>Ak7H$^`(W33m_5=o=21Wx0wgU_j49sM69`OjIH&`$qkyJRxP{`I{>@bHB akC3hCjl-K(6baTb0D-5gpUXO@geCxpsf~XC literal 0 HcmV?d00001 diff --git a/Tests/images/apng/syntax_num_frames_low.png b/Tests/images/apng/syntax_num_frames_low.png new file mode 100644 index 0000000000000000000000000000000000000000..6f895f91d75f5b01cb34f83d0f12354aced90701 GIT binary patch literal 671 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEEw5r`SK=)PP4qz-tx zIEGZrd3(-K5U7Ab@%Qv=56-9z|3;S|N0QSh+Hd9p~7v>S>LFf_RYd5?=F7|Zwb6Mw<&;$VG CGQU&+ literal 0 HcmV?d00001 diff --git a/Tests/images/apng/syntax_num_frames_zero.png b/Tests/images/apng/syntax_num_frames_zero.png new file mode 100644 index 0000000000000000000000000000000000000000..0cb7ea36e3e338376493f0a9d2cf95388528afcd GIT binary patch literal 65 zcmeAS@N?(olHy`uVBq!ia0vp^4M6O`!3HERU8}DLQXGlNAwEDF3_5)e2!I%#u6{1- HoD!M Date: Fri, 29 Nov 2019 16:41:23 +0900 Subject: [PATCH 03/19] Document APNG support --- docs/handbook/image-file-formats.rst | 113 +++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 0e068c1e4..9e68a5730 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -551,6 +551,119 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options: library before building the Python Imaging Library. See the `installation documentation <../installation.html>`_ for details. +APNG sequences +~~~~~~~~~~~~~~~~~ + +The PNG loader includes limited support for reading and writing APNG files. +When an APNG file is loaded, :py:meth:`~PIL.ImageFile.ImageFile.get_format_mimetype` +will return ``"image/apng"``, and the :py:attr:`~PIL.Image.Image.is_animated` property +will be ``True`` (even for single frame APNG files). +The :py:meth:`~PIL.Image.Image.seek` and :py:meth:`~PIL.Image.Image.tell` methods +are supported. + +``im.seek()`` raises an :py:exc:`EOFError` if you try to seek after the last frame. + +The following :py:attr:`~PIL.Image.Image.info` properties will be set for APNG frames, +where applicable: + +**default_image** + Specifies whether or not this APNG file contains a separate default image, + which is not a part of the actual APNG animation. + + When an APNG file contains a default image, the initially loaded image (i.e. + the result of ``seek(0)``) will be the default image. + To account for the presence of the default image, the + py:attr:`~PIL.Image.Image.n_frames` property will be set to ``frame_count + 1``, + where ``frame_count`` is the actual APNG animation frame count. + To load the first APNG animation frame, ``seek(1)`` must be called. + + * ``True`` - The APNG contains default image, which is not an animation frame. + * ``False`` - The APNG does not contain a default image. The ``n_frames`` property + will be set to the actual APNG animation frame count. + The initially loaded image (i.e. ``seek(0)``) will be the first APNG animation + frame. + +**loop** + The number of times to loop this APNG, 0 indicates infinite looping. + +**duration** + The time to display this APNG frame (in milliseconds). + +.. note:: + + The APNG loader returns images the same size as the APNG file's logical screen size. + The returned image contains the pixel data for a given frame, after applying + any APNG frame disposal and frame blend operations (i.e. it contains what a web + browser would render for this frame - the composite of all previous frames and this + frame). + + Any APNG file containing sequence errors is treated as an invalid image. The APNG + loader will not attempt to repair and reorder files containing sequence errors. + +Saving +~~~~~~ + +When calling :py:meth:`~PIL.Image.Image.save`, by default only a single frame PNG file +will be saved. To save an APNG file (including a single frame APNG), the ``save_all`` +parameter must be set to ``True``. The following parameters can also be set: + +**default_image** + Boolean value, specifying whether or not the base image is a default image. + If ``True``, the base image will be used as the default image, and the first image + from the ``append_images`` sequence will be the first APNG animation frame. + If ``False``, the base image will be used as the first APNG animation frame. + Defaults to ``False``. + +**append_images** + A list or tuple of images to append as additional frames. Each of the + images in the list can be single or multiframe images. The size of each frame + should match the size of the base image. Also note that if a frame's mode does + not match that of the base image, the frame will be converted to the base image + mode. + +**loop** + Integer number of times to loop this APNG, 0 indicates infinite looping. + Defaults to 0. + +**duration** + Integer (or list or tuple of integers) length of time to display this APNG frame + (in milliseconds). + Defaults to 0. + +**disposal** + An integer (or list or tuple of integers) specifying the APNG disposal + operation to be used for this frame before rendering the next frame. + Defaults to 0. + + * 0 (:py:data:`~PIL.PngImagePlugin.APNG_DISPOSE_OP_NONE`, default) - + No disposal is done on this frame before rendering the next frame. + * 1 (:py:data:`PIL.PngImagePlugin.APNG_DISPOSE_OP_BACKGROUND`) - + This frame's modified region is cleared to fully transparent black before + rendering the next frame. + * 2 (:py:data:`~PIL.PngImagePlugin.APNG_DISPOSE_OP_PREVIOUS`) - + This frame's modified region is reverted to the previous frame's contents before + rendering the next frame. + +**blend** + An integer (or list or tuple of integers) specifying the APNG blend + operation to be used for this frame before rendering the next frame. + Defaults to 0. + + * 0 (:py:data:`~PIL.PngImagePlugin.APNG_BLEND_OP_SOURCE`) - + All color components of this frame, including alpha, overwrite the previous output + image contents. + * 1 (:py:data:`~PIL.PngImagePlugin.APNG_BLEND_OP_OVER`) - + This frame should be alpha composited with the previous output image contents. + +.. note:: + + The ``duration``, ``disposal`` and ``blend`` parameters can be set to lists or tuples to + specify values for each individual frame in the animation. The length of the list or tuple + must be identical to the total number of actual frames in the APNG animation. + If the APNG contains a default image (i.e. ``default_image`` is set to ``True``), + these list or tuple parameters should not include an entry for the default image. + + PPM ^^^ From 512b060a53cc189770646414ce91e552eb01fe2b Mon Sep 17 00:00:00 2001 From: Peter Rowlands Date: Sun, 1 Dec 2019 14:02:31 +0900 Subject: [PATCH 04/19] Fix tox -e lint errors --- src/PIL/PngImagePlugin.py | 60 +++++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 5bb0e2fff..57ff7c479 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -384,9 +384,7 @@ class PngStream(ChunkStream): # image data if "bbox" in self.im_info: - tile = [( - "zip", self.im_info["bbox"], pos, self.im_rawmode - )] + tile = [("zip", self.im_info["bbox"], pos, self.im_rawmode)] else: if self.im_n_frames is not None: self.im_info["default_image"] = True @@ -588,8 +586,9 @@ class PngStream(ChunkStream): def chunk_fcTL(self, pos, length): s = ImageFile._safe_read(self.fp, length) seq = i32(s) - if (self._seq_num is None and seq != 0) or \ - (self._seq_num is not None and self._seq_num != seq - 1): + if (self._seq_num is None and seq != 0) or ( + self._seq_num is not None and self._seq_num != seq - 1 + ): raise SyntaxError("APNG contains frame sequence errors") self._seq_num = seq width, height = i32(s[4:]), i32(s[8:]) @@ -684,7 +683,7 @@ class PngImageFile(ImageFile.ImageFile): if cid == b"fdAT": self.__prepare_idat = length - 4 else: - self.__prepare_idat = length # used by load_prepare() + self.__prepare_idat = length # used by load_prepare() if self._n_frames is not None: self._close_exclusive_fp_after_loading = False @@ -854,9 +853,9 @@ class PngImageFile(ImageFile.ImageFile): self.png.call(cid, pos, length) except EOFError: pass - self.__idat = length - 4 # sequence_num has already been read + self.__idat = length - 4 # sequence_num has already been read else: - self.__idat = length # empty chunks are allowed + self.__idat = length # empty chunks are allowed # read more data from this chunk if read_bytes <= 0: @@ -918,7 +917,8 @@ class PngImageFile(ImageFile.ImageFile): if self._prev_im and self.blend_op == APNG_BLEND_OP_OVER: updated = self._crop(self.im, self.dispose_extent) self._prev_im.paste( - updated, self.dispose_extent, updated.convert("RGBA")) + updated, self.dispose_extent, updated.convert("RGBA") + ) self.im = self._prev_im self._prev_im = self.im.copy() @@ -1056,8 +1056,11 @@ def _write_multiple_frames(im, fp, chunk, rawmode): base_im = previous["im"] delta = ImageChops.subtract_modulo(im_frame, base_im) bbox = delta.getbbox() - if (not bbox and prev_disposal == encoderinfo.get("disposal") - and prev_blend == encoderinfo.get("blend")): + if ( + not bbox + and prev_disposal == encoderinfo.get("disposal") + and prev_blend == encoderinfo.get("blend") + ): duration = encoderinfo.get("duration", 0) if duration: if "duration" in previous["encoderinfo"]: @@ -1071,10 +1074,7 @@ def _write_multiple_frames(im, fp, chunk, rawmode): # animation control chunk( - fp, - b"acTL", - o32(len(im_frames)), # 0: num_frames - o32(loop), # 4: num_plays + fp, b"acTL", o32(len(im_frames)), o32(loop), # 0: num_frames # 4: num_plays ) # default image IDAT (if it exists) @@ -1097,25 +1097,31 @@ def _write_multiple_frames(im, fp, chunk, rawmode): chunk( fp, b"fcTL", - o32(seq_num), # sequence_number - o32(size[0]), # width - o32(size[1]), # height - o32(bbox[0]), # x_offset - o32(bbox[1]), # y_offset + o32(seq_num), # sequence_number + o32(size[0]), # width + o32(size[1]), # height + o32(bbox[0]), # x_offset + o32(bbox[1]), # y_offset o16(duration), # delay_numerator - o16(1000), # delay_denominator - o8(disposal), # dispose_op - o8(blend), # blend_op + o16(1000), # delay_denominator + o8(disposal), # dispose_op + o8(blend), # blend_op ) seq_num += 1 # frame data if frame == 0 and not default_image: # first frame must be in IDAT chunks for backwards compatibility - ImageFile._save(im_frame, _idat(fp, chunk), - [("zip", (0, 0) + im_frame.size, 0, rawmode)]) + ImageFile._save( + im_frame, + _idat(fp, chunk), + [("zip", (0, 0) + im_frame.size, 0, rawmode)], + ) else: - ImageFile._save(im_frame, _fdat(fp, chunk, seq_num), - [("zip", (0, 0) + im_frame.size, 0, rawmode)]) + ImageFile._save( + im_frame, + _fdat(fp, chunk, seq_num), + [("zip", (0, 0) + im_frame.size, 0, rawmode)], + ) seq_num += 1 From bdcf9805704961c4a73ed3ac7255b431c02a8dcf Mon Sep 17 00:00:00 2001 From: Peter Rowlands Date: Sun, 1 Dec 2019 15:50:09 +0900 Subject: [PATCH 05/19] Fix pypy test failures --- src/PIL/PngImagePlugin.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 57ff7c479..e274a128e 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -757,6 +757,8 @@ class PngImageFile(ImageFile.ImageFile): self.png.rewind() self.__prepare_idat = self.__rewind_idat self.im = None + if self.pyaccess: + self.pyaccess = None self.info = self.png.im_info self.tile = self.png.im_tile self.fp = self.__fp @@ -920,6 +922,8 @@ class PngImageFile(ImageFile.ImageFile): updated, self.dispose_extent, updated.convert("RGBA") ) self.im = self._prev_im + if self.pyaccess: + self.pyaccess = None self._prev_im = self.im.copy() if dispose: From 35148b99c1674b098de00fb4c1701d9e9536cc30 Mon Sep 17 00:00:00 2001 From: Peter Rowlands Date: Sun, 1 Dec 2019 16:30:34 +0900 Subject: [PATCH 06/19] Fix comment [ci skip] --- src/PIL/PngImagePlugin.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index e274a128e..63e9b4491 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -638,8 +638,7 @@ class PngImageFile(ImageFile.ImageFile): self.__fp = self.fp # - # Parse headers up to the first IDAT chunk (or last fdAT chunk for - # APNG) + # Parse headers up to the first IDAT or fDAT chunk self.png = PngStream(self.fp) From 66c84f258b0a1e479cb6692a0ccb5b612cf03594 Mon Sep 17 00:00:00 2001 From: Peter Rowlands Date: Mon, 2 Dec 2019 14:44:32 +0900 Subject: [PATCH 07/19] Add test for saving split fdat chunks --- Tests/test_file_png.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 8e7ba8e12..edb22b150 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -948,6 +948,29 @@ class TestFilePng(PillowTestCase): self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + def test_apng_save_split_fdat(self): + # test to make sure we do not generate sequence errors when writing + # frames with image data spanning multiple fdAT chunks (in this case + # both the default image and first animation frame will span multiple + # data chunks) + test_file = self.tempfile("temp.png") + with Image.open("Tests/images/old-style-jpeg-compression.png") as im: + frames = [im.copy(), Image.new("RGBA", im.size, (255, 0, 0, 255))] + im.save( + test_file, + save_all=True, + default_image=True, + append_images=frames, + ) + with Image.open(test_file) as im: + exception = None + try: + im.seek(im.n_frames - 1) + im.load() + except Exception as e: + exception = e + self.assertIsNone(exception) + def test_apng_save_duration_loop(self): test_file = self.tempfile("temp.png") im = Image.open("Tests/images/apng/delay.png") From 9f4716ff3019a7bb6a8daaaa4447c7b32a9e9d93 Mon Sep 17 00:00:00 2001 From: Peter Rowlands Date: Mon, 2 Dec 2019 14:40:53 +0900 Subject: [PATCH 08/19] Fix split fdAT chunk sequence error --- src/PIL/PngImagePlugin.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 63e9b4491..691d8e966 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1004,6 +1004,7 @@ class _fdat: def write(self, data): self.chunk(self.fp, b"fdAT", o32(self.seq_num), data) + self.seq_num += 1 def _write_multiple_frames(im, fp, chunk, rawmode): @@ -1120,12 +1121,13 @@ def _write_multiple_frames(im, fp, chunk, rawmode): [("zip", (0, 0) + im_frame.size, 0, rawmode)], ) else: + fdat_chunks = _fdat(fp, chunk, seq_num) ImageFile._save( im_frame, - _fdat(fp, chunk, seq_num), + fdat_chunks, [("zip", (0, 0) + im_frame.size, 0, rawmode)], ) - seq_num += 1 + seq_num = fdat_chunks.seq_num def _save_all(im, fp, filename): From 00fcc53a1d2160a71de6104f68c188cd1a11073a Mon Sep 17 00:00:00 2001 From: Peter Rowlands Date: Mon, 2 Dec 2019 14:52:14 +0900 Subject: [PATCH 09/19] Fix lint errors --- Tests/test_file_png.py | 5 +---- src/PIL/PngImagePlugin.py | 4 +--- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index edb22b150..09a9963b8 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -957,10 +957,7 @@ class TestFilePng(PillowTestCase): with Image.open("Tests/images/old-style-jpeg-compression.png") as im: frames = [im.copy(), Image.new("RGBA", im.size, (255, 0, 0, 255))] im.save( - test_file, - save_all=True, - default_image=True, - append_images=frames, + test_file, save_all=True, default_image=True, append_images=frames, ) with Image.open(test_file) as im: exception = None diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 691d8e966..398eade3c 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1123,9 +1123,7 @@ def _write_multiple_frames(im, fp, chunk, rawmode): else: fdat_chunks = _fdat(fp, chunk, seq_num) ImageFile._save( - im_frame, - fdat_chunks, - [("zip", (0, 0) + im_frame.size, 0, rawmode)], + im_frame, fdat_chunks, [("zip", (0, 0) + im_frame.size, 0, rawmode)], ) seq_num = fdat_chunks.seq_num From 0b536fb59959dbd7d93286a462ae607e4b287603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Rowlands=20=28=EB=B3=80=EA=B8=B0=ED=98=B8=29?= Date: Fri, 27 Dec 2019 11:38:44 +0900 Subject: [PATCH 10/19] fix documentation review issue [ci skip] Co-Authored-By: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- docs/handbook/image-file-formats.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 9e68a5730..d34273719 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -573,7 +573,7 @@ where applicable: When an APNG file contains a default image, the initially loaded image (i.e. the result of ``seek(0)``) will be the default image. To account for the presence of the default image, the - py:attr:`~PIL.Image.Image.n_frames` property will be set to ``frame_count + 1``, + :py:attr:`~PIL.Image.Image.n_frames` property will be set to ``frame_count + 1``, where ``frame_count`` is the actual APNG animation frame count. To load the first APNG animation frame, ``seek(1)`` must be called. From 0f84fa77073e550f64f4098c968cb6d641f41c1c Mon Sep 17 00:00:00 2001 From: Peter Rowlands Date: Fri, 10 Jan 2020 14:28:24 +0900 Subject: [PATCH 11/19] Move apng tests into test_file_apng.py --- Tests/test_file_apng.py | 539 ++++++++++++++++++++++++++++++++++++++++ Tests/test_file_png.py | 537 +-------------------------------------- 2 files changed, 540 insertions(+), 536 deletions(-) create mode 100644 Tests/test_file_apng.py diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py new file mode 100644 index 000000000..8c5ea90ff --- /dev/null +++ b/Tests/test_file_apng.py @@ -0,0 +1,539 @@ +from PIL import Image, ImageSequence, PngImagePlugin + +import pytest + +from .helper import PillowTestCase + + +class TestFilePng(PillowTestCase): + + # APNG browser support tests and fixtures via: + # https://philip.html5.org/tests/apng/tests.html + # (referenced from https://wiki.mozilla.org/APNG_Specification) + def test_apng_basic(self): + im = Image.open("Tests/images/apng/single_frame.png") + self.assertTrue(im.is_animated) + self.assertEqual(im.get_format_mimetype(), "image/apng") + self.assertIsNone(im.info.get("default_image")) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + im = Image.open("Tests/images/apng/single_frame_default.png") + self.assertTrue(im.is_animated) + self.assertEqual(im.get_format_mimetype(), "image/apng") + self.assertTrue(im.info.get("default_image")) + self.assertEqual(im.getpixel((0, 0)), (255, 0, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (255, 0, 0, 255)) + im.seek(1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + # test out of bounds seek + with self.assertRaises(EOFError): + im.seek(2) + + # test rewind support + im.seek(0) + self.assertEqual(im.getpixel((0, 0)), (255, 0, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (255, 0, 0, 255)) + im.seek(1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + def test_apng_fdat(self): + im = Image.open("Tests/images/apng/split_fdat.png") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + im = Image.open("Tests/images/apng/split_fdat_zero_chunk.png") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + def test_apng_dispose(self): + im = Image.open("Tests/images/apng/dispose_op_none.png") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + im = Image.open("Tests/images/apng/dispose_op_background.png") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) + self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) + + im = Image.open("Tests/images/apng/dispose_op_background_final.png") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + im = Image.open("Tests/images/apng/dispose_op_previous.png") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + im = Image.open("Tests/images/apng/dispose_op_previous_final.png") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + im = Image.open("Tests/images/apng/dispose_op_previous_first.png") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) + self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) + + def test_apng_dispose_region(self): + im = Image.open("Tests/images/apng/dispose_op_none_region.png") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + im = Image.open("Tests/images/apng/dispose_op_background_before_region.png") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) + self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) + + im = Image.open("Tests/images/apng/dispose_op_background_region.png") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 0, 255, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) + + im = Image.open("Tests/images/apng/dispose_op_previous_region.png") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + def test_apng_blend(self): + im = Image.open("Tests/images/apng/blend_op_source_solid.png") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + im = Image.open("Tests/images/apng/blend_op_source_transparent.png") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) + self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) + + im = Image.open("Tests/images/apng/blend_op_source_near_transparent.png") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 2)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 2)) + + im = Image.open("Tests/images/apng/blend_op_over.png") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + im = Image.open("Tests/images/apng/blend_op_over_near_transparent.png") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 97)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + def test_apng_chunk_order(self): + im = Image.open("Tests/images/apng/fctl_actl.png") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + def test_apng_delay(self): + im = Image.open("Tests/images/apng/delay.png") + im.seek(1) + self.assertEqual(im.info.get("duration"), 500.0) + im.seek(2) + self.assertEqual(im.info.get("duration"), 1000.0) + im.seek(3) + self.assertEqual(im.info.get("duration"), 500.0) + im.seek(4) + self.assertEqual(im.info.get("duration"), 1000.0) + + im = Image.open("Tests/images/apng/delay_round.png") + im.seek(1) + self.assertEqual(im.info.get("duration"), 500.0) + im.seek(2) + self.assertEqual(im.info.get("duration"), 1000.0) + + im = Image.open("Tests/images/apng/delay_short_max.png") + im.seek(1) + self.assertEqual(im.info.get("duration"), 500.0) + im.seek(2) + self.assertEqual(im.info.get("duration"), 1000.0) + + im = Image.open("Tests/images/apng/delay_zero_denom.png") + im.seek(1) + self.assertEqual(im.info.get("duration"), 500.0) + im.seek(2) + self.assertEqual(im.info.get("duration"), 1000.0) + + im = Image.open("Tests/images/apng/delay_zero_numer.png") + im.seek(1) + self.assertEqual(im.info.get("duration"), 0.0) + im.seek(2) + self.assertEqual(im.info.get("duration"), 0.0) + im.seek(3) + self.assertEqual(im.info.get("duration"), 500.0) + im.seek(4) + self.assertEqual(im.info.get("duration"), 1000.0) + + def test_apng_num_plays(self): + im = Image.open("Tests/images/apng/num_plays.png") + self.assertEqual(im.info.get("loop"), 0) + + im = Image.open("Tests/images/apng/num_plays_1.png") + self.assertEqual(im.info.get("loop"), 1) + + def test_apng_mode(self): + im = Image.open("Tests/images/apng/mode_16bit.png") + self.assertEqual(im.mode, "RGBA") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 0, 128, 191)) + self.assertEqual(im.getpixel((64, 32)), (0, 0, 128, 191)) + + im = Image.open("Tests/images/apng/mode_greyscale.png") + self.assertEqual(im.mode, "L") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), 128) + self.assertEqual(im.getpixel((64, 32)), 255) + + im = Image.open("Tests/images/apng/mode_greyscale_alpha.png") + self.assertEqual(im.mode, "LA") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (128, 191)) + self.assertEqual(im.getpixel((64, 32)), (128, 191)) + + im = Image.open("Tests/images/apng/mode_palette.png") + self.assertEqual(im.mode, "P") + im.seek(im.n_frames - 1) + im = im.convert("RGB") + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0)) + + im = Image.open("Tests/images/apng/mode_palette_alpha.png") + self.assertEqual(im.mode, "P") + im.seek(im.n_frames - 1) + im = im.convert("RGBA") + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + im = Image.open("Tests/images/apng/mode_palette_1bit_alpha.png") + self.assertEqual(im.mode, "P") + im.seek(im.n_frames - 1) + im = im.convert("RGBA") + self.assertEqual(im.getpixel((0, 0)), (0, 0, 255, 128)) + self.assertEqual(im.getpixel((64, 32)), (0, 0, 255, 128)) + + def test_apng_chunk_errors(self): + im = Image.open("Tests/images/apng/chunk_no_actl.png") + self.assertFalse(im.is_animated) + + def open(): + im = Image.open("Tests/images/apng/chunk_multi_actl.png") + im.load() + + pytest.warns(UserWarning, open) + self.assertFalse(im.is_animated) + + im = Image.open("Tests/images/apng/chunk_actl_after_idat.png") + self.assertFalse(im.is_animated) + + im = Image.open("Tests/images/apng/chunk_no_fctl.png") + with self.assertRaises(SyntaxError): + im.seek(im.n_frames - 1) + + im = Image.open("Tests/images/apng/chunk_repeat_fctl.png") + with self.assertRaises(SyntaxError): + im.seek(im.n_frames - 1) + + im = Image.open("Tests/images/apng/chunk_no_fdat.png") + with self.assertRaises(SyntaxError): + im.seek(im.n_frames - 1) + + def test_apng_syntax_errors(self): + def open(): + im = Image.open("Tests/images/apng/syntax_num_frames_zero.png") + self.assertFalse(im.is_animated) + with self.assertRaises(OSError): + im.load() + + pytest.warns(UserWarning, open) + + def open(): + im = Image.open("Tests/images/apng/syntax_num_frames_zero_default.png") + self.assertFalse(im.is_animated) + im.load() + + pytest.warns(UserWarning, open) + + # we can handle this case gracefully + exception = None + im = Image.open("Tests/images/apng/syntax_num_frames_low.png") + try: + im.seek(im.n_frames - 1) + except Exception as e: + exception = e + self.assertIsNone(exception) + + with self.assertRaises(SyntaxError): + im = Image.open("Tests/images/apng/syntax_num_frames_high.png") + im.seek(im.n_frames - 1) + im.load() + + def open(): + im = Image.open("Tests/images/apng/syntax_num_frames_invalid.png") + self.assertFalse(im.is_animated) + im.load() + + pytest.warns(UserWarning, open) + + def test_apng_sequence_errors(self): + test_files = [ + "sequence_start.png", + "sequence_gap.png", + "sequence_repeat.png", + "sequence_repeat_chunk.png", + "sequence_reorder.png", + "sequence_reorder_chunk.png", + "sequence_fdat_fctl.png", + ] + for f in test_files: + with self.assertRaises(SyntaxError): + im = Image.open("Tests/images/apng/{0}".format(f)) + im.seek(im.n_frames - 1) + im.load() + + def test_apng_save(self): + im = Image.open("Tests/images/apng/single_frame.png") + test_file = self.tempfile("temp.png") + im.save(test_file, save_all=True) + + with Image.open(test_file) as im: + im.load() + self.assertTrue(im.is_animated) + self.assertEqual(im.get_format_mimetype(), "image/apng") + self.assertIsNone(im.info.get("default_image")) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + im = Image.open("Tests/images/apng/single_frame_default.png") + frames = [] + for im in ImageSequence.Iterator(im): + frames.append(im.copy()) + frames[0].save( + test_file, save_all=True, default_image=True, append_images=frames[1:] + ) + + with Image.open(test_file) as im: + im.load() + self.assertTrue(im.is_animated) + self.assertEqual(im.get_format_mimetype(), "image/apng") + self.assertTrue(im.info.get("default_image")) + im.seek(1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + def test_apng_save_split_fdat(self): + # test to make sure we do not generate sequence errors when writing + # frames with image data spanning multiple fdAT chunks (in this case + # both the default image and first animation frame will span multiple + # data chunks) + test_file = self.tempfile("temp.png") + with Image.open("Tests/images/old-style-jpeg-compression.png") as im: + frames = [im.copy(), Image.new("RGBA", im.size, (255, 0, 0, 255))] + im.save( + test_file, save_all=True, default_image=True, append_images=frames, + ) + with Image.open(test_file) as im: + exception = None + try: + im.seek(im.n_frames - 1) + im.load() + except Exception as e: + exception = e + self.assertIsNone(exception) + + def test_apng_save_duration_loop(self): + test_file = self.tempfile("temp.png") + im = Image.open("Tests/images/apng/delay.png") + frames = [] + durations = [] + loop = im.info.get("loop") + default_image = im.info.get("default_image") + for i, im in enumerate(ImageSequence.Iterator(im)): + frames.append(im.copy()) + if i != 0 or not default_image: + durations.append(im.info.get("duration", 0)) + frames[0].save( + test_file, + save_all=True, + default_image=default_image, + append_images=frames[1:], + duration=durations, + loop=loop, + ) + with Image.open(test_file) as im: + im.load() + self.assertEqual(im.info.get("loop"), loop) + im.seek(1) + self.assertEqual(im.info.get("duration"), 500.0) + im.seek(2) + self.assertEqual(im.info.get("duration"), 1000.0) + im.seek(3) + self.assertEqual(im.info.get("duration"), 500.0) + im.seek(4) + self.assertEqual(im.info.get("duration"), 1000.0) + + # test removal of duplicated frames + frame = Image.new("RGBA", (128, 64), (255, 0, 0, 255)) + frame.save(test_file, save_all=True, append_images=[frame], duration=[500, 250]) + with Image.open(test_file) as im: + im.load() + self.assertEqual(im.n_frames, 1) + self.assertEqual(im.info.get("duration"), 750) + + def test_apng_save_disposal(self): + test_file = self.tempfile("temp.png") + size = (128, 64) + red = Image.new("RGBA", size, (255, 0, 0, 255)) + green = Image.new("RGBA", size, (0, 255, 0, 255)) + transparent = Image.new("RGBA", size, (0, 0, 0, 0)) + + # test APNG_DISPOSE_OP_NONE + red.save( + test_file, + save_all=True, + append_images=[green, transparent], + disposal=PngImagePlugin.APNG_DISPOSE_OP_NONE, + blend=PngImagePlugin.APNG_BLEND_OP_OVER, + ) + with Image.open(test_file) as im: + im.seek(2) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + # test APNG_DISPOSE_OP_BACKGROUND + disposal = [ + PngImagePlugin.APNG_DISPOSE_OP_NONE, + PngImagePlugin.APNG_DISPOSE_OP_BACKGROUND, + PngImagePlugin.APNG_DISPOSE_OP_NONE, + ] + red.save( + test_file, + save_all=True, + append_images=[red, transparent], + disposal=disposal, + blend=PngImagePlugin.APNG_BLEND_OP_OVER, + ) + with Image.open(test_file) as im: + im.seek(2) + self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) + self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) + + disposal = [ + PngImagePlugin.APNG_DISPOSE_OP_NONE, + PngImagePlugin.APNG_DISPOSE_OP_BACKGROUND, + ] + red.save( + test_file, + save_all=True, + append_images=[green], + disposal=disposal, + blend=PngImagePlugin.APNG_BLEND_OP_OVER, + ) + with Image.open(test_file) as im: + im.seek(1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + # test APNG_DISPOSE_OP_PREVIOUS + disposal = [ + PngImagePlugin.APNG_DISPOSE_OP_NONE, + PngImagePlugin.APNG_DISPOSE_OP_PREVIOUS, + PngImagePlugin.APNG_DISPOSE_OP_NONE, + ] + red.save( + test_file, + save_all=True, + append_images=[green, red, transparent], + default_image=True, + disposal=disposal, + blend=PngImagePlugin.APNG_BLEND_OP_OVER, + ) + with Image.open(test_file) as im: + im.seek(3) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + disposal = [ + PngImagePlugin.APNG_DISPOSE_OP_NONE, + PngImagePlugin.APNG_DISPOSE_OP_PREVIOUS, + ] + red.save( + test_file, + save_all=True, + append_images=[green], + disposal=disposal, + blend=PngImagePlugin.APNG_BLEND_OP_OVER, + ) + with Image.open(test_file) as im: + im.seek(1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + def test_apng_save_blend(self): + test_file = self.tempfile("temp.png") + size = (128, 64) + red = Image.new("RGBA", size, (255, 0, 0, 255)) + green = Image.new("RGBA", size, (0, 255, 0, 255)) + transparent = Image.new("RGBA", size, (0, 0, 0, 0)) + + # test APNG_BLEND_OP_SOURCE on solid color + blend = [ + PngImagePlugin.APNG_BLEND_OP_OVER, + PngImagePlugin.APNG_BLEND_OP_SOURCE, + ] + red.save( + test_file, + save_all=True, + append_images=[red, green], + default_image=True, + disposal=PngImagePlugin.APNG_DISPOSE_OP_NONE, + blend=blend, + ) + with Image.open(test_file) as im: + im.seek(2) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + + # test APNG_BLEND_OP_SOURCE on transparent color + blend = [ + PngImagePlugin.APNG_BLEND_OP_OVER, + PngImagePlugin.APNG_BLEND_OP_SOURCE, + ] + red.save( + test_file, + save_all=True, + append_images=[red, transparent], + default_image=True, + disposal=PngImagePlugin.APNG_DISPOSE_OP_NONE, + blend=blend, + ) + with Image.open(test_file) as im: + im.seek(2) + self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) + self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) + + # test APNG_BLEND_OP_OVER + red.save( + test_file, + save_all=True, + append_images=[green, transparent], + default_image=True, + disposal=PngImagePlugin.APNG_DISPOSE_OP_NONE, + blend=PngImagePlugin.APNG_BLEND_OP_OVER, + ) + with Image.open(test_file) as im: + im.seek(1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + im.seek(2) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 09a9963b8..47ac40f8f 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -3,7 +3,7 @@ import zlib from io import BytesIO import pytest -from PIL import Image, ImageFile, ImageSequence, PngImagePlugin +from PIL import Image, ImageFile, PngImagePlugin from .helper import ( PillowLeakTestCase, @@ -625,541 +625,6 @@ class TestFilePng(PillowTestCase): with Image.open(test_file) as reloaded: assert reloaded.info["exif"] == b"Exif\x00\x00exifstring" - # APNG browser support tests and fixtures via: - # https://philip.html5.org/tests/apng/tests.html - # (referenced from https://wiki.mozilla.org/APNG_Specification) - def test_apng_basic(self): - im = Image.open("Tests/images/apng/single_frame.png") - self.assertTrue(im.is_animated) - self.assertEqual(im.get_format_mimetype(), "image/apng") - self.assertIsNone(im.info.get("default_image")) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - im = Image.open("Tests/images/apng/single_frame_default.png") - self.assertTrue(im.is_animated) - self.assertEqual(im.get_format_mimetype(), "image/apng") - self.assertTrue(im.info.get("default_image")) - self.assertEqual(im.getpixel((0, 0)), (255, 0, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (255, 0, 0, 255)) - im.seek(1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - # test out of bounds seek - with self.assertRaises(EOFError): - im.seek(2) - - # test rewind support - im.seek(0) - self.assertEqual(im.getpixel((0, 0)), (255, 0, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (255, 0, 0, 255)) - im.seek(1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - def test_apng_fdat(self): - im = Image.open("Tests/images/apng/split_fdat.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - im = Image.open("Tests/images/apng/split_fdat_zero_chunk.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - def test_apng_dispose(self): - im = Image.open("Tests/images/apng/dispose_op_none.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - im = Image.open("Tests/images/apng/dispose_op_background.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) - - im = Image.open("Tests/images/apng/dispose_op_background_final.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - im = Image.open("Tests/images/apng/dispose_op_previous.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - im = Image.open("Tests/images/apng/dispose_op_previous_final.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - im = Image.open("Tests/images/apng/dispose_op_previous_first.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) - - def test_apng_dispose_region(self): - im = Image.open("Tests/images/apng/dispose_op_none_region.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - im = Image.open("Tests/images/apng/dispose_op_background_before_region.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) - - im = Image.open("Tests/images/apng/dispose_op_background_region.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 0, 255, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) - - im = Image.open("Tests/images/apng/dispose_op_previous_region.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - def test_apng_blend(self): - im = Image.open("Tests/images/apng/blend_op_source_solid.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - im = Image.open("Tests/images/apng/blend_op_source_transparent.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) - - im = Image.open("Tests/images/apng/blend_op_source_near_transparent.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 2)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 2)) - - im = Image.open("Tests/images/apng/blend_op_over.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - im = Image.open("Tests/images/apng/blend_op_over_near_transparent.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 97)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - def test_apng_chunk_order(self): - im = Image.open("Tests/images/apng/fctl_actl.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - def test_apng_delay(self): - im = Image.open("Tests/images/apng/delay.png") - im.seek(1) - self.assertEqual(im.info.get("duration"), 500.0) - im.seek(2) - self.assertEqual(im.info.get("duration"), 1000.0) - im.seek(3) - self.assertEqual(im.info.get("duration"), 500.0) - im.seek(4) - self.assertEqual(im.info.get("duration"), 1000.0) - - im = Image.open("Tests/images/apng/delay_round.png") - im.seek(1) - self.assertEqual(im.info.get("duration"), 500.0) - im.seek(2) - self.assertEqual(im.info.get("duration"), 1000.0) - - im = Image.open("Tests/images/apng/delay_short_max.png") - im.seek(1) - self.assertEqual(im.info.get("duration"), 500.0) - im.seek(2) - self.assertEqual(im.info.get("duration"), 1000.0) - - im = Image.open("Tests/images/apng/delay_zero_denom.png") - im.seek(1) - self.assertEqual(im.info.get("duration"), 500.0) - im.seek(2) - self.assertEqual(im.info.get("duration"), 1000.0) - - im = Image.open("Tests/images/apng/delay_zero_numer.png") - im.seek(1) - self.assertEqual(im.info.get("duration"), 0.0) - im.seek(2) - self.assertEqual(im.info.get("duration"), 0.0) - im.seek(3) - self.assertEqual(im.info.get("duration"), 500.0) - im.seek(4) - self.assertEqual(im.info.get("duration"), 1000.0) - - def test_apng_num_plays(self): - im = Image.open("Tests/images/apng/num_plays.png") - self.assertEqual(im.info.get("loop"), 0) - - im = Image.open("Tests/images/apng/num_plays_1.png") - self.assertEqual(im.info.get("loop"), 1) - - def test_apng_mode(self): - im = Image.open("Tests/images/apng/mode_16bit.png") - self.assertEqual(im.mode, "RGBA") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 0, 128, 191)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 128, 191)) - - im = Image.open("Tests/images/apng/mode_greyscale.png") - self.assertEqual(im.mode, "L") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), 128) - self.assertEqual(im.getpixel((64, 32)), 255) - - im = Image.open("Tests/images/apng/mode_greyscale_alpha.png") - self.assertEqual(im.mode, "LA") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (128, 191)) - self.assertEqual(im.getpixel((64, 32)), (128, 191)) - - im = Image.open("Tests/images/apng/mode_palette.png") - self.assertEqual(im.mode, "P") - im.seek(im.n_frames - 1) - im = im.convert("RGB") - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0)) - - im = Image.open("Tests/images/apng/mode_palette_alpha.png") - self.assertEqual(im.mode, "P") - im.seek(im.n_frames - 1) - im = im.convert("RGBA") - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - im = Image.open("Tests/images/apng/mode_palette_1bit_alpha.png") - self.assertEqual(im.mode, "P") - im.seek(im.n_frames - 1) - im = im.convert("RGBA") - self.assertEqual(im.getpixel((0, 0)), (0, 0, 255, 128)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 255, 128)) - - def test_apng_chunk_errors(self): - im = Image.open("Tests/images/apng/chunk_no_actl.png") - self.assertFalse(im.is_animated) - - def open(): - im = Image.open("Tests/images/apng/chunk_multi_actl.png") - im.load() - - pytest.warns(UserWarning, open) - self.assertFalse(im.is_animated) - - im = Image.open("Tests/images/apng/chunk_actl_after_idat.png") - self.assertFalse(im.is_animated) - - im = Image.open("Tests/images/apng/chunk_no_fctl.png") - with self.assertRaises(SyntaxError): - im.seek(im.n_frames - 1) - - im = Image.open("Tests/images/apng/chunk_repeat_fctl.png") - with self.assertRaises(SyntaxError): - im.seek(im.n_frames - 1) - - im = Image.open("Tests/images/apng/chunk_no_fdat.png") - with self.assertRaises(SyntaxError): - im.seek(im.n_frames - 1) - - def test_apng_syntax_errors(self): - def open(): - im = Image.open("Tests/images/apng/syntax_num_frames_zero.png") - self.assertFalse(im.is_animated) - with self.assertRaises(OSError): - im.load() - - pytest.warns(UserWarning, open) - - def open(): - im = Image.open("Tests/images/apng/syntax_num_frames_zero_default.png") - self.assertFalse(im.is_animated) - im.load() - - pytest.warns(UserWarning, open) - - # we can handle this case gracefully - exception = None - im = Image.open("Tests/images/apng/syntax_num_frames_low.png") - try: - im.seek(im.n_frames - 1) - except Exception as e: - exception = e - self.assertIsNone(exception) - - with self.assertRaises(SyntaxError): - im = Image.open("Tests/images/apng/syntax_num_frames_high.png") - im.seek(im.n_frames - 1) - im.load() - - def open(): - im = Image.open("Tests/images/apng/syntax_num_frames_invalid.png") - self.assertFalse(im.is_animated) - im.load() - - pytest.warns(UserWarning, open) - - def test_apng_sequence_errors(self): - test_files = [ - "sequence_start.png", - "sequence_gap.png", - "sequence_repeat.png", - "sequence_repeat_chunk.png", - "sequence_reorder.png", - "sequence_reorder_chunk.png", - "sequence_fdat_fctl.png", - ] - for f in test_files: - with self.assertRaises(SyntaxError): - im = Image.open("Tests/images/apng/{0}".format(f)) - im.seek(im.n_frames - 1) - im.load() - - def test_apng_save(self): - im = Image.open("Tests/images/apng/single_frame.png") - test_file = self.tempfile("temp.png") - im.save(test_file, save_all=True) - - with Image.open(test_file) as im: - im.load() - self.assertTrue(im.is_animated) - self.assertEqual(im.get_format_mimetype(), "image/apng") - self.assertIsNone(im.info.get("default_image")) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - im = Image.open("Tests/images/apng/single_frame_default.png") - frames = [] - for im in ImageSequence.Iterator(im): - frames.append(im.copy()) - frames[0].save( - test_file, save_all=True, default_image=True, append_images=frames[1:] - ) - - with Image.open(test_file) as im: - im.load() - self.assertTrue(im.is_animated) - self.assertEqual(im.get_format_mimetype(), "image/apng") - self.assertTrue(im.info.get("default_image")) - im.seek(1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - def test_apng_save_split_fdat(self): - # test to make sure we do not generate sequence errors when writing - # frames with image data spanning multiple fdAT chunks (in this case - # both the default image and first animation frame will span multiple - # data chunks) - test_file = self.tempfile("temp.png") - with Image.open("Tests/images/old-style-jpeg-compression.png") as im: - frames = [im.copy(), Image.new("RGBA", im.size, (255, 0, 0, 255))] - im.save( - test_file, save_all=True, default_image=True, append_images=frames, - ) - with Image.open(test_file) as im: - exception = None - try: - im.seek(im.n_frames - 1) - im.load() - except Exception as e: - exception = e - self.assertIsNone(exception) - - def test_apng_save_duration_loop(self): - test_file = self.tempfile("temp.png") - im = Image.open("Tests/images/apng/delay.png") - frames = [] - durations = [] - loop = im.info.get("loop") - default_image = im.info.get("default_image") - for i, im in enumerate(ImageSequence.Iterator(im)): - frames.append(im.copy()) - if i != 0 or not default_image: - durations.append(im.info.get("duration", 0)) - frames[0].save( - test_file, - save_all=True, - default_image=default_image, - append_images=frames[1:], - duration=durations, - loop=loop, - ) - with Image.open(test_file) as im: - im.load() - self.assertEqual(im.info.get("loop"), loop) - im.seek(1) - self.assertEqual(im.info.get("duration"), 500.0) - im.seek(2) - self.assertEqual(im.info.get("duration"), 1000.0) - im.seek(3) - self.assertEqual(im.info.get("duration"), 500.0) - im.seek(4) - self.assertEqual(im.info.get("duration"), 1000.0) - - # test removal of duplicated frames - frame = Image.new("RGBA", (128, 64), (255, 0, 0, 255)) - frame.save(test_file, save_all=True, append_images=[frame], duration=[500, 250]) - with Image.open(test_file) as im: - im.load() - self.assertEqual(im.n_frames, 1) - self.assertEqual(im.info.get("duration"), 750) - - # This also tests reading unknown PNG chunks (fcTL and fdAT) in load_end - with Image.open("Tests/images/iss634.webp") as expected: - assert_image_similar(im, expected, 0.23) - - def test_apng_save_disposal(self): - test_file = self.tempfile("temp.png") - size = (128, 64) - red = Image.new("RGBA", size, (255, 0, 0, 255)) - green = Image.new("RGBA", size, (0, 255, 0, 255)) - transparent = Image.new("RGBA", size, (0, 0, 0, 0)) - - # test APNG_DISPOSE_OP_NONE - red.save( - test_file, - save_all=True, - append_images=[green, transparent], - disposal=PngImagePlugin.APNG_DISPOSE_OP_NONE, - blend=PngImagePlugin.APNG_BLEND_OP_OVER, - ) - with Image.open(test_file) as im: - im.seek(2) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - # test APNG_DISPOSE_OP_BACKGROUND - disposal = [ - PngImagePlugin.APNG_DISPOSE_OP_NONE, - PngImagePlugin.APNG_DISPOSE_OP_BACKGROUND, - PngImagePlugin.APNG_DISPOSE_OP_NONE, - ] - red.save( - test_file, - save_all=True, - append_images=[red, transparent], - disposal=disposal, - blend=PngImagePlugin.APNG_BLEND_OP_OVER, - ) - with Image.open(test_file) as im: - im.seek(2) - self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) - - disposal = [ - PngImagePlugin.APNG_DISPOSE_OP_NONE, - PngImagePlugin.APNG_DISPOSE_OP_BACKGROUND, - ] - red.save( - test_file, - save_all=True, - append_images=[green], - disposal=disposal, - blend=PngImagePlugin.APNG_BLEND_OP_OVER, - ) - with Image.open(test_file) as im: - im.seek(1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - # test APNG_DISPOSE_OP_PREVIOUS - disposal = [ - PngImagePlugin.APNG_DISPOSE_OP_NONE, - PngImagePlugin.APNG_DISPOSE_OP_PREVIOUS, - PngImagePlugin.APNG_DISPOSE_OP_NONE, - ] - red.save( - test_file, - save_all=True, - append_images=[green, red, transparent], - default_image=True, - disposal=disposal, - blend=PngImagePlugin.APNG_BLEND_OP_OVER, - ) - with Image.open(test_file) as im: - im.seek(3) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - disposal = [ - PngImagePlugin.APNG_DISPOSE_OP_NONE, - PngImagePlugin.APNG_DISPOSE_OP_PREVIOUS, - ] - red.save( - test_file, - save_all=True, - append_images=[green], - disposal=disposal, - blend=PngImagePlugin.APNG_BLEND_OP_OVER, - ) - with Image.open(test_file) as im: - im.seek(1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - def test_apng_save_blend(self): - test_file = self.tempfile("temp.png") - size = (128, 64) - red = Image.new("RGBA", size, (255, 0, 0, 255)) - green = Image.new("RGBA", size, (0, 255, 0, 255)) - transparent = Image.new("RGBA", size, (0, 0, 0, 0)) - - # test APNG_BLEND_OP_SOURCE on solid color - blend = [ - PngImagePlugin.APNG_BLEND_OP_OVER, - PngImagePlugin.APNG_BLEND_OP_SOURCE, - ] - red.save( - test_file, - save_all=True, - append_images=[red, green], - default_image=True, - disposal=PngImagePlugin.APNG_DISPOSE_OP_NONE, - blend=blend, - ) - with Image.open(test_file) as im: - im.seek(2) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - # test APNG_BLEND_OP_SOURCE on transparent color - blend = [ - PngImagePlugin.APNG_BLEND_OP_OVER, - PngImagePlugin.APNG_BLEND_OP_SOURCE, - ] - red.save( - test_file, - save_all=True, - append_images=[red, transparent], - default_image=True, - disposal=PngImagePlugin.APNG_DISPOSE_OP_NONE, - blend=blend, - ) - with Image.open(test_file) as im: - im.seek(2) - self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) - - # test APNG_BLEND_OP_OVER - red.save( - test_file, - save_all=True, - append_images=[green, transparent], - default_image=True, - disposal=PngImagePlugin.APNG_DISPOSE_OP_NONE, - blend=PngImagePlugin.APNG_BLEND_OP_OVER, - ) - with Image.open(test_file) as im: - im.seek(1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - im.seek(2) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - @unittest.skipIf(is_win32(), "requires Unix or macOS") @skip_unless_feature("zlib") From d05b73cd08cfd7b40b88f3f1f0f4a03a50fe8161 Mon Sep 17 00:00:00 2001 From: Peter Rowlands Date: Fri, 10 Jan 2020 14:44:47 +0900 Subject: [PATCH 12/19] Use context manager for APNG tests --- Tests/test_file_apng.py | 479 ++++++++++++++++++++-------------------- 1 file changed, 242 insertions(+), 237 deletions(-) diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index 8c5ea90ff..36110e891 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -11,276 +11,280 @@ class TestFilePng(PillowTestCase): # https://philip.html5.org/tests/apng/tests.html # (referenced from https://wiki.mozilla.org/APNG_Specification) def test_apng_basic(self): - im = Image.open("Tests/images/apng/single_frame.png") - self.assertTrue(im.is_animated) - self.assertEqual(im.get_format_mimetype(), "image/apng") - self.assertIsNone(im.info.get("default_image")) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + with Image.open("Tests/images/apng/single_frame.png") as im: + self.assertTrue(im.is_animated) + self.assertEqual(im.get_format_mimetype(), "image/apng") + self.assertIsNone(im.info.get("default_image")) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - im = Image.open("Tests/images/apng/single_frame_default.png") - self.assertTrue(im.is_animated) - self.assertEqual(im.get_format_mimetype(), "image/apng") - self.assertTrue(im.info.get("default_image")) - self.assertEqual(im.getpixel((0, 0)), (255, 0, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (255, 0, 0, 255)) - im.seek(1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + with Image.open("Tests/images/apng/single_frame_default.png") as im: + self.assertTrue(im.is_animated) + self.assertEqual(im.get_format_mimetype(), "image/apng") + self.assertTrue(im.info.get("default_image")) + self.assertEqual(im.getpixel((0, 0)), (255, 0, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (255, 0, 0, 255)) + im.seek(1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - # test out of bounds seek - with self.assertRaises(EOFError): - im.seek(2) + # test out of bounds seek + with self.assertRaises(EOFError): + im.seek(2) - # test rewind support - im.seek(0) - self.assertEqual(im.getpixel((0, 0)), (255, 0, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (255, 0, 0, 255)) - im.seek(1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + # test rewind support + im.seek(0) + self.assertEqual(im.getpixel((0, 0)), (255, 0, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (255, 0, 0, 255)) + im.seek(1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) def test_apng_fdat(self): - im = Image.open("Tests/images/apng/split_fdat.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + with Image.open("Tests/images/apng/split_fdat.png") as im: + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - im = Image.open("Tests/images/apng/split_fdat_zero_chunk.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + with Image.open("Tests/images/apng/split_fdat_zero_chunk.png") as im: + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) def test_apng_dispose(self): - im = Image.open("Tests/images/apng/dispose_op_none.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + with Image.open("Tests/images/apng/dispose_op_none.png") as im: + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - im = Image.open("Tests/images/apng/dispose_op_background.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) + with Image.open("Tests/images/apng/dispose_op_background.png") as im: + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) + self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) - im = Image.open("Tests/images/apng/dispose_op_background_final.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + with Image.open("Tests/images/apng/dispose_op_background_final.png") as im: + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - im = Image.open("Tests/images/apng/dispose_op_previous.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + with Image.open("Tests/images/apng/dispose_op_previous.png") as im: + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - im = Image.open("Tests/images/apng/dispose_op_previous_final.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + with Image.open("Tests/images/apng/dispose_op_previous_final.png") as im: + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - im = Image.open("Tests/images/apng/dispose_op_previous_first.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) + with Image.open("Tests/images/apng/dispose_op_previous_first.png") as im: + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) + self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) def test_apng_dispose_region(self): - im = Image.open("Tests/images/apng/dispose_op_none_region.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + with Image.open("Tests/images/apng/dispose_op_none_region.png") as im: + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - im = Image.open("Tests/images/apng/dispose_op_background_before_region.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) + with Image.open( + "Tests/images/apng/dispose_op_background_before_region.png" + ) as im: + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) + self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) - im = Image.open("Tests/images/apng/dispose_op_background_region.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 0, 255, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) + with Image.open("Tests/images/apng/dispose_op_background_region.png") as im: + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 0, 255, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) - im = Image.open("Tests/images/apng/dispose_op_previous_region.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + with Image.open("Tests/images/apng/dispose_op_previous_region.png") as im: + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) def test_apng_blend(self): - im = Image.open("Tests/images/apng/blend_op_source_solid.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + with Image.open("Tests/images/apng/blend_op_source_solid.png") as im: + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - im = Image.open("Tests/images/apng/blend_op_source_transparent.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) + with Image.open("Tests/images/apng/blend_op_source_transparent.png") as im: + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) + self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) - im = Image.open("Tests/images/apng/blend_op_source_near_transparent.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 2)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 2)) + with Image.open("Tests/images/apng/blend_op_source_near_transparent.png") as im: + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 2)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 2)) - im = Image.open("Tests/images/apng/blend_op_over.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + with Image.open("Tests/images/apng/blend_op_over.png") as im: + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - im = Image.open("Tests/images/apng/blend_op_over_near_transparent.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 97)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + with Image.open("Tests/images/apng/blend_op_over_near_transparent.png") as im: + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 97)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) def test_apng_chunk_order(self): - im = Image.open("Tests/images/apng/fctl_actl.png") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + with Image.open("Tests/images/apng/fctl_actl.png") as im: + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) def test_apng_delay(self): - im = Image.open("Tests/images/apng/delay.png") - im.seek(1) - self.assertEqual(im.info.get("duration"), 500.0) - im.seek(2) - self.assertEqual(im.info.get("duration"), 1000.0) - im.seek(3) - self.assertEqual(im.info.get("duration"), 500.0) - im.seek(4) - self.assertEqual(im.info.get("duration"), 1000.0) + with Image.open("Tests/images/apng/delay.png") as im: + im.seek(1) + self.assertEqual(im.info.get("duration"), 500.0) + im.seek(2) + self.assertEqual(im.info.get("duration"), 1000.0) + im.seek(3) + self.assertEqual(im.info.get("duration"), 500.0) + im.seek(4) + self.assertEqual(im.info.get("duration"), 1000.0) - im = Image.open("Tests/images/apng/delay_round.png") - im.seek(1) - self.assertEqual(im.info.get("duration"), 500.0) - im.seek(2) - self.assertEqual(im.info.get("duration"), 1000.0) + with Image.open("Tests/images/apng/delay_round.png") as im: + im.seek(1) + self.assertEqual(im.info.get("duration"), 500.0) + im.seek(2) + self.assertEqual(im.info.get("duration"), 1000.0) - im = Image.open("Tests/images/apng/delay_short_max.png") - im.seek(1) - self.assertEqual(im.info.get("duration"), 500.0) - im.seek(2) - self.assertEqual(im.info.get("duration"), 1000.0) + with Image.open("Tests/images/apng/delay_short_max.png") as im: + im.seek(1) + self.assertEqual(im.info.get("duration"), 500.0) + im.seek(2) + self.assertEqual(im.info.get("duration"), 1000.0) - im = Image.open("Tests/images/apng/delay_zero_denom.png") - im.seek(1) - self.assertEqual(im.info.get("duration"), 500.0) - im.seek(2) - self.assertEqual(im.info.get("duration"), 1000.0) + with Image.open("Tests/images/apng/delay_zero_denom.png") as im: + im.seek(1) + self.assertEqual(im.info.get("duration"), 500.0) + im.seek(2) + self.assertEqual(im.info.get("duration"), 1000.0) - im = Image.open("Tests/images/apng/delay_zero_numer.png") - im.seek(1) - self.assertEqual(im.info.get("duration"), 0.0) - im.seek(2) - self.assertEqual(im.info.get("duration"), 0.0) - im.seek(3) - self.assertEqual(im.info.get("duration"), 500.0) - im.seek(4) - self.assertEqual(im.info.get("duration"), 1000.0) + with Image.open("Tests/images/apng/delay_zero_numer.png") as im: + im.seek(1) + self.assertEqual(im.info.get("duration"), 0.0) + im.seek(2) + self.assertEqual(im.info.get("duration"), 0.0) + im.seek(3) + self.assertEqual(im.info.get("duration"), 500.0) + im.seek(4) + self.assertEqual(im.info.get("duration"), 1000.0) def test_apng_num_plays(self): - im = Image.open("Tests/images/apng/num_plays.png") - self.assertEqual(im.info.get("loop"), 0) + with Image.open("Tests/images/apng/num_plays.png") as im: + self.assertEqual(im.info.get("loop"), 0) - im = Image.open("Tests/images/apng/num_plays_1.png") - self.assertEqual(im.info.get("loop"), 1) + with Image.open("Tests/images/apng/num_plays_1.png") as im: + self.assertEqual(im.info.get("loop"), 1) def test_apng_mode(self): - im = Image.open("Tests/images/apng/mode_16bit.png") - self.assertEqual(im.mode, "RGBA") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 0, 128, 191)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 128, 191)) + with Image.open("Tests/images/apng/mode_16bit.png") as im: + self.assertEqual(im.mode, "RGBA") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (0, 0, 128, 191)) + self.assertEqual(im.getpixel((64, 32)), (0, 0, 128, 191)) - im = Image.open("Tests/images/apng/mode_greyscale.png") - self.assertEqual(im.mode, "L") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), 128) - self.assertEqual(im.getpixel((64, 32)), 255) + with Image.open("Tests/images/apng/mode_greyscale.png") as im: + self.assertEqual(im.mode, "L") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), 128) + self.assertEqual(im.getpixel((64, 32)), 255) - im = Image.open("Tests/images/apng/mode_greyscale_alpha.png") - self.assertEqual(im.mode, "LA") - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (128, 191)) - self.assertEqual(im.getpixel((64, 32)), (128, 191)) + with Image.open("Tests/images/apng/mode_greyscale_alpha.png") as im: + self.assertEqual(im.mode, "LA") + im.seek(im.n_frames - 1) + self.assertEqual(im.getpixel((0, 0)), (128, 191)) + self.assertEqual(im.getpixel((64, 32)), (128, 191)) - im = Image.open("Tests/images/apng/mode_palette.png") - self.assertEqual(im.mode, "P") - im.seek(im.n_frames - 1) - im = im.convert("RGB") - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0)) + with Image.open("Tests/images/apng/mode_palette.png") as im: + self.assertEqual(im.mode, "P") + im.seek(im.n_frames - 1) + im = im.convert("RGB") + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0)) - im = Image.open("Tests/images/apng/mode_palette_alpha.png") - self.assertEqual(im.mode, "P") - im.seek(im.n_frames - 1) - im = im.convert("RGBA") - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + with Image.open("Tests/images/apng/mode_palette_alpha.png") as im: + self.assertEqual(im.mode, "P") + im.seek(im.n_frames - 1) + im = im.convert("RGBA") + self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) + self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - im = Image.open("Tests/images/apng/mode_palette_1bit_alpha.png") - self.assertEqual(im.mode, "P") - im.seek(im.n_frames - 1) - im = im.convert("RGBA") - self.assertEqual(im.getpixel((0, 0)), (0, 0, 255, 128)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 255, 128)) + with Image.open("Tests/images/apng/mode_palette_1bit_alpha.png") as im: + self.assertEqual(im.mode, "P") + im.seek(im.n_frames - 1) + im = im.convert("RGBA") + self.assertEqual(im.getpixel((0, 0)), (0, 0, 255, 128)) + self.assertEqual(im.getpixel((64, 32)), (0, 0, 255, 128)) def test_apng_chunk_errors(self): - im = Image.open("Tests/images/apng/chunk_no_actl.png") - self.assertFalse(im.is_animated) + with Image.open("Tests/images/apng/chunk_no_actl.png") as im: + self.assertFalse(im.is_animated) def open(): - im = Image.open("Tests/images/apng/chunk_multi_actl.png") - im.load() + with Image.open("Tests/images/apng/chunk_multi_actl.png") as im: + im.load() + self.assertFalse(im.is_animated) pytest.warns(UserWarning, open) - self.assertFalse(im.is_animated) - im = Image.open("Tests/images/apng/chunk_actl_after_idat.png") - self.assertFalse(im.is_animated) + with Image.open("Tests/images/apng/chunk_actl_after_idat.png") as im: + self.assertFalse(im.is_animated) - im = Image.open("Tests/images/apng/chunk_no_fctl.png") - with self.assertRaises(SyntaxError): - im.seek(im.n_frames - 1) + with Image.open("Tests/images/apng/chunk_no_fctl.png") as im: + with self.assertRaises(SyntaxError): + im.seek(im.n_frames - 1) - im = Image.open("Tests/images/apng/chunk_repeat_fctl.png") - with self.assertRaises(SyntaxError): - im.seek(im.n_frames - 1) + with Image.open("Tests/images/apng/chunk_repeat_fctl.png") as im: + with self.assertRaises(SyntaxError): + im.seek(im.n_frames - 1) - im = Image.open("Tests/images/apng/chunk_no_fdat.png") - with self.assertRaises(SyntaxError): - im.seek(im.n_frames - 1) + with Image.open("Tests/images/apng/chunk_no_fdat.png") as im: + with self.assertRaises(SyntaxError): + im.seek(im.n_frames - 1) def test_apng_syntax_errors(self): - def open(): - im = Image.open("Tests/images/apng/syntax_num_frames_zero.png") - self.assertFalse(im.is_animated) - with self.assertRaises(OSError): + def open_frames_zero(): + with Image.open("Tests/images/apng/syntax_num_frames_zero.png") as im: + self.assertFalse(im.is_animated) + with self.assertRaises(OSError): + im.load() + + pytest.warns(UserWarning, open_frames_zero) + + def open_frames_zero_default(): + with Image.open( + "Tests/images/apng/syntax_num_frames_zero_default.png" + ) as im: + self.assertFalse(im.is_animated) im.load() - pytest.warns(UserWarning, open) - - def open(): - im = Image.open("Tests/images/apng/syntax_num_frames_zero_default.png") - self.assertFalse(im.is_animated) - im.load() - - pytest.warns(UserWarning, open) + pytest.warns(UserWarning, open_frames_zero_default) # we can handle this case gracefully exception = None - im = Image.open("Tests/images/apng/syntax_num_frames_low.png") - try: - im.seek(im.n_frames - 1) - except Exception as e: - exception = e - self.assertIsNone(exception) + with Image.open("Tests/images/apng/syntax_num_frames_low.png") as im: + try: + im.seek(im.n_frames - 1) + except Exception as e: + exception = e + self.assertIsNone(exception) with self.assertRaises(SyntaxError): - im = Image.open("Tests/images/apng/syntax_num_frames_high.png") - im.seek(im.n_frames - 1) - im.load() + with Image.open("Tests/images/apng/syntax_num_frames_high.png") as im: + im.seek(im.n_frames - 1) + im.load() def open(): - im = Image.open("Tests/images/apng/syntax_num_frames_invalid.png") - self.assertFalse(im.is_animated) - im.load() + with Image.open("Tests/images/apng/syntax_num_frames_invalid.png") as im: + self.assertFalse(im.is_animated) + im.load() pytest.warns(UserWarning, open) @@ -296,14 +300,14 @@ class TestFilePng(PillowTestCase): ] for f in test_files: with self.assertRaises(SyntaxError): - im = Image.open("Tests/images/apng/{0}".format(f)) - im.seek(im.n_frames - 1) - im.load() + with Image.open("Tests/images/apng/{0}".format(f)) as im: + im.seek(im.n_frames - 1) + im.load() def test_apng_save(self): - im = Image.open("Tests/images/apng/single_frame.png") - test_file = self.tempfile("temp.png") - im.save(test_file, save_all=True) + with Image.open("Tests/images/apng/single_frame.png") as im: + test_file = self.tempfile("temp.png") + im.save(test_file, save_all=True) with Image.open(test_file) as im: im.load() @@ -313,13 +317,13 @@ class TestFilePng(PillowTestCase): self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - im = Image.open("Tests/images/apng/single_frame_default.png") - frames = [] - for im in ImageSequence.Iterator(im): - frames.append(im.copy()) - frames[0].save( - test_file, save_all=True, default_image=True, append_images=frames[1:] - ) + with Image.open("Tests/images/apng/single_frame_default.png") as im: + frames = [] + for frame_im in ImageSequence.Iterator(im): + frames.append(frame_im.copy()) + frames[0].save( + test_file, save_all=True, default_image=True, append_images=frames[1:] + ) with Image.open(test_file) as im: im.load() @@ -352,23 +356,24 @@ class TestFilePng(PillowTestCase): def test_apng_save_duration_loop(self): test_file = self.tempfile("temp.png") - im = Image.open("Tests/images/apng/delay.png") - frames = [] - durations = [] - loop = im.info.get("loop") - default_image = im.info.get("default_image") - for i, im in enumerate(ImageSequence.Iterator(im)): - frames.append(im.copy()) - if i != 0 or not default_image: - durations.append(im.info.get("duration", 0)) - frames[0].save( - test_file, - save_all=True, - default_image=default_image, - append_images=frames[1:], - duration=durations, - loop=loop, - ) + with Image.open("Tests/images/apng/delay.png") as im: + frames = [] + durations = [] + loop = im.info.get("loop") + default_image = im.info.get("default_image") + for i, frame_im in enumerate(ImageSequence.Iterator(im)): + frames.append(frame_im.copy()) + if i != 0 or not default_image: + durations.append(frame_im.info.get("duration", 0)) + frames[0].save( + test_file, + save_all=True, + default_image=default_image, + append_images=frames[1:], + duration=durations, + loop=loop, + ) + with Image.open(test_file) as im: im.load() self.assertEqual(im.info.get("loop"), loop) From 3a254701f6782f401095ad7ae4560b68ef25a4e0 Mon Sep 17 00:00:00 2001 From: Peter Rowlands Date: Fri, 10 Jan 2020 14:53:25 +0900 Subject: [PATCH 13/19] Cleanup documentation - Add changes requested by hugovk --- docs/handbook/image-file-formats.rst | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index d34273719..1dd186c98 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -552,9 +552,10 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options: documentation <../installation.html>`_ for details. APNG sequences -~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~ -The PNG loader includes limited support for reading and writing APNG files. +The PNG loader includes limited support for reading and writing Animated Portable +Network Graphics (APNG) files. When an APNG file is loaded, :py:meth:`~PIL.ImageFile.ImageFile.get_format_mimetype` will return ``"image/apng"``, and the :py:attr:`~PIL.Image.Image.is_animated` property will be ``True`` (even for single frame APNG files). @@ -563,7 +564,7 @@ are supported. ``im.seek()`` raises an :py:exc:`EOFError` if you try to seek after the last frame. -The following :py:attr:`~PIL.Image.Image.info` properties will be set for APNG frames, +These :py:attr:`~PIL.Image.Image.info` properties will be set for APNG frames, where applicable: **default_image** From dcc3f41fa1081dd759be19272a76af9eb286ed7e Mon Sep 17 00:00:00 2001 From: Peter Rowlands Date: Fri, 10 Jan 2020 15:30:48 +0900 Subject: [PATCH 14/19] Add release notes --- docs/handbook/image-file-formats.rst | 2 ++ docs/releasenotes/7.1.0.rst | 14 ++++++++++++++ docs/releasenotes/index.rst | 1 + 3 files changed, 17 insertions(+) create mode 100644 docs/releasenotes/7.1.0.rst diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 1dd186c98..a6b87407d 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -551,6 +551,8 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options: library before building the Python Imaging Library. See the `installation documentation <../installation.html>`_ for details. +.. _apng-sequences: + APNG sequences ~~~~~~~~~~~~~~ diff --git a/docs/releasenotes/7.1.0.rst b/docs/releasenotes/7.1.0.rst new file mode 100644 index 000000000..1d276bd6a --- /dev/null +++ b/docs/releasenotes/7.1.0.rst @@ -0,0 +1,14 @@ +7.1.0 +----- + +API Changes +=========== + +Improved APNG support +^^^^^^^^^^^^^^^^^^^^^ + +Added support for reading and writing Animated Portable Network Graphics (APNG) images. +The PNG plugin now supports using the :py:meth:`~PIL.Image.Image.seek` method and the +:py:class:`~PIL.ImageSequence.Iterator` class to read APNG frame sequences. +The PNG plugin also now supports using the ``append_images`` argument to write APNG frame +sequences. See :ref:`apng-sequences` for further details. diff --git a/docs/releasenotes/index.rst b/docs/releasenotes/index.rst index d329257a9..1838803de 100644 --- a/docs/releasenotes/index.rst +++ b/docs/releasenotes/index.rst @@ -6,6 +6,7 @@ Release Notes .. toctree:: :maxdepth: 2 + 7.1.0 7.0.0 6.2.2 6.2.1 From 9b72f0513c89d448e9bdeb4fd11dc8cbc37246d5 Mon Sep 17 00:00:00 2001 From: Peter Rowlands Date: Thu, 23 Jan 2020 09:49:39 +0900 Subject: [PATCH 15/19] Adjust is_animated behavior - Make is_animated APNG behavior consistent with other Pillow formats - is_animated will be true when n_frames is greater than 1 (for APNG this depends on animation frame count + presence or absence of a default image) --- Tests/test_file_apng.py | 8 ++++++-- docs/handbook/image-file-formats.rst | 7 +++++-- src/PIL/PngImagePlugin.py | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index 36110e891..b48995ab6 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -12,7 +12,8 @@ class TestFilePng(PillowTestCase): # (referenced from https://wiki.mozilla.org/APNG_Specification) def test_apng_basic(self): with Image.open("Tests/images/apng/single_frame.png") as im: - self.assertTrue(im.is_animated) + self.assertFalse(im.is_animated) + self.assertEqual(im.n_frames, 1) self.assertEqual(im.get_format_mimetype(), "image/apng") self.assertIsNone(im.info.get("default_image")) self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) @@ -20,6 +21,7 @@ class TestFilePng(PillowTestCase): with Image.open("Tests/images/apng/single_frame_default.png") as im: self.assertTrue(im.is_animated) + self.assertEqual(im.n_frames, 2) self.assertEqual(im.get_format_mimetype(), "image/apng") self.assertTrue(im.info.get("default_image")) self.assertEqual(im.getpixel((0, 0)), (255, 0, 0, 255)) @@ -311,7 +313,8 @@ class TestFilePng(PillowTestCase): with Image.open(test_file) as im: im.load() - self.assertTrue(im.is_animated) + self.assertFalse(im.is_animated) + self.assertEqual(im.n_frames, 1) self.assertEqual(im.get_format_mimetype(), "image/apng") self.assertIsNone(im.info.get("default_image")) self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) @@ -328,6 +331,7 @@ class TestFilePng(PillowTestCase): with Image.open(test_file) as im: im.load() self.assertTrue(im.is_animated) + self.assertEqual(im.n_frames, 2) self.assertEqual(im.get_format_mimetype(), "image/apng") self.assertTrue(im.info.get("default_image")) im.seek(1) diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index a6b87407d..5d882eb76 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -559,8 +559,11 @@ APNG sequences The PNG loader includes limited support for reading and writing Animated Portable Network Graphics (APNG) files. When an APNG file is loaded, :py:meth:`~PIL.ImageFile.ImageFile.get_format_mimetype` -will return ``"image/apng"``, and the :py:attr:`~PIL.Image.Image.is_animated` property -will be ``True`` (even for single frame APNG files). +will return ``"image/apng"``. The value of the :py:attr:`~PIL.Image.Image.is_animated` +property will be ``True`` when the :py:attr:`~PIL.Image.Image.n_frames` property is +greater than 1. For APNG files, the ``n_frames`` property depends on both the animation +frame count as well as the presence or absence of a default image. See the +``default_image`` property documentation below for more details. The :py:meth:`~PIL.Image.Image.seek` and :py:meth:`~PIL.Image.Image.tell` methods are supported. diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 398eade3c..dd3673b53 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -717,7 +717,7 @@ class PngImageFile(ImageFile.ImageFile): @property def is_animated(self): - return self._n_frames is not None + return self._n_frames is not None and self._n_frames > 1 def verify(self): """Verify PNG file""" From 41a29339ffe8e16bbeaabcb74f9175d3f2e70745 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 23 Feb 2020 15:14:42 +1100 Subject: [PATCH 16/19] Lint fixes --- Tests/test_file_apng.py | 3 +-- Tests/test_file_png.py | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index b48995ab6..e34e23fe3 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -1,6 +1,5 @@ -from PIL import Image, ImageSequence, PngImagePlugin - import pytest +from PIL import Image, ImageSequence, PngImagePlugin from .helper import PillowTestCase diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 47ac40f8f..980b581fe 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -10,7 +10,6 @@ from .helper import ( PillowTestCase, assert_image, assert_image_equal, - assert_image_similar, hopper, is_big_endian, is_win32, @@ -18,7 +17,6 @@ from .helper import ( skip_unless_feature, ) - # sample png stream TEST_PNG_FILE = "Tests/images/hopper.png" From 8373c388402db3e8bf6a5b6595678cc53d4ab078 Mon Sep 17 00:00:00 2001 From: Peter Rowlands Date: Sun, 15 Mar 2020 20:02:10 +0900 Subject: [PATCH 17/19] Drop alpha channels when computing frame delta --- src/PIL/PngImagePlugin.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index dd3673b53..d74f3991b 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1058,7 +1058,9 @@ def _write_multiple_frames(im, fp, chunk, rawmode): base_im = im_frames[-2]["im"] else: base_im = previous["im"] - delta = ImageChops.subtract_modulo(im_frame, base_im) + delta = ImageChops.subtract_modulo( + im_frame.convert("RGB"), base_im.convert("RGB") + ) bbox = delta.getbbox() if ( not bbox From 92c9961ceac9fdfb1b3118ace8c1818acbe4305f Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 31 Mar 2020 22:43:31 +0300 Subject: [PATCH 18/19] Convert from unittest to pytest --- Tests/test_file_apng.py | 1024 ++++++++++++++++++++------------------- 1 file changed, 516 insertions(+), 508 deletions(-) diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index e34e23fe3..deb043fdd 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -1,547 +1,555 @@ import pytest from PIL import Image, ImageSequence, PngImagePlugin -from .helper import PillowTestCase +# APNG browser support tests and fixtures via: +# https://philip.html5.org/tests/apng/tests.html +# (referenced from https://wiki.mozilla.org/APNG_Specification) +def test_apng_basic(): + with Image.open("Tests/images/apng/single_frame.png") as im: + assert not im.is_animated + assert im.n_frames == 1 + assert im.get_format_mimetype() == "image/apng" + assert im.info.get("default_image") is None + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) -class TestFilePng(PillowTestCase): + with Image.open("Tests/images/apng/single_frame_default.png") as im: + assert im.is_animated + assert im.n_frames == 2 + assert im.get_format_mimetype() == "image/apng" + assert im.info.get("default_image") + assert im.getpixel((0, 0)) == (255, 0, 0, 255) + assert im.getpixel((64, 32)) == (255, 0, 0, 255) + im.seek(1) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) - # APNG browser support tests and fixtures via: - # https://philip.html5.org/tests/apng/tests.html - # (referenced from https://wiki.mozilla.org/APNG_Specification) - def test_apng_basic(self): - with Image.open("Tests/images/apng/single_frame.png") as im: - self.assertFalse(im.is_animated) - self.assertEqual(im.n_frames, 1) - self.assertEqual(im.get_format_mimetype(), "image/apng") - self.assertIsNone(im.info.get("default_image")) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - with Image.open("Tests/images/apng/single_frame_default.png") as im: - self.assertTrue(im.is_animated) - self.assertEqual(im.n_frames, 2) - self.assertEqual(im.get_format_mimetype(), "image/apng") - self.assertTrue(im.info.get("default_image")) - self.assertEqual(im.getpixel((0, 0)), (255, 0, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (255, 0, 0, 255)) - im.seek(1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - # test out of bounds seek - with self.assertRaises(EOFError): - im.seek(2) - - # test rewind support - im.seek(0) - self.assertEqual(im.getpixel((0, 0)), (255, 0, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (255, 0, 0, 255)) - im.seek(1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - def test_apng_fdat(self): - with Image.open("Tests/images/apng/split_fdat.png") as im: - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - with Image.open("Tests/images/apng/split_fdat_zero_chunk.png") as im: - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - def test_apng_dispose(self): - with Image.open("Tests/images/apng/dispose_op_none.png") as im: - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - with Image.open("Tests/images/apng/dispose_op_background.png") as im: - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) - - with Image.open("Tests/images/apng/dispose_op_background_final.png") as im: - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - with Image.open("Tests/images/apng/dispose_op_previous.png") as im: - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - with Image.open("Tests/images/apng/dispose_op_previous_final.png") as im: - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - with Image.open("Tests/images/apng/dispose_op_previous_first.png") as im: - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) - - def test_apng_dispose_region(self): - with Image.open("Tests/images/apng/dispose_op_none_region.png") as im: - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - with Image.open( - "Tests/images/apng/dispose_op_background_before_region.png" - ) as im: - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) - - with Image.open("Tests/images/apng/dispose_op_background_region.png") as im: - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 0, 255, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) - - with Image.open("Tests/images/apng/dispose_op_previous_region.png") as im: - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - def test_apng_blend(self): - with Image.open("Tests/images/apng/blend_op_source_solid.png") as im: - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - with Image.open("Tests/images/apng/blend_op_source_transparent.png") as im: - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) - - with Image.open("Tests/images/apng/blend_op_source_near_transparent.png") as im: - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 2)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 2)) - - with Image.open("Tests/images/apng/blend_op_over.png") as im: - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - with Image.open("Tests/images/apng/blend_op_over_near_transparent.png") as im: - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 97)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - def test_apng_chunk_order(self): - with Image.open("Tests/images/apng/fctl_actl.png") as im: - im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - def test_apng_delay(self): - with Image.open("Tests/images/apng/delay.png") as im: - im.seek(1) - self.assertEqual(im.info.get("duration"), 500.0) + # test out of bounds seek + with pytest.raises(EOFError): im.seek(2) - self.assertEqual(im.info.get("duration"), 1000.0) - im.seek(3) - self.assertEqual(im.info.get("duration"), 500.0) - im.seek(4) - self.assertEqual(im.info.get("duration"), 1000.0) - with Image.open("Tests/images/apng/delay_round.png") as im: - im.seek(1) - self.assertEqual(im.info.get("duration"), 500.0) - im.seek(2) - self.assertEqual(im.info.get("duration"), 1000.0) + # test rewind support + im.seek(0) + assert im.getpixel((0, 0)) == (255, 0, 0, 255) + assert im.getpixel((64, 32)) == (255, 0, 0, 255) + im.seek(1) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) - with Image.open("Tests/images/apng/delay_short_max.png") as im: - im.seek(1) - self.assertEqual(im.info.get("duration"), 500.0) - im.seek(2) - self.assertEqual(im.info.get("duration"), 1000.0) - with Image.open("Tests/images/apng/delay_zero_denom.png") as im: - im.seek(1) - self.assertEqual(im.info.get("duration"), 500.0) - im.seek(2) - self.assertEqual(im.info.get("duration"), 1000.0) +def test_apng_fdat(): + with Image.open("Tests/images/apng/split_fdat.png") as im: + im.seek(im.n_frames - 1) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) - with Image.open("Tests/images/apng/delay_zero_numer.png") as im: - im.seek(1) - self.assertEqual(im.info.get("duration"), 0.0) - im.seek(2) - self.assertEqual(im.info.get("duration"), 0.0) - im.seek(3) - self.assertEqual(im.info.get("duration"), 500.0) - im.seek(4) - self.assertEqual(im.info.get("duration"), 1000.0) + with Image.open("Tests/images/apng/split_fdat_zero_chunk.png") as im: + im.seek(im.n_frames - 1) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) - def test_apng_num_plays(self): - with Image.open("Tests/images/apng/num_plays.png") as im: - self.assertEqual(im.info.get("loop"), 0) - with Image.open("Tests/images/apng/num_plays_1.png") as im: - self.assertEqual(im.info.get("loop"), 1) +def test_apng_dispose(): + with Image.open("Tests/images/apng/dispose_op_none.png") as im: + im.seek(im.n_frames - 1) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) - def test_apng_mode(self): - with Image.open("Tests/images/apng/mode_16bit.png") as im: - self.assertEqual(im.mode, "RGBA") + with Image.open("Tests/images/apng/dispose_op_background.png") as im: + im.seek(im.n_frames - 1) + assert im.getpixel((0, 0)) == (0, 0, 0, 0) + assert im.getpixel((64, 32)) == (0, 0, 0, 0) + + with Image.open("Tests/images/apng/dispose_op_background_final.png") as im: + im.seek(im.n_frames - 1) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) + + with Image.open("Tests/images/apng/dispose_op_previous.png") as im: + im.seek(im.n_frames - 1) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) + + with Image.open("Tests/images/apng/dispose_op_previous_final.png") as im: + im.seek(im.n_frames - 1) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) + + with Image.open("Tests/images/apng/dispose_op_previous_first.png") as im: + im.seek(im.n_frames - 1) + assert im.getpixel((0, 0)) == (0, 0, 0, 0) + assert im.getpixel((64, 32)) == (0, 0, 0, 0) + + +def test_apng_dispose_region(): + with Image.open("Tests/images/apng/dispose_op_none_region.png") as im: + im.seek(im.n_frames - 1) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) + + with Image.open("Tests/images/apng/dispose_op_background_before_region.png") as im: + im.seek(im.n_frames - 1) + assert im.getpixel((0, 0)) == (0, 0, 0, 0) + assert im.getpixel((64, 32)) == (0, 0, 0, 0) + + with Image.open("Tests/images/apng/dispose_op_background_region.png") as im: + im.seek(im.n_frames - 1) + assert im.getpixel((0, 0)) == (0, 0, 255, 255) + assert im.getpixel((64, 32)) == (0, 0, 0, 0) + + with Image.open("Tests/images/apng/dispose_op_previous_region.png") as im: + im.seek(im.n_frames - 1) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) + + +def test_apng_blend(): + with Image.open("Tests/images/apng/blend_op_source_solid.png") as im: + im.seek(im.n_frames - 1) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) + + with Image.open("Tests/images/apng/blend_op_source_transparent.png") as im: + im.seek(im.n_frames - 1) + assert im.getpixel((0, 0)) == (0, 0, 0, 0) + assert im.getpixel((64, 32)) == (0, 0, 0, 0) + + with Image.open("Tests/images/apng/blend_op_source_near_transparent.png") as im: + im.seek(im.n_frames - 1) + assert im.getpixel((0, 0)) == (0, 255, 0, 2) + assert im.getpixel((64, 32)) == (0, 255, 0, 2) + + with Image.open("Tests/images/apng/blend_op_over.png") as im: + im.seek(im.n_frames - 1) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) + + with Image.open("Tests/images/apng/blend_op_over_near_transparent.png") as im: + im.seek(im.n_frames - 1) + assert im.getpixel((0, 0)) == (0, 255, 0, 97) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) + + +def test_apng_chunk_order(): + with Image.open("Tests/images/apng/fctl_actl.png") as im: + im.seek(im.n_frames - 1) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) + + +def test_apng_delay(): + with Image.open("Tests/images/apng/delay.png") as im: + im.seek(1) + assert im.info.get("duration") == 500.0 + im.seek(2) + assert im.info.get("duration") == 1000.0 + im.seek(3) + assert im.info.get("duration") == 500.0 + im.seek(4) + assert im.info.get("duration") == 1000.0 + + with Image.open("Tests/images/apng/delay_round.png") as im: + im.seek(1) + assert im.info.get("duration") == 500.0 + im.seek(2) + assert im.info.get("duration") == 1000.0 + + with Image.open("Tests/images/apng/delay_short_max.png") as im: + im.seek(1) + assert im.info.get("duration") == 500.0 + im.seek(2) + assert im.info.get("duration") == 1000.0 + + with Image.open("Tests/images/apng/delay_zero_denom.png") as im: + im.seek(1) + assert im.info.get("duration") == 500.0 + im.seek(2) + assert im.info.get("duration") == 1000.0 + + with Image.open("Tests/images/apng/delay_zero_numer.png") as im: + im.seek(1) + assert im.info.get("duration") == 0.0 + im.seek(2) + assert im.info.get("duration") == 0.0 + im.seek(3) + assert im.info.get("duration") == 500.0 + im.seek(4) + assert im.info.get("duration") == 1000.0 + + +def test_apng_num_plays(): + with Image.open("Tests/images/apng/num_plays.png") as im: + assert im.info.get("loop") == 0 + + with Image.open("Tests/images/apng/num_plays_1.png") as im: + assert im.info.get("loop") == 1 + + +def test_apng_mode(): + with Image.open("Tests/images/apng/mode_16bit.png") as im: + assert im.mode == "RGBA" + im.seek(im.n_frames - 1) + assert im.getpixel((0, 0)) == (0, 0, 128, 191) + assert im.getpixel((64, 32)) == (0, 0, 128, 191) + + with Image.open("Tests/images/apng/mode_greyscale.png") as im: + assert im.mode == "L" + im.seek(im.n_frames - 1) + assert im.getpixel((0, 0)) == 128 + assert im.getpixel((64, 32)) == 255 + + with Image.open("Tests/images/apng/mode_greyscale_alpha.png") as im: + assert im.mode == "LA" + im.seek(im.n_frames - 1) + assert im.getpixel((0, 0)) == (128, 191) + assert im.getpixel((64, 32)) == (128, 191) + + with Image.open("Tests/images/apng/mode_palette.png") as im: + assert im.mode == "P" + im.seek(im.n_frames - 1) + im = im.convert("RGB") + assert im.getpixel((0, 0)) == (0, 255, 0) + assert im.getpixel((64, 32)) == (0, 255, 0) + + with Image.open("Tests/images/apng/mode_palette_alpha.png") as im: + assert im.mode == "P" + im.seek(im.n_frames - 1) + im = im.convert("RGBA") + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) + + with Image.open("Tests/images/apng/mode_palette_1bit_alpha.png") as im: + assert im.mode == "P" + im.seek(im.n_frames - 1) + im = im.convert("RGBA") + assert im.getpixel((0, 0)) == (0, 0, 255, 128) + assert im.getpixel((64, 32)) == (0, 0, 255, 128) + + +def test_apng_chunk_errors(): + with Image.open("Tests/images/apng/chunk_no_actl.png") as im: + assert not im.is_animated + + def open(): + with Image.open("Tests/images/apng/chunk_multi_actl.png") as im: + im.load() + assert not im.is_animated + + pytest.warns(UserWarning, open) + + with Image.open("Tests/images/apng/chunk_actl_after_idat.png") as im: + assert not im.is_animated + + with Image.open("Tests/images/apng/chunk_no_fctl.png") as im: + with pytest.raises(SyntaxError): im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (0, 0, 128, 191)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 128, 191)) - with Image.open("Tests/images/apng/mode_greyscale.png") as im: - self.assertEqual(im.mode, "L") + with Image.open("Tests/images/apng/chunk_repeat_fctl.png") as im: + with pytest.raises(SyntaxError): im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), 128) - self.assertEqual(im.getpixel((64, 32)), 255) - with Image.open("Tests/images/apng/mode_greyscale_alpha.png") as im: - self.assertEqual(im.mode, "LA") + with Image.open("Tests/images/apng/chunk_no_fdat.png") as im: + with pytest.raises(SyntaxError): im.seek(im.n_frames - 1) - self.assertEqual(im.getpixel((0, 0)), (128, 191)) - self.assertEqual(im.getpixel((64, 32)), (128, 191)) - with Image.open("Tests/images/apng/mode_palette.png") as im: - self.assertEqual(im.mode, "P") - im.seek(im.n_frames - 1) - im = im.convert("RGB") - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0)) - with Image.open("Tests/images/apng/mode_palette_alpha.png") as im: - self.assertEqual(im.mode, "P") - im.seek(im.n_frames - 1) - im = im.convert("RGBA") - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - with Image.open("Tests/images/apng/mode_palette_1bit_alpha.png") as im: - self.assertEqual(im.mode, "P") - im.seek(im.n_frames - 1) - im = im.convert("RGBA") - self.assertEqual(im.getpixel((0, 0)), (0, 0, 255, 128)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 255, 128)) - - def test_apng_chunk_errors(self): - with Image.open("Tests/images/apng/chunk_no_actl.png") as im: - self.assertFalse(im.is_animated) - - def open(): - with Image.open("Tests/images/apng/chunk_multi_actl.png") as im: - im.load() - self.assertFalse(im.is_animated) - - pytest.warns(UserWarning, open) - - with Image.open("Tests/images/apng/chunk_actl_after_idat.png") as im: - self.assertFalse(im.is_animated) - - with Image.open("Tests/images/apng/chunk_no_fctl.png") as im: - with self.assertRaises(SyntaxError): - im.seek(im.n_frames - 1) - - with Image.open("Tests/images/apng/chunk_repeat_fctl.png") as im: - with self.assertRaises(SyntaxError): - im.seek(im.n_frames - 1) - - with Image.open("Tests/images/apng/chunk_no_fdat.png") as im: - with self.assertRaises(SyntaxError): - im.seek(im.n_frames - 1) - - def test_apng_syntax_errors(self): - def open_frames_zero(): - with Image.open("Tests/images/apng/syntax_num_frames_zero.png") as im: - self.assertFalse(im.is_animated) - with self.assertRaises(OSError): - im.load() - - pytest.warns(UserWarning, open_frames_zero) - - def open_frames_zero_default(): - with Image.open( - "Tests/images/apng/syntax_num_frames_zero_default.png" - ) as im: - self.assertFalse(im.is_animated) +def test_apng_syntax_errors(): + def open_frames_zero(): + with Image.open("Tests/images/apng/syntax_num_frames_zero.png") as im: + assert not im.is_animated + with pytest.raises(OSError): im.load() - pytest.warns(UserWarning, open_frames_zero_default) + pytest.warns(UserWarning, open_frames_zero) - # we can handle this case gracefully + def open_frames_zero_default(): + with Image.open("Tests/images/apng/syntax_num_frames_zero_default.png") as im: + assert not im.is_animated + im.load() + + pytest.warns(UserWarning, open_frames_zero_default) + + # we can handle this case gracefully + exception = None + with Image.open("Tests/images/apng/syntax_num_frames_low.png") as im: + try: + im.seek(im.n_frames - 1) + except Exception as e: + exception = e + assert exception is None + + with pytest.raises(SyntaxError): + with Image.open("Tests/images/apng/syntax_num_frames_high.png") as im: + im.seek(im.n_frames - 1) + im.load() + + def open(): + with Image.open("Tests/images/apng/syntax_num_frames_invalid.png") as im: + assert not im.is_animated + im.load() + + pytest.warns(UserWarning, open) + + +def test_apng_sequence_errors(): + test_files = [ + "sequence_start.png", + "sequence_gap.png", + "sequence_repeat.png", + "sequence_repeat_chunk.png", + "sequence_reorder.png", + "sequence_reorder_chunk.png", + "sequence_fdat_fctl.png", + ] + for f in test_files: + with pytest.raises(SyntaxError): + with Image.open("Tests/images/apng/{0}".format(f)) as im: + im.seek(im.n_frames - 1) + im.load() + + +def test_apng_save(tmp_path): + with Image.open("Tests/images/apng/single_frame.png") as im: + test_file = str(tmp_path / "temp.png") + im.save(test_file, save_all=True) + + with Image.open(test_file) as im: + im.load() + assert not im.is_animated + assert im.n_frames == 1 + assert im.get_format_mimetype() == "image/apng" + assert im.info.get("default_image") is None + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) + + with Image.open("Tests/images/apng/single_frame_default.png") as im: + frames = [] + for frame_im in ImageSequence.Iterator(im): + frames.append(frame_im.copy()) + frames[0].save( + test_file, save_all=True, default_image=True, append_images=frames[1:] + ) + + with Image.open(test_file) as im: + im.load() + assert im.is_animated + assert im.n_frames == 2 + assert im.get_format_mimetype() == "image/apng" + assert im.info.get("default_image") + im.seek(1) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) + + +def test_apng_save_split_fdat(tmp_path): + # test to make sure we do not generate sequence errors when writing + # frames with image data spanning multiple fdAT chunks (in this case + # both the default image and first animation frame will span multiple + # data chunks) + test_file = str(tmp_path / "temp.png") + with Image.open("Tests/images/old-style-jpeg-compression.png") as im: + frames = [im.copy(), Image.new("RGBA", im.size, (255, 0, 0, 255))] + im.save( + test_file, save_all=True, default_image=True, append_images=frames, + ) + with Image.open(test_file) as im: exception = None - with Image.open("Tests/images/apng/syntax_num_frames_low.png") as im: - try: - im.seek(im.n_frames - 1) - except Exception as e: - exception = e - self.assertIsNone(exception) - - with self.assertRaises(SyntaxError): - with Image.open("Tests/images/apng/syntax_num_frames_high.png") as im: - im.seek(im.n_frames - 1) - im.load() - - def open(): - with Image.open("Tests/images/apng/syntax_num_frames_invalid.png") as im: - self.assertFalse(im.is_animated) - im.load() - - pytest.warns(UserWarning, open) - - def test_apng_sequence_errors(self): - test_files = [ - "sequence_start.png", - "sequence_gap.png", - "sequence_repeat.png", - "sequence_repeat_chunk.png", - "sequence_reorder.png", - "sequence_reorder_chunk.png", - "sequence_fdat_fctl.png", - ] - for f in test_files: - with self.assertRaises(SyntaxError): - with Image.open("Tests/images/apng/{0}".format(f)) as im: - im.seek(im.n_frames - 1) - im.load() - - def test_apng_save(self): - with Image.open("Tests/images/apng/single_frame.png") as im: - test_file = self.tempfile("temp.png") - im.save(test_file, save_all=True) - - with Image.open(test_file) as im: + try: + im.seek(im.n_frames - 1) im.load() - self.assertFalse(im.is_animated) - self.assertEqual(im.n_frames, 1) - self.assertEqual(im.get_format_mimetype(), "image/apng") - self.assertIsNone(im.info.get("default_image")) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + except Exception as e: + exception = e + assert exception is None - with Image.open("Tests/images/apng/single_frame_default.png") as im: - frames = [] - for frame_im in ImageSequence.Iterator(im): - frames.append(frame_im.copy()) - frames[0].save( - test_file, save_all=True, default_image=True, append_images=frames[1:] - ) - with Image.open(test_file) as im: - im.load() - self.assertTrue(im.is_animated) - self.assertEqual(im.n_frames, 2) - self.assertEqual(im.get_format_mimetype(), "image/apng") - self.assertTrue(im.info.get("default_image")) - im.seek(1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - - def test_apng_save_split_fdat(self): - # test to make sure we do not generate sequence errors when writing - # frames with image data spanning multiple fdAT chunks (in this case - # both the default image and first animation frame will span multiple - # data chunks) - test_file = self.tempfile("temp.png") - with Image.open("Tests/images/old-style-jpeg-compression.png") as im: - frames = [im.copy(), Image.new("RGBA", im.size, (255, 0, 0, 255))] - im.save( - test_file, save_all=True, default_image=True, append_images=frames, - ) - with Image.open(test_file) as im: - exception = None - try: - im.seek(im.n_frames - 1) - im.load() - except Exception as e: - exception = e - self.assertIsNone(exception) - - def test_apng_save_duration_loop(self): - test_file = self.tempfile("temp.png") - with Image.open("Tests/images/apng/delay.png") as im: - frames = [] - durations = [] - loop = im.info.get("loop") - default_image = im.info.get("default_image") - for i, frame_im in enumerate(ImageSequence.Iterator(im)): - frames.append(frame_im.copy()) - if i != 0 or not default_image: - durations.append(frame_im.info.get("duration", 0)) - frames[0].save( - test_file, - save_all=True, - default_image=default_image, - append_images=frames[1:], - duration=durations, - loop=loop, - ) - - with Image.open(test_file) as im: - im.load() - self.assertEqual(im.info.get("loop"), loop) - im.seek(1) - self.assertEqual(im.info.get("duration"), 500.0) - im.seek(2) - self.assertEqual(im.info.get("duration"), 1000.0) - im.seek(3) - self.assertEqual(im.info.get("duration"), 500.0) - im.seek(4) - self.assertEqual(im.info.get("duration"), 1000.0) - - # test removal of duplicated frames - frame = Image.new("RGBA", (128, 64), (255, 0, 0, 255)) - frame.save(test_file, save_all=True, append_images=[frame], duration=[500, 250]) - with Image.open(test_file) as im: - im.load() - self.assertEqual(im.n_frames, 1) - self.assertEqual(im.info.get("duration"), 750) - - def test_apng_save_disposal(self): - test_file = self.tempfile("temp.png") - size = (128, 64) - red = Image.new("RGBA", size, (255, 0, 0, 255)) - green = Image.new("RGBA", size, (0, 255, 0, 255)) - transparent = Image.new("RGBA", size, (0, 0, 0, 0)) - - # test APNG_DISPOSE_OP_NONE - red.save( +def test_apng_save_duration_loop(tmp_path): + test_file = str(tmp_path / "temp.png") + with Image.open("Tests/images/apng/delay.png") as im: + frames = [] + durations = [] + loop = im.info.get("loop") + default_image = im.info.get("default_image") + for i, frame_im in enumerate(ImageSequence.Iterator(im)): + frames.append(frame_im.copy()) + if i != 0 or not default_image: + durations.append(frame_im.info.get("duration", 0)) + frames[0].save( test_file, save_all=True, - append_images=[green, transparent], - disposal=PngImagePlugin.APNG_DISPOSE_OP_NONE, - blend=PngImagePlugin.APNG_BLEND_OP_OVER, + default_image=default_image, + append_images=frames[1:], + duration=durations, + loop=loop, ) - with Image.open(test_file) as im: - im.seek(2) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - # test APNG_DISPOSE_OP_BACKGROUND - disposal = [ - PngImagePlugin.APNG_DISPOSE_OP_NONE, - PngImagePlugin.APNG_DISPOSE_OP_BACKGROUND, - PngImagePlugin.APNG_DISPOSE_OP_NONE, - ] - red.save( - test_file, - save_all=True, - append_images=[red, transparent], - disposal=disposal, - blend=PngImagePlugin.APNG_BLEND_OP_OVER, - ) - with Image.open(test_file) as im: - im.seek(2) - self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) + with Image.open(test_file) as im: + im.load() + assert im.info.get("loop") == loop + im.seek(1) + assert im.info.get("duration") == 500.0 + im.seek(2) + assert im.info.get("duration") == 1000.0 + im.seek(3) + assert im.info.get("duration") == 500.0 + im.seek(4) + assert im.info.get("duration") == 1000.0 - disposal = [ - PngImagePlugin.APNG_DISPOSE_OP_NONE, - PngImagePlugin.APNG_DISPOSE_OP_BACKGROUND, - ] - red.save( - test_file, - save_all=True, - append_images=[green], - disposal=disposal, - blend=PngImagePlugin.APNG_BLEND_OP_OVER, - ) - with Image.open(test_file) as im: - im.seek(1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + # test removal of duplicated frames + frame = Image.new("RGBA", (128, 64), (255, 0, 0, 255)) + frame.save(test_file, save_all=True, append_images=[frame], duration=[500, 250]) + with Image.open(test_file) as im: + im.load() + assert im.n_frames == 1 + assert im.info.get("duration") == 750 - # test APNG_DISPOSE_OP_PREVIOUS - disposal = [ - PngImagePlugin.APNG_DISPOSE_OP_NONE, - PngImagePlugin.APNG_DISPOSE_OP_PREVIOUS, - PngImagePlugin.APNG_DISPOSE_OP_NONE, - ] - red.save( - test_file, - save_all=True, - append_images=[green, red, transparent], - default_image=True, - disposal=disposal, - blend=PngImagePlugin.APNG_BLEND_OP_OVER, - ) - with Image.open(test_file) as im: - im.seek(3) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - disposal = [ - PngImagePlugin.APNG_DISPOSE_OP_NONE, - PngImagePlugin.APNG_DISPOSE_OP_PREVIOUS, - ] - red.save( - test_file, - save_all=True, - append_images=[green], - disposal=disposal, - blend=PngImagePlugin.APNG_BLEND_OP_OVER, - ) - with Image.open(test_file) as im: - im.seek(1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) +def test_apng_save_disposal(tmp_path): + test_file = str(tmp_path / "temp.png") + size = (128, 64) + red = Image.new("RGBA", size, (255, 0, 0, 255)) + green = Image.new("RGBA", size, (0, 255, 0, 255)) + transparent = Image.new("RGBA", size, (0, 0, 0, 0)) - def test_apng_save_blend(self): - test_file = self.tempfile("temp.png") - size = (128, 64) - red = Image.new("RGBA", size, (255, 0, 0, 255)) - green = Image.new("RGBA", size, (0, 255, 0, 255)) - transparent = Image.new("RGBA", size, (0, 0, 0, 0)) + # test APNG_DISPOSE_OP_NONE + red.save( + test_file, + save_all=True, + append_images=[green, transparent], + disposal=PngImagePlugin.APNG_DISPOSE_OP_NONE, + blend=PngImagePlugin.APNG_BLEND_OP_OVER, + ) + with Image.open(test_file) as im: + im.seek(2) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) - # test APNG_BLEND_OP_SOURCE on solid color - blend = [ - PngImagePlugin.APNG_BLEND_OP_OVER, - PngImagePlugin.APNG_BLEND_OP_SOURCE, - ] - red.save( - test_file, - save_all=True, - append_images=[red, green], - default_image=True, - disposal=PngImagePlugin.APNG_DISPOSE_OP_NONE, - blend=blend, - ) - with Image.open(test_file) as im: - im.seek(2) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + # test APNG_DISPOSE_OP_BACKGROUND + disposal = [ + PngImagePlugin.APNG_DISPOSE_OP_NONE, + PngImagePlugin.APNG_DISPOSE_OP_BACKGROUND, + PngImagePlugin.APNG_DISPOSE_OP_NONE, + ] + red.save( + test_file, + save_all=True, + append_images=[red, transparent], + disposal=disposal, + blend=PngImagePlugin.APNG_BLEND_OP_OVER, + ) + with Image.open(test_file) as im: + im.seek(2) + assert im.getpixel((0, 0)) == (0, 0, 0, 0) + assert im.getpixel((64, 32)) == (0, 0, 0, 0) - # test APNG_BLEND_OP_SOURCE on transparent color - blend = [ - PngImagePlugin.APNG_BLEND_OP_OVER, - PngImagePlugin.APNG_BLEND_OP_SOURCE, - ] - red.save( - test_file, - save_all=True, - append_images=[red, transparent], - default_image=True, - disposal=PngImagePlugin.APNG_DISPOSE_OP_NONE, - blend=blend, - ) - with Image.open(test_file) as im: - im.seek(2) - self.assertEqual(im.getpixel((0, 0)), (0, 0, 0, 0)) - self.assertEqual(im.getpixel((64, 32)), (0, 0, 0, 0)) + disposal = [ + PngImagePlugin.APNG_DISPOSE_OP_NONE, + PngImagePlugin.APNG_DISPOSE_OP_BACKGROUND, + ] + red.save( + test_file, + save_all=True, + append_images=[green], + disposal=disposal, + blend=PngImagePlugin.APNG_BLEND_OP_OVER, + ) + with Image.open(test_file) as im: + im.seek(1) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) - # test APNG_BLEND_OP_OVER - red.save( - test_file, - save_all=True, - append_images=[green, transparent], - default_image=True, - disposal=PngImagePlugin.APNG_DISPOSE_OP_NONE, - blend=PngImagePlugin.APNG_BLEND_OP_OVER, - ) - with Image.open(test_file) as im: - im.seek(1) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) - im.seek(2) - self.assertEqual(im.getpixel((0, 0)), (0, 255, 0, 255)) - self.assertEqual(im.getpixel((64, 32)), (0, 255, 0, 255)) + # test APNG_DISPOSE_OP_PREVIOUS + disposal = [ + PngImagePlugin.APNG_DISPOSE_OP_NONE, + PngImagePlugin.APNG_DISPOSE_OP_PREVIOUS, + PngImagePlugin.APNG_DISPOSE_OP_NONE, + ] + red.save( + test_file, + save_all=True, + append_images=[green, red, transparent], + default_image=True, + disposal=disposal, + blend=PngImagePlugin.APNG_BLEND_OP_OVER, + ) + with Image.open(test_file) as im: + im.seek(3) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) + + disposal = [ + PngImagePlugin.APNG_DISPOSE_OP_NONE, + PngImagePlugin.APNG_DISPOSE_OP_PREVIOUS, + ] + red.save( + test_file, + save_all=True, + append_images=[green], + disposal=disposal, + blend=PngImagePlugin.APNG_BLEND_OP_OVER, + ) + with Image.open(test_file) as im: + im.seek(1) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) + + +def test_apng_save_blend(tmp_path): + test_file = str(tmp_path / "temp.png") + size = (128, 64) + red = Image.new("RGBA", size, (255, 0, 0, 255)) + green = Image.new("RGBA", size, (0, 255, 0, 255)) + transparent = Image.new("RGBA", size, (0, 0, 0, 0)) + + # test APNG_BLEND_OP_SOURCE on solid color + blend = [ + PngImagePlugin.APNG_BLEND_OP_OVER, + PngImagePlugin.APNG_BLEND_OP_SOURCE, + ] + red.save( + test_file, + save_all=True, + append_images=[red, green], + default_image=True, + disposal=PngImagePlugin.APNG_DISPOSE_OP_NONE, + blend=blend, + ) + with Image.open(test_file) as im: + im.seek(2) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) + + # test APNG_BLEND_OP_SOURCE on transparent color + blend = [ + PngImagePlugin.APNG_BLEND_OP_OVER, + PngImagePlugin.APNG_BLEND_OP_SOURCE, + ] + red.save( + test_file, + save_all=True, + append_images=[red, transparent], + default_image=True, + disposal=PngImagePlugin.APNG_DISPOSE_OP_NONE, + blend=blend, + ) + with Image.open(test_file) as im: + im.seek(2) + assert im.getpixel((0, 0)) == (0, 0, 0, 0) + assert im.getpixel((64, 32)) == (0, 0, 0, 0) + + # test APNG_BLEND_OP_OVER + red.save( + test_file, + save_all=True, + append_images=[green, transparent], + default_image=True, + disposal=PngImagePlugin.APNG_DISPOSE_OP_NONE, + blend=PngImagePlugin.APNG_BLEND_OP_OVER, + ) + with Image.open(test_file) as im: + im.seek(1) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) + im.seek(2) + assert im.getpixel((0, 0)) == (0, 255, 0, 255) + assert im.getpixel((64, 32)) == (0, 255, 0, 255) From 91289f863be0a1ac05565fd57d712b883308a255 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 31 Mar 2020 22:53:35 +0300 Subject: [PATCH 19/19] Use v2 of actions/checkout To fix https://github.com/actions/checkout/issues/23#issuecomment-572688577 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 137cc750a..443eff2a9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,7 +34,7 @@ jobs: name: ${{ matrix.os }} Python ${{ matrix.python-version }} steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: Ubuntu cache uses: actions/cache@v1