UNPKG

svgo

Version:

SVGO is a Node.js library and command-line application for optimizing vector images.

303 lines (266 loc) 7.82 kB
import { attrsGroups, referencesProps } from '../../plugins/_collections.js'; /** * @typedef CleanupOutDataParams * @property {boolean=} noSpaceAfterFlags * @property {boolean=} leadingZero * @property {boolean=} negativeExtraSpace */ const regReferencesUrl = /\burl\((["'])?#(.+?)\1\)/g; const regReferencesHref = /^#(.+?)$/; const regReferencesBegin = /(\w+)\.[a-zA-Z]/; /** * Encode plain SVG data string into Data URI string. * * @param {string} str * @param {import('../types.js').DataUri=} type * @returns {string} */ export const encodeSVGDatauri = (str, type) => { let prefix = 'data:image/svg+xml'; if (!type || type === 'base64') { // base64 prefix += ';base64,'; str = prefix + Buffer.from(str).toString('base64'); } else if (type === 'enc') { // URI encoded str = prefix + ',' + encodeURIComponent(str); } else if (type === 'unenc') { // unencoded str = prefix + ',' + str; } return str; }; /** * Decode SVG Data URI string into plain SVG string. * * @param {string} str * @returns {string} */ export const decodeSVGDatauri = (str) => { const regexp = /data:image\/svg\+xml(;charset=[^;,]*)?(;base64)?,(.*)/; const match = regexp.exec(str); // plain string if (!match) { return str; } const data = match[3]; if (match[2]) { // base64 str = Buffer.from(data, 'base64').toString('utf8'); } else if (data.charAt(0) === '%') { // URI encoded str = decodeURIComponent(data); } else if (data.charAt(0) === '<') { // unencoded str = data; } return str; }; /** * Convert a row of numbers to an optimized string view. * * @example * [0, -1, .5, .5] → "0-1 .5.5" * * @param {ReadonlyArray<number>} data * @param {CleanupOutDataParams} params * @param {import('../types.js').PathDataCommand=} command * @returns {string} */ export const cleanupOutData = (data, params, command) => { let str = ''; let delimiter; /** @type {number} */ let prev; data.forEach((item, i) => { // space delimiter by default delimiter = ' '; // no extra space in front of first number if (i == 0) { delimiter = ''; } // no extra space after arc command flags (large-arc and sweep flags) // a20 60 45 0 1 30 20 → a20 60 45 0130 20 if (params.noSpaceAfterFlags && (command == 'A' || command == 'a')) { const pos = i % 7; if (pos == 4 || pos == 5) { delimiter = ''; } } // remove floating-point numbers leading zeros // 0.5 → .5 // -0.5 → -.5 const itemStr = params.leadingZero ? removeLeadingZero(item) : item.toString(); // no extra space in front of negative number or // in front of a floating number if a previous number is floating too if ( params.negativeExtraSpace && delimiter != '' && (item < 0 || (itemStr.charAt(0) === '.' && prev % 1 !== 0)) ) { delimiter = ''; } // save prev item value prev = item; str += delimiter + itemStr; }); return str; }; /** * Remove floating-point numbers leading zero. * * @param {number} value * @returns {string} * @example * 0.5 → .5 * -0.5 → -.5 */ export const removeLeadingZero = (value) => { const strValue = value.toString(); if (0 < value && value < 1 && strValue.startsWith('0')) { return strValue.slice(1); } if (-1 < value && value < 0 && strValue[1] === '0') { return strValue[0] + strValue.slice(2); } return strValue; }; const hasScriptsEventAttrs = [ ...attrsGroups.animationEvent, ...attrsGroups.documentEvent, ...attrsGroups.documentElementEvent, ...attrsGroups.globalEvent, ...attrsGroups.graphicalEvent, ]; const executableDataMediaTypes = new Set([ 'application/xhtml+xml', 'image/svg+xml', 'text/html', ]); /** * Check whether a URL can contain executable content in a browser. * * @param {string} value * @returns {boolean} */ export const isExecutableUrl = (value) => { // Browsers remove ASCII tabs and newlines from URLs before parsing them. // Normalize them here too so they cannot be embedded in a scheme name. const normalizedValue = value .replace(/[\t\n\r]/g, '') .trimStart() .toLowerCase(); // `javascript:` is executable in modern browsers. `vbscript:` was only // implemented by legacy Internet Explorer, but rejecting it is inexpensive, // provides defense in depth for old environments, and keeps this security- // oriented plugin's scheme denylist explicit and complete. if ( normalizedValue.startsWith('javascript:') || normalizedValue.startsWith('vbscript:') ) { return true; } // A `data:` URL is not inherently executable: images and other inert media // are common in SVGs and should remain intact. Only reject media types that // browsers parse as active HTML, XHTML, or SVG documents. if (!normalizedValue.startsWith('data:')) { return false; } // Data URLs separate the media type from parameters with `;` and from the // payload with `,`. A URL without either separator has no valid media type. const mediaTypeEnd = normalizedValue.slice(5).search(/[;,]/); if (mediaTypeEnd === -1) { return false; } const mediaType = normalizedValue.slice(5, mediaTypeEnd + 5).trim(); return executableDataMediaTypes.has(mediaType); }; /** * If the current node contains any scripts. This does not check parents or * children of the node, only the properties and attributes of the node itself. * * @param {import('../types.js').XastElement} node Current node to check against. * @returns {boolean} If the current node contains scripts. */ export const hasScripts = (node) => { if (node.name === 'script' && node.children.length !== 0) { return true; } if (node.name === 'a') { const hasExecutableLinks = Object.entries(node.attributes).some( ([attrKey, attrValue]) => (attrKey === 'href' || attrKey.endsWith(':href')) && attrValue != null && isExecutableUrl(attrValue), ); if (hasExecutableLinks) { return true; } } return hasScriptsEventAttrs.some((attr) => node.attributes[attr] != null); }; /** * For example, a string that contains one or more of following would match and * return true: * * * `url(#gradient001)` * * `url('#gradient001')` * * @param {string} body * @returns {boolean} If the given string includes a URL reference. */ export const includesUrlReference = (body) => { return new RegExp(regReferencesUrl).test(body); }; /** * @param {string} body * @returns {boolean} * If body includes syntax that resembles a CSS custom property reference. * @example * "black" → false * "var(--css-custom-property)" → true */ export const includesCssVarReference = (body) => { return /var\s*\(\s*--/.test(body); }; /** * @param {string} attribute * @param {string} value * @returns {string[]} */ export const findReferences = (attribute, value) => { const results = []; if (referencesProps.has(attribute)) { const matches = value.matchAll(regReferencesUrl); for (const match of matches) { results.push(match[2]); } } if (attribute === 'href' || attribute.endsWith(':href')) { const match = regReferencesHref.exec(value); if (match != null) { results.push(match[1]); } } if (attribute === 'begin') { const match = regReferencesBegin.exec(value); if (match != null) { results.push(match[1]); } } return results.map((body) => decodeURI(body)); }; /** * Does the same as {@link Number.toFixed} but without casting * the return value to a string. * * @param {number} num * @param {number} precision * @returns {number} */ export const toFixed = (num, precision) => { const pow = 10 ** precision; return Math.round(num * pow) / pow; };