@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.
1,079 lines (1,015 loc) • 44.8 kB
text/typescript
import { parse, RootNode } from 'svg-parser';
import {
parseTransformString,
flattenTransformArray,
copyTransform,
transformToString,
} from './transforms';
import { AnyColor, Colord, colord, extend } from 'colord';
import namesPlugin from 'colord/plugins/names';
import labPlugin from 'colord/plugins/lab';
import {
CIRCLE,
ELLIPSE,
G,
LINE,
PATH,
POLYGON,
POLYLINE,
RECT,
SVG,
SVG_STYLE_COLOR,
SVG_STYLE_FILL,
SVG_STYLE_OPACITY,
SVG_STYLE_STROKE_COLOR,
SVG_STYLE_STROKE_DASH_ARRAY,
} from './constants';
import {
FlatArcSegment,
FlatBezierSegment,
ComputedProperties,
ElementNode,
FlatElement,
FlatPath,
FlatSegment,
GeometryElementProperties,
GeometryElementTagName,
Node,
PathParser,
Properties,
PropertiesFilter,
Style,
Transform,
} from './types';
import {
convertCircleToPath,
convertEllipseToPath,
convertLineToPath,
convertPathToPath,
convertPolygonToPath,
convertPolylineToPath,
convertRectToPath,
} from './convertToPath';
import svgpath from 'svgpath';
// Had to roll back to @adobe/css-tools to version 4.3.0-rc.1 to get this to work.
// https://github.com/adobe/css-tools/issues/116
import { parse as cssParse, type CssDeclarationAST, type CssRuleAST } from '@adobe/css-tools';
import { isNumber } from '@amandaghassaei/type-checks';
import { convertToDashArray } from './utils';
extend([namesPlugin]);
extend([labPlugin]);
// Color input examples
// "#FFF"
// "#ffffff"
// "#ffffffff"
// "rgb(255, 255, 255)"
// "rgba(255, 255, 255, 0.5)"
// "rgba(100% 100% 100% / 50%)"
// "hsl(90, 100%, 100%)"
// "hsla(90, 100%, 100%, 0.5)"
// "hsla(90deg 100% 100% / 50%)"
// "tomato"
export class FlatSVG {
private readonly _rootNode: RootNode;
private _elements?: FlatElement[];
private _paths?: FlatPath[];
private _pathParsers?: (PathParser | undefined)[];
private _segments?: FlatSegment[];
private readonly _preserveArcs: boolean;
/**
* Defs elements that are removed during flattening.
*/
readonly defs: ElementNode[] = [];
/**
* Global style to be applied to children during flattening.
*/
private readonly _globalStyles?: { [key: string]: Style };
/**
* A list of errors generated during parsing.
*/
readonly errors: string[] = [];
/**
* A list of warnings generated during parsing.
*/
readonly warnings: string[] = [];
// Hold onto some extra computed properties so we don't have to recompute during filter operations.
private _computedElementProperties?: ComputedProperties[];
private _computedPathProperties?: ComputedProperties[];
private _computedSegmentProperties?: ComputedProperties[];
/**
* Init a FlatSVG object.
* @param string - SVG string to parse.
* @param options - Optional settings.
* @param options.preserveArcs - Preserve arcs, ellipses, and circles as arcs when calling FlatSVG.paths and FlatSVG.segments. Defaults to false, which will approximate arcs as cubic beziers.
*/
constructor(string: string, options?: { preserveArcs: boolean }) {
if (string === undefined) {
throw new Error('Must pass in an SVG string to FlatSVG().');
}
if (string === '') {
throw new Error('SVG string is empty.');
}
this._rootNode = parse(string);
this._preserveArcs = !!options?.preserveArcs;
// Validate svg.
// Check that a root svg element exists.
if (
this._rootNode.children.length !== 1 ||
this._rootNode.children[0].type !== 'element' ||
this._rootNode.children[0].tagName !== SVG
) {
// console.log(this._rootNode);
this.errors.push(`Malformed SVG: expected only 1 child <svg> element on root node.`);
throw new Error(`Malformed SVG: expected only 1 child <svg> element on root node.`);
}
// Pull out defs/style tags.
const topChildren = this._rootNode.children[0].children;
for (let i = topChildren.length - 1; i >= 0; i--) {
const child = topChildren[i] as ElementNode;
if (child.tagName === 'defs') {
this.defs.push(child);
topChildren.splice(i, 1);
// Check if defs contains style.
if (child.children) {
for (let j = child.children.length - 1; j >= 0; j--) {
const defsChild = child.children[j] as ElementNode;
if (defsChild.tagName === 'style') {
child.children.splice(j, 1);
if (
defsChild.children &&
defsChild.children[0] &&
defsChild.children[0].type === 'text'
) {
this._globalStyles = {
...this._globalStyles,
...this.parseStyleToObject(
defsChild.children[0].value as string
),
};
}
}
}
}
}
if (child.tagName === 'style') {
topChildren.splice(i, 1);
if (child.children && child.children[0] && child.children[0].type === 'text') {
this._globalStyles = {
...this._globalStyles,
...this.parseStyleToObject(child.children[0].value as string),
};
}
}
}
this.deepIterChildren = this.deepIterChildren.bind(this);
// // Check that no children are strings.
// this.deepIterChildren((child) => {
// if (typeof child === 'string') {
// console.log(this.rootNode);
// throw new Error(`Child is a string: ${child}.`);
// }
// });
}
private parseStyleToObject(styleString: string) {
const { errors } = this;
const result = {} as { [key: string]: Style };
const css = cssParse(styleString, { silent: true });
const { stylesheet } = css;
/* c8 ignore next 3 */
if (!stylesheet) {
return result;
}
if (stylesheet.parsingErrors) {
const parsingErrors = stylesheet.parsingErrors
.map((error) => error.message)
.filter((error) => error !== undefined);
errors.push(...parsingErrors);
}
// Extract style info.
/* c8 ignore next 3 */
if (!stylesheet.rules) {
return result;
}
const rules = stylesheet.rules;
for (let i = 0, numRules = rules.length; i < numRules; i++) {
const rule = rules[i];
const selectorStyle: { [key: string]: number | string } = {};
const { declarations, selectors } = rule as CssRuleAST;
if (declarations) {
for (let j = 0, numDeclarations = declarations.length; j < numDeclarations; j++) {
const declaration = declarations[j] as CssDeclarationAST;
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] = { ...result[selector], ...selectorStyle };
}
}
}
return result;
}
/**
* Get the root node of the SVG.
*/
get root() {
return this._rootNode.children[0] as Node as ElementNode;
}
/**
* Get the viewBox of the SVG as [min-x, min-y, width, height].
*/
get viewBox() {
const viewBoxString = this.root.properties!.viewBox;
if (viewBoxString) {
return viewBoxString.split(' ').map((el) => parseFloat(el));
}
return [
Number.parseFloat((this.root.properties!.x || '0') as string),
Number.parseFloat((this.root.properties!.y || '0') as string),
Number.parseFloat((this.root.properties!.width || '0') as string),
Number.parseFloat((this.root.properties!.height || '0') as string),
];
}
/**
* Get the units of the SVG as a string.
*/
get units() {
// If you do not specify any units inside the width and height attributes, the units are assumed to be pixels.
const regex = new RegExp(/(em|ex|px|pt|pc|cm|mm|in)$/);
const { x, y, width, height } = this.root.properties || /* c8 ignore next */ {};
/* c8 ignore next 2 */
const match =
x?.match(regex) || y?.match(regex) || width?.match(regex) || height?.match(regex);
return (match ? match[0] : 'px') as 'in' | 'cm' | 'mm' | 'px' | 'pt' | 'em' | 'ex' | 'pc';
}
private deepIterChildren(
callback: (
child: ElementNode,
transform?: Transform,
ids?: string,
classes?: string,
properties?: GeometryElementProperties
) => void,
node = this.root,
transform?: Transform,
ids?: string,
classes?: string,
properties?: Style
) {
const { _globalStyles } = this;
for (let i = 0, numChildren = node.children.length; i < numChildren; i++) {
const child = node.children[i] as ElementNode;
let childTransform = transform;
let childClasses: string | undefined;
let childIds: string | undefined;
let childProperties: Style | undefined;
if (child.properties) {
// Add transforms to list.
if (child.properties.transform) {
const childTransforms = parseTransformString(
child.properties.transform,
child.tagName
);
// Get errors / warnings.
for (
let transformIndex = 0, numTransforms = childTransforms.length;
transformIndex < numTransforms;
transformIndex++
) {
const { errors, warnings } = childTransforms[transformIndex];
/* c8 ignore next if */
if (errors) this.errors.push(...errors);
/* c8 ignore next if */
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);
}
delete child.properties.transform;
}
let childPropertiesToMerge = child.properties || /* c8 ignore next */ {};
childIds = ids;
if (child.properties.id) {
// Check for styling associated with id.
if (_globalStyles) {
const idsArray = child.properties.id.split(' ');
for (let j = 0, numIds = idsArray.length; j < numIds; j++) {
const idStyle = _globalStyles[`#${idsArray[j]}`];
if (idStyle) {
childPropertiesToMerge = { ...childPropertiesToMerge, ...idStyle };
}
}
}
// Add child ids to ids list.
childIds = `${childIds ? `${childIds} ` : ''}${child.properties.id}`;
delete child.properties.id;
delete childPropertiesToMerge.id;
}
childClasses = classes;
if (child.properties.class) {
// Check for styling associated with class.
if (_globalStyles) {
const classArray = child.properties.class.split(' ');
for (let j = 0, numClasses = classArray.length; j < numClasses; j++) {
const classStyle = _globalStyles[`.${classArray[j]}`];
if (classStyle) {
childPropertiesToMerge = {
...childPropertiesToMerge,
...classStyle,
};
}
}
}
// Add child classes to classes list.
childClasses = `${childClasses ? `${childClasses} ` : ''}${
child.properties.class
}`;
delete child.properties.class;
delete childPropertiesToMerge.class;
}
// Add child properties to properties list.
childProperties = properties;
// Check if the child has inline styles.
if ((childPropertiesToMerge as any).style) {
const style = this.parseStyleToObject(
`#this { ${(childPropertiesToMerge as any).style} }`
)['#this'];
childPropertiesToMerge = { ...style, ...childPropertiesToMerge };
delete (childPropertiesToMerge as any).style;
}
const propertyKeys = Object.keys(childPropertiesToMerge);
for (let j = 0, numProperties = propertyKeys.length; j < numProperties; j++) {
const key = propertyKeys[j] as keyof Properties;
if (childPropertiesToMerge[key] !== undefined) {
// Make a copy.
if (!childProperties || childProperties === properties)
childProperties = { ...properties };
// In the case of opacity, multiply parent and child.
if (key === SVG_STYLE_OPACITY) {
/* c8 ignore next 6 */
if (!isNumber(childPropertiesToMerge[key]))
throw new Error(
`Opacity is not number: "${JSON.stringify(
childPropertiesToMerge[key]
)}".`
);
childProperties[key] =
(childPropertiesToMerge[key] as number) *
(childProperties[key] !== undefined
? (childProperties[key] as number)
: 1);
}
// Only use child style if parent style is not defined.
// @ts-ignore
if (childProperties[key] === undefined)
// @ts-ignore
childProperties[key] = childPropertiesToMerge[key];
}
}
}
// Callback.
if (child.tagName !== G) {
// Make copies of all child properties.
callback(
child,
childTransform ? copyTransform(childTransform) : undefined,
childIds?.slice(),
childClasses?.slice(),
/* c8 ignore next 3 */
childProperties
? ({ ...childProperties } as GeometryElementProperties)
: undefined
);
}
if (child.children) {
this.deepIterChildren(
callback,
child,
childTransform,
childIds,
childClasses,
childProperties
);
}
}
}
/**
* Get a flat list of geometry elements in the SVG.
* The return value is cached internally.
*/
get elements() {
if (this._elements) return this._elements;
// Init output arrays.
const elements: FlatElement[] = [];
const parsingErrors: string[] = [];
const parsingWarnings: string[] = [];
// Flatten all children and return.
this.deepIterChildren((child, transform, ids, classes, properties) => {
/* c8 ignore next 4 */
if (child.value) {
parsingErrors.push(`Skipping child ${child.tagName} with value: ${child.value}`);
return;
}
/* c8 ignore next 6 */
if (child.metadata) {
parsingErrors.push(
`Skipping child ${child.tagName} with metadata: ${child.metadata}`
);
return;
}
if (!child.tagName) {
parsingErrors.push(`Skipping child with no tagName: ${JSON.stringify(child)}.`);
return;
}
/* c8 ignore next 4 */
if (!properties) {
parsingErrors.push(`Skipping child with no properties: ${JSON.stringify(child)}.`);
return;
}
if (ids) properties.ids = ids;
if (classes) properties.class = classes;
const flatChild = {
tagName: child.tagName as GeometryElementTagName,
properties,
} as FlatElement;
if (transform) flatChild.transform = transform;
elements.push(flatChild);
});
this._elements = elements; // Save for later so we don't need to recompute.
// Save any errors or warnings so we can query these later.
this.errors.push(...parsingErrors);
this.warnings.push(...parsingWarnings);
return elements;
}
private static wrapWithSVGTag(root: ElementNode, svgElements: string) {
const properties = root.properties || /* c8 ignore next */ {};
return `<svg ${Object.keys(properties)
.map((key) => `${key}="${properties[key as keyof Properties]}"`)
.join(' ')}>\n${svgElements}\n</svg>`;
}
/**
* Get svg string from elements array.
* @private
*/
private static elementsAsSVG(root: ElementNode, elements: FlatElement[]) {
return FlatSVG.wrapWithSVGTag(
root,
elements
.map((element) => {
const { tagName, properties, transform } = element;
const propertiesKeys = Object.keys(properties);
let propertiesString = '';
for (let i = 0, length = propertiesKeys.length; i < length; i++) {
const key = propertiesKeys[i] as keyof typeof properties;
propertiesString += `${key}="${properties[key]}" `;
}
if (transform)
propertiesString += `transform="${transformToString(transform)}" `;
return `<${tagName} ${propertiesString}/>`;
})
.join('\n')
);
}
/**
* Get svg string from FlatSVG.elements array.
*/
get elementsAsSVG() {
const { elements, root } = this;
return FlatSVG.elementsAsSVG(root, elements);
}
/**
* Get a flat list of SVG geometry represented as paths.
* The return value is cached internally.
*/
get paths() {
if (this._paths) return this._paths;
const { elements, _preserveArcs } = this; // First query elements.
// Init output arrays.
const paths: FlatPath[] = [];
const pathParsers: (PathParser | undefined)[] = [];
const parsingErrors: string[] = [];
const parsingWarnings: string[] = [];
for (let i = 0; i < elements.length; i++) {
const child = elements[i];
const { transform, tagName, properties } = child;
const propertiesCopy: { [key: string]: any } = { ...properties };
// Convert all object types to path with absolute coordinates and transform applied.
let d: string | undefined;
let pathParser: PathParser | undefined;
switch (tagName) {
case LINE:
d = convertLineToPath(properties, parsingErrors, transform);
delete propertiesCopy.x1;
delete propertiesCopy.y1;
delete propertiesCopy.x2;
delete propertiesCopy.y2;
break;
case RECT:
d = convertRectToPath(properties, parsingErrors, transform);
delete propertiesCopy.x;
delete propertiesCopy.y;
delete propertiesCopy.width;
delete propertiesCopy.height;
break;
case POLYGON:
d = convertPolygonToPath(properties, parsingErrors, transform);
delete propertiesCopy.points;
break;
case POLYLINE:
d = convertPolylineToPath(properties, parsingErrors, transform);
delete propertiesCopy.points;
break;
case CIRCLE:
pathParser = convertCircleToPath(
properties,
parsingErrors,
_preserveArcs,
transform
);
if (pathParser) d = pathParser.toString();
delete propertiesCopy.cx;
delete propertiesCopy.cy;
delete propertiesCopy.r;
break;
case ELLIPSE:
pathParser = convertEllipseToPath(
properties,
parsingErrors,
_preserveArcs,
transform
);
if (pathParser) d = pathParser.toString();
delete propertiesCopy.cx;
delete propertiesCopy.cy;
delete propertiesCopy.rx;
delete propertiesCopy.ry;
break;
case PATH:
pathParser = convertPathToPath(
properties,
parsingErrors,
_preserveArcs,
transform
);
if (pathParser) d = pathParser.toString();
delete propertiesCopy.d;
break;
default:
parsingWarnings.push(`Unsupported tagname: "${tagName}".`);
break;
}
if (d === undefined || d === '') {
continue;
}
const path = {
properties: {
...propertiesCopy,
d,
},
};
paths.push(path);
pathParsers.push(pathParser);
}
this._paths = paths; // Save for later so we don't need to recompute.
this._pathParsers = pathParsers; // Save pathParsers in case segments are queried.
// Save any errors or warnings so we can query these later.
this.errors.push(...parsingErrors);
this.warnings.push(...parsingWarnings);
return paths;
}
/**
* Get svg string from paths array.
* @private
*/
private static pathsAsSVG(root: ElementNode, paths: FlatPath[]) {
return FlatSVG.wrapWithSVGTag(
root,
paths
.map((path) => {
const { properties } = path;
const propertiesKeys = Object.keys(properties);
let propertiesString = '';
for (let i = 0, length = propertiesKeys.length; i < length; i++) {
const key = propertiesKeys[i] as keyof typeof properties;
propertiesString += `${key}="${properties[key]}" `;
}
return `<path ${propertiesString}/>`;
})
.join('\n')
);
}
/**
* Get svg string from FlatSVG.paths array.
*/
get pathsAsSVG() {
const { paths, root } = this;
return FlatSVG.pathsAsSVG(root, paths);
}
/**
* Get a flat list of SVG edge segments (as lines, quadratic/cubic beziers, or arcs).
* The return value is cached internally.
*/
get segments() {
if (this._segments) return this._segments;
const { paths } = this; // First query paths.
const { _pathParsers } = this; // Once paths are computed, _pathParsers becomes available.
/* c8 ignore next 3 */
if (!_pathParsers) {
console.warn('Initing new _pathParsers array, we should never hit this.');
}
const pathParsers =
_pathParsers || /* c8 ignore next */ new Array(paths.length).fill(undefined);
// Init output arrays.
const segments: FlatSegment[] = [];
const parsingErrors: string[] = [];
const parsingWarnings: string[] = [];
for (let i = 0, numPaths = paths.length; i < numPaths; i++) {
const path = paths[i];
const { properties } = path;
let pathParser = pathParsers[i];
if (pathParser === undefined) {
// Define a pathParser for elements that were not originally paths.
pathParser = svgpath(properties.d);
pathParsers[i] = pathParser;
}
/* c8 ignore next 4 */
if (pathParser.err) {
// Should not hit this.
parsingErrors.push(`Problem parsing path to segments with ${pathParser.err}.`);
}
// Split paths to segments.
const startPoint = [0, 0];
pathParser.iterate((command: any, index: number, x: number, y: number) => {
const p1 = [x, y] as [number, number];
// Copy parent properties to segment (minus the "d" property).
const propertiesCopy: { [key: string]: any } = { ...properties };
delete propertiesCopy.d;
const segment = {
p1,
properties: propertiesCopy,
} as FlatSegment;
const segmentType = command[0];
/* c8 ignore next 6 */
if (index === 0 && segmentType !== 'M') {
// Should not hit this, it should be caught earlier by SvgPath.
parsingErrors.push(
`Malformed svg path: "${pathParser.toString()}", should start with M command.`
);
}
switch (segmentType) {
case 'M':
startPoint[0] = command[1];
startPoint[1] = command[2];
return;
case 'L':
segment.p2 = [command[1], command[2]];
break;
case 'H':
segment.p2 = [command[1], y];
break;
case 'V':
segment.p2 = [x, command[1]];
break;
case 'Q':
(segment as FlatBezierSegment).controlPoints = [[command[1], command[2]]];
segment.p2 = [command[3], command[4]];
break;
case 'C':
(segment as FlatBezierSegment).controlPoints = [
[command[1], command[2]],
[command[3], command[4]],
];
segment.p2 = [command[5], command[6]];
break;
case 'A':
(segment as FlatArcSegment).rx = command[1];
(segment as FlatArcSegment).ry = command[2];
(segment as FlatArcSegment).xAxisRotation = command[3];
(segment as FlatArcSegment).largeArcFlag = !!command[4];
(segment as FlatArcSegment).sweepFlag = !!command[5];
segment.p2 = [command[6], command[7]];
break;
case 'z':
case 'Z':
// Get first point since last move command.
if (startPoint[0] === x && startPoint[1] === y) {
// Ignore zero length line.
return;
}
segment.p2 = [startPoint[0], startPoint[1]];
break;
/* c8 ignore next 4 */
default:
// Should not hit this.
parsingErrors.push(`Unknown <path> command: ${segmentType}.`);
return;
}
segments.push(segment);
});
}
this._segments = segments; // Save for later so we don't need to recompute.
// We no longer need to hold _pathParsers.
delete this._pathParsers;
// Save any errors or warnings so we can query these later.
this.errors.push(...parsingErrors);
this.warnings.push(...parsingWarnings);
return segments;
}
/**
* Get svg string from paths array.
* @private
*/
private static segmentsAsSVG(root: ElementNode, segments: FlatSegment[]) {
return FlatSVG.wrapWithSVGTag(
root,
segments
.map((segment) => {
const { p1, p2, properties } = segment;
const propertiesKeys = Object.keys(properties);
let propertiesString = '';
for (let i = 0, length = propertiesKeys.length; i < length; i++) {
const key = propertiesKeys[i] as keyof typeof properties;
propertiesString += `${key}="${properties[key]}" `;
}
if ((segment as FlatBezierSegment).controlPoints) {
const { controlPoints } = segment as FlatBezierSegment;
const curveType = controlPoints.length === 1 ? 'Q' : 'C';
let d = `M ${p1[0]} ${p1[1]} ${curveType} ${controlPoints[0][0]} ${controlPoints[0][1]} `;
if (curveType === 'C')
d += `${controlPoints[1][0]} ${controlPoints[1][1]} `;
d += `${p2[0]} ${p2[1]} `;
return `<path d="${d}" ${propertiesString}/>`;
}
if ((segment as FlatArcSegment).rx !== undefined) {
const { rx, ry, xAxisRotation, largeArcFlag, sweepFlag } =
segment as FlatArcSegment;
return `<path d="M ${p1[0]} ${p1[1]} A ${rx} ${ry} ${xAxisRotation} ${
/* c8 ignore next */ largeArcFlag ? 1 : 0
} ${sweepFlag ? 1 : 0} ${p2[0]} ${p2[1]}" ${propertiesString}/>`;
}
return `<line x1="${p1[0]}" y1="${p1[1]}" x2="${p2[0]}" y2="${p2[1]}" ${propertiesString}/>`;
})
.join('\n')
);
}
/**
* Get svg string from FlatSVG.segments array.
*/
get segmentsAsSVG() {
const { segments, root } = this;
return FlatSVG.segmentsAsSVG(root, segments);
}
private static filter(
objects: FlatElement[],
filterFunction: (object: FlatElement, index: number) => boolean
): FlatElement[];
private static filter(
objects: FlatPath[],
filterFunction: (object: FlatPath, index: number) => boolean
): FlatPath[];
private static filter(
objects: FlatSegment[],
filterFunction: (object: FlatSegment, index: number) => boolean
): FlatSegment[];
private static filter(
objects: (FlatElement | FlatPath | FlatSegment)[],
filterFunction: (object: any, index: number) => boolean
) {
const matches: (FlatElement | FlatPath | FlatSegment)[] = [];
// const remaining: (FlatElement | FlatPath | FlatSegment)[] = [];
for (let i = 0; i < objects.length; i++) {
const object = objects[i];
if (filterFunction(object, i)) matches.push(object);
// else remaining.push(object);
}
return matches;
}
private static filterByStyle(
objects: FlatElement[],
filter: PropertiesFilter | PropertiesFilter[],
computedProperties?: ComputedProperties[],
exclude?: boolean[]
): { matches: FlatElement[]; computedProperties: ComputedProperties[] };
private static filterByStyle(
objects: FlatPath[],
filter: PropertiesFilter | PropertiesFilter[],
computedProperties?: ComputedProperties[],
exclude?: boolean[]
): { matches: FlatPath[]; computedProperties: ComputedProperties[] };
private static filterByStyle(
objects: FlatSegment[],
filter: PropertiesFilter | PropertiesFilter[],
computedProperties?: ComputedProperties[],
exclude?: boolean[]
): { matches: FlatSegment[]; computedProperties: ComputedProperties[] };
private static filterByStyle(
objects: (FlatElement | FlatPath | FlatSegment)[],
filter: PropertiesFilter | PropertiesFilter[],
computedProperties?: ComputedProperties[],
exclude?: boolean[]
) {
const filterArray = Array.isArray(filter) ? filter : [filter];
const filterArrayValues: (string | number | Colord | number[])[] = [];
// Precompute colors.
for (let i = 0; i < filterArray.length; i++) {
const { key, value } = filterArray[i];
filterArrayValues.push(value);
switch (key) {
case SVG_STYLE_STROKE_COLOR:
case SVG_STYLE_FILL:
case SVG_STYLE_COLOR:
filterArrayValues[i] = colord(value as AnyColor | Colord);
break;
case SVG_STYLE_STROKE_DASH_ARRAY:
filterArrayValues[i] = convertToDashArray(value as string | number | number[]);
break;
}
}
const matches = FlatSVG.filter(objects as any[], (object, i) => {
if (exclude && exclude[i]) return false;
const { properties } = object;
// Check that this object meets ALL the the style requirements.
for (let j = 0; j < filterArray.length; j++) {
const { key, tolerance } = filterArray[j];
let value = filterArrayValues[j];
// Special handling of certain keys.
let passed = true;
switch (key) {
case SVG_STYLE_STROKE_COLOR:
case SVG_STYLE_FILL:
case SVG_STYLE_COLOR:
case SVG_STYLE_OPACITY:
let color: Colord | undefined;
const computedKey =
key === SVG_STYLE_OPACITY ? SVG_STYLE_STROKE_COLOR : key;
if (computedProperties) {
color = computedProperties[i][computedKey];
}
if (color === undefined) {
color = colord(properties[computedKey] as AnyColor);
// Multiply color.a by properties.opacity.
const opacity = properties[SVG_STYLE_OPACITY];
if (opacity !== undefined) {
const alpha = opacity * color.rgba.a; // Use color.rgba.a instead of alpha() to avoid rounding.
color = color.alpha(alpha); // This makes a copy.
}
// Init computed properties array if needed.
if (!computedProperties) {
computedProperties = new Array(
objects.length
) as ComputedProperties[];
// Fill with empty objects.
// Don't use Array.fill({}) bc all elements will point to same empty object instance.
for (let k = 0; k < objects.length; k++) {
computedProperties[k] = {};
}
}
computedProperties[i][computedKey] = color;
}
if (
key === SVG_STYLE_STROKE_COLOR ||
key === SVG_STYLE_FILL ||
key === SVG_STYLE_COLOR
) {
passed = color.delta(value as AnyColor) <= (tolerance || 0);
break;
}
// Else check color opacity for opacity.
// Use color.rgba.a instead of alpha() to avoid rounding.
passed = Math.abs(color.rgba.a - (value as number)) <= (tolerance || 0);
break;
case SVG_STYLE_STROKE_DASH_ARRAY:
let dashArray: number[] | undefined;
if (computedProperties) {
dashArray = computedProperties[i][key];
}
if (!dashArray) {
dashArray = convertToDashArray(properties[key] as string | number);
// Init computed properties array if needed.
if (!computedProperties) {
computedProperties = new Array(
objects.length
) as ComputedProperties[];
// Fill with empty objects.
// Don't use Array.fill({}) bc all elements will point to same empty object instance.
for (let k = 0; k < objects.length; k++) {
computedProperties[k] = {};
}
}
computedProperties[i][key] = dashArray;
}
if (dashArray.length !== (value as number[]).length) {
if (dashArray.length === (value as number[]).length * 2) {
value = [...(value as number[]), ...(value as number[])];
} else if (dashArray.length * 2 === (value as number[]).length) {
dashArray = [
...(dashArray as number[]),
...(dashArray as number[]),
];
} else {
passed = false;
}
}
if (passed) {
for (let k = 0; k < (value as number[]).length; k++) {
if (
Math.abs((value as number[])[k] - dashArray[k]) >
(tolerance || 0)
)
passed = false;
}
}
break;
default:
// Assume any remaining keys correspond to numbers.
if (!isNumber(value)) {
passed = false;
throw new Error(
`flat-svg cannot handle filters with key "${key}" and value ${JSON.stringify(
value
)} of type ${typeof value}. Please submit an issue to https://github.com/amandaghassaei/flat-svg if this feature should be added.`
);
/* c8 ignore next 2 */
break;
}
if (
properties[key as keyof typeof properties] === undefined ||
Math.abs(
(properties[key as keyof typeof properties] as number) -
(value as number)
) > (tolerance || 0)
) {
passed = false;
}
break;
}
if (!passed) return false;
}
return true;
});
return { matches: matches as (FlatElement | FlatPath | FlatSegment)[], computedProperties };
}
/**
* Filter FlatSVG elements by style properties.
* @param filter - Style properties to filter for.
* @param exclude - Optionally pass an array of booleans of the same length as elements with "true" indicating that element should be excluded from the filter.
*/
filterElementsByStyle(filter: PropertiesFilter | PropertiesFilter[], exclude?: boolean[]) {
const { elements } = this;
const { matches, computedProperties } = FlatSVG.filterByStyle(
elements,
filter,
this._computedElementProperties,
exclude
);
this._computedElementProperties = computedProperties;
return matches;
}
/**
* Filter FlatSVG paths by style properties.
* @param filter - Style properties to filter for.
* @param exclude - Optionally pass an array of booleans of the same length as paths with "true" indicating that path should be excluded from the filter.
*/
filterPathsByStyle(filter: PropertiesFilter | PropertiesFilter[], exclude?: boolean[]) {
const { paths } = this;
const { matches, computedProperties } = FlatSVG.filterByStyle(
paths,
filter,
this._computedPathProperties,
exclude
);
this._computedPathProperties = computedProperties;
return matches;
}
/**
* Filter FlatSVG segments by style properties.
* @param filter - Style properties to filter for.
* @param exclude - Optionally pass an array of booleans of the same length as segments with "true" indicating that segment should be excluded from the filter.
*/
filterSegmentsByStyle(filter: PropertiesFilter | PropertiesFilter[], exclude?: boolean[]) {
const { segments } = this;
const { matches, computedProperties } = FlatSVG.filterByStyle(
segments,
filter,
this._computedSegmentProperties,
exclude
);
this._computedSegmentProperties = computedProperties;
return matches;
}
}