svgo
Version:
303 lines (266 loc) • 7.82 kB
JavaScript
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;
};