- Content
- v1.0.0
- svg
- png
- conversion
- images
SVG to PNG
Convert SVG logos, icons, charts and diagrams to PNG at any size or scale, with a transparent or solid background. Runs on the agent's machine; no API key needed.
SKILL.md
SVG to PNG
Turns SVG into PNG with resvg, a fast and accurate renderer. Runs on this machine; no API key, no account, nothing leaves the machine.
Social platforms, Google Drive previews and most email clients don't accept SVG, so convert first whenever the user wants to post, upload or share an SVG.
Convert
uv run /root/.openclaw/skills/svg-to-png/scripts/svg_to_png.py logo.svg
That writes logo.png next to the SVG, at the SVG's own size, with a transparent background. The first run downloads the renderer, which takes a few seconds; later runs are instant.
Common options:
| Want | Add |
|---|---|
| A set width (height keeps the aspect ratio) | --width 1080 |
| A set height | --height 512 |
| Retina / sharper output | --scale 2 (or 3) |
| Solid background instead of transparent | --background white or --background "#0b0a16" |
| Choose where it goes | --out path/to/file.png (one file) or --out folder/ (several) |
| Every SVG in a folder | pass the folder: ... icons/ --out png/ |
| SVG you just wrote, without saving it first | cat <<'EOF' | uv run .../svg_to_png.py - --out logo.png |
| A custom font the SVG's text uses | --font-dir /path/to/fonts (folder of .ttf/.otf) |
Sizes people ask for: Instagram post 1080×1080 (--width 1080 on a square SVG), story 1080×1920, favicon 512×512, social avatar 400×400, email header about 600 wide.
Reading the result
The script prints one JSON line per file:
{"input": "logo.svg", "output": "/root/work/logo.png", "width": 1024, "height": 512, "bytes": 9216}
On failure the line has "error" instead, and the exit code is 1 (2 for a bad option). One broken file never stops the rest of a folder. Tell the user the output path and the pixel size.
Things to know
- Text: SVG text renders with the fonts on this machine (Inter for sans-serif, DejaVu for serif and monospace). If the design names another font and it matters, get the .ttf and pass
--font-dir, or convert the text to outlines in the SVG. - Images inside the SVG: local image references resolve next to the SVG file. Remote (
http…) images inside an SVG are not fetched; download them first and point the SVG at the local copy. - Limits: SVGs up to 20 MB; output up to 16384 px on a side.
- Posting it: to publish the PNG, stage it with the myclawbots
create_upload_urltool and post itspublic_urlwithpost_to_social. Show the user the image first and get a yes, as for any post.
Scripts
1 file ship with this skill. They are installed beside the SKILL.md.
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["resvg-py==0.5.0"]
# ///
"""
Convert SVG files to PNG with resvg (a Rust renderer; prebuilt wheels, no
system libraries needed).
Usage:
uv run svg_to_png.py logo.svg # -> logo.png next to it
uv run svg_to_png.py logo.svg --width 1024 # height keeps the aspect ratio
uv run svg_to_png.py icons/ --out png/ --scale 2 # every .svg in a folder, 2x
uv run svg_to_png.py a.svg b.svg --background white
cat logo.svg | uv run svg_to_png.py - --out logo.png
Prints one JSON line per file: {"input", "output", "width", "height", "bytes"}
or {"input", "error"}. Exit code 0 when every file converted, 1 otherwise,
2 on bad arguments.
"""
import argparse
import json
import os
import struct
import sys
MAX_SIDE = 16384 # resvg can allocate width*height*4 bytes; keep it sane
MAX_SVG_BYTES = 20 * 1024 * 1024
def png_size(data: bytes) -> tuple[int, int]:
# IHDR is always the first chunk: width and height are big-endian at 16..24.
return struct.unpack(">II", data[16:24])
def output_path(src: str, out: str | None, many: bool) -> str:
stem = "stdin" if src == "-" else os.path.splitext(os.path.basename(src))[0]
if out is None:
if src == "-":
return os.path.abspath("stdin.png")
return os.path.splitext(os.path.abspath(src))[0] + ".png"
if many or out.endswith(os.sep) or os.path.isdir(out):
os.makedirs(out, exist_ok=True)
return os.path.join(os.path.abspath(out), stem + ".png")
parent = os.path.dirname(os.path.abspath(out))
os.makedirs(parent, exist_ok=True)
return os.path.abspath(out if out.lower().endswith(".png") else out + ".png")
def collect(inputs: list[str]) -> list[str]:
files: list[str] = []
for item in inputs:
if item == "-":
files.append("-")
elif os.path.isdir(item):
for name in sorted(os.listdir(item)):
if name.lower().endswith((".svg", ".svgz")):
files.append(os.path.join(item, name))
else:
files.append(item)
return files
def installed_font_families() -> dict:
"""
resvg maps generic names (sans-serif, serif, monospace) to Arial / Times
New Roman / Courier New by default, which the agent image does not have,
so text using them would silently vanish. Point them at the fonts the
image does install (fonts-inter, fonts-dejavu-core) when they are there.
"""
found = set()
for root in ("/usr/share/fonts", "/usr/local/share/fonts", os.path.expanduser("~/.fonts")):
for _dir, _sub, names in os.walk(root):
for n in names:
found.add(n.lower())
has = lambda prefix: any(n.startswith(prefix) for n in found)
families = {}
if has("inter"):
families.update(font_family="Inter", sans_serif_family="Inter")
elif has("dejavusans"):
families.update(font_family="DejaVu Sans", sans_serif_family="DejaVu Sans")
if has("dejavuserif"):
families["serif_family"] = "DejaVu Serif"
if has("dejavusansmono"):
families["monospace_family"] = "DejaVu Sans Mono"
return families
def convert(resvg_py, src: str, args) -> dict:
kwargs = dict(installed_font_families())
if args.width:
kwargs["width"] = args.width
if args.height:
kwargs["height"] = args.height
if args.scale and not (args.width or args.height):
kwargs["zoom"] = args.scale
if args.background:
kwargs["background"] = args.background
if args.dpi:
kwargs["dpi"] = args.dpi
if args.font_dir:
kwargs["font_dirs"] = args.font_dir
if src == "-":
svg = sys.stdin.read()
if len(svg.encode("utf-8")) > MAX_SVG_BYTES:
raise ValueError("SVG is larger than 20 MB")
kwargs["svg_string"] = svg
else:
if not os.path.isfile(src):
raise FileNotFoundError(f"no such file: {src}")
if os.path.getsize(src) > MAX_SVG_BYTES:
raise ValueError("SVG is larger than 20 MB")
kwargs["svg_path"] = src
# Relative <image href="..."> resolves next to the SVG.
kwargs["resources_dir"] = os.path.dirname(os.path.abspath(src))
data = bytes(resvg_py.svg_to_bytes(**kwargs))
width, height = png_size(data)
if width > MAX_SIDE or height > MAX_SIDE:
raise ValueError(f"rendered size {width}x{height} is over {MAX_SIDE}px; pass a smaller --width/--scale")
return {"data": data, "width": width, "height": height}
def main() -> int:
p = argparse.ArgumentParser(description="Convert SVG to PNG.")
p.add_argument("inputs", nargs="+", help="SVG files, folders of SVGs, or - for stdin")
p.add_argument("--out", help="output .png (one input) or folder (several)")
p.add_argument("--width", type=int, help="output width in px (keeps aspect ratio unless --height is set too)")
p.add_argument("--height", type=int, help="output height in px")
p.add_argument("--scale", type=float, help="zoom multiplier on the SVG's own size, e.g. 2 for @2x (ignored with --width/--height)")
p.add_argument("--background", help='CSS colour behind the drawing, e.g. white or "#0b0a16"; default transparent')
p.add_argument("--dpi", type=float, help="DPI for physical units (mm, in, pt) in the SVG")
p.add_argument("--font-dir", action="append", help="folder of .ttf/.otf fonts the SVG's text uses (repeatable)")
args = p.parse_args()
for name in ("width", "height"):
v = getattr(args, name)
if v is not None and not (1 <= v <= MAX_SIDE):
p.error(f"--{name} must be 1..{MAX_SIDE}")
if args.scale is not None and not (0 < args.scale <= 16):
p.error("--scale must be > 0 and <= 16")
files = collect(args.inputs)
if not files:
print(json.dumps({"error": "no .svg files found"}))
return 1
many = len(files) > 1
import resvg_py # after arg checks, so --help works without the dependency
ok = True
for src in files:
try:
result = convert(resvg_py, src, args)
dest = output_path(src, args.out, many)
with open(dest, "wb") as fh:
fh.write(result["data"])
print(json.dumps({"input": src, "output": dest, "width": result["width"],
"height": result["height"], "bytes": len(result["data"])}))
except Exception as exc: # one bad file must not stop the batch
ok = False
print(json.dumps({"input": src, "error": str(exc)}))
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())