@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.
974 lines • 72.5 kB
JavaScript
import { parse } from 'svg-parser';
import { parseTransformString, flattenTransformArray, transformToString, applyTransform, } from './transforms';
import { colord, extend } from 'colord';
import namesPlugin from 'colord/plugins/names';
import labPlugin from 'colord/plugins/lab';
import { SVG_CIRCLE, SVG_ELLIPSE, FLAT_SEGMENT_ARC, FLAT_SEGMENT_BEZIER, FLAT_SEGMENT_LINE, FLAT_SVG_STRAY_VERTEX_MOVETO_ONLY, FLAT_SVG_STRAY_VERTEX_POLYGON_SINGLE_POINT, FLAT_SVG_STRAY_VERTEX_POLYLINE_SINGLE_POINT, SVG_LINE, SVG_PATH, SVG_POLYGON, SVG_POLYLINE, SVG_RECT, } from './constants-public';
import { DEFS, G, STYLE, SVG, SVG_PAINT_NONE, SVG_PATH_CMD_ARC, SVG_PATH_CMD_CLOSE, SVG_PATH_CMD_CURVETO, SVG_PATH_CMD_HLINETO, SVG_PATH_CMD_LINETO, SVG_PATH_CMD_MOVETO, SVG_PATH_CMD_QUADRATIC, SVG_PATH_CMD_VLINETO, SVG_STYLE_CLIP_PATH, SVG_STYLE_COLOR, SVG_STYLE_FILL, SVG_STYLE_FILTER, SVG_STYLE_MASK, SVG_STYLE_OPACITY, SVG_STYLE_STROKE_COLOR, SVG_STYLE_STROKE_DASH_ARRAY, SUPPORTED_GEOMETRY_TAG_NAMES, } from './constants-private';
import { convertCircleToPath, convertEllipseToPath, convertLineToPath, convertPathToPath, convertPolygonToPath, convertPolylineToPath, convertRectToPath, } from './convertToPath';
import svgpath from 'svgpath';
import { parse as cssParse } from '@adobe/css-tools';
import { isNumber, isString } from '@amandaghassaei/type-checks';
import { convertToDashArray, propertiesToAttributesString, wrapWithSVGTag } from './utils';
// Plugins extend colord to accept named colors ("tomato") and Lab/LCH inputs
// (the latter powers Delta E2000 in `.delta()`, used by color-tolerance filters).
//
// Caveat: colord's extend() mutates a single global singleton — there is no per-
// instance or per-bundle extend API. Any other code in the same bundle that
// imports colord will see these plugins applied as a side effect of importing
// flat-svg. Both plugins are additive (they enable new parses / methods, not
// override existing behavior), so the practical impact is limited to "some color
// strings that previously failed to parse now succeed." Documented in README
// Limitations. Cannot be fixed without dropping colord.
extend([namesPlugin]);
extend([labPlugin]);
export class FlatSVG {
/************************************************
* CONSTRUCTOR
************************************************/
/**
* Parse an SVG string and eagerly flatten elements/paths/segments.
* @param string - SVG document to parse.
* @param options - Optional settings.
* @param options.preserveArcs - Keep arcs (and circle/ellipse encodings) as
* `A` commands in paths/segments. Defaults to false, which approximates
* arcs as cubic beziers via svgpath's .unarc().
*/
constructor(string, options) {
var _a;
// Definition items collected from top-level `<defs>` (clipPath, mask, gradient, ...).
this._defs = [];
// Parse-time warnings accumulated during construction (transforms, CSS, viewBox, ...).
this._warnings = [];
this._rootNode = FlatSVG._parseSVGRoot(string, 'FlatSVG()');
this._preserveArcs = !!(options === null || options === void 0 ? void 0 : options.preserveArcs);
// Parse viewBox once at construction so any malformed-viewBox warning fires
// exactly once (the getter would otherwise re-warn on every read). Passing
// the warnings array opts into the tuple-fallback overload, so this always
// resolves to a tuple — keeping the instance `viewBox` getter total.
this._viewBox = FlatSVG._viewBoxFromRoot(this._rootNode, this._warnings);
this._units = FlatSVG._unitsFromRoot(this._rootNode);
// Collect top-level <defs> and <style> without mutating the parse tree.
// <defs> children populate `_defs`; <style> contents merge into `_globalStyles`.
// _deepIterChildren later skips both tags so they don't produce geometry.
const topChildren = this._rootNode.children;
for (let i = 0, numChildren = topChildren.length; i < numChildren; i++) {
const child = topChildren[i];
if (child.tagName === DEFS) {
// <style> children → global CSS rules; others (clipPath, mask,
// gradient, symbol, marker, ...) → FlatSVGDef entries.
if (child.children) {
for (let j = 0, numDefsChildren = child.children.length; j < numDefsChildren; j++) {
const defsChild = child.children[j];
if (!defsChild.tagName)
continue;
if (defsChild.tagName === STYLE &&
defsChild.children &&
defsChild.children[0] &&
defsChild.children[0].type === 'text') {
this._globalStyles = Object.assign(Object.assign({}, this._globalStyles), this._parseStyleToObject(defsChild.children[0].value));
}
else if (defsChild.tagName !== STYLE) {
this._defs.push({
tagName: defsChild.tagName,
id: (_a = defsChild.properties) === null || _a === void 0 ? void 0 : _a.id,
});
}
}
}
}
else if (child.tagName === STYLE &&
child.children &&
child.children[0] &&
child.children[0].type === 'text') {
this._globalStyles = Object.assign(Object.assign({}, this._globalStyles), this._parseStyleToObject(child.children[0].value));
}
}
this._deepIterChildren = this._deepIterChildren.bind(this);
// Eagerly run elements → paths → segments so warnings, unsupportedElements,
// and strayVertices are populated by end-of-constructor. Each stage is
// pure; orchestration lives here, not in the getters.
const elemResult = this._buildElements();
const pathResult = this._buildPaths(elemResult.elements);
const segResult = this._buildSegments(pathResult.paths, pathResult.pathParsers);
this._elements = elemResult.elements;
this._unsupportedElements = elemResult.unsupportedElements;
this._paths = pathResult.paths;
this._strayVertices = pathResult.strayVertices;
this._segments = segResult.segments;
this._warnings.push(...elemResult.warnings, ...pathResult.warnings, ...segResult.warnings);
}
/************************************************
* SVG METADATA PARSING
************************************************/
/**
* Parse an SVG string and return the validated <svg> SVGParserElementNode. Used by
* the constructor and by the static viewBox/units helpers so input
* validation and "must contain a single <svg> root" stay in lockstep.
* Unwraps svg-parser's document-level RootNode here because flat-svg
* forbids any sibling top-level nodes — every caller wants the <svg>
* element, never the wrapper.
* @private
*/
static _parseSVGRoot(string, callerName) {
if (string === undefined || !isString(string)) {
// String(value) coerces any non-string to a printable form: "undefined",
// "123", "[object Object]", "[object Array]", "null", etc. Avoid
// JSON.stringify here — a caller passing a large object would inflate
// the error message with the entire serialized payload.
throw new Error(`Must pass in an SVG string to ${callerName}, got ${String(string)}.`);
}
if (string === '') {
throw new Error(`SVG string passed to ${callerName} is empty.`);
}
const rootNode = parse(string);
if (rootNode.children.length !== 1 ||
rootNode.children[0].type !== 'element' ||
rootNode.children[0].tagName !== SVG) {
const numChildren = rootNode.children.length;
const firstChild = rootNode.children[0];
/* c8 ignore start -- defensive: svg-parser's parse() throws on inputs that would
produce a length-0 root.children or a non-element first child (text-only,
comment-only, CDATA-only, XML-decl-only, trailing text after an element), AND
collapses sibling top-level elements so root.children.length never exceeds 1.
So only firstChild=<tagName> with numChildren=1 reaches here; the firstChildDesc
fallbacks and the `ren` arm of the count-pluralization ternary are unreachable
unless svg-parser's output shape changes. */
const firstChildDesc = !firstChild
? 'no children'
: firstChild.type === 'element'
? `<${firstChild.tagName}>`
: `${firstChild.type} node`;
throw new Error(`Malformed SVG passed to ${callerName}: expected a single root <svg> element, got ${numChildren} root child${numChildren === 1 ? `: ${firstChildDesc}` : `ren`}.`);
/* c8 ignore stop */
}
return rootNode.children[0];
}
static _viewBoxFromRoot(root, warnings) {
var _a;
/* c8 ignore start -- defensive: svg-parser always emits a `properties` object
(empty `{}` for elements with no attributes), so the `?? {}` fallback only
fires if the library changes its contract. Verified for v3.x. */
const properties = (_a = root.properties) !== null && _a !== void 0 ? _a : {};
/* c8 ignore stop */
const viewBoxRaw = properties.viewBox;
if (viewBoxRaw !== undefined && viewBoxRaw !== '') {
// String() coerces single-number viewBoxes (svg-parser hands us a number
// for purely-numeric attributes); split on whitespace and/or commas per
// spec; filter empties so leading/trailing/repeated separators don't
// produce phantom NaN tokens.
const parts = String(viewBoxRaw)
.split(/[\s,]+/)
.filter((s) => s !== '')
.map(parseFloat);
if (parts.length === 4 && parts.every(Number.isFinite)) {
return [parts[0], parts[1], parts[2], parts[3]];
}
// Malformed: signal via undefined unless the caller opted into the
// fallback path by passing a warnings sink.
if (!warnings)
return undefined;
warnings.push(`Malformed viewBox "${viewBoxRaw}".`);
}
// Missing viewBox attribute, or malformed-with-warnings → derive viewport
// from root x/y/width/height (matches browser behavior per SVG 2 §8.2).
return [
Number.parseFloat((properties.x || '0')),
Number.parseFloat((properties.y || '0')),
Number.parseFloat((properties.width || '0')),
Number.parseFloat((properties.height || '0')),
];
}
/**
* Detect length units from the root `<svg>` element's width/height/x/y
* attribute suffixes. First attribute with a recognized suffix wins;
* defaults to 'px' when none of them carry a unit.
* @private
*/
static _unitsFromRoot(root) {
// Default to pixels when no unit suffix is present.
const regex = /(em|ex|px|pt|pc|cm|mm|in)$/;
/* c8 ignore start -- defensive: svg-parser always emits a `properties` object
(empty `{}` for elements with no attributes), so the `|| {}` fallback only
fires if the library changes its contract. Verified for v3.x. */
const { x, y, width, height } = root.properties || {};
/* c8 ignore stop */
if (isNumber(x) || isNumber(y) || isNumber(width) || isNumber(height)) {
return 'px';
}
// First attribute with a recognized unit suffix wins; default to 'px'.
for (const attr of [x, y, width, height]) {
const match = attr === null || attr === void 0 ? void 0 : attr.match(regex);
if (match)
return match[0];
}
return 'px';
}
/**
* Read viewBox without doing a full FlatSVG construction — useful for
* thumbnails / preview sizing. Returns [min-x, min-y, width, height] for a
* valid viewBox or one derived from root x/y/width/height when no viewBox
* attribute is present; returns undefined when the viewBox attribute is
* present but malformed (per SVG 2 §8.2).
* @param string - SVG string to parse.
* @returns Parsed/derived viewBox tuple, or undefined on malformed input.
*/
static viewBox(string) {
const rootNode = FlatSVG._parseSVGRoot(string, 'FlatSVG.viewBox()');
return FlatSVG._viewBoxFromRoot(rootNode);
}
/**
* Read units without doing a full FlatSVG construction. Returns one of
* the SVG-spec unit suffixes; defaults to 'px' if no suffix is present
* on width/height.
* @param string - SVG string to parse.
*/
static units(string) {
const rootNode = FlatSVG._parseSVGRoot(string, 'FlatSVG.units()');
return FlatSVG._unitsFromRoot(rootNode);
}
/**
* Read root-level SVG metadata in a single parse — saves a round trip when
* multiple fields are needed. Each field follows the contract of its
* dedicated static helper (e.g. `FlatSVG.viewBox`, `FlatSVG.units`).
* @param string - SVG string to parse.
* @returns Object with metadata fields derived from the SVG root.
*/
static metadata(string) {
const rootNode = FlatSVG._parseSVGRoot(string, 'FlatSVG.metadata()');
return {
viewBox: FlatSVG._viewBoxFromRoot(rootNode),
units: FlatSVG._unitsFromRoot(rootNode),
};
}
/************************************************
* SETTERS / GETTERS
************************************************/
/**
* Raw svg-parser parse tree root. Untouched by flat-svg's flattening —
* useful for inspecting attributes the library doesn't surface explicitly.
*/
get root() {
return this._rootNode;
}
set root(_value) {
throw new Error(`No root setter on ${this.constructor.name}.`);
}
/**
* Get the viewBox of the SVG as [min-x, min-y, width, height].
*/
get viewBox() {
return this._viewBox;
}
set viewBox(_value) {
throw new Error(`No viewBox setter on ${this.constructor.name}.`);
}
/**
* Length units detected from the SVG's width/height attribute suffixes
* (e.g. 'in', 'mm', 'px'). Defaults to 'px' when no unit suffix is present.
*/
get units() {
return this._units;
}
set units(_value) {
throw new Error(`No units setter on ${this.constructor.name}.`);
}
/**
* Definition items (clipPath, mask, linearGradient, etc.) collected from
* top-level <defs> blocks in the SVG. Excludes <style> children (those feed
* the global CSS rules instead). Each entry has `tagName` and optional `id`.
*/
get defs() {
return this._defs;
}
set defs(_value) {
throw new Error(`No defs setter on ${this.constructor.name}.`);
}
/**
* Parse-time warnings: anything flat-svg couldn't fully interpret but kept
* going from (malformed transforms, CSS parse failures, skipped children,
* unconvertible paths, etc.). Fully populated by end-of-constructor.
*/
get warnings() {
return this._warnings;
}
set warnings(_value) {
throw new Error(`No warnings setter on ${this.constructor.name}.`);
}
/**
* Flattened geometry elements (line / rect / polyline / polygon / circle /
* ellipse / path) with composed ancestor transforms. Coordinates remain in
* source space — apply `element.transform` for viewBox-space geometry.
*/
get elements() {
return this._elements;
}
set elements(_value) {
throw new Error(`No elements setter on ${this.constructor.name}.`);
}
/**
* Geometry re-encoded as `<path>` records with absolute coordinates and
* ancestor transforms baked into `properties.d`. One FlatPath per element.
*/
get paths() {
return this._paths;
}
set paths(_value) {
throw new Error(`No paths setter on ${this.constructor.name}.`);
}
/**
* Per-edge segments split out of FlatSVG.paths — lines, quadratic/cubic
* beziers, and (when `preserveArcs`) arcs. Coordinates in viewBox space.
*/
get segments() {
return this._segments;
}
set segments(_value) {
throw new Error(`No segments setter on ${this.constructor.name}.`);
}
/**
* Reconstructed SVG document from FlatSVG.elements — same `<svg>` wrapper
* as the input, with each element re-emitted as its original tag.
*/
get elementsAsSVG() {
const { elements, root } = this;
return wrapWithSVGTag(root, elements
.map((element) => {
const { tagName, properties, transform } = element;
let propertiesString = propertiesToAttributesString(properties);
if (transform)
propertiesString += `transform="${transformToString(transform)}" `;
return `<${tagName} ${propertiesString}/>`;
})
.join('\n'));
}
set elementsAsSVG(_value) {
throw new Error(`No elementsAsSVG setter on ${this.constructor.name}.`);
}
/**
* Reconstructed SVG document from FlatSVG.paths — same `<svg>` wrapper
* as the input, with every shape re-emitted as a `<path>`.
*/
get pathsAsSVG() {
const { paths, root } = this;
return wrapWithSVGTag(root, paths
.map((path) => {
const { properties } = path;
const propertiesString = propertiesToAttributesString(properties);
return `<path ${propertiesString}/>`;
})
.join('\n'));
}
set pathsAsSVG(_value) {
throw new Error(`No pathsAsSVG setter on ${this.constructor.name}.`);
}
/**
* Reconstructed SVG document from FlatSVG.segments — every edge re-emitted
* as its own `<line>` or `<path>` element under the original `<svg>` wrapper.
*/
get segmentsAsSVG() {
const { segments, root } = this;
return wrapWithSVGTag(root, segments
.map((segment) => {
const { p1, p2, properties } = segment;
const propertiesString = propertiesToAttributesString(properties);
switch (segment.type) {
case FLAT_SEGMENT_BEZIER: {
const { controlPoints } = segment;
const curveType = controlPoints.length === 1
? SVG_PATH_CMD_QUADRATIC
: SVG_PATH_CMD_CURVETO;
let d = `${SVG_PATH_CMD_MOVETO} ${p1[0]} ${p1[1]} ${curveType} ${controlPoints[0][0]} ${controlPoints[0][1]} `;
if (curveType === SVG_PATH_CMD_CURVETO)
d += `${controlPoints[1][0]} ${controlPoints[1][1]} `;
d += `${p2[0]} ${p2[1]} `;
return `<path d="${d}" ${propertiesString}/>`;
}
case FLAT_SEGMENT_ARC: {
const { rx, ry, xAxisRotation, largeArcFlag, sweepFlag } = segment;
return `<path d="M ${p1[0]} ${p1[1]} A ${rx} ${ry} ${xAxisRotation} ${largeArcFlag ? 1 : 0} ${sweepFlag ? 1 : 0} ${p2[0]} ${p2[1]}" ${propertiesString}/>`;
}
case FLAT_SEGMENT_LINE:
return `<line x1="${p1[0]}" y1="${p1[1]}" x2="${p2[0]}" y2="${p2[1]}" ${propertiesString}/>`;
}
})
.join('\n'));
}
set segmentsAsSVG(_value) {
throw new Error(`No segmentsAsSVG setter on ${this.constructor.name}.`);
}
/**
* Elements flat-svg can't convert to paths/segments (<use>, <text>, <image>,
* <foreignObject>, nested <svg>, unknown tags). Routed here at flatten time
* with transform/properties preserved; do NOT appear in elements/paths/
* segments/*AsSVG outputs.
*/
get unsupportedElements() {
return this._unsupportedElements;
}
set unsupportedElements(_value) {
throw new Error(`No unsupportedElements setter on ${this.constructor.name}.`);
}
/**
* True iff any element has a non-empty clipPaths chain. flat-svg does NOT
* perform geometric clipping — clipped elements appear unclipped in
* elements/paths/segments. Use this to warn consumers about ignored masks.
*/
get containsClipPaths() {
const { elements } = this;
for (let i = 0; i < elements.length; i++) {
const clipPaths = elements[i].clipPaths;
if (clipPaths && clipPaths.length > 0)
return true;
}
return false;
}
set containsClipPaths(_value) {
throw new Error(`No containsClipPaths setter on ${this.constructor.name}.`);
}
/**
* Indices into FlatSVG.segments of zero-length segments. A segment is
* zero-length iff endpoints coincide AND no geometry strays away and
* returns:
* - Line: p1 === p2
* - Bezier: p1 === p2 AND every control point === p1 (otherwise the
* curve traces a loop with nonzero arc length)
* - Arc: p1 === p2 (per SVG spec, identical endpoints render nothing
* regardless of radii)
* Returned as indices for use with the `excluded[]` filter pattern.
*/
get zeroLengthSegmentIndices() {
if (this._zeroLengthSegmentIndices)
return this._zeroLengthSegmentIndices;
const { segments } = this;
const zeroLengthSegmentIndices = [];
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
const { p1, p2 } = segment;
if (p1[0] !== p2[0] || p1[1] !== p2[1])
continue;
if (segment.type === FLAT_SEGMENT_BEZIER) {
const { controlPoints } = segment;
let allMatch = true;
for (let j = 0; j < controlPoints.length; j++) {
if (controlPoints[j][0] !== p1[0] || controlPoints[j][1] !== p1[1]) {
allMatch = false;
break;
}
}
if (!allMatch)
continue;
}
// Line or arc with p1 === p2 — falls through as zero-length.
zeroLengthSegmentIndices.push(i);
}
this._zeroLengthSegmentIndices = zeroLengthSegmentIndices;
return zeroLengthSegmentIndices;
}
set zeroLengthSegmentIndices(_value) {
throw new Error(`No zeroLengthSegmentIndices setter on ${this.constructor.name}.`);
}
/**
* Isolated points from degenerate elements that produce no edges (single-
* point polylines, single-point polygons, moveto-only paths). Position is
* in viewBox coordinates (transforms applied). Zero-radius circles/ellipses
* and zero-size rects are NOT stray vertices — they produce zero-length
* segments via `zeroLengthSegmentIndices` instead.
*/
get strayVertices() {
return this._strayVertices;
}
set strayVertices(_value) {
throw new Error(`No strayVertices setter on ${this.constructor.name}.`);
}
/************************************************
* SVG PARSING AND FLATTENING
************************************************/
/**
* Parse a CSS string from a `<style>` block into a selector→FlatSVGStyle map.
* Recognized selectors are bare `.class` and `#id`; unsupported selectors
* still parse but never match during the cascade. Pushes any CSS parse
* errors onto `_warnings`.
* @param styleString - Raw text content of a top-level `<style>` element.
* @returns Map of selector string (e.g. `.foo`, `#bar`) to FlatSVGStyle.
*/
_parseStyleToObject(styleString) {
const { _warnings } = this;
const result = {};
const css = cssParse(styleString, { silent: true });
const { stylesheet } = css;
/* c8 ignore start -- defensive: @adobe/css-tools' parse() returns CssStylesheetAST
with `stylesheet` typed as non-optional. Only fires if the library changes its contract. */
if (!stylesheet) {
return result;
}
/* c8 ignore stop */
if (stylesheet.parsingErrors) {
const cssWarnings = stylesheet.parsingErrors
.map((error) => error.message)
.filter((error) => error !== undefined);
_warnings.push(...cssWarnings);
}
// Extract style info.
/* c8 ignore start -- defensive: @adobe/css-tools always populates `rules` (empty array
for empty CSS). Only fires if the library changes its contract. */
if (!stylesheet.rules) {
return result;
}
/* c8 ignore stop */
const rules = stylesheet.rules;
for (let i = 0, numRules = rules.length; i < numRules; i++) {
const rule = rules[i];
const selectorStyle = {};
const { declarations, selectors } = rule;
if (declarations) {
for (let j = 0, numDeclarations = declarations.length; j < numDeclarations; j++) {
const declaration = declarations[j];
const { property } = declaration;
let { value } = declaration;
if (property && value !== undefined) {
// Cast value as number if needed.
// Try stripping px off the end.
value = value.replace(/px\b/g, '');
if (/^\-?[0-9]?([0-9]+e-?[0-9]+)?(\.[0-9]+)?$/.test(value))
selectorStyle[property] = parseFloat(value);
else
selectorStyle[property] = value;
}
}
}
if (selectors) {
for (let j = 0, numSelectors = selectors.length; j < numSelectors; j++) {
const selector = selectors[j];
result[selector] = Object.assign(Object.assign({}, result[selector]), selectorStyle);
}
}
}
return result;
}
/**
* Recursively walk the SVG parse tree, composing inherited context
* (transforms, ancestor id/class chains, clip-path/mask/filter chains, and
* cascaded styles) and invoking `callback` on each leaf geometry element.
* Recurses only into `<g>` containers; nested `<defs>`/`<style>` and
* unknown tags are routed to unsupportedElements by the caller.
* @param callback - Invoked once per leaf with the composed context.
* @param node - Subtree root to walk; defaults to the SVG root.
* @param inherited - Context accumulated from ancestors; recursive seed.
*/
_deepIterChildren(callback, node = this.root, inherited = {}) {
const { _globalStyles } = this;
const { transform, ancestorIds, ancestorClasses, properties, clipPaths, masks, filters } = inherited;
const isTopLevel = node === this.root;
for (let i = 0, numChildren = node.children.length; i < numChildren; i++) {
const child = node.children[i];
// Top-level <defs>/<style> are already handled in the constructor — skip.
// Nested <defs>/<style> are unsupported (documented limitation): they
// fall through to unsupportedElements; don't recurse into their children.
const isMetaNode = child.tagName === DEFS || child.tagName === STYLE;
if (isMetaNode && isTopLevel)
continue;
// <g> is the only container flat-svg recurses into. Containers
// contribute their id/class to the ancestor chain; leaves keep
// id/class as their own properties.
const isContainer = child.tagName === G;
let childTransform = transform;
// Chains passed DOWN to descendants. For containers, augmented below
// with the container's own id/class; for leaves they stay = inherited.
let childAncestorIds = ancestorIds;
let childAncestorClasses = ancestorClasses;
let childProperties;
// clip-path / mask / filter accumulate outermost→self. Per SVG spec
// these don't inherit as styles — every link composes, so an element
// can have multiple in effect at once.
let childClipPaths = clipPaths;
let childMasks = masks;
let childFilters = filters;
if (child.properties) {
// Add transforms to list.
if (child.properties.transform) {
const childTransforms = parseTransformString(child.properties.transform, child.tagName);
// Get any warnings the transform parser emitted.
for (let transformIndex = 0, numTransforms = childTransforms.length; transformIndex < numTransforms; transformIndex++) {
const { warnings } = childTransforms[transformIndex];
if (warnings)
this._warnings.push(...warnings);
}
// Merge transforms.
if (childTransforms.length) {
if (childTransform) {
childTransforms.unshift(childTransform);
}
// Flatten transforms to a new matrix.
childTransform = flattenTransformArray(childTransforms);
}
}
// Work on a fresh copy so we can delete keys freely without mutating
// child.properties (which is the parsed tree, shared across the original SVG).
let childPropertiesToMerge = Object.assign({}, child.properties);
delete childPropertiesToMerge.transform;
// Extract clip-path / mask / filter — these don't inherit as style
// properties per SVG spec. Append to per-element chain accumulators.
const ownClipPath = childPropertiesToMerge[SVG_STYLE_CLIP_PATH];
if (ownClipPath !== undefined && ownClipPath !== SVG_PAINT_NONE) {
childClipPaths = childClipPaths
? [...childClipPaths, ownClipPath]
: [ownClipPath];
}
delete childPropertiesToMerge[SVG_STYLE_CLIP_PATH];
const ownMask = childPropertiesToMerge[SVG_STYLE_MASK];
if (ownMask !== undefined && ownMask !== SVG_PAINT_NONE) {
childMasks = childMasks ? [...childMasks, ownMask] : [ownMask];
}
delete childPropertiesToMerge[SVG_STYLE_MASK];
const ownFilter = childPropertiesToMerge[SVG_STYLE_FILTER];
if (ownFilter !== undefined && ownFilter !== SVG_PAINT_NONE) {
childFilters = childFilters ? [...childFilters, ownFilter] : [ownFilter];
}
delete childPropertiesToMerge[SVG_STYLE_FILTER];
// Apply global stylesheet rules in CSS specificity order: class < id <
// inline `style="..."`. Each later layer's spread wins over earlier ones.
// Presentation attributes already on childPropertiesToMerge sit at the
// bottom and lose to all three (matches CSS spec).
if (childPropertiesToMerge.class) {
// Apply any global `.class` selector styles.
if (_globalStyles) {
const classArray = childPropertiesToMerge.class.split(' ');
for (let j = 0, numClasses = classArray.length; j < numClasses; j++) {
const classStyle = _globalStyles[`.${classArray[j]}`];
if (classStyle) {
childPropertiesToMerge = Object.assign(Object.assign({}, childPropertiesToMerge), classStyle);
}
}
}
// Containers contribute their class to the descendant ancestor chain
// and strip it from merged properties (so it doesn't inherit to
// grandchildren). Leaves keep their own class on properties.class.
if (isContainer) {
childAncestorClasses = `${childAncestorClasses ? `${childAncestorClasses} ` : ''}${childPropertiesToMerge.class}`;
delete childPropertiesToMerge.class;
}
}
if (childPropertiesToMerge.id) {
// Apply any global `#id` selector styles. Per HTML/SVG, id is a
// single token (unlike class), so no split.
if (_globalStyles) {
const idStyle = _globalStyles[`#${childPropertiesToMerge.id}`];
if (idStyle) {
childPropertiesToMerge = Object.assign(Object.assign({}, childPropertiesToMerge), idStyle);
}
}
// Same container-vs-leaf split as class above.
if (isContainer) {
childAncestorIds = `${childAncestorIds ? `${childAncestorIds} ` : ''}${childPropertiesToMerge.id}`;
delete childPropertiesToMerge.id;
}
}
// Add child properties to properties list.
childProperties = properties;
// Inline `style="..."` wins over class/id selectors per CSS specificity —
// spread it last so its values override.
if (childPropertiesToMerge.style) {
const style = this._parseStyleToObject(`#this { ${childPropertiesToMerge.style} }`)['#this'];
childPropertiesToMerge = Object.assign(Object.assign({}, childPropertiesToMerge), style);
delete childPropertiesToMerge.style;
}
const propertyKeys = Object.keys(childPropertiesToMerge);
for (let j = 0, numProperties = propertyKeys.length; j < numProperties; j++) {
const key = propertyKeys[j];
if (childPropertiesToMerge[key] !== undefined) {
// Make a copy.
if (!childProperties || childProperties === properties)
childProperties = Object.assign({}, properties);
// Opacity is multiplicative per SVG spec — child opacity multiplies
// by the ancestor-accumulated opacity.
if (key === SVG_STYLE_OPACITY) {
if (!isNumber(childPropertiesToMerge[key])) {
// Data problem (malformed SVG), not API misuse — warn and skip.
this._warnings.push(`Invalid <${child.tagName}> opacity value: "${String(childPropertiesToMerge[key])}".`);
continue;
}
childProperties[key] =
childPropertiesToMerge[key] *
(childProperties[key] !== undefined
? childProperties[key]
: 1);
}
else {
// All other style properties: child's explicit value overrides
// any inherited ancestor value (per CSS/SVG spec).
childProperties[key] =
childPropertiesToMerge[key];
}
}
}
}
// Callback fires for leaves (anything we don't recurse into).
if (!isContainer) {
// No defensive copies — InheritedContext is readonly and FlatElement
// exposes shared refs as Readonly/ReadonlyArray. ancestorIds/
// ancestorClasses exclude this element's own id/class.
callback(child, {
transform: childTransform,
ancestorIds,
ancestorClasses,
properties: childProperties,
clipPaths: childClipPaths,
masks: childMasks,
filters: childFilters,
});
}
// Only descend into containers. Children of unsupported tags
// (<use>, <text>, <foreignObject>, nested <svg>) stay buried with
// the parent in unsupportedElements rather than leaking into
// elements/paths/segments under a parent that wasn't processed.
if (isContainer) {
this._deepIterChildren(callback, child, {
transform: childTransform,
// childAncestor* includes this container's id/class.
ancestorIds: childAncestorIds,
ancestorClasses: childAncestorClasses,
properties: childProperties,
clipPaths: childClipPaths,
masks: childMasks,
filters: childFilters,
});
}
}
}
/************************************************
* ELEMENTS
************************************************/
/**
* Walk the parse tree and build the flat element list. Pure — caller stores
* the returned arrays and merges warnings into _warnings.
*/
_buildElements() {
// Init output arrays.
const elements = [];
const unsupportedElements = [];
const parsingWarnings = [];
// Flatten all children and return.
this._deepIterChildren((child, { transform, ancestorIds, ancestorClasses, properties, clipPaths, masks, filters }) => {
/* c8 ignore start -- defensive: svg-parser sets `value` and `metadata` on TextNodes, not on
ElementNodes that reach this callback. Per @types/svg-parser, SVGParserElementNode.value/metadata are
typed as optional but never populated for normal SVG input. Kept as a guard for hand-crafted
or future-version parser nodes that might set these. */
if (child.value) {
parsingWarnings.push(`Skipping child ${child.tagName} with value: ${child.value}`);
return;
}
if (child.metadata) {
parsingWarnings.push(`Skipping child ${child.tagName} with metadata: ${child.metadata}`);
return;
}
/* c8 ignore stop */
if (!child.tagName) {
parsingWarnings.push(`Skipping child with no tagName: ${JSON.stringify(child)}.`);
return;
}
// Unsupported tags (<use>, <text>, <image>, nested <style>/<defs>)
// route to unsupportedElements *before* the property-validation gate
// so meta-nodes without attributes still surface to consumers.
if (!SUPPORTED_GEOMETRY_TAG_NAMES.has(child.tagName)) {
const unsupportedChild = {
tagName: child.tagName,
properties: properties !== null && properties !== void 0 ? properties : {},
};
if (transform)
unsupportedChild.transform = transform;
if (clipPaths)
unsupportedChild.clipPaths = clipPaths;
if (masks)
unsupportedChild.masks = masks;
if (filters)
unsupportedChild.filters = filters;
if (ancestorIds)
unsupportedChild.ancestorIds = ancestorIds;
if (ancestorClasses)
unsupportedChild.ancestorClasses = ancestorClasses;
unsupportedElements.push(unsupportedChild);
return;
}
if (!properties) {
parsingWarnings.push(`Skipping child with no properties: ${JSON.stringify(child)}.`);
return;
}
// Resolve currentColor (case-insensitive) in fill/stroke against
// inherited `color`. Defaults to 'black' (canvas-text default) when
// `color` is missing or itself currentColor — recursive resolution
// is unsupported. Other indirections (var(), inherit, color-mix(),
// stop-color/flood-color/lighting-color) also not resolved; see
// README "Divergences from the SVG spec".
// Do NOT mutate `properties` — siblings/descendants may share it by
// reference. Spread into a fresh object only when something changes.
const props = properties;
const rawColor = typeof props.color === 'string' ? props.color : undefined;
const effectiveColor = rawColor && !/^currentcolor$/i.test(rawColor) ? rawColor : 'black';
const resolvedFill = typeof props.fill === 'string' && /^currentcolor$/i.test(props.fill)
? effectiveColor
: props.fill;
const resolvedStroke = typeof props.stroke === 'string' && /^currentcolor$/i.test(props.stroke)
? effectiveColor
: props.stroke;
const resolvedProperties = resolvedFill !== props.fill || resolvedStroke !== props.stroke
? Object.assign(Object.assign({}, props), { fill: resolvedFill, stroke: resolvedStroke }) : props;
// ancestorIds/ancestorClasses live at the top level (alongside transform/
// clipPaths/masks/filters) — they're flat-svg-internal lineage metadata,
// not real SVG attributes.
//
// Type invariant the cast can't enforce: tagName must be paired with the
// matching FlatElement variant (line ↔ SVGLineProperties, etc.). svg-parser
// produces them from the same DOM element so they're consistent in
// practice, but a future refactor that decouples them would silently
// produce mistyped FlatElements.
const flatChild = {
tagName: child.tagName,
properties: resolvedProperties,
};
if (transform)
flatChild.transform = transform;
if (clipPaths)
flatChild.clipPaths = clipPaths;
if (masks)
flatChild.masks = masks;
if (filters)
flatChild.filters = filters;
if (ancestorIds)
flatChild.ancestorIds = ancestorIds;
if (ancestorClasses)
flatChild.ancestorClasses = ancestorClasses;
elements.push(flatChild);
});
return { elements, unsupportedElements, warnings: parsingWarnings };
}
/************************************************
* PATHS
************************************************/
/**
* Convert flat elements to <path>-like records. Pure. Returns pathParsers
* as a side-channel for _buildSegments — circle/ellipse/path build a parser
* here; line/rect/polygon/polyline get one built lazily downstream.
*/
_buildPaths(elements) {
const { _preserveArcs } = this;
// Init output arrays.
const paths = [];
const pathParsers = [];
const parsingWarnings = [];
const strayVertices = [];
const pushStrayVertex = (x, y, transform, cause, sourceElementIndex) => {
const pos = [x, y];
if (transform)
applyTransform(pos, transform);
strayVertices.push({
position: pos,
cause,
sourceElementIndex,
});
};
for (let i = 0; i < elements.length; i++) {
const child = elements[i];
const { transform, tagName, properties } = child;
const propertiesCopy = Object.assign({}, properties);
// Convert all object types to path with absolute coordinates and transform applied.
let d;
let pathParser;
switch (tagName) {
case SVG_LINE:
d = convertLineToPath(properties, parsingWarnings, transform);
delete propertiesCopy.x1;
delete propertiesCopy.y1;
delete propertiesCopy.x2;
delete propertiesCopy.y2;
break;
case SVG_RECT:
d = convertRectToPath(properties, parsingWarnings, transform);
delete propertiesCopy.x;
delete propertiesCopy.y;
delete propertiesCopy.width;
delete propertiesCopy.height;
break;
case SVG_POLYGON: {
const result = convertPolygonToPath(properties, parsingWarnings, transform);
if (typeof result === 'object') {
pushStrayVertex(result.strayPoint[0], result.strayPoint[1], transform, FLAT_SVG_STRAY_VERTEX_POLYGON_SINGLE_POINT, i);
continue;
}
// result is string | undefined; the d === undefined check below handles undefined.
d = result;
delete propertiesCopy.points;
break;
}
case SVG_POLYLINE: {
const result = convertPolylineToPath(properties, parsingWarnings, transform);
if (typeof result === 'object') {
pushStrayVertex(result.strayPoint[0], result.strayPoint[1], transform, FLAT_SVG_STRAY_VERTEX_POLYLINE_SINGLE_POINT, i);
continue;
}
// result is string | undefined; the d === undefined check below handles undefined.
d = result;
delete propertiesCopy.points;
break;
}
case SVG_CIRCLE:
pathParser = convertCircleToPath(properties, parsingWarnings, _preserveArcs, transform);
if (pathParser)
d = pathParser.toString();
delete propertiesCopy.cx;
delete propertiesCopy.cy;
delete propertiesCopy.r;
break;
case SVG_ELLIPSE:
pathParser = convertEllipseToPath(properties, parsingWarnings, _preserveArcs, transform);
if (pathParser)
d = pathParser.toString();
delete propertiesCopy.cx;
delete propertiesCopy.cy;
delete propertiesCopy.rx;
delete propertiesCopy.ry;
break;
case SVG_PATH:
pathParser = convertPathToPath(properties, parsingWarnings, _preserveArcs, transform);
if (pathParser) {
// Detect dangling M commands (moveto with no subsequent draw).
// pathParser.segments is in source coordinates (.abs()
// only normalizes relative→absolute; .matrix() is queued on
// a lazy stack and doesn't touch segments[]). Pass the
// element's transform to pushStrayVertex so it lands in
// viewBox coordinates — same pattern as the polygon/polyline cases.
const segs = pathParser.segments;
for (let j = 0, numSegs = segs.length; j < numSegs; j++) {
const cmd = segs[j][0];
if (cmd !== SVG_PATH_CMD_MOVETO)
continue;
const next = segs[j + 1];
const nextCmd = next && next[0];
if (next === undefined ||
nextCmd === SVG_PATH_CMD_MOVETO ||
nextCmd === SVG_PATH_CMD_CLOSE) {
pushStrayVertex(segs[j][1], segs[j][2], transform, FLAT_SVG_STRAY_VERTEX_MOVETO_ONLY, i);
}
}
d = pathParser.toString();
}
delete propertiesCopy.d;
break;
/* c8 ignore start -- defensive: SUPPORTED_GEOMETRY_TAG_NAMES gates child.tagName
upstream of this loop, so only the case'd tags reach this switch. Only fires if
that gate or the supported set changes. */
default:
break;
/* c8 ignore stop */
}
if (d === undefined || d === '') {
continue;
}
const path = {
properties: Object.assign(Object.assign({}, propertiesCopy), { d }),
sourceElementIndex: i,
};
paths.push(path);
pathParsers.push(pathParser);
}
return { paths, pathParsers, strayVertices,