2
.github/workflows/test.yml
vendored
|
@ -34,7 +34,7 @@ jobs:
|
||||||
name: ${{ matrix.os }} Python ${{ matrix.python-version }}
|
name: ${{ matrix.os }} Python ${{ matrix.python-version }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Ubuntu cache
|
- name: Ubuntu cache
|
||||||
uses: actions/cache@v1
|
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,
|
PillowLeakTestCase,
|
||||||
assert_image,
|
assert_image,
|
||||||
assert_image_equal,
|
assert_image_equal,
|
||||||
assert_image_similar,
|
|
||||||
hopper,
|
hopper,
|
||||||
is_big_endian,
|
is_big_endian,
|
||||||
is_win32,
|
is_win32,
|
||||||
|
@ -630,16 +629,6 @@ class TestFilePng:
|
||||||
with Image.open(test_file) as reloaded:
|
with Image.open(test_file) as reloaded:
|
||||||
assert reloaded.info["exif"] == b"Exif\x00\x00exifstring"
|
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")
|
@pytest.mark.skipif(is_win32(), reason="Requires Unix or macOS")
|
||||||
@skip_unless_feature("zlib")
|
@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
|
library before building the Python Imaging Library. See the `installation
|
||||||
documentation <../installation.html>`_ for details.
|
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
|
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
|
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
|
has an alpha channel (RGBA, RGBa, PA, LA, La), any transparent pixels are
|
||||||
trimmed.
|
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.
|
# See the README file for information on usage and redistribution.
|
||||||
#
|
#
|
||||||
|
|
||||||
|
import itertools
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import struct
|
import struct
|
||||||
|
import warnings
|
||||||
import zlib
|
import zlib
|
||||||
|
|
||||||
from . import Image, ImageFile, ImagePalette
|
from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence
|
||||||
from ._binary import i8, i16be as i16, i32be as i32, o16be as o16, o32be as o32
|
from ._binary import i8, i16be as i16, i32be as i32, o8, o16be as o16, o32be as o32
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -81,6 +83,16 @@ MAX_TEXT_CHUNK = ImageFile.SAFEBLOCK
|
||||||
MAX_TEXT_MEMORY = 64 * MAX_TEXT_CHUNK
|
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):
|
def _safe_zlib_decompress(s):
|
||||||
dobj = zlib.decompressobj()
|
dobj = zlib.decompressobj()
|
||||||
plaintext = dobj.decompress(s, MAX_TEXT_CHUNK)
|
plaintext = dobj.decompress(s, MAX_TEXT_CHUNK)
|
||||||
|
@ -298,6 +310,9 @@ class PngStream(ChunkStream):
|
||||||
self.im_tile = None
|
self.im_tile = None
|
||||||
self.im_palette = None
|
self.im_palette = None
|
||||||
self.im_custom_mimetype = None
|
self.im_custom_mimetype = None
|
||||||
|
self.im_n_frames = None
|
||||||
|
self._seq_num = None
|
||||||
|
self.rewind_state = None
|
||||||
|
|
||||||
self.text_memory = 0
|
self.text_memory = 0
|
||||||
|
|
||||||
|
@ -309,6 +324,18 @@ class PngStream(ChunkStream):
|
||||||
% self.text_memory
|
% 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):
|
def chunk_iCCP(self, pos, length):
|
||||||
|
|
||||||
# ICC profile
|
# ICC profile
|
||||||
|
@ -356,7 +383,13 @@ class PngStream(ChunkStream):
|
||||||
def chunk_IDAT(self, pos, length):
|
def chunk_IDAT(self, pos, length):
|
||||||
|
|
||||||
# image data
|
# 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
|
self.im_idat = length
|
||||||
raise EOFError
|
raise EOFError
|
||||||
|
|
||||||
|
@ -537,9 +570,49 @@ class PngStream(ChunkStream):
|
||||||
# APNG chunks
|
# APNG chunks
|
||||||
def chunk_acTL(self, pos, length):
|
def chunk_acTL(self, pos, length):
|
||||||
s = ImageFile._safe_read(self.fp, 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"
|
self.im_custom_mimetype = "image/apng"
|
||||||
return s
|
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
|
# PNG reader
|
||||||
|
@ -562,9 +635,10 @@ class PngImageFile(ImageFile.ImageFile):
|
||||||
|
|
||||||
if self.fp.read(8) != _MAGIC:
|
if self.fp.read(8) != _MAGIC:
|
||||||
raise SyntaxError("not a PNG file")
|
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)
|
self.png = PngStream(self.fp)
|
||||||
|
|
||||||
|
@ -598,22 +672,53 @@ class PngImageFile(ImageFile.ImageFile):
|
||||||
self._text = None
|
self._text = None
|
||||||
self.tile = self.png.im_tile
|
self.tile = self.png.im_tile
|
||||||
self.custom_mimetype = self.png.im_custom_mimetype
|
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:
|
if self.png.im_palette:
|
||||||
rawmode, data = self.png.im_palette
|
rawmode, data = self.png.im_palette
|
||||||
self.palette = ImagePalette.raw(rawmode, data)
|
self.palette = ImagePalette.raw(rawmode, data)
|
||||||
|
|
||||||
|
if cid == b"fdAT":
|
||||||
|
self.__prepare_idat = length - 4
|
||||||
|
else:
|
||||||
self.__prepare_idat = length # used by load_prepare()
|
self.__prepare_idat = length # used by load_prepare()
|
||||||
|
|
||||||
|
if self._n_frames is not None:
|
||||||
|
self._close_exclusive_fp_after_loading = False
|
||||||
|
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
|
@property
|
||||||
def text(self):
|
def text(self):
|
||||||
# experimental
|
# experimental
|
||||||
if self._text is None:
|
if self._text is None:
|
||||||
# iTxt, tEXt and zTXt chunks may appear at the end of the file
|
# iTxt, tEXt and zTXt chunks may appear at the end of the file
|
||||||
# So load the file to ensure that they are read
|
# 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()
|
self.load()
|
||||||
|
if self.is_animated:
|
||||||
|
self.seek(frame)
|
||||||
return self._text
|
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):
|
def verify(self):
|
||||||
"""Verify PNG file"""
|
"""Verify PNG file"""
|
||||||
|
|
||||||
|
@ -630,6 +735,97 @@ class PngImageFile(ImageFile.ImageFile):
|
||||||
self.fp.close()
|
self.fp.close()
|
||||||
self.fp = None
|
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):
|
def load_prepare(self):
|
||||||
"""internal: prepare to read PNG file"""
|
"""internal: prepare to read PNG file"""
|
||||||
|
|
||||||
|
@ -649,10 +845,17 @@ class PngImageFile(ImageFile.ImageFile):
|
||||||
|
|
||||||
cid, pos, length = self.png.read()
|
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)
|
self.png.push(cid, pos, length)
|
||||||
return b""
|
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
|
self.__idat = length # empty chunks are allowed
|
||||||
|
|
||||||
# read more data from this chunk
|
# read more data from this chunk
|
||||||
|
@ -677,19 +880,53 @@ class PngImageFile(ImageFile.ImageFile):
|
||||||
|
|
||||||
if cid == b"IEND":
|
if cid == b"IEND":
|
||||||
break
|
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:
|
try:
|
||||||
self.png.call(cid, pos, length)
|
self.png.call(cid, pos, length)
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
break
|
break
|
||||||
except EOFError:
|
except EOFError:
|
||||||
|
if cid == b"fdAT":
|
||||||
|
length -= 4
|
||||||
ImageFile._safe_read(self.fp, length)
|
ImageFile._safe_read(self.fp, length)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
logger.debug("%r %s %s (unknown)", cid, pos, length)
|
logger.debug("%r %s %s (unknown)", cid, pos, length)
|
||||||
ImageFile._safe_read(self.fp, length)
|
ImageFile._safe_read(self.fp, length)
|
||||||
self._text = self.png.im_text
|
self._text = self.png.im_text
|
||||||
|
if not self.is_animated:
|
||||||
self.png.close()
|
self.png.close()
|
||||||
self.png = None
|
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):
|
def _getexif(self):
|
||||||
if "exif" not in self.info:
|
if "exif" not in self.info:
|
||||||
|
@ -713,6 +950,15 @@ class PngImageFile(ImageFile.ImageFile):
|
||||||
self._exif.load(exif_info)
|
self._exif.load(exif_info)
|
||||||
return self._exif
|
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
|
# PNG writer
|
||||||
|
@ -758,7 +1004,147 @@ class _idat:
|
||||||
self.chunk(self.fp, b"IDAT", data)
|
self.chunk(self.fp, b"IDAT", data)
|
||||||
|
|
||||||
|
|
||||||
def _save(im, fp, filename, chunk=putchunk):
|
class _fdat:
|
||||||
|
# wrap encoder output in fdAT chunks
|
||||||
|
|
||||||
|
def __init__(self, fp, chunk, seq_num):
|
||||||
|
self.fp = fp
|
||||||
|
self.chunk = chunk
|
||||||
|
self.seq_num = seq_num
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
self.chunk(self.fp, b"fdAT", o32(self.seq_num), data)
|
||||||
|
self.seq_num += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _write_multiple_frames(im, fp, chunk, rawmode):
|
||||||
|
default_image = im.encoderinfo.get("default_image", im.info.get("default_image"))
|
||||||
|
duration = im.encoderinfo.get("duration", im.info.get("duration", 0))
|
||||||
|
loop = im.encoderinfo.get("loop", im.info.get("loop", 0))
|
||||||
|
disposal = im.encoderinfo.get("disposal", im.info.get("disposal"))
|
||||||
|
blend = im.encoderinfo.get("blend", im.info.get("blend"))
|
||||||
|
|
||||||
|
if default_image:
|
||||||
|
chain = itertools.chain(im.encoderinfo.get("append_images", []))
|
||||||
|
else:
|
||||||
|
chain = itertools.chain([im], im.encoderinfo.get("append_images", []))
|
||||||
|
|
||||||
|
im_frames = []
|
||||||
|
frame_count = 0
|
||||||
|
for im_seq in chain:
|
||||||
|
for im_frame in ImageSequence.Iterator(im_seq):
|
||||||
|
im_frame = im_frame.copy()
|
||||||
|
if im_frame.mode != im.mode:
|
||||||
|
if im.mode == "P":
|
||||||
|
im_frame = im_frame.convert(im.mode, palette=im.palette)
|
||||||
|
else:
|
||||||
|
im_frame = im_frame.convert(im.mode)
|
||||||
|
encoderinfo = im.encoderinfo.copy()
|
||||||
|
if isinstance(duration, (list, tuple)):
|
||||||
|
encoderinfo["duration"] = duration[frame_count]
|
||||||
|
if isinstance(disposal, (list, tuple)):
|
||||||
|
encoderinfo["disposal"] = disposal[frame_count]
|
||||||
|
if isinstance(blend, (list, tuple)):
|
||||||
|
encoderinfo["blend"] = blend[frame_count]
|
||||||
|
frame_count += 1
|
||||||
|
|
||||||
|
if im_frames:
|
||||||
|
previous = im_frames[-1]
|
||||||
|
prev_disposal = previous["encoderinfo"].get("disposal")
|
||||||
|
prev_blend = previous["encoderinfo"].get("blend")
|
||||||
|
if prev_disposal == APNG_DISPOSE_OP_PREVIOUS and len(im_frames) < 2:
|
||||||
|
prev_disposal == APNG_DISPOSE_OP_BACKGROUND
|
||||||
|
|
||||||
|
if prev_disposal == APNG_DISPOSE_OP_BACKGROUND:
|
||||||
|
base_im = previous["im"]
|
||||||
|
dispose = Image.core.fill("RGBA", im.size, (0, 0, 0, 0))
|
||||||
|
bbox = previous["bbox"]
|
||||||
|
if bbox:
|
||||||
|
dispose = dispose.crop(bbox)
|
||||||
|
else:
|
||||||
|
bbox = (0, 0) + im.size
|
||||||
|
base_im.paste(dispose, bbox)
|
||||||
|
elif prev_disposal == APNG_DISPOSE_OP_PREVIOUS:
|
||||||
|
base_im = im_frames[-2]["im"]
|
||||||
|
else:
|
||||||
|
base_im = previous["im"]
|
||||||
|
delta = ImageChops.subtract_modulo(
|
||||||
|
im_frame.convert("RGB"), base_im.convert("RGB")
|
||||||
|
)
|
||||||
|
bbox = delta.getbbox()
|
||||||
|
if (
|
||||||
|
not bbox
|
||||||
|
and prev_disposal == encoderinfo.get("disposal")
|
||||||
|
and prev_blend == encoderinfo.get("blend")
|
||||||
|
):
|
||||||
|
duration = encoderinfo.get("duration", 0)
|
||||||
|
if duration:
|
||||||
|
if "duration" in previous["encoderinfo"]:
|
||||||
|
previous["encoderinfo"]["duration"] += duration
|
||||||
|
else:
|
||||||
|
previous["encoderinfo"]["duration"] = duration
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
bbox = None
|
||||||
|
im_frames.append({"im": im_frame, "bbox": bbox, "encoderinfo": encoderinfo})
|
||||||
|
|
||||||
|
# animation control
|
||||||
|
chunk(
|
||||||
|
fp, b"acTL", o32(len(im_frames)), o32(loop), # 0: num_frames # 4: num_plays
|
||||||
|
)
|
||||||
|
|
||||||
|
# default image IDAT (if it exists)
|
||||||
|
if default_image:
|
||||||
|
ImageFile._save(im, _idat(fp, chunk), [("zip", (0, 0) + im.size, 0, rawmode)])
|
||||||
|
|
||||||
|
seq_num = 0
|
||||||
|
for frame, frame_data in enumerate(im_frames):
|
||||||
|
im_frame = frame_data["im"]
|
||||||
|
if not frame_data["bbox"]:
|
||||||
|
bbox = (0, 0) + im_frame.size
|
||||||
|
else:
|
||||||
|
bbox = frame_data["bbox"]
|
||||||
|
im_frame = im_frame.crop(bbox)
|
||||||
|
size = im_frame.size
|
||||||
|
duration = int(round(frame_data["encoderinfo"].get("duration", 0)))
|
||||||
|
disposal = frame_data["encoderinfo"].get("disposal", APNG_DISPOSE_OP_NONE)
|
||||||
|
blend = frame_data["encoderinfo"].get("blend", APNG_BLEND_OP_SOURCE)
|
||||||
|
# frame control
|
||||||
|
chunk(
|
||||||
|
fp,
|
||||||
|
b"fcTL",
|
||||||
|
o32(seq_num), # sequence_number
|
||||||
|
o32(size[0]), # width
|
||||||
|
o32(size[1]), # height
|
||||||
|
o32(bbox[0]), # x_offset
|
||||||
|
o32(bbox[1]), # y_offset
|
||||||
|
o16(duration), # delay_numerator
|
||||||
|
o16(1000), # delay_denominator
|
||||||
|
o8(disposal), # dispose_op
|
||||||
|
o8(blend), # blend_op
|
||||||
|
)
|
||||||
|
seq_num += 1
|
||||||
|
# frame data
|
||||||
|
if frame == 0 and not default_image:
|
||||||
|
# first frame must be in IDAT chunks for backwards compatibility
|
||||||
|
ImageFile._save(
|
||||||
|
im_frame,
|
||||||
|
_idat(fp, chunk),
|
||||||
|
[("zip", (0, 0) + im_frame.size, 0, rawmode)],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
fdat_chunks = _fdat(fp, chunk, seq_num)
|
||||||
|
ImageFile._save(
|
||||||
|
im_frame, fdat_chunks, [("zip", (0, 0) + im_frame.size, 0, rawmode)],
|
||||||
|
)
|
||||||
|
seq_num = fdat_chunks.seq_num
|
||||||
|
|
||||||
|
|
||||||
|
def _save_all(im, fp, filename):
|
||||||
|
_save(im, fp, filename, save_all=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _save(im, fp, filename, chunk=putchunk, save_all=False):
|
||||||
# save an image to disk (called by the save method)
|
# save an image to disk (called by the save method)
|
||||||
|
|
||||||
mode = im.mode
|
mode = im.mode
|
||||||
|
@ -907,6 +1293,9 @@ def _save(im, fp, filename, chunk=putchunk):
|
||||||
exif = exif[6:]
|
exif = exif[6:]
|
||||||
chunk(fp, b"eXIf", exif)
|
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)])
|
ImageFile._save(im, _idat(fp, chunk), [("zip", (0, 0) + im.size, 0, rawmode)])
|
||||||
|
|
||||||
chunk(fp, b"IEND", b"")
|
chunk(fp, b"IEND", b"")
|
||||||
|
@ -952,6 +1341,7 @@ def getchunks(im, **params):
|
||||||
|
|
||||||
Image.register_open(PngImageFile.format, PngImageFile, _accept)
|
Image.register_open(PngImageFile.format, PngImageFile, _accept)
|
||||||
Image.register_save(PngImageFile.format, _save)
|
Image.register_save(PngImageFile.format, _save)
|
||||||
|
Image.register_save_all(PngImageFile.format, _save_all)
|
||||||
|
|
||||||
Image.register_extensions(PngImageFile.format, [".png", ".apng"])
|
Image.register_extensions(PngImageFile.format, [".png", ".apng"])
|
||||||
|
|
||||||
|
|