Skip to content

Commit 6eb77a5

Browse files
authored
Merge pull request #181 from openzim/svg_support
Add minimal support for SVG conversion and probing
2 parents d647913 + 2321eba commit 6eb77a5

File tree

5 files changed

+121
-12
lines changed

5 files changed

+121
-12
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
- Expose new `optimization.get_optimization_method` to get the proper optimization method to call for a given image format
1818
- Add `optimization.get_optimization_method` to get the proper optimization method to call for a given image format
1919
- New `creator.Creator.convert_and_check_metadata` to convert metadata to bytes or str for known use cases and check proper type is passed to libzim
20+
- Add svg2png image conversion function #113
21+
- Add `conversion.convert_svg2png` image conversion function + support for SVG in `probing.format_for` #113
2022

2123
## Changed
2224

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ dependencies = [
2323
# limited and we use only a very small subset of it.
2424
"regex>=2020.7.14",
2525
"pymupdf>=1.24.0,<2.0",
26+
"CairoSVG>=2.2.0,<3.0",
2627
# youtube-dl should be updated as frequently as possible
2728
"yt-dlp"
2829
]

src/zimscraperlib/image/conversion.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33

44
from __future__ import annotations
55

6+
import io
67
import pathlib
78
from typing import IO
89

10+
import cairosvg.svg
911
from PIL.Image import open as pilopen
1012

