@amandaghassaei/flat-svg
Version:
A TypeScript library for converting nested SVGs into a flat list of elements, paths, or segments and applying style-based filters.
342 lines • 14.3 kB
JavaScript
import { SVG_POLYGON, SVG_POLYLINE } from './constants-public';
import { applyTransform } from './transforms';
import svgpath from 'svgpath';
import { isNonNegativeNumber, isNumber, isString } from '@amandaghassaei/type-checks';
// Convert SVG geometry to absolute-coordinate path strings (L, H, V, B, C only).
const temp = [0, 0];
/**
* Convert an SVG `<line>` to a path d-string. Missing x1/y1/x2/y2 default to 0;
* non-numeric values push a warning and return undefined.
* @param properties Source `<line>` attributes.
* @param parsingWarnings Mutable array — populated when input is invalid.
* @param transform Optional matrix baked into the emitted coordinates.
* @returns Path d-string, or undefined on invalid input.
*/
export function convertLineToPath(properties, parsingWarnings, transform) {
let { x1, x2, y1, y2 } = properties;
// x1, x2, y1, y2 default to 0.
if (x1 === undefined)
x1 = 0;
if (x2 === undefined)
x2 = 0;
if (y1 === undefined)
y1 = 0;
if (y2 === undefined)
y2 = 0;
if (!isNumber(x1) || !isNumber(x2) || !isNumber(y1) || !isNumber(y2)) {
parsingWarnings.push(`Invalid <line> properties: ${JSON.stringify({ x1, y1, x2, y2 })}.`);
return;
}
if (transform) {
temp[0] = x1;
temp[1] = y1;
[x1, y1] = applyTransform(temp, transform);
temp[0] = x2;
temp[1] = y2;
[x2, y2] = applyTransform(temp, transform);
}
return `M${x1},${y1} L${x2},${y2}`;
}
/**
* Convert an SVG `<rect>` to a path d-string with four explicit edges + Z.
* Pushes a warning and returns undefined on invalid x/y/width/height.
* @param properties Source `<rect>` attributes.
* @param parsingWarnings Mutable array — populated when input is invalid.
* @param transform Optional matrix baked into the emitted coordinates.
* @returns Path d-string, or undefined on invalid input.
*/
export function convertRectToPath(properties, parsingWarnings, transform) {
let { x, y } = properties;
// x and y default to 0.
if (x === undefined)
x = 0;
if (y === undefined)
y = 0;
const { width, height } = properties;
if (!isNumber(x) ||
!isNumber(y) ||
!isNonNegativeNumber(width) ||
!isNonNegativeNumber(height)) {
parsingWarnings.push(`Invalid <rect> properties: ${JSON.stringify({ x, y, width, height })}.`);
return;
}
let x1 = x;
let y1 = y;
let x2 = x + width;
let y2 = y;
let x3 = x + width;
let y3 = y + height;
let x4 = x;
let y4 = y + height;
if (transform) {
temp[0] = x1;
temp[1] = y1;
[x1, y1] = applyTransform(temp, transform);
temp[0] = x2;
temp[1] = y2;
[x2, y2] = applyTransform(temp, transform);
temp[0] = x3;
temp[1] = y3;
[x3, y3] = applyTransform(temp, transform);
temp[0] = x4;
temp[1] = y4;
[x4, y4] = applyTransform(temp, transform);
}
// 4 explicit L edges + redundant Z (dropped by Z-to-self heuristic) → uniform
// "4 edges = 4 segments" for every rect, even degenerate ones. Matches how
// Illustrator/Inkscape serialize <rect>.
return `M${x1},${y1} L${x2},${y2} L${x3},${y3} L${x4},${y4} L${x1},${y1} Z`;
}
/**
* Convert an SVG `<circle>` to a svgpath PathParser. Encoded as two arcs
* (or a degenerate there-and-back line when r=0); arcs are flattened to
* cubic beziers unless `_preserveArcs` is true.
* @param properties Source `<circle>` attributes.
* @param parsingWarnings Mutable array — populated when input is invalid.
* @param _preserveArcs Keep `A` commands; otherwise approximate with cubics.
* @param transform Optional matrix baked into the emitted coordinates.
* @returns svgpath PathParser, or undefined on invalid input.
*/
export function convertCircleToPath(properties, parsingWarnings, _preserveArcs, transform) {
let { cx, cy, r } = properties;
// cx, cy, r default to 0.
if (cx === undefined)
cx = 0;
if (cy === undefined)
cy = 0;
if (r === undefined)
r = 0;
if (!isNumber(cx) || !isNumber(cy) || !isNonNegativeNumber(r)) {
parsingWarnings.push(`Invalid <circle> properties: ${JSON.stringify({ cx, cy, r })}.`);
return;
}
const pathParser = _convertEllipseToPath(cx, cy, r, r, _preserveArcs, transform);
/* c8 ignore start -- defensive: _convertEllipseToPath always returns a valid svgpath (it constructs the
d-string from validated numeric inputs and never produces a parse error). The err check is here
to catch a future change in _convertEllipseToPath's contract. */
if (pathParser.err) {
parsingWarnings.push(`Problem parsing <circle> ${JSON.stringify({ cx, cy, r })} with ${pathParser.err}.`);
return;
}
/* c8 ignore stop */
return pathParser;
}
/**
* Convert an SVG `<ellipse>` to a svgpath PathParser. Same encoding as
* convertCircleToPath but with separate rx / ry radii.
* @param properties Source `<ellipse>` attributes.
* @param parsingWarnings Mutable array — populated when input is invalid.
* @param _preserveArcs Keep `A` commands; otherwise approximate with cubics.
* @param transform Optional matrix baked into the emitted coordinates.
* @returns svgpath PathParser, or undefined on invalid input.
*/
export function convertEllipseToPath(properties, parsingWarnings, _preserveArcs, transform) {
let { cx, cy, rx, ry } = properties;
// cx, cy, rx, ry default to 0.
if (cx === undefined)
cx = 0;
if (cy === undefined)
cy = 0;
if (rx === undefined)
rx = 0;
if (ry === undefined)
ry = 0;
if (!isNumber(cx) || !isNumber(cy) || !isNonNegativeNumber(rx) || !isNonNegativeNumber(ry)) {
parsingWarnings.push(`Invalid <ellipse> properties: ${JSON.stringify({ cx, cy, rx, ry })}.`);
return;
}
const pathParser = _convertEllipseToPath(cx, cy, rx, ry, _preserveArcs, transform);
/* c8 ignore start -- defensive: same rationale as convertCircleToPath above — _convertEllipseToPath
always returns a valid svgpath from validated numeric inputs. */
if (pathParser.err) {
parsingWarnings.push(`Problem parsing <ellipse> ${JSON.stringify({ cx, cy, rx, ry })} with ${pathParser.err}.`);
return;
}
/* c8 ignore stop */
return pathParser;
}
// Reference: https://stackoverflow.com/questions/59011294/ellipse-to-path-convertion-using-javascript
function _convertEllipseToPath(cx, cy, rx, ry, _preserveArcs, transform) {
// Degenerate ellipses (rx=0 || ry=0): emit as a there-and-back line so
// every degenerate case yields exactly 2 segments uniformly. Diverges
// from browser rendering, which treats rx=0 || ry=0 as no-render.
let d;
if (rx === 0 || ry === 0) {
d = `M${cx - rx},${cy - ry} L${cx + rx},${cy + ry} L${cx - rx},${cy - ry} Z`;
}
else {
// Normal ellipse: encode as 2 arcs.
d = `M${cx - rx},${cy} a${rx},${ry} 0 1,0 ${rx * 2},0 a ${rx},${ry} 0 1,0 -${rx * 2},0`;
}
let pathParser = svgpath(d).abs();
if (!_preserveArcs)
pathParser = pathParser.unarc();
if (transform)
pathParser = pathParser.matrix([
transform.a,
transform.b,
transform.c,
transform.d,
transform.e,
transform.f,
]);
return pathParser;
}
/**
* Convert an SVG `<polygon>` to a path d-string with explicit L-back-to-start
* + Z. A single-point points list returns `{ strayPoint }` instead of a path.
* @param properties Source `<polygon>` attributes.
* @param parsingWarnings Mutable array — populated when input is invalid.
* @param transform Optional matrix baked into the emitted coordinates.
* @returns Path d-string, stray-point object, or undefined on invalid input.
*/
export function convertPolygonToPath(properties, parsingWarnings, transform) {
const { points } = properties;
if (!isString(points)) {
parsingWarnings.push(`Invalid <polygon> properties: ${JSON.stringify({ points })}.`);
return undefined;
}
return _convertPointsToPath(points, parsingWarnings, SVG_POLYGON, transform);
}
/**
* Convert an SVG `<polyline>` to a path d-string. Same as convertPolygonToPath
* but without the closing edge + Z.
* @param properties Source `<polyline>` attributes.
* @param parsingWarnings Mutable array — populated when input is invalid.
* @param transform Optional matrix baked into the emitted coordinates.
* @returns Path d-string, stray-point object, or undefined on invalid input.
*/
export function convertPolylineToPath(properties, parsingWarnings, transform) {
const { points } = properties;
if (!isString(points)) {
parsingWarnings.push(`Invalid <polyline> properties: ${JSON.stringify({ points })}.`);
return undefined;
}
return _convertPointsToPath(points, parsingWarnings, SVG_POLYLINE, transform);
}
/**
* Tokenize a points attribute into (x, y) pairs. Per SVG spec, coordinates may
* be separated by any combination of commas and whitespace. Returns undefined
* if any token isn't a valid number, or if there aren't at least 2 valid tokens.
* Trailing odd tokens are truncated (browser-compatible) — diverges from strict
* spec but matches what real-world SVG renderers do.
*/
function _parsePointPairs(pointsString) {
const tokens = pointsString.trim().split(/[\s,]+/).filter((s) => s !== '');
const pairCount = Math.floor(tokens.length / 2);
if (pairCount === 0)
return undefined;
const pairs = [];
for (let i = 0; i < pairCount; i++) {
const x = parseFloat(tokens[2 * i]);
const y = parseFloat(tokens[2 * i + 1]);
if (isNaN(x) || isNaN(y))
return undefined;
pairs.push([x, y]);
}
return pairs;
}
function _convertPointsToPath(pointsString, parsingWarnings, elementType, transform) {
const pairs = _parsePointPairs(pointsString);
if (!pairs) {
parsingWarnings.push(`Unable to parse points string: "${pointsString}" in <${elementType}>.`);
return undefined;
}
if (pairs.length === 1) {
// Single-point polygon/polyline produces no edges — surface as a stray
// vertex so the caller can flag it diagnostically rather than emit a
// zero-length path. Caller applies any transform (kept here in source
// coords, parallel to how other stray-vertex sites work).
return { strayPoint: pairs[0] };
}
let d = '';
let firstX = 0;
let firstY = 0;
for (let i = 0; i < pairs.length; i++) {
let x = pairs[i][0];
let y = pairs[i][1];
if (transform) {
temp[0] = x;
temp[1] = y;
[x, y] = applyTransform(temp, transform);
}
if (i === 0) {
firstX = x;
firstY = y;
d = `M${x},${y}`;
}
else {
d += ` L${x},${y}`;
}
}
if (elementType === SVG_POLYGON) {
// Preserve the "N points → N edges" invariant in both cases:
// 1. Last ≠ first: append an explicit L back to the first point.
// The implicit Z then closes a zero-length segment (dropped by
// FlatSVG's z-to-self heuristic). N-1 user-visible L's + 1
// explicit L back = N edges.
// 2. Last == first (Illustrator + possibly others emit <polygon>
// points with the first point duplicated at the end as a
// redundant-but-spec-legal self-closure): skip the explicit L
// and let Z provide the closing edge implicitly. (N-1) user-
// visible L's + 1 implicit Z-edge = N edges. Without this
// branch we would emit N+1 edges with a zero-length one at
// the join, which produces spurious zero-length-segment
// warnings in downstream consumers for essentially every
// Illustrator-exported SVG.
// Exact equality on untransformed source coords is correct: pairs[]
// values come straight from parseFloat() with no arithmetic in
// between, so identical source token strings produce bit-identical
// floats. Checking pre-transform also avoids float drift that the
// matrix multiply in applyTransform could introduce.
// (Polyline branch is unchanged — polylines don't auto-close, so a
// duplicate end point on a <polyline> remains a real zero-length
// edge by design.)
const last = pairs[pairs.length - 1];
const first = pairs[0];
if (last[0] !== first[0] || last[1] !== first[1]) {
d += ` L${firstX},${firstY} Z`;
}
else {
d += ' Z';
}
}
return d;
}
/**
* Normalize an SVG `<path>` d-string: absolute coordinates (.abs()), short
* forms expanded to full Q/C (.unshort()), and arcs flattened to cubics
* (.unarc()) unless `_preserveArcs` is true.
* @param properties Source `<path>` attributes.
* @param parsingWarnings Mutable array — populated when input is invalid.
* @param _preserveArcs Keep `A` commands; otherwise approximate with cubics.
* @param transform Optional matrix baked into the emitted coordinates.
* @returns svgpath PathParser, or undefined on invalid input.
*/
export function convertPathToPath(properties, parsingWarnings, _preserveArcs, transform) {
const { d } = properties;
if (!isString(d)) {
parsingWarnings.push(`Invalid <path> properties: ${JSON.stringify({ d })}.`);
return;
}
// .abs() → absolute coords; .unshort() → expand T/S to Q/C.
let pathParser = svgpath(d).abs().unshort();
if (!_preserveArcs)
pathParser = pathParser.unarc();
if (transform) {
pathParser = pathParser.matrix([
transform.a,
transform.b,
transform.c,
transform.d,
transform.e,
transform.f,
]);
}
if (pathParser.err) {
parsingWarnings.push(`Problem parsing <path> ${JSON.stringify({ d })} with ${pathParser.err}.`);
return;
}
return pathParser;
}
//# sourceMappingURL=convertToPath.js.map