From 0e67b6295d1ed83ccdb212177f600764a364d19f Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 8 Apr 2026 11:35:59 -0400 Subject: [PATCH 1/2] Make cairosvg optional and skip rasterizing --- great_docs/core.py | 16 +++++++++++++++- pyproject.toml | 4 +++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/great_docs/core.py b/great_docs/core.py index 2d43b01b..7597d498 100644 --- a/great_docs/core.py +++ b/great_docs/core.py @@ -718,9 +718,13 @@ def _generate_favicons(self, logo_src: Path, dest_dir: Path) -> dict[str, str]: """ import io - import cairosvg from PIL import Image + try: + import cairosvg + except ImportError: + cairosvg = None # type: ignore[assignment] + result: dict[str, str] = {} suffix = logo_src.suffix.lower() @@ -733,6 +737,16 @@ def _generate_favicons(self, logo_src: Path, dest_dir: Path) -> dict[str, str]: result["icon-svg"] = "favicon.svg" result["icon"] = "favicon.svg" + if cairosvg is None: + print( + "Favicon: cairosvg is not installed; skipping raster favicon " + "generation from SVG. Install it with:\n" + " pip install 'great-docs[svg]'\n" + "On Linux you also need: apt install libcairo2-dev\n" + "On macOS: brew install cairo" + ) + return result + # Rasterize SVG → PNG preserving aspect ratio, then resize png_data = cairosvg.svg2png(url=str(logo_src), scale=4) raw = Image.open(io.BytesIO(png_data)) diff --git a/pyproject.toml b/pyproject.toml index 6acbc8d8..9f5e641a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,11 +39,13 @@ dependencies = [ "pygments>=2.0.0", "requests>=2.25.0", "Pillow>=9.0.0", - "cairosvg>=2.5.0", "ruff>=0.9.9", ] [project.optional-dependencies] +svg = [ + "cairosvg>=2.5.0", +] dev = [ "pytest>=6.0", "pytest-cov>=3.0", From de79d83e74dada2367bd976334cad39d8a9e74ce Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 8 Apr 2026 11:42:37 -0400 Subject: [PATCH 2/2] Add mock cairosvg and autouse fixtures in tests --- tests/test_great_docs.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_great_docs.py b/tests/test_great_docs.py index 515bb8c5..3445378e 100644 --- a/tests/test_great_docs.py +++ b/tests/test_great_docs.py @@ -9477,9 +9477,30 @@ def test_downscale_preserves_aspect_ratio(self): assert result.size == (100, 100) +def _make_mock_cairosvg(): + """Create a mock cairosvg module that returns valid PNG data from svg2png.""" + import io as _io + + mock_mod = types.ModuleType("cairosvg") + + def svg2png(**kwargs): + img = PILImage.new("RGBA", (256, 256), (0, 0, 255, 255)) + buf = _io.BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + + mock_mod.svg2png = svg2png # type: ignore[attr-defined] + return mock_mod + + class TestGenerateFaviconsSvg: """Tests for _generate_favicons with SVG source.""" + @pytest.fixture(autouse=True) + def _mock_cairosvg(self): + with patch.dict("sys.modules", {"cairosvg": _make_mock_cairosvg()}): + yield + def test_svg_generates_all_files(self): """SVG source should produce ico, svg, 16px, 32px, and apple-touch-icon.""" with tempfile.TemporaryDirectory() as tmp_dir: @@ -9725,6 +9746,11 @@ def test_favicon_invalid_type_returns_none(self): class TestFaviconLinkInjection: """Tests for favicon tag injection into _quarto.yml.""" + @pytest.fixture(autouse=True) + def _mock_cairosvg(self): + with patch.dict("sys.modules", {"cairosvg": _make_mock_cairosvg()}): + yield + def _build_with_favicon( self, tmp_dir: str, favicon_config: str | None = None, create_logo: bool = False ) -> dict: