From 75059f829a0208c0377f2e676569f631584bde86 Mon Sep 17 00:00:00 2001 From: Frank Conrads Date: Mon, 8 Jun 2026 13:40:45 +0200 Subject: [PATCH] Add SVG compression via npx svgo Add vector extension support for .svg and route SVG files through npx svgo before raster compression. Keep behavior fail-safe: missing npx/svgo or non-zero svgo exit returns None and preserves existing flow. Extend tests for SVG discovery, SVG routing priority, and missing npx handling. Co-Authored-By: Abacus.AI CLI --- pptx_image_compress.py | 46 ++++++++++++++++++++++++++++++++++++- test_pptx_image_compress.py | 40 +++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/pptx_image_compress.py b/pptx_image_compress.py index 791f4b3..9b5ec35 100644 --- a/pptx_image_compress.py +++ b/pptx_image_compress.py @@ -44,7 +44,9 @@ from typing import Callable, List, Optional __version__ = "1.1.7" -ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".webp", ".gif"} +RASTER_EXT = {".jpg", ".jpeg", ".png", ".webp", ".gif"} +VECTOR_EXT = {".svg"} +ALLOWED_EXT = RASTER_EXT | VECTOR_EXT PROGRESS_BAR_LEN = 40 TEMP_PREFIX = "pptx_compress_" DEFAULT_MIN_SAVINGS = "2%" @@ -236,6 +238,45 @@ def compress_raster_image( ) +def compress_svg_with_svgo( + original: Path, + out_dir: Path, +) -> Path | None: + if original.suffix.lower() not in VECTOR_EXT: + return None + npx_exe = which("npx") + if not npx_exe: + return None + out_dir.mkdir(parents=True, exist_ok=True) + out_file = out_dir / original.name + cmd = [ + npx_exe, + "--yes", + "svgo", + str(original), + "-o", + str(out_file), + ] + try: + r = subprocess.run(cmd, capture_output=True, text=True) + if r.returncode != 0: + sys.stderr.write(f"[svgo] Fehler bei {original.name}:{r.stderr}") + return None + return out_file if out_file.exists() else None + except Exception as ex: + sys.stderr.write(f"[svgo] Ausnahme bei {original.name}: {ex}") + return None + + +def compress_vector_image( + original: Path, + out_dir: Path, +) -> Path | None: + if original.suffix.lower() == ".svg": + return compress_svg_with_svgo(original=original, out_dir=out_dir) + return None + + def compress_image_with_routing( compressor: Callable[..., Path | None], original: Path, @@ -244,6 +285,9 @@ def compress_image_with_routing( quality: int, min_savings: str, ) -> Path | None: + vector_out = compress_vector_image(original=original, out_dir=out_dir) + if vector_out is not None: + return vector_out return compress_raster_image( compressor=compressor, original=original, diff --git a/test_pptx_image_compress.py b/test_pptx_image_compress.py index 3c48a1f..2811c56 100644 --- a/test_pptx_image_compress.py +++ b/test_pptx_image_compress.py @@ -2,6 +2,7 @@ import tempfile import unittest import zipfile from pathlib import Path +from unittest import mock import pptx_image_compress as pic @@ -14,8 +15,9 @@ class TestPptxImageCompress(unittest.TestCase): (media_dir / "b.png").write_bytes(b"1") (media_dir / "c.txt").write_bytes(b"1") (media_dir / "d.GIF").write_bytes(b"1") + (media_dir / "e.svg").write_bytes(b"") images = pic.discover_images(media_dir) - self.assertEqual([p.name for p in images], ["a.jpg", "b.png", "d.GIF"]) + self.assertEqual([p.name for p in images], ["a.jpg", "b.png", "d.GIF", "e.svg"]) def test_image_result_to_log_line(self): image_result = pic.ImageProcessResult( @@ -271,6 +273,42 @@ class TestPptxImageCompress(unittest.TestCase): self.fail("Output should not be None") self.assertEqual(out.name, "image1.png") self.assertEqual(out.stat().st_size, 80) + def test_compress_svg_with_svgo_returns_none_when_npx_missing(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + svg = root / "vector.svg" + svg.write_text("", encoding="utf-8") + out_dir = root / "out" + + with mock.patch("pptx_image_compress.which", return_value=None): + out = pic.compress_svg_with_svgo(svg, out_dir) + + self.assertEqual(out, None) + + def test_compress_image_with_routing_uses_svg_backend(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + original = root / "vector.svg" + original.write_text("", encoding="utf-8") + out_dir = root / "out" + out_dir.mkdir(parents=True, exist_ok=True) + vector_out = out_dir / "vector.svg" + vector_out.write_text("", encoding="utf-8") + + def fake_compressor(original_path: Path, out_subdir: Path, caesium_threads: int | None, quality: int, min_savings: str): + raise AssertionError("Raster compressor should not be called for svg") + + with mock.patch("pptx_image_compress.compress_vector_image", return_value=vector_out): + out = pic.compress_image_with_routing( + compressor=fake_compressor, + original=original, + out_dir=out_dir, + caesium_threads=1, + quality=90, + min_savings="2%", + ) + + self.assertEqual(out, vector_out) if __name__ == "__main__":