diff --git a/CHANGELOG.md b/CHANGELOG.md index ddd1713..f269fa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add a CI job building the documentation on every PR/push so this kind of breakage is caught automatically (#279) - Add support for Python 3.15 (#281) - Build wheels against the stable ABI (abi3) so a single wheel per platform covers all supported CPython versions (#263) +- Add `libzim.illustration` module with the `IllustrationInfo` value object, and width/height/scale-aware illustration APIs: `Creator.add_illustration()`/`add_illustration_info()`, `Archive.get_illustration_item()`/`get_illustration_item_info()`, `Archive.get_illustrations_info()` (#232, #233) ## [3.13.1] - 2026-10-05 diff --git a/README.md b/README.md index 847810c..7e1970a 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,8 @@ with Creator("test.zim").config_indexing(True, "eng") as creator: creator.set_mainpath("home") creator.add_item(item) creator.add_item(item2) - creator.add_illustration(48, illustration) + # square illustration at scale 1 + creator.add_illustration(illustration, 48) for name, value in { "creator": "python-libzim", "description": "Created in python", diff --git a/libzim/__init__.pyi b/libzim/__init__.pyi index 6a18645..3ffc7b2 100644 --- a/libzim/__init__.pyi +++ b/libzim/__init__.pyi @@ -1,4 +1,5 @@ from libzim import ( + illustration, # noqa: F401 # pyright: ignore[reportUnusedImport] reader, # noqa: F401 # pyright: ignore[reportUnusedImport] search, # noqa: F401 # pyright: ignore[reportUnusedImport] suggestion, # noqa: F401 # pyright: ignore[reportUnusedImport] diff --git a/libzim/illustration.pyi b/libzim/illustration.pyi new file mode 100644 index 0000000..8782bc1 --- /dev/null +++ b/libzim/illustration.pyi @@ -0,0 +1,15 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import NamedTuple + +class IllustrationInfo(NamedTuple): + """Information about an illustration in a ZIM archive.""" + + width: int = 0 + height: int = 0 + scale: float = 1.0 + extra_attributes: Mapping[str, str] = {} + @staticmethod + def from_metadata_item_name(name: str) -> IllustrationInfo: ... + def as_metadata_item_name(self) -> str: ... diff --git a/libzim/libwrapper.h b/libzim/libwrapper.h index c66eba6..df1e71b 100644 --- a/libzim/libwrapper.h +++ b/libzim/libwrapper.h @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -136,6 +137,8 @@ class Entry : public Wrapper class Archive : public Wrapper { public: + typedef zim::Archive::IllustrationInfos IllustrationInfos; + Archive() = default; Archive(const std::string& filename) : Wrapper(zim::Archive(filename)) {}; Archive(const zim::Archive& o) : Wrapper(o) {}; @@ -147,6 +150,7 @@ class Archive : public Wrapper FORWARD(wrapper::Entry, getRandomEntry) FORWARD(wrapper::Item, getIllustrationItem) FORWARD(std::set, getIllustrationSizes) + FORWARD(zim::Archive::IllustrationInfos, getIllustrationInfos) std::string getUuid() const { auto u = mp_base->getUuid(); std::string uuids(u.data, u.size()); diff --git a/libzim/libzim.pyx b/libzim/libzim.pyx index a194ae6..8345144 100644 --- a/libzim/libzim.pyx +++ b/libzim/libzim.pyx @@ -42,8 +42,9 @@ import sys import traceback import warnings from collections import OrderedDict -from collections.abc import Generator, Iterator -from types import ModuleType +from collections import namedtuple as _namedtuple +from collections.abc import Generator, Iterator, Mapping +from types import MappingProxyType, ModuleType from typing import TextIO from uuid import UUID @@ -481,20 +482,63 @@ cdef class _Creator: self.c_creator.setMainPath(main_path.encode('UTF-8')) return self - def add_illustration(self, int size: pyint, content: bytes): + def add_illustration( + self, + content: bytes, + width: pyint, + height: pyint = None, + scale: float = 1.0, + ): """Add a PNG illustration to Archive. + Shortcut building an IllustrationInfo from width/height/scale and + calling :meth:`add_illustration_info`. + + Refer to https://wiki.openzim.org/wiki/Metadata for more details. + + Args: + content (bytes): The binary content of the PNG illustration. + width (int): Width in CSS pixels. + height (int): Height in CSS pixels. Defaults to width (square + illustration). + scale (float): Device pixel ratio. Defaults to 1.0. + + Raises: + RuntimeError: If an illustration with the same attributes already exists. + + Examples: + # Square illustration at scale 1 + creator.add_illustration(png_data, 48) + + # Explicit width, height and scale + creator.add_illustration(png_data, 48, 48, 2.0) + """ + if height is None: + height = width + self.add_illustration_info(content, IllustrationInfo(width, height, scale)) + + def add_illustration_info(self, content: bytes, info: IllustrationInfo): + """Add a PNG illustration to Archive from an IllustrationInfo. + + Mirrors libzim's own addIllustration(IllustrationInfo, content), for + full control over the illustration (e.g. extra attributes). + Refer to https://wiki.openzim.org/wiki/Metadata for more details. Args: - size (int): The width of the square PNG illustration in pixels. content (bytes): The binary content of the PNG illustration. + info (IllustrationInfo): The illustration's metadata. Raises: - RuntimeError: If an illustration with the same width already exists. + RuntimeError: If an illustration with the same attributes already exists. + + Examples: + info = IllustrationInfo(48, 48, 2.0) + creator.add_illustration_info(png_data, info) """ cdef string _content = content - self.c_creator.addIllustration(size, _content) + cdef zim.IllustrationInfo c_ii = _illustration_info_to_c(info) + self.c_creator.addIllustration(c_ii, _content) # def set_uuid(self, uuid) -> _Creator: # self.c_creator.setUuid(uuid) @@ -747,6 +791,7 @@ with Creator(pathlib.Path("myfile.zim")) as creator: # example creator.add_item(MyItemSubclass(path, title, mimetype, content) creator.set_mainpath(path) + creator.add_illustration(png_data, 48, 48, 2.0) ```""" writer_public_objects = [ Creator, @@ -762,6 +807,119 @@ writer_public_objects = [ writer = create_module(writer_module_name, writer_module_doc, writer_public_objects) +############################################################################### +# Illustration module # +############################################################################### + +illustration_module_name = f"{__name__}.illustration" + +cdef zim.IllustrationInfo _illustration_info_to_c(info) except *: + """Convert a Python IllustrationInfo into the libzim C++ struct.""" + cdef zim.IllustrationInfo c_info + c_info.width = info.width + c_info.height = info.height + c_info.scale = info.scale + c_info.extraAttributes = zim.Attributes({}) + for key, val in info.extra_attributes.items(): + c_info.extraAttributes[key.encode('UTF-8')] = val.encode('UTF-8') + return c_info + +cdef _illustration_info_from_c(zim.IllustrationInfo info): + """Convert a libzim C++ struct into a Python IllustrationInfo.""" + extra_attributes = {} + for item in info.extraAttributes: + extra_attributes[item.first.decode('UTF-8')] = item.second.decode('UTF-8') + return IllustrationInfo(info.width, info.height, info.scale, extra_attributes) + +_IllustrationInfoTuple = _namedtuple( + "IllustrationInfo", ["width", "height", "scale", "extra_attributes"] +) + +class IllustrationInfo(_IllustrationInfoTuple): + """Information about an illustration in a ZIM archive. + + Attributes: + width (int): Width of the illustration in CSS pixels. + height (int): Height of the illustration in CSS pixels. + scale (float): Device pixel ratio (scale) of the illustration. + extra_attributes (Mapping[str, str]): Additional attributes as + key-value pairs. + """ + __module__ = illustration_module_name + __slots__ = () + + def __new__( + cls, + width: pyint = 0, + height: pyint = 0, + scale: float = 1.0, + extra_attributes: Mapping[str, str] | None = None, + ): + """Create an IllustrationInfo. + + Args: + width: Width of the illustration in CSS pixels. + height: Height of the illustration in CSS pixels. + scale: Device pixel ratio (default: 1.0). + extra_attributes: Additional attributes as key-value pairs + (optional). Copied into an immutable mapping, so later + mutating the dict passed in has no effect on the created + IllustrationInfo. + """ + frozen_attributes = MappingProxyType( + dict(extra_attributes) if extra_attributes is not None else {} + ) + return super().__new__(cls, width, height, scale, frozen_attributes) + + @staticmethod + def from_metadata_item_name(name: str) -> "IllustrationInfo": + """Parse an illustration metadata item name into IllustrationInfo. + + Args: + name: The metadata item name (e.g., "Illustration_48x48@2"). + + Returns: + The parsed IllustrationInfo. + + Raises: + RuntimeError: If the name cannot be parsed. + """ + cdef string _name = name.encode('UTF-8') + cdef zim.IllustrationInfo c_info = zim.IllustrationInfo.fromMetadataItemName(_name) + return _illustration_info_from_c(move(c_info)) + + def as_metadata_item_name(self) -> str: + """Convert this IllustrationInfo to a metadata item name. + + Returns: + The metadata item name (e.g., "Illustration_48x48@2"). + """ + cdef zim.IllustrationInfo c_info = _illustration_info_to_c(self) + return c_info.asMetadataItemName().decode('UTF-8') + + +illustration_module_doc = """Illustration data structures for ZIM archives + +This module provides classes for working with illustrations in ZIM archives. + +Usage: + +```python +from libzim.illustration import IllustrationInfo + +# Create an IllustrationInfo +info = IllustrationInfo(48, 48, 2.0) +print(f"Metadata name: {info.as_metadata_item_name()}") + +# Parse from metadata name +parsed = IllustrationInfo.from_metadata_item_name("Illustration_48x48@2") +```""" +illustration_public_objects = [ + IllustrationInfo, +] +illustration = create_module(illustration_module_name, illustration_module_doc, illustration_public_objects) + + ############################################################################### #  Reader module # ############################################################################### @@ -1306,14 +1464,14 @@ cdef class Archive: """Sizes for which an illustration is available (@1 scale only). .. deprecated:: 3.8.0 - Use :meth:`get_illustration_infos` instead for full illustration metadata + Use :meth:`get_illustrations_info` instead for full illustration metadata including width, height, and scale information. Returns: The set of available sizes of the illustration. """ warnings.warn( - "get_illustration_sizes() is deprecated, use get_illustration_infos() instead", + "get_illustration_sizes() is deprecated, use get_illustrations_info() instead", DeprecationWarning, stacklevel=2 ) @@ -1329,19 +1487,101 @@ cdef class Archive: return self.c_archive.hasIllustration(size) return self.c_archive.hasIllustration() - def get_illustration_item(self, size: pyint = None) -> Item: + def get_illustration_item( + self, + width: pyint = None, + height: pyint = None, + scale: float = None, + ) -> Item: """Get the illustration Metadata item of the archive. + Shortcut building an IllustrationInfo from width/height/scale and + calling :meth:`get_illustration_item_info`. + + Args: + width (int): Width in CSS pixels. If not provided, gets the + default illustration (48x48@1); `height` and `scale` must + not be passed in that case. + height (int): Height in CSS pixels. Defaults to width (square + illustration). + scale (float): Device pixel ratio. Defaults to 1.0. + Returns: The illustration item. + + Raises: + KeyError: If the requested illustration is not found. + ValueError: If height or scale is passed without width. + + Examples: + # Get default illustration (48x48@1) + item = archive.get_illustration_item() + + # Get illustration by size (square at scale 1) + item = archive.get_illustration_item(96) + + # Get illustration by width, height and scale + item = archive.get_illustration_item(48, 48, 2.0) """ + if width is None: + if height is not None or scale is not None: + raise ValueError("height and scale must only be provided with width") + return self.get_illustration_item_info(IllustrationInfo(48, 48, 1.0)) + if height is None: + height = width + info = IllustrationInfo(width, height, scale if scale is not None else 1.0) + return self.get_illustration_item_info(info) + + def get_illustration_item_info(self, info: IllustrationInfo) -> Item: + """Get the illustration Metadata item of the archive for an IllustrationInfo. + + Mirrors libzim's own getIllustrationItem(IllustrationInfo). + + Args: + info (IllustrationInfo): The illustration's metadata. + + Returns: + The illustration item. + + Raises: + KeyError: If the requested illustration is not found. + + Examples: + info = IllustrationInfo(48, 48, 2.0) + item = archive.get_illustration_item_info(info) + """ + cdef zim.IllustrationInfo c_ii = _illustration_info_to_c(info) try: - if size is not None: - return Item.from_item(move(self.c_archive.getIllustrationItem(size))) - return Item.from_item(move(self.c_archive.getIllustrationItem())) + return Item.from_item(move(self.c_archive.getIllustrationItem(c_ii))) except RuntimeError as e: raise KeyError(str(e)) + def get_illustrations_info(self, width: pyint = None, height: pyint = None, + min_scale: float = None) -> list[IllustrationInfo]: + """Get information about available illustrations. + + Args: + width: Optional width to filter illustrations (must be provided with height). + height: Optional height to filter illustrations (must be provided with width). + min_scale: Optional minimum scale to filter illustrations (requires width and height). + + Returns: + List of IllustrationInfo objects describing available illustrations. + + Note: + - When called without arguments, returns all available illustrations. + - When called with width, height, and min_scale, filters illustrations. + """ + cdef zim.Archive.IllustrationInfos infos + provided = (width, height, min_scale) + if all(v is not None for v in provided): + infos = self.c_archive.getIllustrationInfos(width, height, min_scale) + elif any(v is not None for v in provided): + raise ValueError("Either provide all of (width, height, min_scale) or none of them") + else: + infos = self.c_archive.getIllustrationInfos() + return [_illustration_info_from_c(info) for info in infos] + @property def dirent_cache_max_size(self) -> pyint: """Maximum size of the dirent cache. @@ -1380,7 +1620,7 @@ def get_cluster_cache_max_size() -> pyint: """Get the maximum size of the cluster cache. Returns: - (int): the maximum memory size used by the cluster cache (in bytes). + (int): the maximum memory size used by the cluster cache (in bytes). """ return zim.getClusterCacheMaxSize() @@ -1400,7 +1640,7 @@ def get_cluster_cache_current_size() -> pyint: """Get the current size of the cluster cache. Returns: - (int): the current memory size (in bytes) used by the cluster cache. + (int): the current memory size (in bytes) used by the cluster cache. """ return zim.getClusterCacheCurrentSize() @@ -1743,6 +1983,7 @@ class ModuleLoader(importlib.abc.Loader): @staticmethod def create_module(spec): return { + 'libzim.illustration': illustration, 'libzim.writer': writer, 'libzim.reader': reader, 'libzim.search': search, @@ -1766,4 +2007,4 @@ class ModuleFinder(importlib.abc.MetaPathFinder): # register finder for our submodules sys.meta_path.insert(0, ModuleFinder()) -__all__ = ["writer", "reader", "search", "suggestion", "version"] +__all__ = ["illustration", "writer", "reader", "search", "suggestion", "version"] diff --git a/libzim/reader.pyi b/libzim/reader.pyi index 143e099..184742a 100644 --- a/libzim/reader.pyi +++ b/libzim/reader.pyi @@ -3,6 +3,8 @@ from __future__ import annotations import pathlib from uuid import UUID +from libzim.illustration import IllustrationInfo + class Item: @property def title(self) -> str: ... @@ -76,7 +78,19 @@ class Archive: def media_count(self) -> int: ... def get_illustration_sizes(self) -> set[int]: ... def has_illustration(self, size: int | None = None) -> bool: ... - def get_illustration_item(self, size: int | None = None) -> Item: ... + def get_illustration_item( + self, + width: int | None = None, + height: int | None = None, + scale: float | None = None, + ) -> Item: ... + def get_illustration_item_info(self, info: IllustrationInfo) -> Item: ... + def get_illustrations_info( + self, + width: int | None = None, + height: int | None = None, + min_scale: float | None = None, + ) -> list[IllustrationInfo]: ... @property def dirent_cache_max_size(self) -> int: ... @dirent_cache_max_size.setter diff --git a/libzim/writer.pyi b/libzim/writer.pyi index bd38bc0..c51c9d2 100644 --- a/libzim/writer.pyi +++ b/libzim/writer.pyi @@ -7,6 +7,8 @@ import types from collections.abc import Callable, Generator from typing import Self +from libzim.illustration import IllustrationInfo + class Compression(enum.Enum): none: Self zstd: Self @@ -60,7 +62,10 @@ class Creator: def config_indexing(self, indexing: bool, language: str) -> Self: ... def config_nbworkers(self, nb_workers: int) -> Self: ... def set_mainpath(self, main_path: str) -> Self: ... - def add_illustration(self, size: int, content: bytes) -> None: ... + def add_illustration( + self, content: bytes, width: int, height: int | None = None, scale: float = 1.0 + ) -> None: ... + def add_illustration_info(self, content: bytes, info: IllustrationInfo) -> None: ... def add_item(self, writer_item: Item) -> None: ... def add_metadata( self, diff --git a/libzim/zim.pxd b/libzim/zim.pxd index 691f0e9..51ee4b1 100644 --- a/libzim/zim.pxd +++ b/libzim/zim.pxd @@ -27,6 +27,21 @@ from libcpp.set cimport set from libcpp.string cimport string from libcpp.vector cimport vector +cdef extern from "zim/illustration.h" namespace "zim": + cdef cppclass Attributes(map[string, string]): + Attributes() except + + Attributes(const map[string, string]&) except + + @staticmethod + Attributes parse(string s) except + + + cdef cppclass IllustrationInfo: + uint32_t width + uint32_t height + float scale + Attributes extraAttributes + string asMetadataItemName() except + + @staticmethod + IllustrationInfo fromMetadataItemName(const string& s) except + cdef extern from "zim/zim.h" namespace "zim": ctypedef uint64_t size_type @@ -74,6 +89,7 @@ cdef extern from "zim/writer/creator.h" namespace "zim::writer": void finishZimCreation() except + nogil void setMainPath(string mainPath) void addIllustration(unsigned int size, string content) except + nogil + void addIllustration(const IllustrationInfo& ii, string content) except + nogil cdef extern from "zim/search.h" namespace "zim": cdef cppclass Query: @@ -139,6 +155,8 @@ cdef extern from "libwrapper.h" namespace "wrapper": int getIndex() except + cdef cppclass Archive: + ctypedef vector[IllustrationInfo] IllustrationInfos + Archive() except + Archive(string filename) except + @@ -156,6 +174,9 @@ cdef extern from "libwrapper.h" namespace "wrapper": Entry getRandomEntry() except + Item getIllustrationItem() except + Item getIllustrationItem(int size) except + + Item getIllustrationItem(const IllustrationInfo& ii) except + + IllustrationInfos getIllustrationInfos() except + + IllustrationInfos getIllustrationInfos(uint32_t w, uint32_t h, float minScale) except + size_type getEntryCount() except + size_type getAllEntryCount() except + size_type getArticleCount() except + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4238532 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,108 @@ +import base64 + +import pytest + + +@pytest.fixture(scope="module") +def favicon_data_48(): + """48x48 white PNG.""" + return base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAASElEQVR4nO3OoQHAIBAAsdL9d3485iSI" + "ZIKsmfle8t8OnISKUBEqQkWoCBWhIlSEilARKkJFqAgVoSJUhIpQESpCRagIFaGyAXGwA118GEzeAAAA" + "AElFTkSuQmCC" + ) + + +@pytest.fixture(scope="module") +def favicon_data_96(): + """96x96 white PNG.""" + return base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAIAAABt+uBvAAAA1ElEQVR4nO3QwQ3AIBDAsNL9dz4mQHnC" + "w54gypqZj7P/dsDrDAoGBYOCQcGgYFAwKBgUDAoGBYOCQcGgYFAwKBgUDAoGBYOCQcGgYFAwKBgUDAoG" + "BYOCQcGgYFAwKBgUDAoGBYOCQcGgYFAwKBgUDAoGBYOCQcGgYFAwKBgUDAoGBYOCQcGgYFAwKBgUDAoG" + "BYOCQcGgYFAwKBgUDAoGBYOCQcGgYFAwKBgUDAoGBYOCQcGgYFAwKBgUDAoGBYOCQcGgYFAwKBgUDAoG" + "BYOCQcGgYFDY+poDvQAivBYAAAAASUVORK5CYII=" + ) + + +@pytest.fixture(scope="module") +def favicon_data_64_32(): + """64x32 white PNG.""" + return base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAIAAAAt/+nTAAAAQElEQVR4nO3PQQ0AIBDAMMC/50MEj4Zk" + "VbDtmVk/OzrgVQNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oD2gUyKwM953EnbgAAAABJRU5ErkJg" + "gg==" + ) + + +@pytest.fixture(scope="module") +def favicon_data_128_64(): + """128x64 white PNG.""" + return base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAIAAAABACAIAAABdtOgoAAAApUlEQVR4nO3RQREAIAzAMMC/501GHjQK" + "etc7MyfO0wG/awDWAKwBWAOwBmANwBqANQBrANYArAFYA7AGYA3AGoA1AGsA1gCsAVgDsAZgDcAagDUA" + "awDWAKwBWAOwBmANwBqANQBrANYArAFYA7AGYA3AGoA1AGsA1gCsAVgDsAZgDcAagDUAawDWAKwBWAOw" + "BmANwBqANQBrANYArAFYA7AGYA3AFhvOA32iFHl+AAAAAElFTkSuQmCC" + ) + + +@pytest.fixture(scope="module") +def favicon_data_64_128(): + """64x128 white PNG.""" + return base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAEAAAACACAIAAAA04/g9AAAAoElEQVR4nO3PQQ0AIBDAMMC/50MEj4Zk" + "VbDtmVk/OzrgVQNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNa" + "A1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNa" + "A1oDWgNaA1oDWgNaA1oD2gUMxQP9ijHcFQAAAABJRU5ErkJggg==" + ) + + +@pytest.fixture(scope="module") +def favicon_data_144(): + """144x144 white PNG (for 48x48@3).""" + return base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAIAAABoJHXvAAABY0lEQVR4nO3RwQkAIBDAMHX/nc8ZfEkh" + "maDQPTOLjvM7gDeGxRgWY1iMYTGGxRgWY1iMYTGGxRgWY1iMYTGGxRgWY1iMYTGGxRgWY1iMYTGGxRgW" + "Y1iMYTGGxRgWY1iMYTGGxRgWY1iMYTGGxRgWY1iMYTGGxRgWY1iMYTGGxRgWY1iMYTGGxRgWY1iMYTGG" + "xRgWY1iMYTGGxRgWY1iMYTGGxRgWY1iMYTGGxRgWY1iMYTGGxRgWY1iMYTGGxRgWY1iMYTGGxRgWY1iM" + "YTGGxRgWY1iMYTGGxRgWY1iMYTGGxRgWY1iMYTGGxRgWY1iMYTGGxRgWY1iMYTGGxRgWY1iMYTGGxRgW" + "Y1iMYTGGxRgWY1iMYTGGxRgWY1iMYTGGxRgWY1iMYTGGxRgWY1iMYTGGxRgWY1iMYTGGxRgWY1iMYTGG" + "xRgWY1iMYTGGxRgWY1iMYTGGxRgWcwH2lQQd7GyBMgAAAABJRU5ErkJggg==" + ) + + +@pytest.fixture(scope="module") +def favicon_data_192(): + """192x192 white PNG (for 96x96@2).""" + return base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAMAAAADACAIAAADdvvtQAAAB6ElEQVR4nO3SwQ3AIBDAsNL9dz52IA+E" + "ZE+QR9bMfHDqvx3A2wxEYiASA5EYiMRAJAYiMRCJgUgMRGIgEgORGIjEQCQGIjEQiYFIDERiIBIDkRiI" + "xEAkBiIxEImBSAxEYiASA5EYiMRAJAYiMRCJgUgMRGIgEgORGIjEQCQGIjEQiYFIDERiIBIDkRiIxEAk" + "BiIxEImBSAxEYiASA5EYiMRAJAYiMRCJgUgMRGIgEgORGIjEQCQGIjEQiYFIDERiIBIDkRiIxEAkBiIx" + "EImBSAxEYiASA5EYiMRAJAYiMRCJgUgMRGIgEgORGIjEQCQGIjEQiYFIDERiIBIDkRiIxEAkBiIxEImB" + "SAxEYiASA5EYiMRAJAYiMRCJgUgMRGIgEgORGIjEQCQGIjEQiYFIDERiIBIDkRiIxEAkBiIxEImBSAxE" + "YiASA5EYiMRAJAYiMRCJgUgMRGIgEgORGIjEQCQGIjEQiYFIDERiIBIDkRiIxEAkBiIxEImBSAxEYiAS" + "A5EYiMRAJAYiMRCJgUgMRGIgEgORGIjEQCQGIjEQiYFIDERiIBIDkRiIxEAkBiIxEImBSAxEYiASA5EY" + "iMRAJAYiMRCJgUgMRGIgEgORGIjEQCQGIjEQiYFIDERiIBIDkRiIxEAkBiLZx2MEfQuoaCoAAAAASUVO" + "RK5CYII=" + ) + + +@pytest.fixture(scope="module") +def favicon_data_256(): + """256x256 white PNG.""" + return base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAACvUlEQVR4nO3TMQEAIAzAMMC/5yFjRxMF" + "fXpn5kDV2w6ATQYgzQCkGYA0A5BmANIMQJoBSDMAaQYgzQCkGYA0A5BmANIMQJoBSDMAaQYgzQCkGYA0" + "A5BmANIMQJoBSDMAaQYgzQCkGYA0A5BmANIMQJoBSDMAaQYgzQCkGYA0A5BmANIMQJoBSDMAaQYgzQCk" + "GYA0A5BmANIMQJoBSDMAaQYgzQCkGYA0A5BmANIMQJoBSDMAaQYgzQCkGYA0A5BmANIMQJoBSDMAaQYg" + "zQCkGYA0A5BmANIMQJoBSDMAaQYgzQCkGYA0A5BmANIMQJoBSDMAaQYgzQCkGYA0A5BmANIMQJoBSDMA" + "aQYgzQCkGYA0A5BmANIMQJoBSDMAaQYgzQCkGYA0A5BmANIMQJoBSDMAaQYgzQCkGYA0A5BmANIMQJoB" + "SDMAaQYgzQCkGYA0A5BmANIMQJoBSDMAaQYgzQCkGYA0A5BmANIMQJoBSDMAaQYgzQCkGYA0A5BmANIM" + "QJoBSDMAaQYgzQCkGYA0A5BmANIMQJoBSDMAaQYgzQCkGYA0A5BmANIMQJoBSDMAaQYgzQCkGYA0A5Bm" + "ANIMQJoBSDMAaQYgzQCkGYA0A5BmANIMQJoBSDMAaQYgzQCkGYA0A5BmANIMQJoBSDMAaQYgzQCkGYA0" + "A5BmANIMQJoBSDMAaQYgzQCkGYA0A5BmANIMQJoBSDMAaQYgzQCkGYA0A5BmANIMQJoBSDMAaQYgzQCk" + "GYA0A5BmANIMQJoBSDMAaQYgzQCkGYA0A5BmANIMQJoBSDMAaQYgzQCkGYA0A5BmANIMQJoBSDMAaQYg" + "zQCkGYA0A5BmANIMQJoBSDMAaQYgzQCkGYA0A5BmANIMQJoBSDMAaQYgzQCkGYA0A5BmANIMQNoHq+gE" + "/QPNMGIAAAAASUVORK5CYII=" + ) diff --git a/tests/test_illustration_archive.py b/tests/test_illustration_archive.py new file mode 100644 index 0000000..3519e33 --- /dev/null +++ b/tests/test_illustration_archive.py @@ -0,0 +1,314 @@ +import pytest + +from libzim.illustration import ( # pyright: ignore [reportMissingModuleSource] + IllustrationInfo, +) +from libzim.reader import Archive # pyright: ignore [reportMissingModuleSource] +from libzim.writer import Creator # pyright: ignore [reportMissingModuleSource] + + +@pytest.fixture(scope="function") +def zim_with_varied_illustrations( + tmp_path, + favicon_data_48, + favicon_data_96, + favicon_data_144, + favicon_data_192, + favicon_data_128_64, + favicon_data_64_128, + favicon_data_256, +): + """Create a ZIM file with various illustration sizes and scales.""" + fpath = tmp_path / "test_illustrations.zim" + with Creator(fpath) as c: + c.add_metadata("Title", "Test ZIM") + + # Add multiple illustrations with different dimensions and scales + # 48x48 at different scales + c.add_illustration(favicon_data_48, 48, scale=1.0) # 48x48 PNG + c.add_illustration(favicon_data_96, 48, scale=2.0) # 96x96 PNG (48*2) + c.add_illustration(favicon_data_144, 48, scale=3.0) # 144x144 PNG (48*3) + + # 96x96 at different scales + c.add_illustration(favicon_data_96, 96, scale=1.0) # 96x96 PNG + c.add_illustration(favicon_data_192, 96, scale=2.0) # 192x192 PNG (96*2) + + # Non-square illustrations + c.add_illustration(favicon_data_128_64, 128, 64) + c.add_illustration(favicon_data_64_128, 64, 128) + + # Large illustration + c.add_illustration(favicon_data_256, 256) + + return fpath + + +class TestArchiveGetIllustrationInfos: + """Test Archive.get_illustrations_info() method.""" + + def test_get_all_illustration_infos(self, zim_with_varied_illustrations): + """Test getting all illustration infos without filtering.""" + zim = Archive(zim_with_varied_illustrations) + infos = zim.get_illustrations_info() + + # Should return all 8 illustrations + assert len(infos) == 8 + + # All should be IllustrationInfo instances + assert all(isinstance(info, IllustrationInfo) for info in infos) + + # Verify we have expected dimensions + dims = {(info.width, info.height) for info in infos} + assert (48, 48) in dims + assert (96, 96) in dims + assert (128, 64) in dims + assert (64, 128) in dims + assert (256, 256) in dims + + def test_get_illustrations_info_filter_48x48(self, zim_with_varied_illustrations): + """Test filtering illustrations by 48x48 dimensions.""" + zim = Archive(zim_with_varied_illustrations) + # Get all 48x48 illustrations with minimum scale 1.0 + infos = zim.get_illustrations_info(width=48, height=48, min_scale=1.0) + + # Should have 3 illustrations: @1, @2, @3 + assert len(infos) >= 3 + + # All should be 48x48 + assert all(info.width == 48 and info.height == 48 for info in infos) + + # All should have scale >= 1.0 + assert all(info.scale >= 1.0 for info in infos) + + def test_get_illustrations_info_filter_min_scale( + self, zim_with_varied_illustrations + ): + """Test filtering by minimum scale.""" + zim = Archive(zim_with_varied_illustrations) + # Get 48x48 illustrations with scale >= 2.0 + infos = zim.get_illustrations_info(width=48, height=48, min_scale=2.0) + + # Should have at least 2: @2 and @3 + assert len(infos) >= 2 + + # All should be 48x48 + assert all(info.width == 48 and info.height == 48 for info in infos) + + # All should have scale >= 2.0 + assert all(info.scale >= 2.0 for info in infos) + + def test_get_illustrations_info_filter_high_scale( + self, zim_with_varied_illustrations + ): + """Test filtering with high minimum scale.""" + zim = Archive(zim_with_varied_illustrations) + # Get 48x48 illustrations with scale >= 3.0 + infos = zim.get_illustrations_info(width=48, height=48, min_scale=3.0) + + # Should have at least 1: @3 + assert len(infos) >= 1 + + # All should have scale >= 3.0 + assert all(info.scale >= 3.0 for info in infos) + + def test_get_illustrations_info_filter_96x96(self, zim_with_varied_illustrations): + """Test filtering for 96x96 illustrations.""" + zim = Archive(zim_with_varied_illustrations) + infos = zim.get_illustrations_info(width=96, height=96, min_scale=1.0) + + # Should have 2: @1 and @2 + assert len(infos) >= 2 + + # All should be 96x96 + assert all(info.width == 96 and info.height == 96 for info in infos) + + def test_get_illustrations_info_no_match(self, zim_with_varied_illustrations): + """Test filtering with no matching illustrations.""" + zim = Archive(zim_with_varied_illustrations) + # Request dimensions that don't exist + infos = zim.get_illustrations_info(width=999, height=999, min_scale=1.0) + + # Should return empty list + assert len(infos) == 0 + + def test_get_illustrations_info_partial_args_error( + self, zim_with_varied_illustrations + ): + """Test that providing partial filter arguments raises error.""" + zim = Archive(zim_with_varied_illustrations) + + # Only width should raise error + with pytest.raises(ValueError, match=r"Either provide all of.*or none"): + zim.get_illustrations_info(width=48) + + # Only height should raise error + with pytest.raises(ValueError, match=r"Either provide all of.*or none"): + zim.get_illustrations_info(height=48) + + # Width and height but no min_scale should raise error + with pytest.raises(ValueError, match=r"Either provide all of.*or none"): + zim.get_illustrations_info(width=48, height=48) + + # Only min_scale should raise error + with pytest.raises(ValueError, match=r"Either provide all of.*or none"): + zim.get_illustrations_info(min_scale=1.0) + + +class TestArchiveGetIllustrationItem: + """Test Archive.get_illustration_item() and get_illustration_item_info().""" + + def test_get_illustration_item_with_info( + self, + zim_with_varied_illustrations, + favicon_data_48, + favicon_data_96, + favicon_data_144, + favicon_data_192, + favicon_data_128_64, + favicon_data_64_128, + favicon_data_256, + ): + """Test getting illustration item using IllustrationInfo.""" + zim = Archive(zim_with_varied_illustrations) + infos = zim.get_illustrations_info() + + # Map (width, height, scale) to expected PNG data + # Physical pixels = CSS pixels * scale + expected_data = { + (48, 48, 1.0): favicon_data_48, # 48x48 PNG + (48, 48, 2.0): favicon_data_96, # 96x96 PNG (48*2) + (48, 48, 3.0): favicon_data_144, # 144x144 PNG (48*3) + (96, 96, 1.0): favicon_data_96, # 96x96 PNG + (96, 96, 2.0): favicon_data_192, # 192x192 PNG (96*2) + (128, 64, 1.0): favicon_data_128_64, # 128x64 PNG + (64, 128, 1.0): favicon_data_64_128, # 64x128 PNG + (256, 256, 1.0): favicon_data_256, # 256x256 PNG + } + + # Get item for each illustration + for info in infos: + item = zim.get_illustration_item_info(info) + expected = expected_data[(info.width, info.height, info.scale)] + assert bytes(item.content) == expected + # Verify path contains the illustration metadata name + assert "Illustration" in item.path + + def test_get_illustration_item_specific_scale( + self, zim_with_varied_illustrations, favicon_data_96 + ): + """Test getting specific scale illustration.""" + zim = Archive(zim_with_varied_illustrations) + + # Get the 48x48@2 illustration specifically (96x96 physical pixels) + info = IllustrationInfo(48, 48, 2.0) + item = zim.get_illustration_item_info(info) + assert bytes(item.content) == favicon_data_96 + + def test_get_illustration_item_nonexistent(self, zim_with_varied_illustrations): + """Test getting non-existent illustration raises error.""" + zim = Archive(zim_with_varied_illustrations) + + # Try to get illustration that doesn't exist + info = IllustrationInfo(999, 999, 1.0) + with pytest.raises(KeyError): + zim.get_illustration_item_info(info) + + def test_get_illustration_item_default( + self, zim_with_varied_illustrations, favicon_data_48 + ): + """Test that get_illustration_item() without argument defaults to size 48.""" + zim = Archive(zim_with_varied_illustrations) + + # No argument should default to 48x48@1 + item = zim.get_illustration_item() + assert bytes(item.content) == favicon_data_48 + assert "Illustration_48x48@1" in item.path + + def test_get_illustration_item_old_api_still_works( + self, zim_with_varied_illustrations, favicon_data_48, favicon_data_96 + ): + """Test that old API (size parameter) still works.""" + zim = Archive(zim_with_varied_illustrations) + + # Old API with size should work for @1 scale illustrations + item = zim.get_illustration_item(48) + assert bytes(item.content) == favicon_data_48 + + item = zim.get_illustration_item(96) + assert bytes(item.content) == favicon_data_96 + + def test_get_illustration_item_width_height_scale( + self, zim_with_varied_illustrations, favicon_data_192, favicon_data_128_64 + ): + """Test getting illustration item using explicit width/height/scale.""" + zim = Archive(zim_with_varied_illustrations) + + # Square, explicit scale, no height (defaults to width) + item = zim.get_illustration_item(96, scale=2.0) + assert bytes(item.content) == favicon_data_192 + + # Non-square, explicit height + item = zim.get_illustration_item(128, 64) + assert bytes(item.content) == favicon_data_128_64 + + def test_get_illustration_item_height_scale_require_width( + self, zim_with_varied_illustrations + ): + """Test that height/scale are rejected without a width.""" + zim = Archive(zim_with_varied_illustrations) + + with pytest.raises(ValueError, match="height and scale must only be"): + zim.get_illustration_item(height=48) + with pytest.raises(ValueError, match="height and scale must only be"): + zim.get_illustration_item(scale=2.0) + + +class TestDeprecationWarnings: + """Test deprecation warnings for old API.""" + + def test_get_illustration_sizes_deprecated(self, zim_with_varied_illustrations): + """Test that get_illustration_sizes() emits deprecation warning.""" + zim = Archive(zim_with_varied_illustrations) + + with pytest.warns( + DeprecationWarning, match="get_illustration_sizes.*deprecated" + ): + sizes = zim.get_illustration_sizes() + # Should still work despite deprecation + assert isinstance(sizes, set) + # Should contain sizes for @1 scale illustrations + assert 48 in sizes or 96 in sizes + + +class TestEmptyArchive: + """Test illustration methods on archives with no illustrations.""" + + def test_get_illustrations_info_empty(self, tmp_path): + """Test get_illustrations_info on archive with no illustrations.""" + fpath = tmp_path / "empty_illustrations.zim" + with Creator(fpath) as c: + c.add_metadata("Title", "Test ZIM") + + zim = Archive(fpath) + infos = zim.get_illustrations_info() + assert len(infos) == 0 + + def test_get_illustration_item_empty(self, tmp_path): + """Test get_illustration_item on archive with no illustrations.""" + fpath = tmp_path / "empty_illustrations.zim" + with Creator(fpath) as c: + c.add_metadata("Title", "Test ZIM") + + zim = Archive(fpath) + with pytest.raises(KeyError): + zim.get_illustration_item() + + def test_has_illustration_empty(self, tmp_path): + """Test has_illustration on archive with no illustrations.""" + fpath = tmp_path / "empty_illustrations.zim" + with Creator(fpath) as c: + c.add_metadata("Title", "Test ZIM") + + zim = Archive(fpath) + assert zim.has_illustration() is False + assert zim.has_illustration(48) is False diff --git a/tests/test_illustration_info.py b/tests/test_illustration_info.py new file mode 100644 index 0000000..cfb4a42 --- /dev/null +++ b/tests/test_illustration_info.py @@ -0,0 +1,185 @@ +import pytest + +from libzim.illustration import ( # pyright: ignore [reportMissingModuleSource] + IllustrationInfo, +) + + +class TestIllustrationInfo: + + def test_default_construction(self): + """Test creating an IllustrationInfo with defaults.""" + info = IllustrationInfo() + assert info.width == 0 + assert info.height == 0 + assert info.scale == 1.0 + assert info.extra_attributes == {} + + def test_construction_with_parameters(self): + """Test creating an IllustrationInfo with parameters.""" + info = IllustrationInfo(width=48, height=48, scale=2.0) + assert info.width == 48 + assert info.height == 48 + assert info.scale == 2.0 + assert info.extra_attributes == {} + + def test_non_square_illustration(self): + """Test creating a non-square illustration.""" + info = IllustrationInfo(width=128, height=64, scale=1.5) + assert info.width == 128 + assert info.height == 64 + assert info.scale == 1.5 + + def test_immutable(self): + """Test that IllustrationInfo fields cannot be reassigned.""" + info = IllustrationInfo() + with pytest.raises(AttributeError): + info.width = 96 # pyright: ignore [reportAttributeAccessIssue] + with pytest.raises(AttributeError): + info.height = 48 # pyright: ignore [reportAttributeAccessIssue] + with pytest.raises(AttributeError): + info.scale = 3.0 # pyright: ignore [reportAttributeAccessIssue] + with pytest.raises(AttributeError): + info.extra_attributes = { # pyright: ignore [reportAttributeAccessIssue] + "foo": "bar" + } + + def test_extra_attributes_contruction(self): + """Test setting extra attributes in constructor.""" + info = IllustrationInfo(48, 48, 1.0, {"foo": "bar", "baz": "qux"}) + assert info.extra_attributes == {"foo": "bar", "baz": "qux"} + + def test_extra_attributes_not_mutable(self): + """Test that extra_attributes is a read-only mapping. + + To change extra attributes, build a new IllustrationInfo (or use + `._replace()`) rather than mutating the existing one in place. + """ + info = IllustrationInfo(48, 48, 1.0, {"original": "value"}) + + with pytest.raises(AttributeError): + info.extra_attributes.update( # pyright: ignore [reportAttributeAccessIssue] + {"new": "item"} + ) + with pytest.raises(TypeError): + attrs = info.extra_attributes + attrs["another"] = "value" # pyright: ignore [reportIndexIssue] + + # Mutating the dict passed to the constructor doesn't affect + # the stored (copied) extra_attributes. + source = {"original": "value"} + info = IllustrationInfo(48, 48, 1.0, source) + source["new"] = "item" + assert info.extra_attributes == {"original": "value"} + + # The idiomatic way to change fields is to build a new instance. + updated = info._replace(extra_attributes={"original": "value", "new": "item"}) + assert updated.extra_attributes == {"original": "value", "new": "item"} + assert info.extra_attributes == {"original": "value"} + + def test_as_metadata_item_name(self): + """Test converting IllustrationInfo to metadata item name.""" + info = IllustrationInfo(48, 48, 1.0) + name = info.as_metadata_item_name() + # Should be in format like "Illustration_48x48@1" + assert "48" in name + assert "Illustration" in name + + def test_as_metadata_item_name_with_scale(self): + """Test metadata item name includes scale.""" + info = IllustrationInfo(48, 48, 2.0) + name = info.as_metadata_item_name() + assert "48" in name + assert "2" in name or "@2" in name # Scale should be included + + def test_from_metadata_item_name(self): + """Test parsing metadata item name into IllustrationInfo.""" + # First create an info and convert to name + original_info = IllustrationInfo(48, 48, 2.0) + name = original_info.as_metadata_item_name() + + # Parse it back + parsed_info = IllustrationInfo.from_metadata_item_name(name) + assert parsed_info.width == 48 + assert parsed_info.height == 48 + assert parsed_info.scale == 2.0 + + def test_from_metadata_item_name_square(self): + """Test parsing square illustration metadata name.""" + original = IllustrationInfo(96, 96, 1.0) + name = original.as_metadata_item_name() + parsed = IllustrationInfo.from_metadata_item_name(name) + assert parsed.width == 96 + assert parsed.height == 96 + assert parsed.scale == 1.0 + + def test_repr(self): + """Test __repr__ returns useful string.""" + info = IllustrationInfo(48, 48, 2.0) + repr_str = repr(info) + assert "IllustrationInfo" in repr_str + assert "48" in repr_str + assert "2" in repr_str or "2.0" in repr_str + + def test_equality(self): + """Test __eq__ compares IllustrationInfo correctly.""" + info1 = IllustrationInfo(48, 48, 2.0) + info2 = IllustrationInfo(48, 48, 2.0) + info3 = IllustrationInfo(48, 48, 1.0) + info4 = IllustrationInfo(96, 96, 2.0) + + assert info1 == info2 + assert info1 != info3 # Different scale + assert info1 != info4 # Different dimensions + assert info1 != "not an IllustrationInfo" + assert info1 != 42 + + def test_equality_with_extra_attributes(self): + """Test equality considers extra attributes.""" + info1 = IllustrationInfo(48, 48, 1.0, {"key": "value"}) + info2 = IllustrationInfo(48, 48, 1.0, {"key": "value"}) + assert info1 == info2 + + info3 = IllustrationInfo(48, 48, 1.0, {"different": "value"}) + assert info1 != info3 + + def test_roundtrip_conversion(self): + """Test converting to and from metadata item name preserves data.""" + original = IllustrationInfo(64, 32, 1.5) + name = original.as_metadata_item_name() + restored = IllustrationInfo.from_metadata_item_name(name) + + assert restored.width == original.width + assert restored.height == original.height + assert restored.scale == original.scale + + +class TestIllustrationInfoEdgeCases: + """Test edge cases and error handling.""" + + def test_from_metadata_item_name_invalid(self): + """Test parsing invalid metadata item names raises error.""" + with pytest.raises(RuntimeError): + IllustrationInfo.from_metadata_item_name("invalid_name") + + def test_from_metadata_item_name_empty(self): + """Test parsing empty string raises error.""" + with pytest.raises(RuntimeError): + IllustrationInfo.from_metadata_item_name("") + + def test_zero_dimensions(self): + """Test creating illustration with zero dimensions.""" + info = IllustrationInfo(0, 0, 1.0) + assert info.width == 0 + assert info.height == 0 + + def test_fractional_scale(self): + """Test creating illustration with fractional scale.""" + info = IllustrationInfo(48, 48, 1.5) + assert info.scale == 1.5 + + def test_very_large_dimensions(self): + """Test creating illustration with large dimensions.""" + info = IllustrationInfo(4096, 4096, 1.0) + assert info.width == 4096 + assert info.height == 4096 diff --git a/tests/test_libzim_creator.py b/tests/test_libzim_creator.py index d5e7b7b..49cb337 100644 --- a/tests/test_libzim_creator.py +++ b/tests/test_libzim_creator.py @@ -1,8 +1,5 @@ -#!/usr/bin/env python - from __future__ import annotations -import base64 import itertools import os import pathlib @@ -13,6 +10,9 @@ import pytest import libzim.writer # pyright: ignore [reportMissingModuleSource] +from libzim.illustration import ( # pyright: ignore [reportMissingModuleSource] + IllustrationInfo, +) from libzim.reader import Archive # pyright: ignore [reportMissingModuleSource] from libzim.search import Query, Searcher # pyright: ignore [reportMissingModuleSource] from libzim.suggestion import ( # pyright: ignore [reportMissingModuleSource] @@ -61,14 +61,6 @@ def fpath(tmpdir): return pathlib.Path(tmpdir / "test.zim") -@pytest.fixture(scope="module") -def favicon_data(): - return base64.b64decode( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQ" - "ImWO4ISn6HwAE2QIGKsd69QAAAABJRU5ErkJggg==" - ) - - @pytest.fixture(scope="module") def lipsum(): return ( @@ -346,22 +338,106 @@ def test_creator_mainpath(fpath, lipsum_item): assert zim.main_entry -def test_creator_illustration(fpath, favicon_data): +def test_creator_illustration(fpath, favicon_data_48, favicon_data_96): with Creator(fpath) as c: - c.add_illustration(48, favicon_data) - c.add_illustration(96, favicon_data) + c.add_illustration(favicon_data_48, 48) + c.add_illustration(favicon_data_96, 96) zim = Archive(fpath) assert zim.has_illustration() is True assert zim.has_illustration(48) is True assert zim.has_illustration(96) is True assert zim.has_illustration(128) is False - assert bytes(zim.get_illustration_item().content) == favicon_data - assert bytes(zim.get_illustration_item(96).content) == favicon_data + assert bytes(zim.get_illustration_item().content) == favicon_data_48 + assert bytes(zim.get_illustration_item(96).content) == favicon_data_96 with pytest.warns(DeprecationWarning, match="get_illustration_sizes.*deprecated"): assert zim.get_illustration_sizes() == {48, 96} +def test_creator_illustration_with_info( + fpath, favicon_data_48, favicon_data_96, favicon_data_64_32 +): + """Test creating illustrations with IllustrationInfo (new API).""" + + with Creator(fpath) as c: + # Add illustrations with different scales and dimensions + info1 = IllustrationInfo(48, 48, 1.0) + c.add_illustration_info(favicon_data_48, info1) + + # 48x48@2 means 48 CSS pixels at 2x scale = 96x96 physical pixels + info2 = IllustrationInfo(48, 48, 2.0) + c.add_illustration_info(favicon_data_96, info2) + + info3 = IllustrationInfo(96, 96, 1.0) + c.add_illustration_info(favicon_data_96, info3) + + # Non-square illustration + info4 = IllustrationInfo(64, 32, 1.0) + c.add_illustration_info(favicon_data_64_32, info4) + + zim = Archive(fpath) + assert zim.has_illustration() is True + + # Test get_illustrations_info() - should return all illustrations + infos = zim.get_illustrations_info() + assert len(infos) == 4 + + # Verify we have the expected illustrations + found_specs = {(info.width, info.height, info.scale) for info in infos} + assert (48, 48, 1.0) in found_specs + assert (48, 48, 2.0) in found_specs + assert (96, 96, 1.0) in found_specs + assert (64, 32, 1.0) in found_specs + + # Test get_illustration_item_info with IllustrationInfo + # Map (width, height, scale) to expected PNG data + # Note: scale affects physical pixel size (width*scale x height*scale) + expected_data = { + (48, 48, 1.0): favicon_data_48, # 48x48 PNG + (48, 48, 2.0): favicon_data_96, # 96x96 PNG (48*2 x 48*2) + (96, 96, 1.0): favicon_data_96, # 96x96 PNG + (64, 32, 1.0): favicon_data_64_32, # 64x32 PNG + } + for info in infos: + item = zim.get_illustration_item_info(info) + expected = expected_data[(info.width, info.height, info.scale)] + assert bytes(item.content) == expected + # Verify the item path matches the metadata name + assert info.as_metadata_item_name() in item.path + + +def test_creator_illustration_square(fpath, favicon_data_48, favicon_data_96): + """Test that passing a plain int (square illustration) still works.""" + with Creator(fpath) as c: + c.add_illustration(favicon_data_48, 48) + c.add_illustration(favicon_data_96, 96) + + zim = Archive(fpath) + # Old API still works + assert zim.has_illustration(48) is True + assert zim.has_illustration(96) is True + assert bytes(zim.get_illustration_item(48).content) == favicon_data_48 + assert bytes(zim.get_illustration_item(96).content) == favicon_data_96 + + +def test_creator_illustration_width_height_scale( + fpath, favicon_data_96, favicon_data_64_32 +): + """Test add_illustration with explicit width, height and scale.""" + with Creator(fpath) as c: + # Square, explicit scale, no height (defaults to width) + c.add_illustration(favicon_data_96, 48, scale=2.0) + # Non-square, explicit height + c.add_illustration(favicon_data_64_32, 64, 32) + + zim = Archive(fpath) + found_specs = { + (info.width, info.height, info.scale) for info in zim.get_illustrations_info() + } + assert (48, 48, 2.0) in found_specs + assert (64, 32, 1.0) in found_specs + + def test_creator_additem(fpath, lipsum_item): # ensure we can't add if not started c = Creator(fpath) @@ -423,7 +499,7 @@ def test_creator_metadata(fpath, lipsum_item): assert zim.get_metadata(name).decode("UTF-8") == value -def test_creator_metadata_overwrite(fpath, lipsum_item, favicon_data): +def test_creator_metadata_overwrite(fpath, lipsum_item, favicon_data_48): """re-adding an Entry (even Metadata) now raises an exception (libzim 7.2+)""" with Creator(fpath) as c: c.add_item(lipsum_item) @@ -438,10 +514,10 @@ def test_creator_metadata_overwrite(fpath, lipsum_item, favicon_data): with pytest.raises(RuntimeError, match="Impossible to add"): c.add_redirection("home", lipsum_item.get_path(), "Home again", {}) - c.add_illustration(48, favicon_data) + c.add_illustration(favicon_data_48, 48) # this currently segfaults but it should not with pytest.raises(RuntimeError, match="Impossible to add"): - c.add_illustration(48, favicon_data) + c.add_illustration(favicon_data_48, 48) zim = Archive(fpath) assert zim.get_metadata("Key").decode("UTF-8") == "first" diff --git a/tests/test_libzim_reader.py b/tests/test_libzim_reader.py index 43082eb..dea67e8 100644 --- a/tests/test_libzim_reader.py +++ b/tests/test_libzim_reader.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - import gc import os import pathlib