ginokent Blog
RSS JA

svg-rs: A Zero-Dependency SVG Parser and SMIL Animation Evaluator Written in Rust

Motivation

There are a few existing crates for working with SVG in Rust, but they all pull in too many dependencies for my taste. The fewer dependencies, the better.
None of them can evaluate SMIL animations (<animate>, etc.) either. Even if you only want path flattening or stroke conversion, those features are tightly coupled to a rendering pipeline and can’t be extracted on their own.


I knew exactly what I needed, so I wrote it myself.

  • SVG parsing
  • Bézier curve flattening
  • Stroke-to-fill conversion
  • SMIL animation evaluation

All built on std only, with zero external crate dependencies.


ginokent/svg-rs

Overview

svg-rs is a library that parses SVG into PathSegments and handles flattening, stroke conversion, and SMIL evaluation. Each feature is an independent function, so you can use only what you need.

  • SVG Parsing — Parses the d attribute of <path> elements as well as <rect>, <circle>, <ellipse>, <line>, <polyline>, and <polygon>, normalizing them all into PathSegments internally
  • Gradients & clipPath — Supports <linearGradient>, <radialGradient>, <clipPath>, and <defs>/<use>
  • Path Flattening — Approximates quadratic/cubic Bézier curves and arcs into polylines with a configurable tolerance
  • Stroke-to-Fill Conversion — Converts strokes to fill paths, taking line width, line cap, and line join into account
  • SMIL Animation Evaluation — Interprets <animate>, <set>, and <animateTransform> to compute attribute values at any given time

Installation

As a library:

cargo add --git https://github.com/ginokent/svg-rs.git

As a CLI tool:

cargo install --git https://github.com/ginokent/svg-rs.git --features cli

Usage

Parsing SVG

Pass SVG data to parse() and it returns an SvgDocument.

use svg::parse;

fn main() {
    let svg_data = br#"
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
            <rect x="10" y="10" width="80" height="80" fill="blue" />
            <circle cx="50" cy="50" r="30" fill="red" />
        </svg>
    "#;

    let doc = parse(svg_data).expect("SVG のパースに失敗");

    for child in &doc.root.children {
        println!("{:?}", child);
    }
}

All shape elements are normalized into lists of PathSegments, so downstream processing can be written uniformly.

Path Flattening

Converts Bézier curves and arcs into sequences of line segments. This is needed for rasterization or polygonization.

use svg::{parse, flatten, SvgNode};

fn main() {
    let svg_data = br#"
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
            <path d="M10,80 C40,10 65,10 95,80 S150,150 180,80" fill="none" stroke="black" />
        </svg>
    "#;

    let doc = parse(svg_data).expect("SVG のパースに失敗");
    let segments = match &doc.root.children[0] {
        SvgNode::Path(path) => &path.segments,
        _ => panic!("パス要素が見つからない"),
    };

    // tolerance が小さいほど精度が上がるけど点が増える
    let polylines = flatten(segments, 0.5);

    for polyline in &polylines {
        println!("点の数: {}", polyline.len());
        for (x, y) in polyline {
            println!("  ({}, {})", x, y);
        }
    }
}

Tolerance is the maximum error in pixels. A value of 0.5 is usually sufficient.

Stroke-to-Fill Conversion

Converts strokes (lines) into fill paths. This simplifies renderer implementation by allowing strokes and fills to be handled uniformly.

use svg::{parse, stroke_to_fill, StrokeStyle, SvgNode, Paint, LineCap, LineJoin, Color};

fn main() {
    let svg_data = br#"
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
            <path d="M10,50 L90,50" stroke="black" stroke-width="10"
                  stroke-linecap="round" stroke-linejoin="round" />
        </svg>
    "#;

    let doc = parse(svg_data).expect("SVG のパースに失敗");
    let segments = match &doc.root.children[0] {
        SvgNode::Path(path) => &path.segments,
        _ => panic!("パス要素が見つからない"),
    };

    let style = StrokeStyle {
        paint: Paint::Color(Color { r: 0, g: 0, b: 0, a: 255 }),
        width: 10.0,
        cap: LineCap::Round,
        join: LineJoin::Round,
        dash: None,
        opacity: 1.0,
    };

    let fill_segments = stroke_to_fill(segments, &style);
    println!("変換後のセグメント数: {}", fill_segments.len());
}

Evaluating SMIL Animations

Interprets SVG SMIL animation elements and returns attribute values at a specified elapsed time.

use svg::{parse, evaluate};

fn main() {
    let svg_data = br#"
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
            <rect x="10" y="10" width="80" height="80" fill="blue">
                <animate attributeName="x" from="10" to="50" dur="2s" fill="freeze" />
            </rect>
        </svg>
    "#;

    let doc = parse(svg_data).expect("SVG のパースに失敗");
    let animation = &doc.animations[0];

    for t in [0.0, 1.0, 2.0] {
        if let Some(value) = evaluate(animation, t) {
            println!("t={:.1}s: {:?}", t, value);
        }
    }
}

Supported SMIL elements:

ElementExample Attributes
<animate>x, y, width, height, opacity, fill, etc.
<set>Any attribute (discrete value switching)
<animateTransform>translate, rotate, scale, skewX, skewY
<animateMotion>Recognized but not fully supported

CLI Tool (svg-cli)

A CLI tool is also included to access svg-rs features from the command line.


Rendering a static PNG:

svg-cli -i input.svg -o output.png --width 512 --height 512

Generating an APNG from an SVG with SMIL animations:

svg-cli -i input.svg -o output.apng --fps 30 --duration 3.0

Rendering a still frame at a specific time:

svg-cli -i input.svg -o frame.png --time 1.5

Main options:

OptionDescription
-i, --inputInput SVG (required)
-o, --outputOutput file (required, .png or .apng)
--width, --heightOutput size (auto-set from viewBox)
--fpsFrame rate (default: 30)
--durationAnimation length in seconds (default: 3.0)
--timeTime offset for still frames in seconds (default: 0.0)

Project Structure

svg-rs/
├── src/
│   ├── lib.rs              # Public API
│   ├── types.rs            # All public type definitions
│   ├── parse.rs            # SVG parsing entry point
│   ├── xml/                # XML tokenizer and tree construction
│   ├── svg/                # SVG element conversion, styles, colors
│   ├── path/               # Flattening and stroke conversion
│   ├── smil/               # SMIL parser and evaluator
│   └── bin/cli/            # CLI (main, render, animation, apng)
├── tests/
├── Cargo.toml
└── README.md

Each module is self-contained, so you can read xml or path in isolation and still understand them.

Key Features

Fast builds. With no external crates, a clean build finishes in seconds. CI is fast too.


Works with Wasm. Since it only uses std, it compiles to wasm32-unknown-unknown without any issues. You can use it as-is for things like rendering SVG with Wasm in the browser. no_std support is something I’d like to add eventually.


No supply chain risk. This goes without saying, but with zero dependencies, there’s no need to worry about supply chain attacks.

Limitations

Currently unsupported features:

  • Styling via CSS <style> elements (inline styles are supported)
  • <text> elements
  • <filter> elements
  • <mask> elements
  • Full support for <animateMotion> (recognized but not fully parsed)

These will be addressed as needed.

Conclusion

I built a Rust library that handles SVG parsing, flattening, stroke conversion, and SMIL evaluation with zero dependencies. It also supports gradients and clipPath. It’s useful for building lightweight SVG renderers from scratch or generating APNGs from SMIL animations.


Repository: ginokent/svg-rs