Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions src/svgsmith/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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


Expand Down
79 changes: 79 additions & 0 deletions src/svgsmith/render.py
Original file line number Diff line number Diff line change
@@ -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 <svg> tag's
# size attributes with a regex; CairoSVG handles the actual (safe) rendering.
_SVG_TAG = re.compile(r"<svg\b[^>]*>", 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
39 changes: 39 additions & 0 deletions tests/test_render.py
Original file line number Diff line number Diff line change
@@ -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 = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 20" '
'style="width:100%;height:100%"><path d="M0 0 L40 0 L40 20 L0 20 Z" fill="#3366cc"/></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 = '<!DOCTYPE svg [<!ENTITY x "y">]>' + _SVG
assert _viewbox_size(hostile) == (40, 20)
Loading