2
.github/workflows/test.yml
vendored
|
@ -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
|
||||
|
|
BIN
Tests/images/apng/blend_op_over.png
Normal file
After Width: | Height: | Size: 579 B |
BIN
Tests/images/apng/blend_op_over_near_transparent.png
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
Tests/images/apng/blend_op_source_near_transparent.png
Normal file
After Width: | Height: | Size: 614 B |
BIN
Tests/images/apng/blend_op_source_solid.png
Normal file
After Width: | Height: | Size: 671 B |
BIN
Tests/images/apng/blend_op_source_transparent.png
Normal file
After Width: | Height: | Size: 534 B |
BIN
Tests/images/apng/chunk_actl_after_idat.png
Normal file
After Width: | Height: | Size: 470 B |
BIN
Tests/images/apng/chunk_multi_actl.png
Normal file
After Width: | Height: | Size: 490 B |
BIN
Tests/images/apng/chunk_no_actl.png
Normal file
After Width: | Height: | Size: 450 B |
BIN
Tests/images/apng/chunk_no_fctl.png
Normal file
After Width: | Height: | Size: 432 B |
BIN
Tests/images/apng/chunk_no_fdat.png
Normal file
After Width: | Height: | Size: 508 B |
BIN
Tests/images/apng/chunk_repeat_fctl.png
Normal file
After Width: | Height: | Size: 508 B |
BIN
Tests/images/apng/delay.png
Normal file
After Width: | Height: | Size: 1.0 KiB |
BIN
Tests/images/apng/delay_round.png
Normal file
After Width: | Height: | Size: 646 B |
BIN
Tests/images/apng/delay_short_max.png
Normal file
After Width: | Height: | Size: 646 B |
BIN
Tests/images/apng/delay_zero_denom.png
Normal file
After Width: | Height: | Size: 646 B |
BIN
Tests/images/apng/delay_zero_numer.png
Normal file
After Width: | Height: | Size: 1.0 KiB |
BIN
Tests/images/apng/dispose_op_background.png
Normal file
After Width: | Height: | Size: 572 B |
BIN
Tests/images/apng/dispose_op_background_before_region.png
Normal file
After Width: | Height: | Size: 327 B |
BIN
Tests/images/apng/dispose_op_background_final.png
Normal file
After Width: | Height: | Size: 508 B |
BIN
Tests/images/apng/dispose_op_background_region.png
Normal file
After Width: | Height: | Size: 492 B |
BIN
Tests/images/apng/dispose_op_none.png
Normal file
After Width: | Height: | Size: 617 B |
BIN
Tests/images/apng/dispose_op_none_region.png
Normal file
After Width: | Height: | Size: 613 B |
BIN
Tests/images/apng/dispose_op_previous.png
Normal file
After Width: | Height: | Size: 780 B |
BIN
Tests/images/apng/dispose_op_previous_final.png
Normal file
After Width: | Height: | Size: 508 B |
BIN
Tests/images/apng/dispose_op_previous_first.png
Normal file
After Width: | Height: | Size: 371 B |
BIN
Tests/images/apng/dispose_op_previous_region.png
Normal file
After Width: | Height: | Size: 677 B |
BIN
Tests/images/apng/fctl_actl.png
Normal file
After Width: | Height: | Size: 508 B |
BIN
Tests/images/apng/mode_16bit.png
Normal file
After Width: | Height: | Size: 915 B |
BIN
Tests/images/apng/mode_greyscale.png
Normal file
After Width: | Height: | Size: 331 B |
BIN
Tests/images/apng/mode_greyscale_alpha.png
Normal file
After Width: | Height: | Size: 668 B |
BIN
Tests/images/apng/mode_palette.png
Normal file
After Width: | Height: | Size: 262 B |
BIN
Tests/images/apng/mode_palette_1bit_alpha.png
Normal file
After Width: | Height: | Size: 276 B |
BIN
Tests/images/apng/mode_palette_alpha.png
Normal file
After Width: | Height: | Size: 308 B |
BIN
Tests/images/apng/num_plays.png
Normal file
After Width: | Height: | Size: 646 B |
BIN
Tests/images/apng/num_plays_1.png
Normal file
After Width: | Height: | Size: 646 B |
BIN
Tests/images/apng/sequence_fdat_fctl.png
Normal file
After Width: | Height: | Size: 671 B |
BIN
Tests/images/apng/sequence_gap.png
Normal file
After Width: | Height: | Size: 671 B |
BIN
Tests/images/apng/sequence_reorder.png
Normal file
After Width: | Height: | Size: 687 B |
BIN
Tests/images/apng/sequence_reorder_chunk.png
Normal file
After Width: | Height: | Size: 687 B |
BIN
Tests/images/apng/sequence_repeat.png
Normal file
After Width: | Height: | Size: 671 B |
BIN
Tests/images/apng/sequence_repeat_chunk.png
Normal file
After Width: | Height: | Size: 834 B |
BIN
Tests/images/apng/sequence_start.png
Normal file
After Width: | Height: | Size: 671 B |
BIN
Tests/images/apng/single_frame.png
Normal file
After Width: | Height: | Size: 307 B |
BIN
Tests/images/apng/single_frame_default.png
Normal file
After Width: | Height: | Size: 470 B |
BIN
Tests/images/apng/split_fdat.png
Normal file
After Width: | Height: | Size: 486 B |
BIN
Tests/images/apng/split_fdat_zero_chunk.png
Normal file
After Width: | Height: | Size: 502 B |
BIN
Tests/images/apng/syntax_num_frames_high.png
Normal file
After Width: | Height: | Size: 671 B |
BIN
Tests/images/apng/syntax_num_frames_invalid.png
Normal file
After Width: | Height: | Size: 470 B |
BIN
Tests/images/apng/syntax_num_frames_low.png
Normal file
After Width: | Height: | Size: 671 B |
BIN
Tests/images/apng/syntax_num_frames_zero.png
Normal file
After Width: | Height: | Size: 65 B |
BIN
Tests/images/apng/syntax_num_frames_zero_default.png
Normal file
After Width: | Height: | Size: 269 B |
555
Tests/test_file_apng.py
Normal 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)
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
^^^
|
||||
|
||||
|
|
|
@ -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.
|
|
@ -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,22 +672,53 @@ 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)
|
||||
|
||||
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):
|
||||
# experimental
|
||||
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,10 +845,17 @@ 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""
|
||||
|
||||
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
|
||||
|
@ -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
|
||||
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,6 +1293,9 @@ def _save(im, fp, filename, chunk=putchunk):
|
|||
exif = exif[6:]
|
||||
chunk(fp, b"eXIf", exif)
|
||||
|
||||
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"])
|
||||
|
||||
|
|