Skip to content

Commit 75fb60b

Browse files
committed
Add utility to fetch and prepare ZIM illustration
1 parent 082cbbb commit 75fb60b

File tree

5 files changed

+132
-2
lines changed

5 files changed

+132
-2
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616

1717
- JS rewriting abusively rewrite import function (#255)
1818

19+
### Added
20+
21+
- Add utility to fetch and prepare ZIM illustration (#254)
22+
1923
## [5.1.1] - 2025-02-17
2024

2125
### Changed

src/zimscraperlib/constants.py

+3
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,6 @@
2929
# default timeout to get responses from upstream when doing web requests ; this is not
3030
# the total time it gets to download the whole resource
3131
DEFAULT_WEB_REQUESTS_TIMEOUT = 10
32+
33+
DEFAULT_ZIM_ILLLUSTRATION_SIZE = 48
34+
DEFAULT_ZIM_ILLLUSTRATION_SCALE = 1
+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import io
2+
import pathlib
3+
4+
from zimscraperlib.constants import DEFAULT_ZIM_ILLLUSTRATION_SIZE
5+
from zimscraperlib.image.conversion import convert_image, convert_svg2png
6+
from zimscraperlib.image.optimization import optimize_png
7+
from zimscraperlib.image.probing import format_for
8+
from zimscraperlib.image.transformation import resize_image
9+
from zimscraperlib.inputs import handle_user_provided_file
10+
11+
12+
def get_zim_illustration(
13+
illustration_location: pathlib.Path | str,
14+
width: int = DEFAULT_ZIM_ILLLUSTRATION_SIZE,
15+
height: int = DEFAULT_ZIM_ILLLUSTRATION_SIZE,
16+
resize_method: str = "contain",
17+
) -> io.BytesIO:
18+
"""Get ZIM-ready illustration from any image path or URL
19+
20+
illustration_location will be downloaded if needed. Image is automatically
21+
converted to PNG, resized and optimized as needed.
22+
23+
Arguments:
24+
illustration_location: path or URL to an image
25+
width: target illustration width
26+
height: target illustration height
27+
resize_method: method to resize the image ; in general only 'contain' or
28+
'cover' make sense, but 'crop', 'width', 'height' and 'thumbnail' can be used
29+
"""
30+
31+
illustration_path = handle_user_provided_file(illustration_location)
32+
33+
if not illustration_path:
34+
# given handle_user_provided_file logic, this is not supposed to happen besides
35+
# when empty string is passed, hence the simple error message
36+
raise ValueError("Illustration is missing")
37+
38+
illustration = io.BytesIO()
39+
illustration_format = format_for(illustration_path, from_suffix=False)
40+
if illustration_format == "SVG":
41+
convert_svg2png(illustration_path, illustration, width, height)
42+
else:
43+
if illustration_format != "PNG":
44+
convert_image(illustration_path, illustration, fmt="PNG")
45+
else:
46+
illustration = io.BytesIO(illustration_path.read_bytes())
47+
resize_image(illustration, width, height, method=resize_method)
48+
49+
optimized_illustration = io.BytesIO()
50+
optimize_png(illustration, optimized_illustration)
51+
52+
return optimized_illustration

src/zimscraperlib/zim/metadata.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import regex
1111

1212
from zimscraperlib.constants import (
13+
DEFAULT_ZIM_ILLLUSTRATION_SCALE,
14+
DEFAULT_ZIM_ILLLUSTRATION_SIZE,
1315
ILLUSTRATIONS_METADATA_RE,
1416
MAXIMUM_DESCRIPTION_METADATA_LENGTH,
1517
MAXIMUM_LONG_DESCRIPTION_METADATA_LENGTH,
@@ -423,8 +425,8 @@ def __init__(
423425
@mandatory
424426
class DefaultIllustrationMetadata(IllustrationBasedMetadata):
425427
meta_name = "Illustration_48x48@1"
426-
illustration_size: int = 48
427-
illustration_scale: int = 1
428+
illustration_size: int = DEFAULT_ZIM_ILLLUSTRATION_SIZE
429+
illustration_scale: int = DEFAULT_ZIM_ILLLUSTRATION_SCALE
428430

429431

430432
@mandatory

tests/image/test_illustration.py

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
from PIL.Image import open as pilopen
5+
6+
from zimscraperlib.image.illustration import get_zim_illustration
7+
8+
COMMONS_IMAGE_PATH = (Path(__file__) / "../../files/commons.png").resolve()
9+
COMMONS_48_IMAGE_PATH = (Path(__file__) / "../../files/commons48.png").resolve()
10+
NINJA_IMAGE_PATH = (Path(__file__) / "../../files/ninja.webp").resolve()
11+
12+
13+
@pytest.mark.parametrize(
14+
"user_illustration, expected_max_filesize",
15+
[
16+
pytest.param(COMMONS_IMAGE_PATH, 5000, id="big_commons"),
17+
pytest.param(COMMONS_48_IMAGE_PATH, 4000, id="small_commons"),
18+
pytest.param(NINJA_IMAGE_PATH, 5000, id="ninja"),
19+
pytest.param(
20+
"https://upload.wikimedia.org/wikipedia/commons/thumb/4/4a/Commons-logo.svg/250px-Commons-logo.svg.png",
21+
4000,
22+
id="png_url",
23+
),
24+
pytest.param(
25+
"https://upload.wikimedia.org/wikipedia/commons/4/4a/Commons-logo.svg",
26+
4000,
27+
id="svg_url",
28+
),
29+
],
30+
)
31+
def test_get_zim_illustration(
32+
user_illustration: str | Path,
33+
expected_max_filesize: int,
34+
):
35+
image = get_zim_illustration(user_illustration)
36+
assert len(image.getvalue()) < expected_max_filesize
37+
with pilopen(image) as image_details:
38+
assert image_details.format == "PNG"
39+
assert image_details.size == (48, 48)
40+
41+
42+
def test_get_missing_user_zim_illustration():
43+
with pytest.raises(Exception, match="missing.png could not be found"):
44+
get_zim_illustration("./missing.png")
45+
46+
47+
def test_get_missing_default_zim_illustration():
48+
with pytest.raises(Exception, match="Illustration is missing"):
49+
get_zim_illustration("")
50+
51+
52+
def test_get_zim_illustration_custom_size():
53+
image = get_zim_illustration(NINJA_IMAGE_PATH, 96, 120)
54+
assert len(image.getvalue()) < 21000
55+
with pilopen(image) as image_details:
56+
assert image_details.format == "PNG"
57+
assert image_details.size == (96, 120)
58+
59+
60+
def test_get_zim_illustration_method():
61+
image_cover = get_zim_illustration(NINJA_IMAGE_PATH, resize_method="cover")
62+
image_contain = get_zim_illustration(NINJA_IMAGE_PATH, resize_method="contain")
63+
# cover image is always bigger than contain image size more pixels are
64+
# "used/non-transparent"
65+
assert len(image_cover.getvalue()) > len(image_contain.getvalue())
66+
for image in [image_cover, image_contain]:
67+
with pilopen(image) as image_details:
68+
assert image_details.format == "PNG"
69+
assert image_details.size == (48, 48)

0 commit comments

Comments
 (0)