From 7f00cd90e1d6392f217b51d270866da4b3de50ea Mon Sep 17 00:00:00 2001 From: Andy Edmonds Date: Thu, 15 Oct 2026 21:01:43 -0700 Subject: [PATCH] feat(rasterize): SVG to PNG without Cairo, fonts pinned for ImageMagick The critique agent rasterized SVGs with cairosvg, which on Windows installs and then fails at import because there is no libcairo-2.dll. muriel.rasterize keeps cairosvg first and falls back to ImageMagick 7, whose Windows build carries librsvg and Cairo itself. ImageMagick's librsvg 2.40 tries only the first family of a font stack and rounds weight 600 to Bold. So before the render each stack is resolved against `magick -list font`, with ui-sans-serif / system-ui / ui-monospace mapped to the faces a browser picks. On Windows, intermediate weights become their named faces ("Segoe UI Semibold"), chosen by the CSS weight-matching rule. The CLI reports the backend, each substitution, leftover font fallbacks, and a note when elements are present, since this librsvg drops them. The critique agent, svg channel, and repo permission allowlist now point at `python -m muriel.rasterize`. Co-Authored-By: Claude Opus 5.5 --- .claude/settings.json | 4 + CHANGELOG.md | 15 + muriel/__main__.py | 2 + muriel/rasterize.py | 624 ++++++++++++++++++ muriel/squint.py | 2 +- plugins/muriel/agents/muriel-critique.md | 6 +- plugins/muriel/skills/compose/channels/svg.md | 1 + tests/test_rasterize.py | 366 ++++++++++ 8 files changed, 1016 insertions(+), 4 deletions(-) create mode 100644 muriel/rasterize.py create mode 100644 tests/test_rasterize.py diff --git a/.claude/settings.json b/.claude/settings.json index 9e6afbe..6d5339c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -10,6 +10,10 @@ "Bash(python3 -m muriel.oklch:*)", "Bash(.venv/bin/python -m muriel.oklch:*)", "Bash(uv run python -m muriel.oklch:*)", + "Bash(python -m muriel.rasterize:*)", + "Bash(python3 -m muriel.rasterize:*)", + "Bash(.venv/bin/python -m muriel.rasterize:*)", + "Bash(uv run python -m muriel.rasterize:*)", "Bash(cairosvg:*)", "Bash(.venv/bin/cairosvg:*)", "Bash(python -m cairosvg:*)", diff --git a/CHANGELOG.md b/CHANGELOG.md index f911c6d..1a22fa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ version numbers follow [Semantic Versioning](https://semver.org/). ### Added +- **`muriel rasterize` — SVG to PNG without Cairo.** The critique agent's + rasterize step called `cairosvg`, which on Windows installs and then fails + at import (no `libcairo-2.dll`). `muriel.rasterize` keeps cairosvg first + and falls back to ImageMagick 7 (`magick`), whose Windows build carries + librsvg and Cairo itself. Its librsvg reads only the first family of a + `font-family` stack and rounds weight 600 to Bold, so before the render + every stack is resolved against `magick -list font`. The CSS keywords muriel's + generators lead with (`ui-sans-serif`, `system-ui`, `ui-monospace`) + resolve to the faces a browser picks. On Windows, intermediate weights + become their named faces ("Segoe UI Semibold"), chosen by the CSS + weight-matching rule. Prints the backend, every substitution, leftover + font fallbacks, and a note when `` elements are present, since + ImageMagick's librsvg drops them. The critique agent and the repo's + permission allowlist now point at it. + - **Diagram icon library: 30 curated Lucide glyphs, and named icons on `cycle`.** `muriel.tools.diagrams.icons.GLYPHS` maps process-verb and concept slots (`observe`, `measure`, `decide`, `refresh`, `test`, `ship`, diff --git a/muriel/__main__.py b/muriel/__main__.py index 913fac7..45eca44 100644 --- a/muriel/__main__.py +++ b/muriel/__main__.py @@ -18,6 +18,7 @@ muriel diagram-check fig.svg # a11y + label + contrast gate muriel dimensions # print the dimensions registry muriel heroshot in.png out.png --tilt 12 + muriel rasterize fig.svg -o fig.png # SVG to PNG, cairosvg or ImageMagick muriel tilt-shift raw.png hero.png muriel venn spec.json out.png muriel styleguide examples/muriel-brand.toml --css @@ -58,6 +59,7 @@ "import-tokens": ("muriel.design_tokens_import", "Convert a DTCG / open-codesign tokens.json into a muriel brand.toml"), "import-corpus": ("muriel.tools.corpus_audit", "Audit a DESIGN.md corpus through design_md_import + 8:1 floor"), "palettes": ("muriel.palettes", "List / generate / validate categorical palettes (CVD + contrast)"), + "rasterize": ("muriel.rasterize", "SVG to PNG for a visual audit: cairosvg, else ImageMagick with fonts pinned to installed faces"), "smartcrop": ("muriel.tools.smartcrop", "Saliency-aware crop solver (edges + optional faces / text / saliency)"), "squint": ("muriel.squint", "Dimension-scaled blur ladder + thumbnail companions — does hierarchy survive degradation?"), "styleguide": ("muriel.styleguide", "Load a brand.toml; print / derive CSS / matplotlibrc / contrast audit"), diff --git a/muriel/rasterize.py b/muriel/rasterize.py new file mode 100644 index 0000000..ec57bb3 --- /dev/null +++ b/muriel/rasterize.py @@ -0,0 +1,624 @@ +""" +muriel.rasterize — SVG to PNG for a visual audit, on whatever renderer the box has. + +The critique agent judges a PNG, not SVG source, so every SVG audit starts +with a rasterize step. cairosvg (the ``[critique]`` extra) was the only +path, and on Windows it installs cleanly and then fails at import: it binds +the system Cairo library through cffi, and Windows ships no +``libcairo-2.dll``. ImageMagick's Windows build carries librsvg and Cairo +inside it, so ``magick`` renders SVG with nothing else installed. + +Backends, first that works wins: + +1. ``cairosvg`` — unchanged wherever it already works. An import that + raises OSError (installed, no Cairo library) counts as unavailable. +2. ``magick`` — ImageMagick 7's librsvg delegate, with fonts pinned first. + +Font pinning +------------ + +The librsvg that ImageMagick bundles (2.40) hands a whole ``font-family`` +value to Pango as one name. It does not walk a CSS stack: ``Inter, Arial, +sans-serif`` with Inter absent falls back to Pango's generic "Sans" with an +"expect ugly output" warning and never reaches Arial, and the CSS-only +keywords muriel's generators lead with (``ui-sans-serif``, ``system-ui``, +``ui-monospace``) match nothing. So before the render every +``font-family`` — attribute, inline style, ``" + 'A', root_attrs="")) + style = ET.fromstring(out).find(f"{{{SVG_NS}}}style") + self.assertIn("font-family: 'Segoe UI';", style.text) + self.assertEqual(_texts(out)[0].get("font-weight"), "600") + self.assertTrue(any("