UNPKG

@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.

235 lines (225 loc) 10.6 kB
import { Transform, TransformParsed } from './types'; import { removeWhitespacePadding } from './utils'; export function initIdentityTransform() { const transform: Transform = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0, }; return transform; } // Parse transforms ourselves so we can attach errors and warnings for more feedback in ui. // https://gist.github.com/petersirka/dfac415e1e1e4993af826c0ff706eb4d/ // https://github.com/fontello/svgpath/blob/master/lib/transform_parse.js // https://www.w3.org/TR/SVG11/coords.html#TransformAttribute export function parseTransformString(string: string, tagName?: string) { const transformStrings = string.match( /(translate|matrix|rotate|skewX|skewY|scale)\s*\(\s*(.*?)\s*\)/gi ); const unusedCharacters: string[] = [string.slice()]; // Place to store any characters in transform that were missed. const transforms: TransformParsed[] = []; if (transformStrings) { // Loop through all transforms (many may be chained together e.g. "translate(1, 45) rotate(56)"). for (let i = 0; i < transformStrings.length; i++) { const transform = initIdentityTransform() as TransformParsed; // Init identity transform to start. const transformString = transformStrings[i]; // Transform as a string. // Keep track of what hasn't been matched. const lastString = unusedCharacters.pop()!; const matchIndex = lastString.indexOf(transformString); unusedCharacters.push( lastString.slice(0, matchIndex), lastString.slice(matchIndex + transformString.length) ); // Split transform into components: transform name and parameters. const transformComponents = transformString.split(/[\(\)]+/); if (transformComponents.length > 2) transformComponents.pop(); // Remove empty string at the end of split. if (transformComponents.length !== 2) { transform.errors = [`Malformed transform: "${transformString}".`]; transforms.push(transform); continue; } const transformName = removeWhitespacePadding(transformComponents[0]).toLowerCase(); // First try splitting by commas. let params = removeWhitespacePadding(transformComponents[1]).split(','); // Then split by spaces if commas not found. if (params.length === 1) params = params[0].split(/\s+/); // Convert parameters to float. const floatParams: number[] = []; for (let j = 0; j < params.length; j++) { const param = params[j]; floatParams.push(parseFloat(param)); // Remove infinity cases. if (floatParams[j] === Infinity || floatParams[j] === -Infinity) { floatParams[j] = NaN; } } let expectedNumParameters: number[] = []; switch (transformName) { case 'translate': // translate(<tx> [<ty>]), which specifies a translation by tx and ty. If <ty> is not provided, it is assumed to be zero. expectedNumParameters = [1, 2]; transform.e = floatParams[0] || 0; transform.f = floatParams[1] || 0; break; case 'scale': // scale(<sx> [<sy>]), which specifies a scale operation by sx and sy. If <sy> is not provided, it is assumed to be equal to <sx>. expectedNumParameters = [1, 2]; // Default value of 1, but allow zero scale to pass through. transform.a = floatParams[0] === 0 ? 0 : floatParams[0] || 1; transform.d = floatParams[1] === 0 ? 0 : floatParams[1] || transform.a; break; case 'rotate': { // rotate(<rotate-angle> [<cx> <cy>]), which specifies a rotation by <rotate-angle> degrees about a given point. // If optional parameters <cx> and <cy> are not supplied, the rotate is about the origin of the current user coordinate system. // If optional parameters <cx> and <cy> are supplied, the rotate is about the point (cx, cy). expectedNumParameters = [1, 3]; // Rotation angle is in degrees. const a = ((floatParams[0] || 0) * Math.PI) / 180; if (a !== 0) { const x = floatParams[1] || 0; const y = floatParams[2] || 0; const cosA = Math.cos(a); const sinA = Math.sin(a); transform.a = cosA; transform.b = sinA; transform.c = -sinA; transform.d = cosA; transform.e = -x * cosA + y * sinA + x; transform.f = -x * sinA - y * cosA + y; } break; } case 'skewx': { // skewX(<skew-angle>), which specifies a skew transformation along the x-axis. expectedNumParameters = [1]; // Rotation angle is in degrees. const a = ((floatParams[0] || 0) * Math.PI) / 180; if (a !== 0) transform.c = Math.tan(a); break; } case 'skewy': { // skewY(<skew-angle>), which specifies a skew transformation along the y-axis. expectedNumParameters = [1]; // Rotation angle is in degrees. const a = ((floatParams[0] || 0) * Math.PI) / 180; if (a !== 0) transform.b = Math.tan(a); break; } case 'matrix': // matrix(<a> <b> <c> <d> <e> <f>), which specifies a transformation in the form of a transformation matrix of six values. expectedNumParameters = [6]; // For elements with default value of 1, allow zero to pass through. transform.a = floatParams[0] === 0 ? 0 : floatParams[0] || 1; transform.b = floatParams[1] || 0; transform.c = floatParams[2] || 0; transform.d = floatParams[3] === 0 ? 0 : floatParams[3] || 1; transform.e = floatParams[4] || 0; transform.f = floatParams[5] || 0; break; /* c8 ignore next 5 */ default: // It should not be possible to hit this. // Should be caught by regex at top of function, any invalid transforms go to unusedCharacters. transform.errors = [`Unknown transform ${transformName}.`]; break; } // Add warnings if necessary. const warnings: string[] = []; // Check that correct number of params supplied. let numParams = params.length; if (numParams === 1 && params[0] === '') { numParams = 0; } if (expectedNumParameters.indexOf(numParams) < 0) { warnings.push( `Found ${ tagName ? `${tagName} ` : '' }element with malformed transform: "${transformString}" containing ${numParams} parameters, expected ${expectedNumParameters.join( ' or ' )} parameter${ expectedNumParameters[expectedNumParameters.length - 1] > 1 ? 's' : '' }.` ); } else { // Check if any params are invalid. for (let j = 0; j < floatParams.length; j++) { if (isNaN(floatParams[j])) { warnings.push( `Found ${ tagName ? `${tagName} ` : '' }element with invalid transform: "${transformString}", transform parameters must be finite numbers.` ); break; } } } // Attach warning to transform. if (warnings.length) transform.warnings = warnings; transforms.push(transform); } } // Check if anything was missed: for (let i = unusedCharacters.length - 1; i >= 0; i--) { unusedCharacters[i] = removeWhitespacePadding(unusedCharacters[i]); if (unusedCharacters[i] === '' || unusedCharacters[i] === ',') unusedCharacters.splice(i, 1); } if (unusedCharacters.length) { const transform = initIdentityTransform() as TransformParsed; transform.errors = [ `Malformed transform, unmatched characters: [ ${unusedCharacters .map((str) => `"${str}"`) .join(', ')} ].`, ]; transforms.push(transform); } return transforms; } export function flattenTransformArray(transforms: Transform[]) { // Flatten transforms to a single matrix. const transform = copyTransform(transforms[0]); for (let i = 1; i < transforms.length; i++) { dotTransforms(transform, transforms[i]); } return transform; } export function dotTransforms(t1: Transform, t2: Transform) { const a = t1.a * t2.a + t1.c * t2.b; const b = t1.b * t2.a + t1.d * t2.b; const c = t1.a * t2.c + t1.c * t2.d; const d = t1.b * t2.c + t1.d * t2.d; const e = t1.a * t2.e + t1.c * t2.f + t1.e; const f = t1.b * t2.e + t1.d * t2.f + t1.f; // Modify t1 in place. t1.a = a; t1.b = b; t1.c = c; t1.d = d; t1.e = e; t1.f = f; return t1; } export function applyTransform(p: [number, number], t: Transform) { const x = t.a * p[0] + t.c * p[1] + t.e; const y = t.b * p[0] + t.d * p[1] + t.f; // Apply transform in place. p[0] = x; p[1] = y; return p; } export function copyTransform(t: Transform) { return { a: t.a, b: t.b, c: t.c, d: t.d, e: t.e, f: t.f, } as Transform; } export function transformToString(t: Transform) { return `matrix(${t.a} ${t.b} ${t.c} ${t.d} ${t.e} ${t.f})`; }