Repository Validate / repository-checks (pull_request) Successful in 4s
Implemented image saving into the caller directory and exact-size downscaling behind --resize.
tools/images.py: added stdlib-only helpers `png_chunks`, `decode_png`, `encode_png`, `ico_wrap`, `ico_unwrap`, plus `decode_image`, `resample_rows`, `encode_image` and the `DecodedImage` dataclass; also fixed a real pixel-stride bug in RGBA upsampling (channel step was used as pixel stride, corrupting 8-bit pixels).
tools/example.py: removed the inlined codec (now delegated to tools.images); `--download` writes each selected image into the caller-specified directory; each stored file is read back and decoded, so a reported path only names a file that exists and opens as the requested format (`format`/`width`/`height`/`bytes` reported per item, no path for a failed item); `--download` without `--output-dir` is now an `invalid_request` (`request_field`, field=output_dir) instead of a hidden default, and DEFAULT_OUTPUT_DIR is gone; `--resize` stores exactly the requested width/height (one open dimension keeps the source value), re-encodes in the requested format, never upscales and leaves a fitting image's bytes untouched; a per-item save failure is reported in that item and ends the run with `partial_download` (exit 6), including `saved_paths` in the error details.
tests/test_download_resize.py: new focused suite over tmp_path dirs and locally built png/ico fixtures (save paths exist and decode, requested-format check, missing --output-dir invalid_request, failing save => partial_download, exact resize dims for png and ico, no upscaling, byte/dimension preservation without --resize, plus codec round-trip tests).
tests/test_cli.py: updated the aspect-ratio resize expectation to the required exact 100x100 stored size.
Target branch task/1-task is already the current head (d5bb24f), so the tested revision contains the target.
Tests:
Facade commands run from /workspace with .venv/bin on PATH (prepared via prepare_environment tools restore): `python -m compileall tools tests` (tools.validate) OK; `python -m tools.example examples/input.json` (tools.smoke) exit 0 with status dry_run; `python -m tools.example --help` (acceptance help-command) exit 0; `python -c "import tools, tools.example, tools.images"` (package_import) OK; `pytest` (tools.test, the exact facade entrypoint) => 142 passed. New tests: tests/test_download_resize.py (13 tests). No network-dependent live-source check was run (sandbox has no egress); all fixtures are locally built real png/ico payloads.
Progress:
Completed issue 7: stdlib PNG/ICO decode/encode/wrap/unwrap plus resample helpers live in tools/images.py; tools/example.py saves selected images into the caller directory, requires --output-dir with --download (invalid_request otherwise), verifies every stored file by decoding it before reporting its path, downscales to exactly the requested width/height in the requested format only when --resize is set (never upscaling, otherwise byte-identical), and reports a per-item save failure with the partial_download status/exit code. New tests in tests/test_download_resize.py cover all of that; tests/test_cli.py resize expectation aligned with the exact-size requirement. Facade validate/smoke/help/import all pass and `pytest` reports 142 passed on the revision that already contains target task/1-task.
Published by developer-agent through Tool Backend.
Closes #7
772 lines
28 KiB
Python
772 lines
28 KiB
Python
"""Stdlib-only inspection and construction of the image formats we accept.
|
|
|
|
The CLI decides what to keep from the **downloaded bytes** and never from the
|
|
metadata a source reports about a file: a search hit that claims to be a
|
|
``image/png`` of 800x600 pixels is kept only when the bytes really are a PNG of
|
|
at least the requested size. This module is the single place that knows how to
|
|
read the real format and the real pixel dimensions out of those bytes.
|
|
|
|
It deliberately depends on nothing but the standard library:
|
|
|
|
* :func:`detect_format` recognises the container signatures of the two accepted
|
|
formats (``png``, ``ico``) plus the formats a real image source commonly
|
|
serves instead (``jpeg``, ``gif``, ``bmp``, ``webp``, ``svg``, ``tiff``,
|
|
``pdf``). Every recognised-but-different format, and every payload that is
|
|
not a recognised image at all, counts as *another format* rather than an
|
|
access problem: "the source has images, but none of them are the format you
|
|
asked for" is a different answer from "the source could not be used".
|
|
* :func:`read_png_dimensions` reads the ``IHDR`` header of a PNG.
|
|
* :func:`read_ico_dimensions` reads the ICONDIR/ICONDIRENTRY directory of an
|
|
ICO file (the directory stores the real sizes, including the ``0``-means-256
|
|
encoding).
|
|
* :func:`inspect_image` combines both into an :class:`ImageInfo`.
|
|
* :func:`png_bytes` and :func:`ico_bytes` *build* the same formats so tests (and
|
|
callers) can produce real payloads without Pillow or any other dependency.
|
|
* :func:`decode_png`, :func:`encode_png`, :func:`ico_wrap` and
|
|
:func:`ico_unwrap` decode and re-encode those containers, and
|
|
:func:`decode_image`, :func:`resample_rows` and :func:`encode_image` are the
|
|
building blocks the CLI uses to downscale a downloaded image and store it in
|
|
the requested format -- still with nothing but the standard library.
|
|
|
|
:class:`ImageFormatError` -- a :class:`~tools.errors.ToolkitError` subclass with
|
|
a stable ``code`` -- is reserved for bytes that are genuinely unusable: an
|
|
unknown container, or a ``png``/``ico`` payload whose header is truncated or
|
|
corrupt. A readable payload of another format is never an error, so the CLI
|
|
keeps a working search out of the ``access_error`` status.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import struct
|
|
import zlib
|
|
from dataclasses import dataclass
|
|
from typing import Any, Iterable, Sequence
|
|
|
|
from .errors import ToolkitError
|
|
|
|
__all__ = [
|
|
"BMP",
|
|
"GIF",
|
|
"ICO",
|
|
"ICO_ENTRY_SIZE",
|
|
"ICO_HEADER_SIZE",
|
|
"ICO_MAX_DIMENSION",
|
|
"ImageFormatError",
|
|
"ImageInfo",
|
|
"JPEG",
|
|
"KNOWN_FORMATS",
|
|
"PDF",
|
|
"PNG",
|
|
"PNG_SIGNATURE",
|
|
"SVG",
|
|
"TIFF",
|
|
"WEBP",
|
|
"detect_format",
|
|
"ico_bytes",
|
|
"ico_entries",
|
|
"inspect_image",
|
|
"png_bytes",
|
|
"read_ico_dimensions",
|
|
"read_png_dimensions",
|
|
"DecodedImage",
|
|
"decode_image",
|
|
"decode_png",
|
|
"encode_image",
|
|
"encode_png",
|
|
"ico_unwrap",
|
|
"ico_wrap",
|
|
"png_chunks",
|
|
"resample_rows",
|
|
]
|
|
|
|
#: The two formats the CLI accepts as search requests.
|
|
PNG = "png"
|
|
ICO = "ico"
|
|
#: Formats that are recognised (so they can be reported as "another format")
|
|
#: but never accepted by the CLI.
|
|
JPEG = "jpeg"
|
|
GIF = "gif"
|
|
BMP = "bmp"
|
|
WEBP = "webp"
|
|
#: Vector and document formats a live image source serves instead of a bitmap.
|
|
#: Naming them lets a search say *which* other formats the topic offered instead
|
|
#: of degrading into an access problem.
|
|
SVG = "svg"
|
|
TIFF = "tiff"
|
|
PDF = "pdf"
|
|
|
|
#: Every format :func:`detect_format` can name.
|
|
KNOWN_FORMATS: tuple[str, ...] = (PNG, ICO, JPEG, GIF, BMP, WEBP, SVG, TIFF, PDF)
|
|
|
|
#: The 8-byte signature every PNG file starts with.
|
|
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
|
|
#: ICONDIR: reserved (0), type (1 = icon) and the entry count.
|
|
ICO_HEADER_SIZE = 6
|
|
#: One ICONDIRENTRY: 16 bytes of width/height/colours/planes/bpp/size/offset.
|
|
ICO_ENTRY_SIZE = 16
|
|
#: A single ICO dimension byte of 0 encodes 256 pixels.
|
|
ICO_MAX_DIMENSION = 256
|
|
|
|
#: Magic prefixes of the recognised but unsupported formats.
|
|
_JPEG_SIGNATURE = b"\xff\xd8\xff"
|
|
_GIF_SIGNATURES = (b"GIF87a", b"GIF89a")
|
|
_BMP_SIGNATURE = b"BM"
|
|
_RIFF_SIGNATURE = b"RIFF"
|
|
_WEBP_SIGNATURE = b"WEBP"
|
|
_TIFF_SIGNATURES = (b"II*\x00", b"MM\x00*")
|
|
_PDF_SIGNATURE = b"%PDF-"
|
|
#: How many leading bytes are searched for an ``<svg`` root element.
|
|
_SVG_PROBE = 1024
|
|
|
|
#: Number of payload bytes worth quoting in an error detail.
|
|
_SIGNATURE_PROBE = 16
|
|
|
|
|
|
class ImageFormatError(ToolkitError):
|
|
"""Raised when downloaded bytes are not a readable image of a known format.
|
|
|
|
The bytes could not be attributed to any format the toolkit understands, or
|
|
they claim to be an accepted format but their header is truncated/corrupt.
|
|
It derives from :class:`~tools.errors.ToolkitError`, so the CLI reports it
|
|
as an access problem (the source handed us something unusable) rather than
|
|
as a "no such format" / "no such size" verdict about the topic.
|
|
"""
|
|
|
|
code = "image_unreadable"
|
|
|
|
def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
|
|
super().__init__(message, details=details)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ImageInfo:
|
|
"""The real format and real pixel size of one payload."""
|
|
|
|
format: str
|
|
width: int
|
|
height: int
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
"""JSON-ready projection used in CLI items."""
|
|
|
|
return {"format": self.format, "width": self.width, "height": self.height}
|
|
|
|
@property
|
|
def area(self) -> int:
|
|
return self.width * self.height
|
|
|
|
|
|
def _probe(payload: bytes) -> bytes:
|
|
return bytes(payload[:_SIGNATURE_PROBE])
|
|
|
|
|
|
def _looks_like_svg(data: bytes) -> bool:
|
|
"""True when *data* starts with an SVG root element (or its XML prologue)."""
|
|
|
|
head = data[:_SVG_PROBE]
|
|
if head.startswith(b"\xef\xbb\xbf"):
|
|
head = head[3:]
|
|
head = head.lstrip()
|
|
if head.lower().startswith(b"<svg"):
|
|
return True
|
|
return head.startswith(b"<?xml") and b"<svg" in data[:_SVG_PROBE].lower()
|
|
|
|
|
|
def detect_format(payload: bytes) -> str | None:
|
|
"""Return the real container format of *payload*, or ``None``.
|
|
|
|
A recognised format other than ``png``/``ico`` is a perfectly good answer:
|
|
the source does have images, just not in the requested format. ``None``
|
|
means the bytes are not a recognised image at all -- still "not the requested
|
|
format" from the caller's point of view, and never an access problem on its
|
|
own.
|
|
"""
|
|
|
|
if not isinstance(payload, (bytes, bytearray, memoryview)):
|
|
return None
|
|
data = bytes(payload)
|
|
if data.startswith(PNG_SIGNATURE):
|
|
return PNG
|
|
if data[:4] == b"\x00\x00\x01\x00":
|
|
return ICO
|
|
if data.startswith(_JPEG_SIGNATURE):
|
|
return JPEG
|
|
if data.startswith(_GIF_SIGNATURES):
|
|
return GIF
|
|
if data.startswith(_BMP_SIGNATURE) and len(data) >= 14:
|
|
return BMP
|
|
if data.startswith(_RIFF_SIGNATURE) and data[8:12] == _WEBP_SIGNATURE:
|
|
return WEBP
|
|
if data.startswith(_TIFF_SIGNATURES):
|
|
return TIFF
|
|
if data.startswith(_PDF_SIGNATURE):
|
|
return PDF
|
|
if _looks_like_svg(data):
|
|
return SVG
|
|
return None
|
|
|
|
|
|
def read_png_dimensions(payload: bytes) -> tuple[int, int]:
|
|
"""Return ``(width, height)`` from the ``IHDR`` chunk of a PNG payload."""
|
|
|
|
data = bytes(payload)
|
|
if not data.startswith(PNG_SIGNATURE):
|
|
raise ImageFormatError(
|
|
"payload does not start with the PNG signature",
|
|
details={"signature": _probe(data).hex()},
|
|
)
|
|
offset = len(PNG_SIGNATURE)
|
|
if len(data) < offset + 8:
|
|
raise ImageFormatError("PNG header is truncated")
|
|
(length,) = struct.unpack(">I", data[offset : offset + 4])
|
|
chunk_type = data[offset + 4 : offset + 8]
|
|
if chunk_type != b"IHDR":
|
|
raise ImageFormatError(
|
|
"PNG payload does not start with an IHDR chunk",
|
|
details={"first_chunk": chunk_type.decode("latin-1")},
|
|
)
|
|
if length != 13:
|
|
raise ImageFormatError(
|
|
"PNG IHDR chunk has an unexpected length",
|
|
details={"length": length},
|
|
)
|
|
header = data[offset + 8 : offset + 8 + length]
|
|
if len(header) != length:
|
|
raise ImageFormatError("PNG IHDR chunk is truncated")
|
|
width, height = struct.unpack(">II", header[:8])
|
|
if width < 1 or height < 1:
|
|
raise ImageFormatError(
|
|
"PNG image declares no pixels",
|
|
details={"width": width, "height": height},
|
|
)
|
|
return int(width), int(height)
|
|
|
|
|
|
def ico_entries(payload: bytes) -> tuple[tuple[int, int, int, int], ...]:
|
|
"""Return ``(width, height, size, offset)`` for every ICO directory entry."""
|
|
|
|
data = bytes(payload)
|
|
if len(data) < ICO_HEADER_SIZE:
|
|
raise ImageFormatError("ICO header is truncated")
|
|
reserved, kind, count = struct.unpack("<HHH", data[:ICO_HEADER_SIZE])
|
|
if reserved != 0 or kind != 1 or count < 1:
|
|
raise ImageFormatError(
|
|
"payload is not an ICO image",
|
|
details={"reserved": reserved, "type": kind, "count": count},
|
|
)
|
|
directory_end = ICO_HEADER_SIZE + count * ICO_ENTRY_SIZE
|
|
if len(data) < directory_end:
|
|
raise ImageFormatError(
|
|
"ICO directory is truncated",
|
|
details={"count": count, "bytes": len(data)},
|
|
)
|
|
entries: list[tuple[int, int, int, int]] = []
|
|
for index in range(count):
|
|
start = ICO_HEADER_SIZE + index * ICO_ENTRY_SIZE
|
|
raw_width, raw_height, _colours, _reserved, _planes, _bits, size, offset = struct.unpack(
|
|
"<BBBBHHII", data[start : start + ICO_ENTRY_SIZE]
|
|
)
|
|
width = raw_width or ICO_MAX_DIMENSION
|
|
height = raw_height or ICO_MAX_DIMENSION
|
|
if size < 1 or offset < directory_end or offset + size > len(data):
|
|
raise ImageFormatError(
|
|
"ICO directory entry points outside the payload",
|
|
details={"index": index, "size": size, "offset": offset, "bytes": len(data)},
|
|
)
|
|
entries.append((width, height, size, offset))
|
|
return tuple(entries)
|
|
|
|
|
|
def read_ico_dimensions(payload: bytes) -> tuple[int, int]:
|
|
"""Return the dimensions of the largest image stored in an ICO payload.
|
|
|
|
The ICONDIRENTRY directory is authoritative: it carries one width/height pair
|
|
per stored image, so no decoding of the embedded bitmap or PNG is needed.
|
|
"""
|
|
|
|
entries = ico_entries(payload)
|
|
width, height, _size, _offset = max(entries, key=lambda entry: entry[0] * entry[1])
|
|
return int(width), int(height)
|
|
|
|
|
|
def inspect_image(payload: bytes) -> ImageInfo:
|
|
"""Return the real format and pixel size of *payload*.
|
|
|
|
Raises :class:`ImageFormatError` when the bytes are not an image at all, or
|
|
when they are not one of the two formats whose dimensions can be read.
|
|
"""
|
|
|
|
image_format = detect_format(payload)
|
|
if image_format is None:
|
|
raise ImageFormatError(
|
|
"downloaded payload is not a recognised image format",
|
|
details={"signature": _probe(bytes(payload)).hex()},
|
|
)
|
|
if image_format == PNG:
|
|
width, height = read_png_dimensions(payload)
|
|
return ImageInfo(format=PNG, width=width, height=height)
|
|
if image_format == ICO:
|
|
width, height = read_ico_dimensions(payload)
|
|
return ImageInfo(format=ICO, width=width, height=height)
|
|
raise ImageFormatError(
|
|
f"pixel dimensions are only readable for png and ico, not {image_format}",
|
|
details={"format": image_format},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Builders: the same formats, produced with the standard library only
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _png_chunk(chunk_type: bytes, chunk_data: bytes) -> bytes:
|
|
crc = zlib.crc32(chunk_type + chunk_data) & 0xFFFFFFFF
|
|
return struct.pack(">I", len(chunk_data)) + chunk_type + chunk_data + struct.pack(">I", crc)
|
|
|
|
|
|
def png_bytes(
|
|
width: int,
|
|
height: int,
|
|
*,
|
|
rgba: tuple[int, int, int, int] = (255, 255, 255, 255),
|
|
rows: Sequence[bytes] | None = None,
|
|
level: int = 6,
|
|
) -> bytes:
|
|
"""Build a valid 8-bit RGBA PNG of exactly ``width`` x ``height`` pixels.
|
|
|
|
``rows`` may supply raw (filter-0) RGBA scanlines; otherwise every pixel is
|
|
filled with ``rgba``. The result is a real PNG file: a correct signature,
|
|
a 13-byte ``IHDR``, a zlib-compressed ``IDAT`` and an empty ``IEND``.
|
|
"""
|
|
|
|
if isinstance(width, bool) or isinstance(height, bool):
|
|
raise ValueError("width and height must be integers")
|
|
if width < 1 or height < 1:
|
|
raise ValueError("width and height must be >= 1")
|
|
if rows is None:
|
|
pixel = bytes(rgba)
|
|
line = pixel * width
|
|
raw = bytearray()
|
|
for _ in range(height):
|
|
raw.append(0)
|
|
raw += line
|
|
raw_bytes = bytes(raw)
|
|
else:
|
|
if len(rows) != height:
|
|
raise ValueError("rows must contain exactly height scanlines")
|
|
raw = bytearray()
|
|
for row in rows:
|
|
if len(row) != width * 4:
|
|
raise ValueError("each scanline must hold width * 4 bytes")
|
|
raw.append(0)
|
|
raw += row
|
|
raw_bytes = bytes(raw)
|
|
header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
|
|
return (
|
|
PNG_SIGNATURE
|
|
+ _png_chunk(b"IHDR", header)
|
|
+ _png_chunk(b"IDAT", zlib.compress(raw_bytes, level))
|
|
+ _png_chunk(b"IEND", b"")
|
|
)
|
|
|
|
|
|
def ico_bytes(
|
|
width: int,
|
|
height: int,
|
|
*,
|
|
rgba: tuple[int, int, int, int] = (255, 255, 255, 255),
|
|
frames: Iterable[tuple[int, int]] | None = None,
|
|
) -> bytes:
|
|
"""Build an ICO payload whose directory lists the requested sizes.
|
|
|
|
By default the ICO stores one embedded PNG of ``width`` x ``height`` pixels
|
|
and the directory entry repeats those dimensions (``0`` encodes 256). Pass
|
|
``frames`` to describe several directory entries instead, for example
|
|
``ico_bytes(48, 48, frames=[(16, 16), (48, 48)])``: the dimensions of the
|
|
*largest* entry then become the reported size, which is exactly what
|
|
:func:`read_ico_dimensions` returns.
|
|
"""
|
|
|
|
sizes = list(frames) if frames is not None else [(width, height)]
|
|
if not sizes:
|
|
raise ValueError("an ICO payload needs at least one frame")
|
|
payloads: list[bytes] = []
|
|
for frame_width, frame_height in sizes:
|
|
if not 1 <= frame_width <= ICO_MAX_DIMENSION:
|
|
raise ValueError("ICO widths must be between 1 and 256 pixels")
|
|
if not 1 <= frame_height <= ICO_MAX_DIMENSION:
|
|
raise ValueError("ICO heights must be between 1 and 256 pixels")
|
|
payloads.append(png_bytes(frame_width, frame_height, rgba=rgba))
|
|
header = struct.pack("<HHH", 0, 1, len(payloads))
|
|
offset = ICO_HEADER_SIZE + ICO_ENTRY_SIZE * len(payloads)
|
|
entries = bytearray()
|
|
for (frame_width, frame_height), payload in zip(sizes, payloads):
|
|
entries += struct.pack(
|
|
"<BBBBHHII",
|
|
frame_width % ICO_MAX_DIMENSION,
|
|
frame_height % ICO_MAX_DIMENSION,
|
|
0,
|
|
0,
|
|
1,
|
|
32,
|
|
len(payload),
|
|
offset,
|
|
)
|
|
offset += len(payload)
|
|
return header + bytes(entries) + b"".join(payloads)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Decoding and re-encoding: PNG pixels and ICO containers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
#: Channels per PNG colour type (0 grey, 2 rgb, 3 palette, 4 grey+alpha, 6 rgba).
|
|
_PNG_CHANNELS = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DecodedImage:
|
|
"""The container format, pixel size and 8-bit RGBA scanlines of a payload."""
|
|
|
|
format: str
|
|
width: int
|
|
height: int
|
|
rows: tuple[bytes, ...]
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
"""JSON-ready projection: format plus the real pixel size."""
|
|
|
|
return {"format": self.format, "width": self.width, "height": self.height}
|
|
|
|
|
|
def png_chunks(payload: bytes) -> tuple[tuple[bytes, bytes], ...]:
|
|
"""Split a PNG payload into ``(chunk_type, chunk_data)`` pairs.
|
|
|
|
Raises :class:`ImageFormatError` when the signature is missing, a chunk is
|
|
truncated or the stream never reaches ``IEND``: those bytes cannot be
|
|
decoded, so stitching a half image would be worse than reporting it.
|
|
"""
|
|
|
|
data = bytes(payload)
|
|
if not data.startswith(PNG_SIGNATURE):
|
|
raise ImageFormatError(
|
|
"payload does not start with the PNG signature",
|
|
details={"signature": _probe(data).hex()},
|
|
)
|
|
chunks: list[tuple[bytes, bytes]] = []
|
|
offset = len(PNG_SIGNATURE)
|
|
while offset + 8 <= len(data):
|
|
(length,) = struct.unpack(">I", data[offset : offset + 4])
|
|
chunk_type = data[offset + 4 : offset + 8]
|
|
chunk_data = data[offset + 8 : offset + 8 + length]
|
|
if len(chunk_data) != length:
|
|
raise ImageFormatError(
|
|
"PNG chunk is truncated",
|
|
details={"chunk": chunk_type.decode("latin-1"), "length": length},
|
|
)
|
|
chunks.append((chunk_type, chunk_data))
|
|
offset += 12 + length
|
|
if chunk_type == b"IEND":
|
|
return tuple(chunks)
|
|
raise ImageFormatError("PNG image has no IEND chunk")
|
|
|
|
|
|
def _unfilter_rows(raw: bytes, width: int, height: int, bpp: int) -> list[bytearray]:
|
|
"""Undo the per-scanline PNG filters, returning one row per pixel line."""
|
|
|
|
stride = width * bpp
|
|
if len(raw) < height * (stride + 1):
|
|
raise ImageFormatError(
|
|
"PNG image data is truncated",
|
|
details={"width": width, "height": height, "bytes": len(raw)},
|
|
)
|
|
rows: list[bytearray] = []
|
|
previous = bytearray(stride)
|
|
position = 0
|
|
for _ in range(height):
|
|
filter_type = raw[position]
|
|
position += 1
|
|
line = bytearray(raw[position : position + stride])
|
|
position += stride
|
|
if filter_type == 1:
|
|
for index in range(bpp, stride):
|
|
line[index] = (line[index] + line[index - bpp]) & 0xFF
|
|
elif filter_type == 2:
|
|
for index in range(stride):
|
|
line[index] = (line[index] + previous[index]) & 0xFF
|
|
elif filter_type == 3:
|
|
for index in range(stride):
|
|
left = line[index - bpp] if index >= bpp else 0
|
|
line[index] = (line[index] + ((left + previous[index]) >> 1)) & 0xFF
|
|
elif filter_type == 4:
|
|
for index in range(stride):
|
|
left = line[index - bpp] if index >= bpp else 0
|
|
up = previous[index]
|
|
up_left = previous[index - bpp] if index >= bpp else 0
|
|
estimate = left + up - up_left
|
|
to_left = abs(estimate - left)
|
|
to_up = abs(estimate - up)
|
|
to_up_left = abs(estimate - up_left)
|
|
if to_left <= to_up and to_left <= to_up_left:
|
|
predictor = left
|
|
elif to_up <= to_up_left:
|
|
predictor = up
|
|
else:
|
|
predictor = up_left
|
|
line[index] = (line[index] + predictor) & 0xFF
|
|
elif filter_type != 0:
|
|
raise ImageFormatError(
|
|
"unsupported PNG filter type",
|
|
details={"filter_type": int(filter_type)},
|
|
)
|
|
previous = line
|
|
rows.append(line)
|
|
return rows
|
|
|
|
|
|
def _row_to_rgba(
|
|
row: bytearray,
|
|
width: int,
|
|
color_type: int,
|
|
depth: int,
|
|
palette: bytes,
|
|
transparency: bytes | None,
|
|
) -> bytes:
|
|
"""Convert one unfiltered scanline into 8-bit RGBA samples."""
|
|
|
|
step = depth // 8
|
|
# Bytes per pixel: one sample per channel, so the channel offsets inside a
|
|
# pixel are ``step`` apart while pixels are ``channels * step`` apart.
|
|
pixel_stride = _PNG_CHANNELS[color_type] * step
|
|
out = bytearray()
|
|
if color_type == 3:
|
|
for x in range(width):
|
|
index = row[x]
|
|
base = index * 3
|
|
if base + 2 >= len(palette):
|
|
raise ImageFormatError(
|
|
"PNG palette index is out of range",
|
|
details={"index": int(index), "palette_bytes": len(palette)},
|
|
)
|
|
alpha = transparency[index] if transparency and index < len(transparency) else 255
|
|
out += bytes((palette[base], palette[base + 1], palette[base + 2], alpha))
|
|
return bytes(out)
|
|
for x in range(width):
|
|
offset = x * pixel_stride
|
|
if color_type == 0:
|
|
value = row[offset]
|
|
out += bytes((value, value, value, 255))
|
|
elif color_type == 4:
|
|
value = row[offset]
|
|
out += bytes((value, value, value, row[offset + step]))
|
|
elif color_type == 2:
|
|
out += bytes((row[offset], row[offset + step], row[offset + 2 * step], 255))
|
|
else:
|
|
out += bytes(
|
|
(
|
|
row[offset],
|
|
row[offset + step],
|
|
row[offset + 2 * step],
|
|
row[offset + 3 * step],
|
|
)
|
|
)
|
|
return bytes(out)
|
|
|
|
|
|
def decode_png(payload: bytes) -> tuple[int, int, tuple[bytes, ...]]:
|
|
"""Decode a PNG payload into ``(width, height, rgba_rows)``.
|
|
|
|
Supports the non-interlaced 8-bit and 16-bit PNG colour types ImageMagick,
|
|
GIMP and the Wikimedia thumbnailers produce; anything else raises
|
|
:class:`ImageFormatError` instead of guessing at pixels.
|
|
"""
|
|
|
|
header: tuple[int, ...] | None = None
|
|
palette = b""
|
|
transparency: bytes | None = None
|
|
data = bytearray()
|
|
for chunk_type, chunk_data in png_chunks(payload):
|
|
if chunk_type == b"IHDR":
|
|
if len(chunk_data) < 13:
|
|
raise ImageFormatError("PNG header is truncated")
|
|
header = struct.unpack(">IIBBBBB", chunk_data[:13])
|
|
elif chunk_type == b"PLTE":
|
|
palette = chunk_data
|
|
elif chunk_type == b"tRNS":
|
|
transparency = chunk_data
|
|
elif chunk_type == b"IDAT":
|
|
data += chunk_data
|
|
if header is None:
|
|
raise ImageFormatError("PNG image has no IHDR chunk")
|
|
width, height, depth, color_type, compression, filter_method, interlace = header
|
|
if width < 1 or height < 1:
|
|
raise ImageFormatError("PNG image declares no pixels")
|
|
if compression != 0 or filter_method != 0:
|
|
raise ImageFormatError(
|
|
"unsupported PNG compression or filter method",
|
|
details={"compression": compression, "filter_method": filter_method},
|
|
)
|
|
if interlace != 0:
|
|
raise ImageFormatError(
|
|
"interlaced PNG images are not supported",
|
|
details={"interlace": interlace},
|
|
)
|
|
channels = _PNG_CHANNELS.get(color_type)
|
|
if channels is None:
|
|
raise ImageFormatError(
|
|
"unsupported PNG colour type",
|
|
details={"color_type": color_type},
|
|
)
|
|
if depth not in (8, 16):
|
|
raise ImageFormatError(
|
|
"unsupported PNG bit depth",
|
|
details={"bit_depth": depth},
|
|
)
|
|
if color_type == 3 and depth > 8:
|
|
raise ImageFormatError(
|
|
"unsupported PNG palette bit depth",
|
|
details={"bit_depth": depth},
|
|
)
|
|
if color_type == 3 and not palette:
|
|
raise ImageFormatError("PNG palette image has no PLTE chunk")
|
|
try:
|
|
raw = zlib.decompress(bytes(data))
|
|
except zlib.error as exc:
|
|
raise ImageFormatError(
|
|
"PNG image data is corrupt",
|
|
details={"reason": str(exc)},
|
|
) from exc
|
|
rows = _unfilter_rows(raw, width, height, channels * (depth // 8))
|
|
rgba_rows = tuple(
|
|
_row_to_rgba(row, width, color_type, depth, palette, transparency) for row in rows
|
|
)
|
|
return int(width), int(height), rgba_rows
|
|
|
|
|
|
def encode_png(width: int, height: int, rows: Sequence[bytes]) -> bytes:
|
|
"""Encode 8-bit RGBA ``rows`` as a real PNG with correct chunk CRCs."""
|
|
|
|
return png_bytes(width, height, rows=list(rows))
|
|
|
|
|
|
def ico_wrap(embedded_png: bytes, width: int, height: int) -> bytes:
|
|
"""Wrap a PNG payload in a single-entry ICO container of that size."""
|
|
|
|
if not 1 <= width <= ICO_MAX_DIMENSION:
|
|
raise ImageFormatError(
|
|
"ICO widths must be between 1 and 256 pixels",
|
|
details={"width": width},
|
|
)
|
|
if not 1 <= height <= ICO_MAX_DIMENSION:
|
|
raise ImageFormatError(
|
|
"ICO heights must be between 1 and 256 pixels",
|
|
details={"height": height},
|
|
)
|
|
png_payload = bytes(embedded_png)
|
|
if not png_payload.startswith(PNG_SIGNATURE):
|
|
raise ImageFormatError("an ICO container can only wrap a PNG payload here")
|
|
header = struct.pack("<HHH", 0, 1, 1)
|
|
entry = struct.pack(
|
|
"<BBBBHHII",
|
|
width % ICO_MAX_DIMENSION,
|
|
height % ICO_MAX_DIMENSION,
|
|
0,
|
|
0,
|
|
1,
|
|
32,
|
|
len(png_payload),
|
|
ICO_HEADER_SIZE + ICO_ENTRY_SIZE,
|
|
)
|
|
return header + entry + png_payload
|
|
|
|
|
|
def ico_unwrap(payload: bytes) -> tuple[int, int, bytes]:
|
|
"""Return the ``(width, height, embedded_png)`` of an ICO's largest frame."""
|
|
|
|
entries = ico_entries(payload)
|
|
width, height, size, offset = max(entries, key=lambda entry: entry[0] * entry[1])
|
|
embedded = bytes(payload)[offset : offset + size]
|
|
if not embedded.startswith(PNG_SIGNATURE):
|
|
raise ImageFormatError(
|
|
"ICO frames stored as raw bitmaps are not supported",
|
|
details={"width": width, "height": height},
|
|
)
|
|
return int(width), int(height), embedded
|
|
|
|
|
|
def decode_image(payload: bytes) -> DecodedImage:
|
|
"""Decode a PNG or ICO payload into :class:`DecodedImage` RGBA scanlines."""
|
|
|
|
data = bytes(payload)
|
|
image_format = detect_format(data)
|
|
if image_format == PNG:
|
|
width, height, rows = decode_png(data)
|
|
return DecodedImage(format=PNG, width=width, height=height, rows=rows)
|
|
if image_format == ICO:
|
|
width, height, embedded = ico_unwrap(data)
|
|
inner_width, inner_height, rows = decode_png(embedded)
|
|
if (inner_width, inner_height) != (width, height):
|
|
# The directory is authoritative for the reported size, but the
|
|
# pixels must agree or the container is inconsistent.
|
|
raise ImageFormatError(
|
|
"ICO directory and embedded PNG disagree about the size",
|
|
details={
|
|
"directory": {"width": width, "height": height},
|
|
"embedded": {"width": inner_width, "height": inner_height},
|
|
},
|
|
)
|
|
return DecodedImage(format=ICO, width=width, height=height, rows=rows)
|
|
raise ImageFormatError(
|
|
"pixel data is only decodable for png and ico",
|
|
details={"format": image_format},
|
|
)
|
|
|
|
|
|
def resample_rows(
|
|
rows: Sequence[bytes],
|
|
width: int,
|
|
height: int,
|
|
target_width: int,
|
|
target_height: int,
|
|
) -> tuple[bytes, ...]:
|
|
"""Nearest-neighbour resample of RGBA ``rows`` to exactly the target size."""
|
|
|
|
if target_width < 1 or target_height < 1:
|
|
raise ImageFormatError("resampled dimensions must be at least 1x1")
|
|
if len(rows) != height:
|
|
raise ImageFormatError(
|
|
"row count does not match the source height",
|
|
details={"rows": len(rows), "height": height},
|
|
)
|
|
if width < 1 or height < 1:
|
|
raise ImageFormatError("source image has no pixels")
|
|
columns = [min(width - 1, (x * width) // target_width) * 4 for x in range(target_width)]
|
|
resized: list[bytes] = []
|
|
for y in range(target_height):
|
|
source = rows[min(height - 1, (y * height) // target_height)]
|
|
line = bytearray()
|
|
for offset in columns:
|
|
line += source[offset : offset + 4]
|
|
resized.append(bytes(line))
|
|
return tuple(resized)
|
|
|
|
|
|
def encode_image(
|
|
image_format: str,
|
|
width: int,
|
|
height: int,
|
|
rows: Sequence[bytes],
|
|
) -> bytes:
|
|
"""Encode RGBA ``rows`` in ``image_format`` (``png`` or ``ico``)."""
|
|
|
|
png_payload = encode_png(width, height, rows)
|
|
if image_format == PNG:
|
|
return png_payload
|
|
if image_format == ICO:
|
|
return ico_wrap(png_payload, width, height)
|
|
raise ImageFormatError(
|
|
"only png and ico can be encoded",
|
|
details={"format": image_format},
|
|
)
|