Repository: python-pillow/Pillow

Issue:
Add apng support
apng is not what it used to. Now google and apple support it.


Discussion:

Resolving patch:
diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py
--- 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_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 @@ def __init__(self, fp):
         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 @@ def check_text_memory(self, chunklen):
                 % 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,13 @@ def chunk_IHDR(self, pos, length):
     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 +570,49 @@ def chunk_eXIf(self, pos, length):
     # 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 +635,10 @@ def _open(self):
 
         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 or fDAT chunk
 
         self.png = PngStream(self.fp)
 
@@ -598,12 +672,27 @@ def _open(self):
         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 +700,25 @@ def text(self):
         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 and self._n_frames > 1
+
     def verify(self):
         """Verify PNG file"""
 
@@ -630,6 +735,97 @@ def verify(self):
             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
+                if self.pyaccess:
+                    self.pyaccess = 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 @@ def load_read(self, read_bytes):
 
             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,53 @@ def load_end(self):
 
             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
+                if self.pyaccess:
+                    self.pyaccess = None
+            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 +940,15 @@ def getexif(self):
             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 +994,147 @@ def write(self, data):
         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)
+        self.seq_num += 1
+
+
+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.convert("RGB"), base_im.convert("RGB")
+                )
+                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)), o32(loop),  # 0: num_frames  # 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:
+            fdat_chunks = _fdat(fp, chunk, seq_num)
+            ImageFile._save(
+                im_frame, fdat_chunks, [("zip", (0, 0) + im_frame.size, 0, rawmode)],
+            )
+            seq_num = fdat_chunks.seq_num
+
+
+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 +1283,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 +1331,7 @@ def append(fp, cid, *data):
 
 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"])
 


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
--- 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
diff --git a/Tests/images/apng/blend_op_over.png b/Tests/images/apng/blend_op_over.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/blend_op_over.png differ
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
Binary files /dev/null and b/Tests/images/apng/blend_op_over_near_transparent.png differ
diff --git a/Tests/images/apng/blend_op_source_near_transparent.png b/Tests/images/apng/blend_op_source_near_transparent.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/blend_op_source_near_transparent.png differ
diff --git a/Tests/images/apng/blend_op_source_solid.png b/Tests/images/apng/blend_op_source_solid.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/blend_op_source_solid.png differ
diff --git a/Tests/images/apng/blend_op_source_transparent.png b/Tests/images/apng/blend_op_source_transparent.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/blend_op_source_transparent.png differ
diff --git a/Tests/images/apng/chunk_actl_after_idat.png b/Tests/images/apng/chunk_actl_after_idat.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/chunk_actl_after_idat.png differ
diff --git a/Tests/images/apng/chunk_multi_actl.png b/Tests/images/apng/chunk_multi_actl.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/chunk_multi_actl.png differ
diff --git a/Tests/images/apng/chunk_no_actl.png b/Tests/images/apng/chunk_no_actl.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/chunk_no_actl.png differ
diff --git a/Tests/images/apng/chunk_no_fctl.png b/Tests/images/apng/chunk_no_fctl.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/chunk_no_fctl.png differ
diff --git a/Tests/images/apng/chunk_no_fdat.png b/Tests/images/apng/chunk_no_fdat.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/chunk_no_fdat.png differ
diff --git a/Tests/images/apng/chunk_repeat_fctl.png b/Tests/images/apng/chunk_repeat_fctl.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/chunk_repeat_fctl.png differ
diff --git a/Tests/images/apng/delay.png b/Tests/images/apng/delay.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/delay.png differ
diff --git a/Tests/images/apng/delay_round.png b/Tests/images/apng/delay_round.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/delay_round.png differ
diff --git a/Tests/images/apng/delay_short_max.png b/Tests/images/apng/delay_short_max.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/delay_short_max.png differ
diff --git a/Tests/images/apng/delay_zero_denom.png b/Tests/images/apng/delay_zero_denom.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/delay_zero_denom.png differ
diff --git a/Tests/images/apng/delay_zero_numer.png b/Tests/images/apng/delay_zero_numer.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/delay_zero_numer.png differ
diff --git a/Tests/images/apng/dispose_op_background.png b/Tests/images/apng/dispose_op_background.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/dispose_op_background.png differ
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
Binary files /dev/null and b/Tests/images/apng/dispose_op_background_before_region.png differ
diff --git a/Tests/images/apng/dispose_op_background_final.png b/Tests/images/apng/dispose_op_background_final.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/dispose_op_background_final.png differ
diff --git a/Tests/images/apng/dispose_op_background_region.png b/Tests/images/apng/dispose_op_background_region.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/dispose_op_background_region.png differ
diff --git a/Tests/images/apng/dispose_op_none.png b/Tests/images/apng/dispose_op_none.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/dispose_op_none.png differ
diff --git a/Tests/images/apng/dispose_op_none_region.png b/Tests/images/apng/dispose_op_none_region.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/dispose_op_none_region.png differ
diff --git a/Tests/images/apng/dispose_op_previous.png b/Tests/images/apng/dispose_op_previous.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/dispose_op_previous.png differ
diff --git a/Tests/images/apng/dispose_op_previous_final.png b/Tests/images/apng/dispose_op_previous_final.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/dispose_op_previous_final.png differ
diff --git a/Tests/images/apng/dispose_op_previous_first.png b/Tests/images/apng/dispose_op_previous_first.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/dispose_op_previous_first.png differ
diff --git a/Tests/images/apng/dispose_op_previous_region.png b/Tests/images/apng/dispose_op_previous_region.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/dispose_op_previous_region.png differ
diff --git a/Tests/images/apng/fctl_actl.png b/Tests/images/apng/fctl_actl.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/fctl_actl.png differ
diff --git a/Tests/images/apng/mode_16bit.png b/Tests/images/apng/mode_16bit.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/mode_16bit.png differ
diff --git a/Tests/images/apng/mode_greyscale.png b/Tests/images/apng/mode_greyscale.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/mode_greyscale.png differ
diff --git a/Tests/images/apng/mode_greyscale_alpha.png b/Tests/images/apng/mode_greyscale_alpha.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/mode_greyscale_alpha.png differ
diff --git a/Tests/images/apng/mode_palette.png b/Tests/images/apng/mode_palette.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/mode_palette.png differ
diff --git a/Tests/images/apng/mode_palette_1bit_alpha.png b/Tests/images/apng/mode_palette_1bit_alpha.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/mode_palette_1bit_alpha.png differ
diff --git a/Tests/images/apng/mode_palette_alpha.png b/Tests/images/apng/mode_palette_alpha.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/mode_palette_alpha.png differ
diff --git a/Tests/images/apng/num_plays.png b/Tests/images/apng/num_plays.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/num_plays.png differ
diff --git a/Tests/images/apng/num_plays_1.png b/Tests/images/apng/num_plays_1.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/num_plays_1.png differ
diff --git a/Tests/images/apng/sequence_fdat_fctl.png b/Tests/images/apng/sequence_fdat_fctl.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/sequence_fdat_fctl.png differ
diff --git a/Tests/images/apng/sequence_gap.png b/Tests/images/apng/sequence_gap.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/sequence_gap.png differ
diff --git a/Tests/images/apng/sequence_reorder.png b/Tests/images/apng/sequence_reorder.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/sequence_reorder.png differ
diff --git a/Tests/images/apng/sequence_reorder_chunk.png b/Tests/images/apng/sequence_reorder_chunk.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/sequence_reorder_chunk.png differ
diff --git a/Tests/images/apng/sequence_repeat.png b/Tests/images/apng/sequence_repeat.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/sequence_repeat.png differ
diff --git a/Tests/images/apng/sequence_repeat_chunk.png b/Tests/images/apng/sequence_repeat_chunk.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/sequence_repeat_chunk.png differ
diff --git a/Tests/images/apng/sequence_start.png b/Tests/images/apng/sequence_start.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/sequence_start.png differ
diff --git a/Tests/images/apng/single_frame.png b/Tests/images/apng/single_frame.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/single_frame.png differ
diff --git a/Tests/images/apng/single_frame_default.png b/Tests/images/apng/single_frame_default.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/single_frame_default.png differ
diff --git a/Tests/images/apng/split_fdat.png b/Tests/images/apng/split_fdat.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/split_fdat.png differ
diff --git a/Tests/images/apng/split_fdat_zero_chunk.png b/Tests/images/apng/split_fdat_zero_chunk.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/split_fdat_zero_chunk.png differ
diff --git a/Tests/images/apng/syntax_num_frames_high.png b/Tests/images/apng/syntax_num_frames_high.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/syntax_num_frames_high.png differ
diff --git a/Tests/images/apng/syntax_num_frames_invalid.png b/Tests/images/apng/syntax_num_frames_invalid.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/syntax_num_frames_invalid.png differ
diff --git a/Tests/images/apng/syntax_num_frames_low.png b/Tests/images/apng/syntax_num_frames_low.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/syntax_num_frames_low.png differ
diff --git a/Tests/images/apng/syntax_num_frames_zero.png b/Tests/images/apng/syntax_num_frames_zero.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/syntax_num_frames_zero.png differ
diff --git a/Tests/images/apng/syntax_num_frames_zero_default.png b/Tests/images/apng/syntax_num_frames_zero_default.png
new file mode 100644
Binary files /dev/null and b/Tests/images/apng/syntax_num_frames_zero_default.png differ
diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py
new file mode 100644
--- /dev/null
+++ b/Tests/test_file_apng.py
@@ -0,0 +1,555 @@
+import pytest
+from PIL import Image, ImageSequence, PngImagePlugin
+
+
+# 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)
+
+    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)
+
+        # test out of bounds seek
+        with pytest.raises(EOFError):
+            im.seek(2)
+
+        # 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)
+
+
+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/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_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)
+
+    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)
+
+    with Image.open("Tests/images/apng/chunk_repeat_fctl.png") as im:
+        with pytest.raises(SyntaxError):
+            im.seek(im.n_frames - 1)
+
+    with Image.open("Tests/images/apng/chunk_no_fdat.png") as im:
+        with pytest.raises(SyntaxError):
+            im.seek(im.n_frames - 1)
+
+
+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)
+
+    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
+        try:
+            im.seek(im.n_frames - 1)
+            im.load()
+        except Exception as e:
+            exception = e
+        assert exception is None
+
+
+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,
+            default_image=default_image,
+            append_images=frames[1:],
+            duration=durations,
+            loop=loop,
+        )
+
+    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
+
+    # 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
+
+
+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))
+
+    # 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_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)
+
+    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_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)
diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py
--- a/Tests/test_file_png.py
+++ b/Tests/test_file_png.py
@@ -9,7 +9,6 @@
     PillowLeakTestCase,
     assert_image,
     assert_image_equal,
-    assert_image_similar,
     hopper,
     is_big_endian,
     is_win32,
@@ -623,16 +622,6 @@ def test_exif_argument(self, tmp_path):
         with Image.open(test_file) as reloaded:
             assert reloaded.info["exif"] == b"Exif\x00\x00exifstring"
 
-    @skip_unless_feature("webp")
-    @skip_unless_feature("webp_anim")
-    def test_apng(self):
-        with Image.open("Tests/images/iss634.apng") as im:
-            assert im.get_format_mimetype() == "image/apng"
-
-            # 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)
-
 
 @pytest.mark.skipif(is_win32(), reason="Requires Unix or macOS")
 @skip_unless_feature("zlib")