"""Utility module to run odiff."""
from __future__ import annotations
import base64
import platform
import subprocess
from dataclasses import dataclass
from pathlib import Path
from shlex import quote
from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING
from apngasm_python.apngasm import APNGAsmBinder
from PIL import Image
from odiff_py import ODIFF_EXE
if TYPE_CHECKING:
from collections.abc import Iterable
from collections.abc import Sequence
try:
from typing import Self # type:ignore[attr-defined]
except ImportError:
from typing_extensions import Self
# Image manipulation tool like transparency background style (e.g. photoshop)
# Taken from https://stackoverflow.com/a/47061022/3990615
CHECKER_TRANSPARENCY_CSS = " ".join(
"""
background: -webkit-linear-gradient(45deg, rgba(0, 0, 0, 0.0980392) 25%, transparent 25%, transparent 75%, rgba(0, 0, 0, 0.0980392) 75%, rgba(0, 0, 0, 0.0980392) 0), -webkit-linear-gradient(45deg, rgba(0, 0, 0, 0.0980392) 25%, transparent 25%, transparent 75%, rgba(0, 0, 0, 0.0980392) 75%, rgba(0, 0, 0, 0.0980392) 0), white;
background: -moz-linear-gradient(45deg, rgba(0, 0, 0, 0.0980392) 25%, transparent 25%, transparent 75%, rgba(0, 0, 0, 0.0980392) 75%, rgba(0, 0, 0, 0.0980392) 0), -moz-linear-gradient(45deg, rgba(0, 0, 0, 0.0980392) 25%, transparent 25%, transparent 75%, rgba(0, 0, 0, 0.0980392) 75%, rgba(0, 0, 0, 0.0980392) 0), white;
background: linear-gradient(45deg, rgba(0, 0, 0, 0.0980392) 25%, transparent 25%, transparent 75%, rgba(0, 0, 0, 0.0980392) 75%, rgba(0, 0, 0, 0.0980392) 0), linear-gradient(45deg, rgba(0, 0, 0, 0.0980392) 25%, transparent 25%, transparent 75%, rgba(0, 0, 0, 0.0980392) 75%, rgba(0, 0, 0, 0.0980392) 0), white;
background-repeat: repeat, repeat;
background-position: 0px 0, 5px 5px;
-webkit-transform-origin: 0 0 0;
transform-origin: 0 0 0;
-webkit-background-origin: padding-box, padding-box;
background-origin: padding-box, padding-box;
-webkit-background-clip: border-box, border-box;
background-clip: border-box, border-box;
-webkit-background-size: 10px 10px, 10px 10px;
background-size: 10px 10px, 10px 10px;
-webkit-box-shadow: none;
box-shadow: none;
text-shadow: none;
-webkit-transition: none;
-moz-transition: none;
-o-transition: none;
transition: none;
-webkit-transform: scaleX(1) scaleY(1) scaleZ(1);
transform: scaleX(1) scaleY(1) scaleZ(1);
""".splitlines() # noqa: E501
)
[docs]
def run_odiff(*args: str, capture_output: bool = True) -> tuple[int, str, str]:
"""Run odiff binary.
Parameters
----------
*args : str
Arguments passed directly to the ``odiff`` executable.
capture_output : bool
Whether to capture the output or not. The only place where ``False`` should be used is to
invoke the executable as CLI tool so the user sees the output. Defaults to True
Returns
-------
tuple[int, str, str]
Return code of running odiff and stdout.
"""
cmd: str | Sequence[str] = (quote(ODIFF_EXE.as_posix()), *args)
if platform.system().lower() == "windows":
cmd = " ".join(cmd)
result = subprocess.run(cmd, capture_output=capture_output, text=True) # noqa: PLW1510, S603
return result.returncode, result.stdout, result.stderr
[docs]
def load_image(image_path: str | Path) -> Image.Image:
"""Load an ``Image.Image`` instance, populate the instance and close file descriptor.
Parameters
----------
image_path : str | Path
Path to the image that should be loaded.
Returns
-------
Image.Image
"""
with Image.open(image_path) as im:
im.load()
return im
[docs]
def png_images_to_apng_bytes(
images: Iterable[Image.Image | None],
out_file: str | Path | None = None,
*,
delay_num: int = 500,
delay_den: int = 1000,
) -> bytes:
"""Convert png images to ``bytes`` representation of an animated png.
Parameters
----------
images : Iterable[Image.Image | None]
Sequence of images to create ``apng`` from. If an image is ``None`` it will be skipped.
out_file : str | Path | None
Path to an output file to generate. Defaults to None
delay_num : int
The delay numerator for frames. Defaults to 500
delay_den : int
The delay denominator for frames. Defaults to 1000
Returns
-------
bytes
See Also
--------
APNGAsmBinder.add_frame_from_pillow
"""
apngasm = APNGAsmBinder()
for image in images:
if image is not None:
apngasm.add_frame_from_pillow(image, delay_num=delay_num, delay_den=delay_den)
with TemporaryDirectory() as tmp_dir:
out_file = Path(out_file) if out_file is not None else Path(tmp_dir) / "animated.apng"
apngasm.assemble(out_file.as_posix())
return out_file.read_bytes()
[docs]
@dataclass
class APNG:
"""APNG wrapper class."""
data: bytes
use_checker_transparency: bool = True
[docs]
@classmethod
def from_images(
cls,
images: Iterable[Image.Image | None],
*,
delay_num: int = 500,
delay_den: int = 1000,
) -> Self:
"""Create new instance from images.
Parameters
----------
images : Iterable[Image.Image | None]
Images to create the apng instance from.
delay_num : int
The delay numerator for frames. Defaults to 500
delay_den : int
The delay denominator for frames. Defaults to 1000
Returns
-------
Self
"""
return cls(
data=png_images_to_apng_bytes(images=images, delay_num=delay_num, delay_den=delay_den)
)
[docs]
@classmethod
def from_file(cls, path: str | Path) -> Self:
"""Create new instance from apng file.
Parameters
----------
path : str | Path
File to load.
Returns
-------
Self
"""
return cls(data=Path(path).read_bytes())
[docs]
def save(self, path: str | Path) -> Path:
"""Save to file at ``path``.
Parameters
----------
path : str | Path
Path to save the file to.
Returns
-------
Path
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(self.data)
return path
[docs]
def __str__(self) -> str: # noqa:DOC
"""Create string repr for instance."""
img_str = base64.b64encode(self.data).decode(encoding="utf-8")
return (
'<img style="border: 1px solid; '
f'{CHECKER_TRANSPARENCY_CSS if self.use_checker_transparency is True else ""}" '
f'src="data:image/apng;base64,{img_str}">'
)
[docs]
def _repr_markdown_(self) -> str: # noqa:DOC
"""Magic method for rendering automatically in jupyter notebooks."""
return str(self)