Pillow/Tests/test_file_apng.py
2023-10-19 19:12:01 +11:00

689 lines
22 KiB
Python

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)
@pytest.mark.parametrize(
"filename",
("Tests/images/apng/split_fdat.png", "Tests/images/apng/split_fdat_zero_chunk.png"),
)
def test_apng_fdat(filename):
with Image.open(filename) 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_dispose_op_previous_frame():
# Test that the dispose settings being used are from the previous frame
#
# Image created with:
# red = Image.new("RGBA", (128, 64), (255, 0, 0, 255))
# green = red.copy()
# green.paste(Image.new("RGBA", (64, 32), (0, 255, 0, 255)))
# blue = red.copy()
# blue.paste(Image.new("RGBA", (64, 32), (0, 255, 0, 255)), (64, 32))
#
# red.save(
# "Tests/images/apng/dispose_op_previous_frame.png",
# save_all=True,
# append_images=[green, blue],
# disposal=[
# PngImagePlugin.Disposal.OP_NONE,
# PngImagePlugin.Disposal.OP_PREVIOUS,
# PngImagePlugin.Disposal.OP_PREVIOUS
# ],
# )
with Image.open("Tests/images/apng/dispose_op_previous_frame.png") as im:
im.seek(im.n_frames - 1)
assert im.getpixel((0, 0)) == (255, 0, 0, 255)
def test_apng_dispose_op_background_p_mode():
with Image.open("Tests/images/apng/dispose_op_background_p_mode.png") as im:
im.seek(1)
im.load()
assert im.size == (128, 64)
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_blend_transparency():
with Image.open("Tests/images/blend_transparency.png") as im:
im.seek(1)
assert im.getpixel((0, 0)) == (255, 0, 0)
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_grayscale.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_grayscale_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)) == (255, 0, 0, 0)
assert im.getpixel((64, 32)) == (255, 0, 0, 0)
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
with pytest.warns(UserWarning):
with Image.open("Tests/images/apng/chunk_multi_actl.png") as im:
im.load()
assert not im.is_animated
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():
with pytest.warns(UserWarning):
with Image.open("Tests/images/apng/syntax_num_frames_zero.png") as im:
assert not im.is_animated
with pytest.raises(OSError):
im.load()
with pytest.warns(UserWarning):
with Image.open("Tests/images/apng/syntax_num_frames_zero_default.png") as im:
assert not im.is_animated
im.load()
# 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(OSError):
with Image.open("Tests/images/apng/syntax_num_frames_high.png") as im:
im.seek(im.n_frames - 1)
im.load()
with pytest.warns(UserWarning):
with Image.open("Tests/images/apng/syntax_num_frames_invalid.png") as im:
assert not im.is_animated
im.load()
@pytest.mark.parametrize(
"test_file",
(
"sequence_start.png",
"sequence_gap.png",
"sequence_repeat.png",
"sequence_repeat_chunk.png",
"sequence_reorder.png",
"sequence_reorder_chunk.png",
"sequence_fdat_fctl.png",
),
)
def test_apng_sequence_errors(test_file):
with pytest.raises(SyntaxError):
with Image.open(f"Tests/images/apng/{test_file}") 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_alpha(tmp_path):
test_file = str(tmp_path / "temp.png")
im = Image.new("RGBA", (1, 1), (255, 0, 0, 255))
im2 = Image.new("RGBA", (1, 1), (255, 0, 0, 127))
im.save(test_file, save_all=True, append_images=[im2])
with Image.open(test_file) as reloaded:
assert reloaded.getpixel((0, 0)) == (255, 0, 0, 255)
reloaded.seek(1)
assert reloaded.getpixel((0, 0)) == (255, 0, 0, 127)
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, frame], duration=[500, 100, 150]
)
with Image.open(test_file) as im:
im.load()
assert im.n_frames == 1
assert im.info.get("duration") == 750
# test info duration
frame.info["duration"] = 750
frame.save(test_file, save_all=True)
with Image.open(test_file) as im:
assert im.info.get("duration") == 750
def test_apng_save_duplicate_duration(tmp_path):
test_file = str(tmp_path / "temp.png")
frame = Image.new("RGB", (1, 1))
# Test a single duration is correctly combined across duplicate frames
frame.save(test_file, save_all=True, append_images=[frame, frame], duration=500)
with Image.open(test_file) as im:
assert im.n_frames == 1
assert im.info.get("duration") == 1500
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 OP_NONE
red.save(
test_file,
save_all=True,
append_images=[green, transparent],
disposal=PngImagePlugin.Disposal.OP_NONE,
blend=PngImagePlugin.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 OP_BACKGROUND
disposal = [
PngImagePlugin.Disposal.OP_NONE,
PngImagePlugin.Disposal.OP_BACKGROUND,
PngImagePlugin.Disposal.OP_NONE,
]
red.save(
test_file,
save_all=True,
append_images=[red, transparent],
disposal=disposal,
blend=PngImagePlugin.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.Disposal.OP_NONE,
PngImagePlugin.Disposal.OP_BACKGROUND,
]
red.save(
test_file,
save_all=True,
append_images=[green],
disposal=disposal,
blend=PngImagePlugin.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 OP_PREVIOUS
disposal = [
PngImagePlugin.Disposal.OP_NONE,
PngImagePlugin.Disposal.OP_PREVIOUS,
PngImagePlugin.Disposal.OP_NONE,
]
red.save(
test_file,
save_all=True,
append_images=[green, red, transparent],
default_image=True,
disposal=disposal,
blend=PngImagePlugin.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.Disposal.OP_NONE,
PngImagePlugin.Disposal.OP_PREVIOUS,
]
red.save(
test_file,
save_all=True,
append_images=[green],
disposal=disposal,
blend=PngImagePlugin.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 info disposal
red.info["disposal"] = PngImagePlugin.Disposal.OP_BACKGROUND
red.save(
test_file,
save_all=True,
append_images=[Image.new("RGBA", (10, 10), (0, 255, 0, 255))],
)
with Image.open(test_file) as im:
im.seek(1)
assert im.getpixel((64, 32)) == (0, 0, 0, 0)
def test_apng_save_disposal_previous(tmp_path):
test_file = str(tmp_path / "temp.png")
size = (128, 64)
blue = Image.new("RGBA", size, (0, 0, 255, 255))
red = Image.new("RGBA", size, (255, 0, 0, 255))
green = Image.new("RGBA", size, (0, 255, 0, 255))
# test OP_NONE
blue.save(
test_file,
save_all=True,
append_images=[red, green],
disposal=PngImagePlugin.Disposal.OP_PREVIOUS,
)
with Image.open(test_file) as im:
assert im.getpixel((0, 0)) == (0, 0, 255, 255)
im.seek(2)
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 OP_SOURCE on solid color
blend = [
PngImagePlugin.Blend.OP_OVER,
PngImagePlugin.Blend.OP_SOURCE,
]
red.save(
test_file,
save_all=True,
append_images=[red, green],
default_image=True,
disposal=PngImagePlugin.Disposal.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 OP_SOURCE on transparent color
blend = [
PngImagePlugin.Blend.OP_OVER,
PngImagePlugin.Blend.OP_SOURCE,
]
red.save(
test_file,
save_all=True,
append_images=[red, transparent],
default_image=True,
disposal=PngImagePlugin.Disposal.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 OP_OVER
red.save(
test_file,
save_all=True,
append_images=[green, transparent],
default_image=True,
disposal=PngImagePlugin.Disposal.OP_NONE,
blend=PngImagePlugin.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)
# test info blend
red.info["blend"] = PngImagePlugin.Blend.OP_OVER
red.save(test_file, save_all=True, append_images=[green, transparent])
with Image.open(test_file) as im:
im.seek(2)
assert im.getpixel((0, 0)) == (0, 255, 0, 255)
def test_seek_after_close():
im = Image.open("Tests/images/apng/delay.png")
im.seek(1)
im.close()
with pytest.raises(ValueError):
im.seek(0)
@pytest.mark.parametrize("mode", ("RGBA", "RGB", "P"))
@pytest.mark.parametrize("default_image", (True, False))
def test_different_modes_in_later_frames(mode, default_image, tmp_path):
test_file = str(tmp_path / "temp.png")
im = Image.new("L", (1, 1))
im.save(
test_file,
save_all=True,
default_image=default_image,
append_images=[Image.new(mode, (1, 1))],
)
with Image.open(test_file) as reloaded:
assert reloaded.mode == mode