From de46d68f6304ec35d1429be4dacb6c856dc48f20 Mon Sep 17 00:00:00 2001 From: benoit74 Date: Thu, 6 Nov 2025 13:43:01 +0000 Subject: [PATCH] Add support for new illustrations APIs in libzim 9.4.0 --- libzim/__init__.pyi | 1 + libzim/illustration.pyi | 33 ++++ libzim/libwrapper.h | 4 + libzim/libzim.pyx | 213 +++++++++++++++++++++-- libzim/reader.pyi | 19 +- libzim/writer.pyi | 7 +- libzim/zim.pxd | 21 +++ tests/conftest.py | 11 ++ tests/test_illustration_archive.py | 267 +++++++++++++++++++++++++++++ tests/test_illustration_info.py | 183 ++++++++++++++++++++ tests/test_libzim_creator.py | 77 ++++++++- 11 files changed, 815 insertions(+), 21 deletions(-) create mode 100644 libzim/illustration.pyi create mode 100644 tests/conftest.py create mode 100644 tests/test_illustration_archive.py create mode 100644 tests/test_illustration_info.py 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..0882b3b --- /dev/null +++ b/libzim/illustration.pyi @@ -0,0 +1,33 @@ +from __future__ import annotations + +class IllustrationInfo: + """Information about an illustration in a ZIM archive.""" + + def __init__( + self, + width: int = 0, + height: int = 0, + scale: float = 1.0, + extra_attributes: dict[str, str] | None = None, + ) -> None: ... + @staticmethod + def from_metadata_item_name(name: str) -> IllustrationInfo: ... + @property + def width(self) -> int: ... + @width.setter + def width(self, value: int) -> None: ... + @property + def height(self) -> int: ... + @height.setter + def height(self, value: int) -> None: ... + @property + def scale(self) -> float: ... + @scale.setter + def scale(self, value: float) -> None: ... + @property + def extra_attributes(self) -> dict[str, str]: ... + @extra_attributes.setter + def extra_attributes(self, value: dict[str, str]) -> None: ... + def as_metadata_item_name(self) -> str: ... + def __repr__(self) -> str: ... + def __eq__(self, other: object) -> bool: ... 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 db63e4c..3a6b622 100644 --- a/libzim/libzim.pyx +++ b/libzim/libzim.pyx @@ -479,20 +479,34 @@ cdef class _Creator: self.c_creator.setMainPath(mainPath.encode('UTF-8')) return self - def add_illustration(self, int size: pyint, content: bytes): + def add_illustration(self, size_or_info, content: bytes): """Add a PNG illustration to Archive. Refer to https://wiki.openzim.org/wiki/Metadata for more details. Args: - size (int): The width of the square PNG illustration in pixels. + size_or_info: Either an int (width of the square PNG illustration in pixels) + or an IllustrationInfo object with width, height, and scale. content (bytes): The binary content of the PNG illustration. Raises: - RuntimeError: If an illustration with the same width already exists. + RuntimeError: If an illustration with the same attributes already exists. + + Examples: + # Old style (square illustration at scale 1) + creator.add_illustration(48, png_data) + + # New style (with dimensions and scale) + info = IllustrationInfo(48, 48, 2.0) + creator.add_illustration(info, png_data) """ cdef string _content = content - self.c_creator.addIllustration(size, _content) + if isinstance(size_or_info, IllustrationInfo): + self.c_creator.addIllustration((size_or_info).c_info, _content) + elif isinstance(size_or_info, int): + self.c_creator.addIllustration(size_or_info, _content) + else: + raise TypeError(f"First argument must be int or IllustrationInfo, not {type(size_or_info)}") # def set_uuid(self, uuid) -> _Creator: # self.c_creator.setUuid(uuid) @@ -762,6 +776,146 @@ writer_public_objects = [ writer = create_module(writer_module_name, writer_module_doc, writer_public_objects) +############################################################################### +# Illustration module # +############################################################################### + +illustration_module_name = f"{__name__}.illustration" + +cdef class IllustrationInfo: + """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 (dict): Additional attributes as key-value pairs. + """ + __module__ = illustration_module_name + cdef zim.IllustrationInfo c_info + def __cinit__(self, width: pyint = 0, height: pyint = 0, scale: float = 1.0, extra_attributes: Dict[str, str] = 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). + """ + # Initialize struct fields directly + self.c_info.width = width + self.c_info.height = height + self.c_info.scale = scale + self.c_info.extraAttributes = zim.Attributes({}) + + # Set extra attributes if provided (need to encode strings to bytes) + if extra_attributes is not None: + for key, val in extra_attributes.items(): + self.c_info.extraAttributes[key.encode('UTF-8')] = val.encode('UTF-8') + + @staticmethod + cdef from_illustration_info(zim.IllustrationInfo info): + """Creates a Python IllustrationInfo from a C++ IllustrationInfo. + + Args: + info: A C++ IllustrationInfo + + Returns: + IllustrationInfo: Casted illustration info + """ + cdef IllustrationInfo ii = IllustrationInfo() + ii.c_info = move(info) + return ii + @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 info = zim.IllustrationInfo.fromMetadataItemName(_name) + return IllustrationInfo.from_illustration_info(move(info)) + @property + def width(self) -> pyint: + """Width of the illustration in CSS pixels.""" + return self.c_info.width + @width.setter + def width(self, value: pyint): + self.c_info.width = value + @property + def height(self) -> pyint: + """Height of the illustration in CSS pixels.""" + return self.c_info.height + @height.setter + def height(self, value: pyint): + self.c_info.height = value + @property + def scale(self) -> float: + """Device pixel ratio (scale) of the illustration.""" + return self.c_info.scale + @scale.setter + def scale(self, value: float): + self.c_info.scale = value + @property + def extra_attributes(self) -> Dict[str, str]: + """Additional attributes as key-value pairs.""" + result = {} + for item in self.c_info.extraAttributes: + result[item.first.decode('UTF-8')] = item.second.decode('UTF-8') + return result + @extra_attributes.setter + def extra_attributes(self, value: Dict[str, str]): + """Set additional attributes.""" + self.c_info.extraAttributes.clear() + for key, val in value.items(): + self.c_info.extraAttributes[key.encode('UTF-8')] = val.encode('UTF-8') + 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"). + """ + return self.c_info.asMetadataItemName().decode('UTF-8') + def __repr__(self) -> str: + return f"IllustrationInfo(width={self.width}, height={self.height}, scale={self.scale})" + def __eq__(self, other) -> pybool: + if not isinstance(other, IllustrationInfo): + return False + return (self.width == other.width and + self.height == other.height and + self.scale == other.scale and + self.extra_attributes == other.extra_attributes) + + +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 # ############################################################################### @@ -1329,19 +1483,57 @@ 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, size: pyint = None, info: IllustrationInfo = None) -> Item: """Get the illustration Metadata item of the archive. + Args: + size: Optional size of the illustration (for backward compatibility). + info: Optional IllustrationInfo with width, height, and scale. + Returns: The illustration item. + + Note: + Either provide size (int) or info (IllustrationInfo), not both. + If neither is provided, returns the default illustration item. """ try: - if size is not None: - return Item.from_item(move(self.c_archive.getIllustrationItem(size))) + if info is not None: + return Item.from_item(move(self.c_archive.getIllustrationItem(info.c_info))) + elif size is not None: + return Item.from_item(move(self.c_archive.getIllustrationItem(size))) return Item.from_item(move(self.c_archive.getIllustrationItem())) except RuntimeError as e: raise KeyError(str(e)) + def get_illustration_infos(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 + if width is not None and height is not None and min_scale is not None: + infos = self.c_archive.getIllustrationInfos(width, height, min_scale) + elif width is None and height is None and min_scale is None: + infos = self.c_archive.getIllustrationInfos() + else: + raise ValueError("Either provide all of (width, height, min_scale) or none of them") + result = [] + for info in infos: + result.append(IllustrationInfo.from_illustration_info(info)) + return result + @property def dirent_cache_max_size(self) -> pyint: """Maximum size of the dirent cache. @@ -1380,7 +1572,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 +1592,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 +1935,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 +1959,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..119c018 100644 --- a/libzim/reader.pyi +++ b/libzim/reader.pyi @@ -1,8 +1,11 @@ from __future__ import annotations import pathlib +from typing import overload from uuid import UUID +from libzim.illustration import IllustrationInfo + class Item: @property def title(self) -> str: ... @@ -76,7 +79,21 @@ 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: ... + @overload + def get_illustration_item(self) -> Item: ... + @overload + def get_illustration_item(self, size: int) -> Item: ... + @overload + def get_illustration_item(self, *, info: IllustrationInfo) -> Item: ... + @overload + def get_illustration_infos(self) -> list[IllustrationInfo]: ... + @overload + def get_illustration_infos( + 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 7bf3afa..0f05d1d 100644 --- a/libzim/writer.pyi +++ b/libzim/writer.pyi @@ -5,7 +5,9 @@ import enum import pathlib import types from collections.abc import Callable, Generator -from typing import Self +from typing import Self, overload + +from libzim.illustration import IllustrationInfo class Compression(enum.Enum): none: Self @@ -60,7 +62,10 @@ class Creator: def config_indexing(self, indexing: bool, language: str) -> Self: ... def config_nbworkers(self, nbWorkers: int) -> Self: ... # noqa: N803 def set_mainpath(self, mainPath: str) -> Self: ... # noqa: N803 + @overload def add_illustration(self, size: int, content: bytes) -> None: ... + @overload + def add_illustration(self, info: IllustrationInfo, content: bytes) -> 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..c171692 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) nogil except + 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..18ea530 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +import base64 + +import pytest + + +@pytest.fixture(scope="module") +def favicon_data(): + return base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQ" + "ImWO4ISn6HwAE2QIGKsd69QAAAABJRU5ErkJggg==" + ) diff --git a/tests/test_illustration_archive.py b/tests/test_illustration_archive.py new file mode 100644 index 0000000..bfece5c --- /dev/null +++ b/tests/test_illustration_archive.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 + +# This file is part of python-libzim +# (see https://github.com/libzim/python-libzim) +# +# Copyright (c) 2025 Benoit Arnaud +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +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): + """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(IllustrationInfo(48, 48, 1.0), favicon_data) + c.add_illustration(IllustrationInfo(48, 48, 2.0), favicon_data) + c.add_illustration(IllustrationInfo(48, 48, 3.0), favicon_data) + + # 96x96 at different scales + c.add_illustration(IllustrationInfo(96, 96, 1.0), favicon_data) + c.add_illustration(IllustrationInfo(96, 96, 2.0), favicon_data) + + # Non-square illustrations + c.add_illustration(IllustrationInfo(128, 64, 1.0), favicon_data) + c.add_illustration(IllustrationInfo(64, 128, 1.0), favicon_data) + + # Large illustration + c.add_illustration(IllustrationInfo(256, 256, 1.0), favicon_data) + + return fpath + + +class TestArchiveGetIllustrationInfos: + """Test Archive.get_illustration_infos() 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_illustration_infos() + + # 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_illustration_infos_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_illustration_infos(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_illustration_infos_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_illustration_infos(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_illustration_infos_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_illustration_infos(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_illustration_infos_filter_96x96(self, zim_with_varied_illustrations): + """Test filtering for 96x96 illustrations.""" + zim = Archive(zim_with_varied_illustrations) + infos = zim.get_illustration_infos(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_illustration_infos_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_illustration_infos(width=999, height=999, min_scale=1.0) + + # Should return empty list + assert len(infos) == 0 + + def test_get_illustration_infos_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_illustration_infos(width=48) + + # Only height should raise error + with pytest.raises(ValueError, match=r"Either provide all of.*or none"): + zim.get_illustration_infos(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_illustration_infos(width=48, height=48) + + # Only min_scale should raise error + with pytest.raises(ValueError, match=r"Either provide all of.*or none"): + zim.get_illustration_infos(min_scale=1.0) + + +class TestArchiveGetIllustrationItem: + """Test Archive.get_illustration_item() with IllustrationInfo.""" + + def test_get_illustration_item_with_info( + self, zim_with_varied_illustrations, favicon_data + ): + """Test getting illustration item using IllustrationInfo.""" + zim = Archive(zim_with_varied_illustrations) + infos = zim.get_illustration_infos() + + # Get item for each illustration + for info in infos: + item = zim.get_illustration_item(info=info) + assert bytes(item.content) == favicon_data + # 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 + ): + """Test getting specific scale illustration.""" + zim = Archive(zim_with_varied_illustrations) + + # Get the 48x48@2 illustration specifically + info = IllustrationInfo(48, 48, 2.0) + item = zim.get_illustration_item(info=info) + assert bytes(item.content) == favicon_data + + 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_old_api_still_works( + self, zim_with_varied_illustrations, favicon_data + ): + """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(size=48) + assert bytes(item.content) == favicon_data + + item = zim.get_illustration_item(size=96) + assert bytes(item.content) == favicon_data + + +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_illustration_infos_empty(self, tmp_path): + """Test get_illustration_infos 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_illustration_infos() + 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..57f4fee --- /dev/null +++ b/tests/test_illustration_info.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 + +# This file is part of python-libzim +# (see https://github.com/libzim/python-libzim) +# +# Copyright (c) 2025 Benoit Arnaud +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import pytest + +from libzim.illustration import ( # pyright: ignore [reportMissingModuleSource] + IllustrationInfo, +) + + +class TestIllustrationInfo: + """Tests for IllustrationInfo class.""" + + 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_property_setters(self): + """Test setting properties after construction.""" + info = IllustrationInfo() + info.width = 96 + info.height = 48 + info.scale = 3.0 + assert info.width == 96 + assert info.height == 48 + assert info.scale == 3.0 + + 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_setter(self): + """Test setting extra attributes.""" + info = IllustrationInfo(48, 48, 1.0) + info.extra_attributes = {"foo": "bar", "baz": "qux"} + assert info.extra_attributes == {"foo": "bar", "baz": "qux"} + + 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) + info2 = IllustrationInfo(48, 48, 1.0) + info1.extra_attributes = {"key": "value"} + info2.extra_attributes = {"key": "value"} + assert info1 == info2 + + info3 = IllustrationInfo(48, 48, 1.0) + info3.extra_attributes = {"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 ef00932..307a07e 100644 --- a/tests/test_libzim_creator.py +++ b/tests/test_libzim_creator.py @@ -2,7 +2,6 @@ from __future__ import annotations -import base64 import datetime import itertools import os @@ -14,6 +13,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] @@ -62,14 +64,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 ( @@ -373,6 +367,71 @@ def test_creator_illustration(fpath, favicon_data): assert zim.get_illustration_sizes() == {48, 96} +def test_creator_illustration_with_info(fpath, favicon_data): + """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(info1, favicon_data) + + info2 = IllustrationInfo(48, 48, 2.0) + c.add_illustration(info2, favicon_data) + + info3 = IllustrationInfo(96, 96, 1.0) + c.add_illustration(info3, favicon_data) + + # Non-square illustration + info4 = IllustrationInfo(64, 32, 1.0) + c.add_illustration(info4, favicon_data) + + zim = Archive(fpath) + assert zim.has_illustration() is True + + # Test get_illustration_infos() - should return all illustrations + infos = zim.get_illustration_infos() + 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 with IllustrationInfo + for info in infos: + item = zim.get_illustration_item(info=info) + assert bytes(item.content) == favicon_data + # Verify the item path matches the metadata name + assert info.as_metadata_item_name() in item.path + + +def test_creator_illustration_backward_compatibility(fpath, favicon_data): + """Test that old-style add_illustration still works.""" + with Creator(fpath) as c: + # Old API: just size (int) + c.add_illustration(48, favicon_data) + c.add_illustration(96, favicon_data) + + 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(size=48).content) == favicon_data + assert bytes(zim.get_illustration_item(size=96).content) == favicon_data + + +def test_creator_illustration_invalid_type(fpath, favicon_data): + """Test that add_illustration raises TypeError for invalid input.""" + with Creator(fpath) as c: + with pytest.raises(TypeError, match="must be int or IllustrationInfo"): + c.add_illustration( + "invalid", # pyright: ignore [reportCallIssue, reportArgumentType] + favicon_data, + ) + + def test_creator_additem(fpath, lipsum_item): # ensure we can't add if not started c = Creator(fpath)