1113
from zimscraperlib.constants import ALPHA_NOT_SUPPORTED
@@ -40,6 +42,39 @@ def convert_image(
4042
save_image(image, dst, fmt, **params)
4143

4244

45+
def convert_svg2png(
46+
src: str | pathlib.Path | io.BytesIO,
47+
dst: pathlib.Path | IO[bytes],
48+
width: int | None = None,
49+
height: int | None = None,
50+
):
51+
"""Convert a SVG to a PNG
52+
53+
Output width and height might be specified if resize is needed.
54+
PNG background is transparent.
55+
"""
56+
kwargs = {}
57+
if isinstance(src, pathlib.Path):
58+
src = str(src)
59+
if isinstance(src, str):
60+
kwargs["url"] = src
61+
else:
62+
kwargs["bytestring"] = src.getvalue()
63+
if width:
64+
kwargs["output_width"] = width
65+
if height:
66+
kwargs["output_height"] = height
67+
if isinstance(dst, pathlib.Path):
68+
cairosvg.svg2png(write_to=str(dst), **kwargs)
69+
else:
70+
result = cairosvg.svg2png(**kwargs)
71+
if not isinstance(result, bytes):
72+
raise Exception(
73+
"Unexpected type returned by cairosvg.svg2png"
74+
) # pragma: no cover
75+
dst.write(result)
76+
77+
4378
def create_favicon(src: pathlib.Path, dst: pathlib.Path) -> None:
4479
"""generate a squared favicon from a source image"""
4580
if dst.suffix != ".ico":

src/zimscraperlib/image/probing.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
import colorthief
1313
import PIL.Image
1414

15+
from zimscraperlib.filesystem import get_content_mimetype, get_file_mimetype
16+
1517

1618
def get_colors(
1719
src: pathlib.Path, *, use_palette: bool | None = True
@@ -59,8 +61,23 @@ def format_for(
5961
) -> str | None:
6062
"""Pillow format of a given filename, either Pillow-detected or from suffix"""
6163
if not from_suffix:
62-
with PIL.Image.open(src) as img:
63-
return img.format
64+
try:
65+
with PIL.Image.open(src) as img:
66+
return img.format
67+
except PIL.UnidentifiedImageError:
68+
# Fallback based on mimetype for SVG which are not supported by PIL
69+
if (
70+
isinstance(src, pathlib.Path)
71+
and get_file_mimetype(src) == "image/svg+xml"
72+
):
73+
return "SVG"
74+
elif (
75+
isinstance(src, io.BytesIO)
76+
and get_content_mimetype(src.getvalue()) == "image/svg+xml"
77+
):
78+
return "SVG"
79+
else: # pragma: no cover
80+
raise
6481

6582
if not isinstance(src, pathlib.Path):
6683
raise ValueError(
@@ -70,8 +87,11 @@ def format_for(
7087
from PIL.Image import EXTENSION as PIL_FMT_EXTENSION
7188
from PIL.Image import init as init_pil
7289

73-
init_pil()
74-
return PIL_FMT_EXTENSION[src.suffix] if src.suffix in PIL_FMT_EXTENSION else None
90+
init_pil() # populate the PIL_FMT_EXTENSION dictionary
91+
92+
known_extensions = {".svg": "SVG"}
93+
known_extensions.update(PIL_FMT_EXTENSION)
94+
return known_extensions[src.suffix] if src.suffix in known_extensions else None
7595

7696

7797
def is_valid_image(

tests/image/test_image.py

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@
1717
from resizeimage.imageexceptions import ImageSizeError
1818

1919
from zimscraperlib.image import presets
20-
from zimscraperlib.image.conversion import convert_image, create_favicon
20+
from zimscraperlib.image.conversion import (
21+
convert_image,
22+
convert_svg2png,
23+
create_favicon,
24+
)
2125
from zimscraperlib.image.optimization import (
2226
ensure_matches,
2327
get_optimization_method,
@@ -64,8 +68,15 @@ def get_src_dst(
6468
jpg_image: pathlib.Path | None = None,
6569
gif_image: pathlib.Path | None = None,
6670
webp_image: pathlib.Path | None = None,
71+
svg_image: pathlib.Path | None = None,
6772
) -> tuple[pathlib.Path, pathlib.Path]:
68-
options = {"png": png_image, "jpg": jpg_image, "webp": webp_image, "gif": gif_image}
73+
options = {
74+
"png": png_image,
75+
"jpg": jpg_image,
76+
"webp": webp_image,
77+
"gif": gif_image,
78+
"svg": svg_image,
79+
}
6980
if fmt not in options:
7081
raise LookupError(f"Unsupported fmt passed: {fmt}")
7182
src = options[fmt]
@@ -328,6 +339,42 @@ def test_convert_path_src_io_dst(png_image: pathlib.Path):
328339
assert dst_image.format == "PNG"
329340

330341

342+
def test_convert_svg_io_src_path_dst(svg_image: pathlib.Path, tmp_path: pathlib.Path):
343+
src = io.BytesIO(svg_image.read_bytes())
344+
dst = tmp_path / "test.png"
345+
convert_svg2png(src, dst)
346+
dst_image = Image.open(dst)
347+
assert dst_image.format == "PNG"
348+
349+
350+
def test_convert_svg_io_src_io_dst(svg_image: pathlib.Path):
351+
src = io.BytesIO(svg_image.read_bytes())
352+
dst = io.BytesIO()
353+
convert_svg2png(src, dst)
354+
dst_image = Image.open(dst)
355+
assert dst_image.format == "PNG"
356+
357+
358+
def test_convert_svg_path_src_path_dst(svg_image: pathlib.Path, tmp_path: pathlib.Path):
359+
src = svg_image
360+
dst = tmp_path / "test.png"
361+
convert_svg2png(src, dst, width=96, height=96)
362+
dst_image = Image.open(dst)
363+
assert dst_image.format == "PNG"
364+
assert dst_image.width == 96
365+
assert dst_image.height == 96
366+
367+
368+
def test_convert_svg_path_src_io_dst(svg_image: pathlib.Path):
369+
src = svg_image
370+
dst = io.BytesIO()
371+
convert_svg2png(src, dst, width=96, height=96)
372+
dst_image = Image.open(dst)
373+
assert dst_image.format == "PNG"
374+
assert dst_image.width == 96
375+
assert dst_image.height == 96
376+
377+
331378
@pytest.mark.parametrize(
332379
"fmt,exp_size",
333380
[("png", 128), ("jpg", 128)],
@@ -576,10 +623,10 @@ def test_ensure_matches(webp_image):
576623

577624
@pytest.mark.parametrize(
578625
"fmt,expected",
579-
[("png", "PNG"), ("jpg", "JPEG"), ("gif", "GIF"), ("webp", "WEBP")],
626+
[("png", "PNG"), ("jpg", "JPEG"), ("gif", "GIF"), ("webp", "WEBP"), ("svg", "SVG")],
580627
)
581628
def test_format_for_real_images_suffix(
582-
png_image, jpg_image, gif_image, webp_image, tmp_path, fmt, expected
629+
png_image, jpg_image, gif_image, webp_image, svg_image, tmp_path, fmt, expected
583630
):
584631
src, _ = get_src_dst(
585632
tmp_path,
@@ -588,16 +635,17 @@ def test_format_for_real_images_suffix(
588635
jpg_image=jpg_image,
589636
gif_image=gif_image,
590637
webp_image=webp_image,
638+
svg_image=svg_image,
591639
)
592640
assert format_for(src) == expected
593641

594642

595643
@pytest.mark.parametrize(
596644
"fmt,expected",
597-
[("png", "PNG"), ("jpg", "JPEG"), ("gif", "GIF"), ("webp", "WEBP")],
645+
[("png", "PNG"), ("jpg", "JPEG"), ("gif", "GIF"), ("webp", "WEBP"), ("svg", "SVG")],
598646
)
599647
def test_format_for_real_images_content_path(
600-
png_image, jpg_image, gif_image, webp_image, tmp_path, fmt, expected
648+
png_image, jpg_image, gif_image, webp_image, svg_image, tmp_path, fmt, expected
601649
):
602650
src, _ = get_src_dst(
603651
tmp_path,
@@ -606,16 +654,17 @@ def test_format_for_real_images_content_path(
606654
jpg_image=jpg_image,
607655
gif_image=gif_image,
608656
webp_image=webp_image,
657+
svg_image=svg_image,
609658
)
610659
assert format_for(src, from_suffix=False) == expected
611660

612661

613662
@pytest.mark.parametrize(
614663
"fmt,expected",
615-
[("png", "PNG"), ("jpg", "JPEG"), ("gif", "GIF"), ("webp", "WEBP")],
664+
[("png", "PNG"), ("jpg", "JPEG"), ("gif", "GIF"), ("webp", "WEBP"), ("svg", "SVG")],
616665
)
617666
def test_format_for_real_images_content_bytes(
618-
png_image, jpg_image, gif_image, webp_image, tmp_path, fmt, expected
667+
png_image, jpg_image, gif_image, webp_image, svg_image, tmp_path, fmt, expected
619668
):
620669
src, _ = get_src_dst(
621670
tmp_path,
@@ -624,6 +673,7 @@ def test_format_for_real_images_content_bytes(
624673
jpg_image=jpg_image,
625674
gif_image=gif_image,
626675
webp_image=webp_image,
676+
svg_image=svg_image,
627677
)
628678
assert format_for(io.BytesIO(src.read_bytes()), from_suffix=False) == expected
629679

@@ -635,6 +685,7 @@ def test_format_for_real_images_content_bytes(
635685
("image.jpg", "JPEG"),
636686
("image.gif", "GIF"),
637687
("image.webp", "WEBP"),
688+
("image.svg", "SVG"),
638689
("image.raster", None),
639690
],
640691
)

0 commit comments

Comments
 (0)