diff --git a/README.md b/README.md index 964b706..79f4e93 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,16 @@ svgsmith convert input.png \ > *"crisp logo"* → `--mode binary`; *"keep the raw look"* → `--no-smooth`. The > [`vectorize` skill](skills/vectorize/SKILL.md) encodes this mapping. +### Rasterize (SVG → PNG) + +The inverse command renders an SVG back to a PNG (preview, thumbnail, round-trip): + +```bash +svgsmith rasterize input.svg --out out.png # intrinsic (viewBox) size +svgsmith rasterize input.svg --width 512 # fixed width +svgsmith rasterize input.svg --scale 2 --background white +``` + ### Output The SVG is **responsive and scalable**: it carries a `viewBox` and no fixed pixel diff --git a/src/svgsmith/cli.py b/src/svgsmith/cli.py index 4974c6e..fac7be1 100644 --- a/src/svgsmith/cli.py +++ b/src/svgsmith/cli.py @@ -90,6 +90,35 @@ def _convert(args: argparse.Namespace) -> int: return EXIT_OK if report.passed_threshold else EXIT_BELOW_THRESHOLD +def _rasterize(args: argparse.Namespace) -> int: + """Handle ``svgsmith rasterize`` — render an SVG to PNG.""" + if not args.input: + _log("error: an input SVG path is required") + return EXIT_ERROR + from pathlib import Path + + from svgsmith.render import rasterize as render_png + + out = args.out or str(Path(args.input).with_suffix(".png")) + try: + render_png( + args.input, + out, + width=args.width, + height=args.height, + scale=args.scale, + background=args.background, + ) + except FileNotFoundError: + _log(f"error: input not found: {args.input}") + return EXIT_ERROR + except Exception as exc: # noqa: BLE001 - surface any failure as a hard error + _log(f"error: rasterize failed: {exc}") + return EXIT_ERROR + _log(f"wrote {out}") + return EXIT_OK + + def build_parser() -> argparse.ArgumentParser: """Build the top-level argument parser and its subcommands.""" parser = argparse.ArgumentParser( @@ -196,6 +225,27 @@ def build_parser() -> argparse.ArgumentParser: ) convert.set_defaults(func=_convert) + rasterize = subparsers.add_parser( + "rasterize", + help="Render an SVG back to a PNG bitmap.", + description="Rasterize an SVG to PNG (preview, thumbnail, round-trip).", + ) + rasterize.add_argument("input", nargs="?", help="Path to the input SVG file.") + rasterize.add_argument( + "--out", default=None, help="Output PNG path (default: input with a .png extension)." + ) + rasterize.add_argument("--width", type=int, default=None, help="Output width in px.") + rasterize.add_argument("--height", type=int, default=None, help="Output height in px.") + rasterize.add_argument( + "--scale", type=float, default=None, help="Scale factor over the intrinsic size." + ) + rasterize.add_argument( + "--background", + default=None, + help="Background color (e.g. white, #ffffff). Default: transparent.", + ) + rasterize.set_defaults(func=_rasterize) + return parser diff --git a/src/svgsmith/render.py b/src/svgsmith/render.py new file mode 100644 index 0000000..8f5a071 --- /dev/null +++ b/src/svgsmith/render.py @@ -0,0 +1,79 @@ +"""Rasterize SVG back to PNG. + +The inverse of the vectorizer: render an SVG to a PNG bitmap via CairoSVG (already +a dependency, used by the verify loop). Useful for previews, thumbnails, and +round-tripping. Sizing precedence: explicit ``--scale`` or ``--width``/``--height``, +otherwise the SVG's intrinsic size (its ``viewBox`` extents when present). +""" + +from __future__ import annotations + +import re + +import cairosvg + +# The input SVG may be arbitrary/user-supplied, so we do NOT run a full XML parse +# over it (XXE / entity-expansion surface). We only scrape the root tag's +# size attributes with a regex; CairoSVG handles the actual (safe) rendering. +_SVG_TAG = re.compile(r"]*>", re.IGNORECASE | re.DOTALL) +_ATTR = lambda name: re.compile(rf'{name}\s*=\s*"([^"]*)"', re.IGNORECASE) # noqa: E731 + + +def _viewbox_size(svg: str) -> tuple[int, int] | None: + """Intrinsic pixel size from the root viewBox (or width/height), if any.""" + tag_match = _SVG_TAG.search(svg) + if not tag_match: + return None + tag = tag_match.group(0) + + vb = _ATTR("viewBox").search(tag) + if vb: + parts = [p for p in re.split(r"[,\s]+", vb.group(1).strip()) if p] + if len(parts) == 4: + try: + return round(float(parts[2])), round(float(parts[3])) + except ValueError: + pass + + def _len(name: str) -> float: + m = _ATTR(name).search(tag) + if not m: + return 0.0 + num = re.match(r"[-+]?(?:\d*\.\d+|\d+\.?)", m.group(1).strip()) + return float(num.group()) if num else 0.0 + + w, h = _len("width"), _len("height") + return (round(w), round(h)) if w and h else None + + +def rasterize( + svg_path: str, + out_path: str, + *, + width: int | None = None, + height: int | None = None, + scale: float | None = None, + background: str | None = None, +) -> str: + """Render the SVG at ``svg_path`` to a PNG at ``out_path``; return ``out_path``.""" + with open(svg_path, encoding="utf-8") as handle: + svg = handle.read() + + kwargs: dict = {} + if scale is not None: + kwargs["scale"] = scale + if width is not None: + kwargs["output_width"] = width + if height is not None: + kwargs["output_height"] = height + if background is not None: + kwargs["background_color"] = background + # Give CairoSVG an explicit size when none was requested — a responsive SVG + # (viewBox, no fixed width/height) would otherwise rasterize at a default size. + if not kwargs.get("scale") and "output_width" not in kwargs and "output_height" not in kwargs: + size = _viewbox_size(svg) + if size: + kwargs["output_width"], kwargs["output_height"] = size + + cairosvg.svg2png(bytestring=svg.encode("utf-8"), write_to=out_path, **kwargs) + return out_path diff --git a/tests/test_render.py b/tests/test_render.py new file mode 100644 index 0000000..3a1df10 --- /dev/null +++ b/tests/test_render.py @@ -0,0 +1,39 @@ +"""Tests for SVG -> PNG rasterization (the `rasterize` subcommand).""" + +from pathlib import Path + +from PIL import Image + +from svgsmith.render import _viewbox_size, rasterize + +_SVG = ( + '' +) + + +def _write(tmp_path: Path) -> Path: + p = tmp_path / "in.svg" + p.write_text(_SVG, encoding="utf-8") + return p + + +def test_viewbox_size_from_regex(): + assert _viewbox_size(_SVG) == (40, 20) + + +def test_rasterize_defaults_to_viewbox_size(tmp_path): + out = rasterize(str(_write(tmp_path)), str(tmp_path / "out.png")) + assert Image.open(out).size == (40, 20) + + +def test_rasterize_explicit_width(tmp_path): + out = rasterize(str(_write(tmp_path)), str(tmp_path / "out.png"), width=200) + assert Image.open(out).size[0] == 200 + + +def test_rasterize_does_not_full_parse_untrusted_svg(): + # _viewbox_size must not choke on (or expand) entity/DTD content — it only + # regex-scrapes the root tag, never a full XML parse. + hostile = ']>' + _SVG + assert _viewbox_size(hostile) == (40, 20)