Files
product-54354138-1/tools/images.py
T
Tool Backend 810ce59af4
Repository Validate / repository-checks (pull_request) Successful in 4s
change: Implement 5: Save selected images to the caller directory and downscale only behind the resize flag
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
2026-11-03 02:25:41 +00:00

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},
)