Merge pull request #4243 from pmrowla/apng

Add APNG support
This commit is contained in:
Hugo van Kemenade 2020-04-01 00:23:57 +03:00 committed by GitHub
commit f27873a888
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 1085 additions and 23 deletions

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

BIN
Tests/images/apng/delay.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 613 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 677 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 915 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 834 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

555
Tests/test_file_apng.py Normal file
View File

@ -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)

View File

@ -9,7 +9,6 @@ from .helper import (
PillowLeakTestCase,
assert_image,
assert_image_equal,
assert_image_similar,
hopper,
is_big_endian,
is_win32,
@ -630,16 +629,6 @@ class TestFilePng:
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")

View File

@ -556,6 +556,125 @@ 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
~~~~~~~~~~~~~~
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"``. 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.
``im.seek()`` raises an :py:exc:`EOFError` if you try to seek after the last frame.
These :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
^^^

View File

@ -54,3 +54,12 @@ box, for an RGB image it trims black pixels. Similarly, for an RGBA image it
would trim black transparent pixels. This is now changed so that if an image
has an alpha channel (RGBA, RGBa, PA, LA, La), any transparent pixels are
trimmed.
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.

View File

@ -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,13 @@ 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 +570,49 @@ 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 +635,10 @@ 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 or fDAT chunk
self.png = PngStream(self.fp)
@ -598,12 +672,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 +700,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 and self._n_frames > 1
def verify(self):
"""Verify PNG file"""
@ -630,6 +735,97 @@ 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
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 @@ 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,53 @@ 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
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:
@ -713,6 +950,15 @@ class PngImageFile(ImageFile.ImageFile):
self._exif.load(exif_info)
return self._exif
def _close__fp(self):
try:
if self.__fp != self.fp:
self.__fp.close()
except AttributeError:
pass
finally:
self.__fp = None
# --------------------------------------------------------------------
# PNG writer
@ -758,7 +1004,147 @@ 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)
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
@ -907,7 +1293,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"")
@ -952,6 +1341,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"])