A while back I was building an icon font with IcoMoon. I dragged in my SVGs, hit generate, and about half the icons came out wrong — hollow outlines, missing pieces, shapes that looked nothing like the source.
The reason turned out to be simple, and it's a trap a lot of tooling sets for you: my icons were drawn with strokes, and IcoMoon wants fills. It builds glyphs from filled outlines, so a shape that's really "a thin path rendered at 2px" has no filled area to turn into a glyph. It just… disappears.
The fix is an operation you'll see called "outline stroke," "stroke to path," or "stroke to fill": take a stroked path and replace it with a filled shape that traces the outline of that stroke. Illustrator has it. Inkscape has it (Path → Stroke to Path). I wanted it in the browser, no install, no upload — so I built it. And it turned out to be a much deeper geometry problem than "just draw the outline."
Stroke vs fill: why anything cares
A stroke is a path plus an instruction: draw me at width N. The geometry stored in the file is a thin centerline; the thickness is a rendering property.
A fill is a closed shape painted solid. The thickness is the geometry.
On screen they can look identical. Structurally they're completely different, and a surprising number of tools only understand fills:
- Icon-font generators (IcoMoon and friends) build glyphs from filled contours.
- Cutting machines — Cricut, laser cutters, vinyl plotters — cut along fill contours. A stroke is either ignored or cut down its centerline, which is not what you drew.
- Font editors and some game engines want outlines, not stroked paths.
- Older PDF and print pipelines choke on complex strokes (dashes, variable joins).
Whenever one of these meets a stroke, you get empty shapes or wrong output. Converting stroke → fill up front makes the asset portable.
The naive approach, and why it falls apart
The obvious idea: offset the path by half the stroke width to each side and close it off. Two parallel copies, join the ends, done.
It falls apart almost immediately:
-
Corners. At every vertex the two offset edges have to be joined — and SVG defines three different joins (
miter,round,bevel) with amiter-limitthat clips sharp spikes. A naive offset produces gaps or overshoots at every corner. -
Line ends.
butt,round, andsquarecaps each need their own end geometry. - Self-overlap. When a path curves back on itself, the offset outline crosses itself. You don't want the crossing — you want the union of the swept area.
- Holes. A closed stroked shape has an inner and outer contour. Get the winding wrong and the hole fills in solid.
So this isn't an offset problem, it's a polygon offsetting with union problem. That's a well-studied thing, and the reference implementation in this space is Angus Johnson's Clipper. I used the JS port, clipper-lib.
The actual pipeline
Clipper works on integer polygons, so getting an SVG path into it is most of the work.
1. Normalize the path
svgpath flattens all the shorthand and arc commands down to something uniform — absolute coordinates, expanded shorthands, arcs converted to cubic Béziers:
const svgpathMod = await import("svgpath");
const svgpath = svgpathMod.default ?? svgpathMod;
svgpath(d)
.abs() // absolute coordinates
.unshort() // expand S/T shorthand
.unarc() // arcs → cubic béziers
.iterate((seg, i, x, y) => {
// now every segment is M / L / C / Q / Z
});
2. Flatten curves to polylines
Clipper doesn't know about Béziers — it needs points. Rather than sampling every curve at a fixed step (which over-samples flat curves and under-samples tight ones), flatten adaptively: recursive subdivision with a flatness test, so a curve only gets split where it's actually bending.
The tolerance is scaled to the artwork so a tiny icon and a huge illustration both stay smooth:
// flatten tolerance ≈ 0.06% of the viewBox diagonal, floored
const tol = Math.max(Math.hypot(w || 100, h || 100) * 0.0006, 1e-4);
That keeps the polygonal approximation well under a pixel at normal sizes, with a hard recursion cap so a pathological curve can't blow the stack.
3. Scale to integers
Clipper's robustness comes from integer arithmetic, so multiply everything by a fixed factor before feeding it in:
const SCALE = 10000; // → 1e-4 unit precision
const X = Math.round(p.x * SCALE);
const Y = Math.round(p.y * SCALE);
4. Offset by half the stroke width
This is where the real work happens — and where the naive version was hopeless. ClipperOffset takes the miter limit and an arc tolerance, and you map SVG's joins and caps onto Clipper's JoinType/EndType:
const { JoinType, EndType } = Clipper;
const jt =
join === "round" ? JoinType.jtRound
: join === "bevel" ? JoinType.jtSquare // Clipper has no exact bevel; square is closest
: JoinType.jtMiter;
const cap =
linecap === "round" ? EndType.etOpenRound
: linecap === "square" ? EndType.etOpenSquare
: EndType.etOpenButt;
const delta = (strokeWidth / 2) * SCALE; // offset outward
const co = new Clipper.ClipperOffset(miterLimit, Math.max(0.25, delta * 0.01));
co.AddPath(path, jt, subpathClosed ? EndType.etClosedLine : cap);
co.Execute(solution, delta);
The offset delta is half the stroke width (a stroke is centered on its path, so it extends width/2 each way). The arc tolerance scales with the stroke width, so round joins on thick strokes get proportionally more segments and stay smooth.
5. Back to a path — and let winding carve the holes
Clipper returns oriented polygons: outer contours wound one way, holes wound the other. Concatenate them all into a single d, and SVG's default fill-rule: nonzero reads the orientation and punches the holes out automatically:
let d = "";
for (const poly of solution) {
if (poly.length < 3) continue;
d += `M${poly[0].X} ${poly[0].Y}`;
for (let i = 1; i < poly.length; i++) d += `L${poly[i].X} ${poly[i].Y}`;
d += "Z";
}
No fill-rule is set — the initial nonzero is exactly what you want, because Clipper hands back correctly oriented contours. That's the quiet payoff of using a real offsetting engine instead of hand-rolling it.
The details that bite
A few things are worth knowing before you ship this:
-
Bevel joins are approximated. Clipper has no true bevel, so I substitute
jtSquare. Close, not identical. - The output is polyline-only. Curves are flattened and never re-fitted to Béziers, so fidelity is bounded by your flatten tolerance. At ~0.06% of the diagonal it's sub-pixel, but it is an approximation — I haven't formally measured a symmetric-difference error, so I won't quote a number I can't back up.
-
One join type per path. Clipper takes a single
JoinTypeper path, so astroke-linejoincan't vary per vertex. SVG 2'sarcsjoin isn't supported and silently degrades to miter. -
vector-effect: non-scaling-strokegets baked at the current scale — the whole point of the operation is to freeze the stroke into geometry. - Gradient and pattern strokes keep their paint reference; object-bounding-box gradients can shift once the geometry changes.
- Dashed strokes work — you split the centerline into its "on" runs first, then offset each run.
One note on the dependency
clipper-lib is excellent at the actual math, but it's unmaintained — the last release was around 2017. It's battle-tested and correct, which is why I kept it, but if you're starting fresh, look at Clipper2 (the newer port from the same author). Being honest about the state of your dependencies is cheaper than pretending everything's pristine.
The browser part
The reason I went down this road at all is that I wanted it to run entirely client-side — the SVG is parsed, flattened, offset and re-serialized in the page, and never touches a server. It's now one of the tools in garagemade, a free, browser-based SVG editor I've been building. But the geometry above is the interesting part, and it's the same whether you build it into an app or a one-off script.
If you've fought stroke-to-fill from a different angle — a cleaner curve refit, a better join approximation — I'd genuinely like to hear it.
Top comments (0)