This commit is contained in:
2025-07-23 16:57:31 +03:30
parent 96b0d450b6
commit ffe1d06893
4701 changed files with 449700 additions and 367 deletions
+57
View File
@@ -0,0 +1,57 @@
'use strict';
exports.builtin = [
require('../plugins/preset-default.js'),
require('../plugins/addAttributesToSVGElement.js'),
require('../plugins/addClassesToSVGElement.js'),
require('../plugins/cleanupAttrs.js'),
require('../plugins/cleanupEnableBackground.js'),
require('../plugins/cleanupIds.js'),
require('../plugins/cleanupListOfValues.js'),
require('../plugins/cleanupNumericValues.js'),
require('../plugins/collapseGroups.js'),
require('../plugins/convertColors.js'),
require('../plugins/convertEllipseToCircle.js'),
require('../plugins/convertOneStopGradients.js'),
require('../plugins/convertPathData.js'),
require('../plugins/convertShapeToPath.js'),
require('../plugins/convertStyleToAttrs.js'),
require('../plugins/convertTransform.js'),
require('../plugins/mergeStyles.js'),
require('../plugins/inlineStyles.js'),
require('../plugins/mergePaths.js'),
require('../plugins/minifyStyles.js'),
require('../plugins/moveElemsAttrsToGroup.js'),
require('../plugins/moveGroupAttrsToElems.js'),
require('../plugins/prefixIds.js'),
require('../plugins/removeAttributesBySelector.js'),
require('../plugins/removeAttrs.js'),
require('../plugins/removeComments.js'),
require('../plugins/removeDesc.js'),
require('../plugins/removeDimensions.js'),
require('../plugins/removeDoctype.js'),
require('../plugins/removeEditorsNSData.js'),
require('../plugins/removeElementsByAttr.js'),
require('../plugins/removeEmptyAttrs.js'),
require('../plugins/removeEmptyContainers.js'),
require('../plugins/removeEmptyText.js'),
require('../plugins/removeHiddenElems.js'),
require('../plugins/removeMetadata.js'),
require('../plugins/removeNonInheritableGroupAttrs.js'),
require('../plugins/removeOffCanvasPaths.js'),
require('../plugins/removeRasterImages.js'),
require('../plugins/removeScriptElement.js'),
require('../plugins/removeStyleElement.js'),
require('../plugins/removeTitle.js'),
require('../plugins/removeUnknownsAndDefaults.js'),
require('../plugins/removeUnusedNS.js'),
require('../plugins/removeUselessDefs.js'),
require('../plugins/removeUselessStrokeAndFill.js'),
require('../plugins/removeViewBox.js'),
require('../plugins/removeXlink.js'),
require('../plugins/removeXMLNS.js'),
require('../plugins/removeXMLProcInst.js'),
require('../plugins/reusePaths.js'),
require('../plugins/sortAttrs.js'),
require('../plugins/sortDefsChildren.js'),
];
+262
View File
@@ -0,0 +1,262 @@
'use strict';
/**
* @typedef {import('./types').XastNode} XastNode
* @typedef {import('./types').XastInstruction} XastInstruction
* @typedef {import('./types').XastDoctype} XastDoctype
* @typedef {import('./types').XastComment} XastComment
* @typedef {import('./types').XastRoot} XastRoot
* @typedef {import('./types').XastElement} XastElement
* @typedef {import('./types').XastCdata} XastCdata
* @typedef {import('./types').XastText} XastText
* @typedef {import('./types').XastParent} XastParent
* @typedef {import('./types').XastChild} XastChild
*/
// @ts-ignore sax will be replaced with something else later
const SAX = require('@trysound/sax');
const { textElems } = require('../plugins/_collections');
class SvgoParserError extends Error {
/**
* @param message {string}
* @param line {number}
* @param column {number}
* @param source {string}
* @param file {void | string}
*/
constructor(message, line, column, source, file) {
super(message);
this.name = 'SvgoParserError';
this.message = `${file || '<input>'}:${line}:${column}: ${message}`;
this.reason = message;
this.line = line;
this.column = column;
this.source = source;
if (Error.captureStackTrace) {
Error.captureStackTrace(this, SvgoParserError);
}
}
toString() {
const lines = this.source.split(/\r?\n/);
const startLine = Math.max(this.line - 3, 0);
const endLine = Math.min(this.line + 2, lines.length);
const lineNumberWidth = String(endLine).length;
const startColumn = Math.max(this.column - 54, 0);
const endColumn = Math.max(this.column + 20, 80);
const code = lines
.slice(startLine, endLine)
.map((line, index) => {
const lineSlice = line.slice(startColumn, endColumn);
let ellipsisPrefix = '';
let ellipsisSuffix = '';
if (startColumn !== 0) {
ellipsisPrefix = startColumn > line.length - 1 ? ' ' : '…';
}
if (endColumn < line.length - 1) {
ellipsisSuffix = '…';
}
const number = startLine + 1 + index;
const gutter = ` ${number.toString().padStart(lineNumberWidth)} | `;
if (number === this.line) {
const gutterSpacing = gutter.replace(/[^|]/g, ' ');
const lineSpacing = (
ellipsisPrefix + line.slice(startColumn, this.column - 1)
).replace(/[^\t]/g, ' ');
const spacing = gutterSpacing + lineSpacing;
return `>${gutter}${ellipsisPrefix}${lineSlice}${ellipsisSuffix}\n ${spacing}^`;
}
return ` ${gutter}${ellipsisPrefix}${lineSlice}${ellipsisSuffix}`;
})
.join('\n');
return `${this.name}: ${this.message}\n\n${code}\n`;
}
}
const entityDeclaration = /<!ENTITY\s+(\S+)\s+(?:'([^']+)'|"([^"]+)")\s*>/g;
const config = {
strict: true,
trim: false,
normalize: false,
lowercase: true,
xmlns: true,
position: true,
};
/**
* Convert SVG (XML) string to SVG-as-JS object.
*
* @type {(data: string, from?: string) => XastRoot}
*/
const parseSvg = (data, from) => {
const sax = SAX.parser(config.strict, config);
/**
* @type {XastRoot}
*/
const root = { type: 'root', children: [] };
/**
* @type {XastParent}
*/
let current = root;
/**
* @type {XastParent[]}
*/
const stack = [root];
/**
* @type {(node: XastChild) => void}
*/
const pushToContent = (node) => {
// TODO remove legacy parentNode in v4
Object.defineProperty(node, 'parentNode', {
writable: true,
value: current,
});
current.children.push(node);
};
/**
* @type {(doctype: string) => void}
*/
sax.ondoctype = (doctype) => {
/**
* @type {XastDoctype}
*/
const node = {
type: 'doctype',
// TODO parse doctype for name, public and system to match xast
name: 'svg',
data: {
doctype,
},
};
pushToContent(node);
const subsetStart = doctype.indexOf('[');
if (subsetStart >= 0) {
entityDeclaration.lastIndex = subsetStart;
let entityMatch = entityDeclaration.exec(data);
while (entityMatch != null) {
sax.ENTITIES[entityMatch[1]] = entityMatch[2] || entityMatch[3];
entityMatch = entityDeclaration.exec(data);
}
}
};
/**
* @type {(data: { name: string, body: string }) => void}
*/
sax.onprocessinginstruction = (data) => {
/**
* @type {XastInstruction}
*/
const node = {
type: 'instruction',
name: data.name,
value: data.body,
};
pushToContent(node);
};
/**
* @type {(comment: string) => void}
*/
sax.oncomment = (comment) => {
/**
* @type {XastComment}
*/
const node = {
type: 'comment',
value: comment.trim(),
};
pushToContent(node);
};
/**
* @type {(cdata: string) => void}
*/
sax.oncdata = (cdata) => {
/**
* @type {XastCdata}
*/
const node = {
type: 'cdata',
value: cdata,
};
pushToContent(node);
};
/**
* @type {(data: { name: string, attributes: Record<string, { value: string }>}) => void}
*/
sax.onopentag = (data) => {
/**
* @type {XastElement}
*/
let element = {
type: 'element',
name: data.name,
attributes: {},
children: [],
};
for (const [name, attr] of Object.entries(data.attributes)) {
element.attributes[name] = attr.value;
}
pushToContent(element);
current = element;
stack.push(element);
};
/**
* @type {(text: string) => void}
*/
sax.ontext = (text) => {
if (current.type === 'element') {
// prevent trimming of meaningful whitespace inside textual tags
if (textElems.has(current.name)) {
/**
* @type {XastText}
*/
const node = {
type: 'text',
value: text,
};
pushToContent(node);
} else if (/\S/.test(text)) {
/**
* @type {XastText}
*/
const node = {
type: 'text',
value: text.trim(),
};
pushToContent(node);
}
}
};
sax.onclosetag = () => {
stack.pop();
current = stack[stack.length - 1];
};
/**
* @type {(e: any) => void}
*/
sax.onerror = (e) => {
const error = new SvgoParserError(
e.reason,
e.line + 1,
e.column,
data,
from,
);
if (e.message.indexOf('Unexpected end') === -1) {
throw error;
}
};
sax.write(data).close();
return root;
};
exports.parseSvg = parseSvg;
+380
View File
@@ -0,0 +1,380 @@
'use strict';
const { removeLeadingZero, toFixed } = require('./svgo/tools');
/**
* @typedef {import('./types').PathDataItem} PathDataItem
* @typedef {import('./types').PathDataCommand} PathDataCommand
*/
// Based on https://www.w3.org/TR/SVG11/paths.html#PathDataBNF
const argsCountPerCommand = {
M: 2,
m: 2,
Z: 0,
z: 0,
L: 2,
l: 2,
H: 1,
h: 1,
V: 1,
v: 1,
C: 6,
c: 6,
S: 4,
s: 4,
Q: 4,
q: 4,
T: 2,
t: 2,
A: 7,
a: 7,
};
/**
* @type {(c: string) => c is PathDataCommand}
*/
const isCommand = (c) => {
return c in argsCountPerCommand;
};
/**
* @type {(c: string) => boolean}
*/
const isWsp = (c) => {
const codePoint = c.codePointAt(0);
return (
codePoint === 0x20 ||
codePoint === 0x9 ||
codePoint === 0xd ||
codePoint === 0xa
);
};
/**
* @type {(c: string) => boolean}
*/
const isDigit = (c) => {
const codePoint = c.codePointAt(0);
if (codePoint == null) {
return false;
}
return 48 <= codePoint && codePoint <= 57;
};
/**
* @typedef {'none' | 'sign' | 'whole' | 'decimal_point' | 'decimal' | 'e' | 'exponent_sign' | 'exponent'} ReadNumberState
*/
/**
* @type {(string: string, cursor: number) => [number, ?number]}
*/
const readNumber = (string, cursor) => {
let i = cursor;
let value = '';
let state = /** @type {ReadNumberState} */ ('none');
for (; i < string.length; i += 1) {
const c = string[i];
if (c === '+' || c === '-') {
if (state === 'none') {
state = 'sign';
value += c;
continue;
}
if (state === 'e') {
state = 'exponent_sign';
value += c;
continue;
}
}
if (isDigit(c)) {
if (state === 'none' || state === 'sign' || state === 'whole') {
state = 'whole';
value += c;
continue;
}
if (state === 'decimal_point' || state === 'decimal') {
state = 'decimal';
value += c;
continue;
}
if (state === 'e' || state === 'exponent_sign' || state === 'exponent') {
state = 'exponent';
value += c;
continue;
}
}
if (c === '.') {
if (state === 'none' || state === 'sign' || state === 'whole') {
state = 'decimal_point';
value += c;
continue;
}
}
if (c === 'E' || c == 'e') {
if (
state === 'whole' ||
state === 'decimal_point' ||
state === 'decimal'
) {
state = 'e';
value += c;
continue;
}
}
break;
}
const number = Number.parseFloat(value);
if (Number.isNaN(number)) {
return [cursor, null];
} else {
// step back to delegate iteration to parent loop
return [i - 1, number];
}
};
/**
* @type {(string: string) => PathDataItem[]}
*/
const parsePathData = (string) => {
/**
* @type {PathDataItem[]}
*/
const pathData = [];
/**
* @type {?PathDataCommand}
*/
let command = null;
let args = /** @type {number[]} */ ([]);
let argsCount = 0;
let canHaveComma = false;
let hadComma = false;
for (let i = 0; i < string.length; i += 1) {
const c = string.charAt(i);
if (isWsp(c)) {
continue;
}
// allow comma only between arguments
if (canHaveComma && c === ',') {
if (hadComma) {
break;
}
hadComma = true;
continue;
}
if (isCommand(c)) {
if (hadComma) {
return pathData;
}
if (command == null) {
// moveto should be leading command
if (c !== 'M' && c !== 'm') {
return pathData;
}
} else {
// stop if previous command arguments are not flushed
if (args.length !== 0) {
return pathData;
}
}
command = c;
args = [];
argsCount = argsCountPerCommand[command];
canHaveComma = false;
// flush command without arguments
if (argsCount === 0) {
pathData.push({ command, args });
}
continue;
}
// avoid parsing arguments if no command detected
if (command == null) {
return pathData;
}
// read next argument
let newCursor = i;
let number = null;
if (command === 'A' || command === 'a') {
const position = args.length;
if (position === 0 || position === 1) {
// allow only positive number without sign as first two arguments
if (c !== '+' && c !== '-') {
[newCursor, number] = readNumber(string, i);
}
}
if (position === 2 || position === 5 || position === 6) {
[newCursor, number] = readNumber(string, i);
}
if (position === 3 || position === 4) {
// read flags
if (c === '0') {
number = 0;
}
if (c === '1') {
number = 1;
}
}
} else {
[newCursor, number] = readNumber(string, i);
}
if (number == null) {
return pathData;
}
args.push(number);
canHaveComma = true;
hadComma = false;
i = newCursor;
// flush arguments when necessary count is reached
if (args.length === argsCount) {
pathData.push({ command, args });
// subsequent moveto coordinates are treated as implicit lineto commands
if (command === 'M') {
command = 'L';
}
if (command === 'm') {
command = 'l';
}
args = [];
}
}
return pathData;
};
exports.parsePathData = parsePathData;
/**
* @type {(number: number, precision?: number) => {
* roundedStr: string,
* rounded: number
* }}
*/
const roundAndStringify = (number, precision) => {
if (precision != null) {
number = toFixed(number, precision);
}
return {
roundedStr: removeLeadingZero(number),
rounded: number,
};
};
/**
* Elliptical arc large-arc and sweep flags are rendered with spaces
* because many non-browser environments are not able to parse such paths
*
* @type {(
* command: string,
* args: number[],
* precision?: number,
* disableSpaceAfterFlags?: boolean
* ) => string}
*/
const stringifyArgs = (command, args, precision, disableSpaceAfterFlags) => {
let result = '';
let previous;
for (let i = 0; i < args.length; i++) {
const { roundedStr, rounded } = roundAndStringify(args[i], precision);
if (
disableSpaceAfterFlags &&
(command === 'A' || command === 'a') &&
// consider combined arcs
(i % 7 === 4 || i % 7 === 5)
) {
result += roundedStr;
} else if (i === 0 || rounded < 0) {
// avoid space before first and negative numbers
result += roundedStr;
} else if (
!Number.isInteger(previous) &&
rounded != 0 &&
rounded < 1 &&
rounded > -1
) {
// remove space before decimal with zero whole
// only when previous number is also decimal
result += roundedStr;
} else {
result += ` ${roundedStr}`;
}
previous = rounded;
}
return result;
};
/**
* @typedef {{
* pathData: PathDataItem[];
* precision?: number;
* disableSpaceAfterFlags?: boolean;
* }} StringifyPathDataOptions
*/
/**
* @param {StringifyPathDataOptions} options
* @returns {string}
*/
const stringifyPathData = ({ pathData, precision, disableSpaceAfterFlags }) => {
if (pathData.length === 1) {
const { command, args } = pathData[0];
return (
command + stringifyArgs(command, args, precision, disableSpaceAfterFlags)
);
}
let result = '';
let prev = { ...pathData[0] };
// match leading moveto with following lineto
if (pathData[1].command === 'L') {
prev.command = 'M';
} else if (pathData[1].command === 'l') {
prev.command = 'm';
}
for (let i = 1; i < pathData.length; i++) {
const { command, args } = pathData[i];
if (
(prev.command === command &&
prev.command !== 'M' &&
prev.command !== 'm') ||
// combine matching moveto and lineto sequences
(prev.command === 'M' && command === 'L') ||
(prev.command === 'm' && command === 'l')
) {
prev.args = [...prev.args, ...args];
if (i === pathData.length - 1) {
result +=
prev.command +
stringifyArgs(
prev.command,
prev.args,
precision,
disableSpaceAfterFlags,
);
}
} else {
result +=
prev.command +
stringifyArgs(
prev.command,
prev.args,
precision,
disableSpaceAfterFlags,
);
if (i === pathData.length - 1) {
result +=
command +
stringifyArgs(command, args, precision, disableSpaceAfterFlags);
} else {
prev = { command, args };
}
}
}
return result;
};
exports.stringifyPathData = stringifyPathData;
+295
View File
@@ -0,0 +1,295 @@
'use strict';
/**
* @typedef {import('./types').XastParent} XastParent
* @typedef {import('./types').XastRoot} XastRoot
* @typedef {import('./types').XastElement} XastElement
* @typedef {import('./types').XastInstruction} XastInstruction
* @typedef {import('./types').XastDoctype} XastDoctype
* @typedef {import('./types').XastText} XastText
* @typedef {import('./types').XastCdata} XastCdata
* @typedef {import('./types').XastComment} XastComment
* @typedef {import('./types').StringifyOptions} StringifyOptions
*/
const { textElems } = require('../plugins/_collections');
/**
* @typedef {{
* indent: string,
* textContext: ?XastElement,
* indentLevel: number,
* }} State
*/
/**
* @typedef {Required<StringifyOptions>} Options
*/
/**
* @type {(char: string) => string}
*/
const encodeEntity = (char) => {
return entities[char];
};
/** @type {Options} */
const defaults = {
doctypeStart: '<!DOCTYPE',
doctypeEnd: '>',
procInstStart: '<?',
procInstEnd: '?>',
tagOpenStart: '<',
tagOpenEnd: '>',
tagCloseStart: '</',
tagCloseEnd: '>',
tagShortStart: '<',
tagShortEnd: '/>',
attrStart: '="',
attrEnd: '"',
commentStart: '<!--',
commentEnd: '-->',
cdataStart: '<![CDATA[',
cdataEnd: ']]>',
textStart: '',
textEnd: '',
indent: 4,
regEntities: /[&'"<>]/g,
regValEntities: /[&"<>]/g,
encodeEntity,
pretty: false,
useShortTags: true,
eol: 'lf',
finalNewline: false,
};
/** @type {Record<string, string>} */
const entities = {
'&': '&amp;',
"'": '&apos;',
'"': '&quot;',
'>': '&gt;',
'<': '&lt;',
};
/**
* convert XAST to SVG string
*
* @type {(data: XastRoot, config: StringifyOptions) => string}
*/
const stringifySvg = (data, userOptions = {}) => {
/**
* @type {Options}
*/
const config = { ...defaults, ...userOptions };
const indent = config.indent;
let newIndent = ' ';
if (typeof indent === 'number' && Number.isNaN(indent) === false) {
newIndent = indent < 0 ? '\t' : ' '.repeat(indent);
} else if (typeof indent === 'string') {
newIndent = indent;
}
/**
* @type {State}
*/
const state = {
indent: newIndent,
textContext: null,
indentLevel: 0,
};
const eol = config.eol === 'crlf' ? '\r\n' : '\n';
if (config.pretty) {
config.doctypeEnd += eol;
config.procInstEnd += eol;
config.commentEnd += eol;
config.cdataEnd += eol;
config.tagShortEnd += eol;
config.tagOpenEnd += eol;
config.tagCloseEnd += eol;
config.textEnd += eol;
}
let svg = stringifyNode(data, config, state);
if (config.finalNewline && svg.length > 0 && !svg.endsWith('\n')) {
svg += eol;
}
return svg;
};
exports.stringifySvg = stringifySvg;
/**
* @type {(node: XastParent, config: Options, state: State) => string}
*/
const stringifyNode = (data, config, state) => {
let svg = '';
state.indentLevel += 1;
for (const item of data.children) {
if (item.type === 'element') {
svg += stringifyElement(item, config, state);
}
if (item.type === 'text') {
svg += stringifyText(item, config, state);
}
if (item.type === 'doctype') {
svg += stringifyDoctype(item, config);
}
if (item.type === 'instruction') {
svg += stringifyInstruction(item, config);
}
if (item.type === 'comment') {
svg += stringifyComment(item, config);
}
if (item.type === 'cdata') {
svg += stringifyCdata(item, config, state);
}
}
state.indentLevel -= 1;
return svg;
};
/**
* create indent string in accordance with the current node level.
*
* @type {(config: Options, state: State) => string}
*/
const createIndent = (config, state) => {
let indent = '';
if (config.pretty && state.textContext == null) {
indent = state.indent.repeat(state.indentLevel - 1);
}
return indent;
};
/**
* @type {(node: XastDoctype, config: Options) => string}
*/
const stringifyDoctype = (node, config) => {
return config.doctypeStart + node.data.doctype + config.doctypeEnd;
};
/**
* @type {(node: XastInstruction, config: Options) => string}
*/
const stringifyInstruction = (node, config) => {
return (
config.procInstStart + node.name + ' ' + node.value + config.procInstEnd
);
};
/**
* @type {(node: XastComment, config: Options) => string}
*/
const stringifyComment = (node, config) => {
return config.commentStart + node.value + config.commentEnd;
};
/**
* @type {(node: XastCdata, config: Options, state: State) => string}
*/
const stringifyCdata = (node, config, state) => {
return (
createIndent(config, state) +
config.cdataStart +
node.value +
config.cdataEnd
);
};
/**
* @type {(node: XastElement, config: Options, state: State) => string}
*/
const stringifyElement = (node, config, state) => {
// empty element and short tag
if (node.children.length === 0) {
if (config.useShortTags) {
return (
createIndent(config, state) +
config.tagShortStart +
node.name +
stringifyAttributes(node, config) +
config.tagShortEnd
);
} else {
return (
createIndent(config, state) +
config.tagShortStart +
node.name +
stringifyAttributes(node, config) +
config.tagOpenEnd +
config.tagCloseStart +
node.name +
config.tagCloseEnd
);
}
// non-empty element
} else {
let tagOpenStart = config.tagOpenStart;
let tagOpenEnd = config.tagOpenEnd;
let tagCloseStart = config.tagCloseStart;
let tagCloseEnd = config.tagCloseEnd;
let openIndent = createIndent(config, state);
let closeIndent = createIndent(config, state);
if (state.textContext) {
tagOpenStart = defaults.tagOpenStart;
tagOpenEnd = defaults.tagOpenEnd;
tagCloseStart = defaults.tagCloseStart;
tagCloseEnd = defaults.tagCloseEnd;
openIndent = '';
} else if (textElems.has(node.name)) {
tagOpenEnd = defaults.tagOpenEnd;
tagCloseStart = defaults.tagCloseStart;
closeIndent = '';
state.textContext = node;
}
const children = stringifyNode(node, config, state);
if (state.textContext === node) {
state.textContext = null;
}
return (
openIndent +
tagOpenStart +
node.name +
stringifyAttributes(node, config) +
tagOpenEnd +
children +
closeIndent +
tagCloseStart +
node.name +
tagCloseEnd
);
}
};
/**
* @type {(node: XastElement, config: Options) => string}
*/
const stringifyAttributes = (node, config) => {
let attrs = '';
for (const [name, value] of Object.entries(node.attributes)) {
// TODO remove attributes without values support in v3
if (value !== undefined) {
const encodedValue = value
.toString()
.replace(config.regValEntities, config.encodeEntity);
attrs += ' ' + name + config.attrStart + encodedValue + config.attrEnd;
} else {
attrs += ' ' + name;
}
}
return attrs;
};
/**
* @type {(node: XastText, config: Options, state: State) => string}
*/
const stringifyText = (node, config, state) => {
return (
createIndent(config, state) +
config.textStart +
node.value.replace(config.regEntities, config.encodeEntity) +
(state.textContext ? '' : config.textEnd)
);
};
+336
View File
@@ -0,0 +1,336 @@
'use strict';
/**
* @typedef {import('css-tree').Rule} CsstreeRule
* @typedef {import('./types').Specificity} Specificity
* @typedef {import('./types').Stylesheet} Stylesheet
* @typedef {import('./types').StylesheetRule} StylesheetRule
* @typedef {import('./types').StylesheetDeclaration} StylesheetDeclaration
* @typedef {import('./types').ComputedStyles} ComputedStyles
* @typedef {import('./types').XastRoot} XastRoot
* @typedef {import('./types').XastElement} XastElement
* @typedef {import('./types').XastParent} XastParent
* @typedef {import('./types').XastChild} XastChild
*/
const csstree = require('css-tree');
const csswhat = require('css-what');
const {
syntax: { specificity },
} = require('csso');
const { visit, matches } = require('./xast.js');
const {
attrsGroups,
inheritableAttrs,
presentationNonInheritableGroupAttrs,
} = require('../plugins/_collections.js');
// @ts-ignore not defined in @types/csstree
const csstreeWalkSkip = csstree.walk.skip;
/**
* @type {(ruleNode: CsstreeRule, dynamic: boolean) => StylesheetRule[]}
*/
const parseRule = (ruleNode, dynamic) => {
/**
* @type {StylesheetDeclaration[]}
*/
const declarations = [];
// collect declarations
ruleNode.block.children.forEach((cssNode) => {
if (cssNode.type === 'Declaration') {
declarations.push({
name: cssNode.property,
value: csstree.generate(cssNode.value),
important: cssNode.important === true,
});
}
});
/** @type {StylesheetRule[]} */
const rules = [];
csstree.walk(ruleNode.prelude, (node) => {
if (node.type === 'Selector') {
const newNode = csstree.clone(node);
let hasPseudoClasses = false;
csstree.walk(newNode, (pseudoClassNode, item, list) => {
if (pseudoClassNode.type === 'PseudoClassSelector') {
hasPseudoClasses = true;
list.remove(item);
}
});
rules.push({
specificity: specificity(node),
dynamic: hasPseudoClasses || dynamic,
// compute specificity from original node to consider pseudo classes
selector: csstree.generate(newNode),
declarations,
});
}
});
return rules;
};
/**
* @type {(css: string, dynamic: boolean) => StylesheetRule[]}
*/
const parseStylesheet = (css, dynamic) => {
/** @type {StylesheetRule[]} */
const rules = [];
const ast = csstree.parse(css, {
parseValue: false,
parseAtrulePrelude: false,
});
csstree.walk(ast, (cssNode) => {
if (cssNode.type === 'Rule') {
rules.push(...parseRule(cssNode, dynamic || false));
return csstreeWalkSkip;
}
if (cssNode.type === 'Atrule') {
if (
cssNode.name === 'keyframes' ||
cssNode.name === '-webkit-keyframes'
) {
return csstreeWalkSkip;
}
csstree.walk(cssNode, (ruleNode) => {
if (ruleNode.type === 'Rule') {
rules.push(...parseRule(ruleNode, dynamic || true));
return csstreeWalkSkip;
}
});
return csstreeWalkSkip;
}
});
return rules;
};
/**
* @type {(css: string) => StylesheetDeclaration[]}
*/
const parseStyleDeclarations = (css) => {
/** @type {StylesheetDeclaration[]} */
const declarations = [];
const ast = csstree.parse(css, {
context: 'declarationList',
parseValue: false,
});
csstree.walk(ast, (cssNode) => {
if (cssNode.type === 'Declaration') {
declarations.push({
name: cssNode.property,
value: csstree.generate(cssNode.value),
important: cssNode.important === true,
});
}
});
return declarations;
};
/**
* @param {Stylesheet} stylesheet
* @param {XastElement} node
* @returns {ComputedStyles}
*/
const computeOwnStyle = (stylesheet, node) => {
/** @type {ComputedStyles} */
const computedStyle = {};
const importantStyles = new Map();
// collect attributes
for (const [name, value] of Object.entries(node.attributes)) {
if (attrsGroups.presentation.has(name)) {
computedStyle[name] = { type: 'static', inherited: false, value };
importantStyles.set(name, false);
}
}
// collect matching rules
for (const { selector, declarations, dynamic } of stylesheet.rules) {
if (matches(node, selector)) {
for (const { name, value, important } of declarations) {
const computed = computedStyle[name];
if (computed && computed.type === 'dynamic') {
continue;
}
if (dynamic) {
computedStyle[name] = { type: 'dynamic', inherited: false };
continue;
}
if (
computed == null ||
important === true ||
importantStyles.get(name) === false
) {
computedStyle[name] = { type: 'static', inherited: false, value };
importantStyles.set(name, important);
}
}
}
}
// collect inline styles
const styleDeclarations =
node.attributes.style == null
? []
: parseStyleDeclarations(node.attributes.style);
for (const { name, value, important } of styleDeclarations) {
const computed = computedStyle[name];
if (computed && computed.type === 'dynamic') {
continue;
}
if (
computed == null ||
important === true ||
importantStyles.get(name) === false
) {
computedStyle[name] = { type: 'static', inherited: false, value };
importantStyles.set(name, important);
}
}
return computedStyle;
};
/**
* Compares selector specificities.
* Derived from https://github.com/keeganstreet/specificity/blob/8757133ddd2ed0163f120900047ff0f92760b536/specificity.js#L207
*
* @param {Specificity} a
* @param {Specificity} b
* @returns {number}
*/
const compareSpecificity = (a, b) => {
for (let i = 0; i < 4; i += 1) {
if (a[i] < b[i]) {
return -1;
} else if (a[i] > b[i]) {
return 1;
}
}
return 0;
};
exports.compareSpecificity = compareSpecificity;
/**
* @type {(root: XastRoot) => Stylesheet}
*/
const collectStylesheet = (root) => {
/** @type {StylesheetRule[]} */
const rules = [];
/** @type {Map<XastElement, XastParent>} */
const parents = new Map();
visit(root, {
element: {
enter: (node, parentNode) => {
parents.set(node, parentNode);
if (node.name !== 'style') {
return;
}
if (
node.attributes.type == null ||
node.attributes.type === '' ||
node.attributes.type === 'text/css'
) {
const dynamic =
node.attributes.media != null && node.attributes.media !== 'all';
for (const child of node.children) {
if (child.type === 'text' || child.type === 'cdata') {
rules.push(...parseStylesheet(child.value, dynamic));
}
}
}
},
},
});
// sort by selectors specificity
rules.sort((a, b) => compareSpecificity(a.specificity, b.specificity));
return { rules, parents };
};
exports.collectStylesheet = collectStylesheet;
/**
* @param {Stylesheet} stylesheet
* @param {XastElement} node
* @returns {ComputedStyles}
*/
const computeStyle = (stylesheet, node) => {
const { parents } = stylesheet;
const computedStyles = computeOwnStyle(stylesheet, node);
let parent = parents.get(node);
while (parent != null && parent.type !== 'root') {
const inheritedStyles = computeOwnStyle(stylesheet, parent);
for (const [name, computed] of Object.entries(inheritedStyles)) {
if (
computedStyles[name] == null &&
inheritableAttrs.has(name) &&
!presentationNonInheritableGroupAttrs.has(name)
) {
computedStyles[name] = { ...computed, inherited: true };
}
}
parent = parents.get(parent);
}
return computedStyles;
};
exports.computeStyle = computeStyle;
/**
* Determines if the CSS selector includes or traverses the given attribute.
*
* Classes and IDs are generated as attribute selectors, so you can check for
* if a `.class` or `#id` is included by passing `name=class` or `name=id`
* respectively.
*
* @param {csstree.ListItem<csstree.CssNode>|string} selector
* @param {string} name
* @param {?string} value
* @param {boolean} traversed
* @returns {boolean}
*/
const includesAttrSelector = (
selector,
name,
value = null,
traversed = false,
) => {
const selectors =
typeof selector === 'string'
? csswhat.parse(selector)
: csswhat.parse(csstree.generate(selector.data));
for (const subselector of selectors) {
const hasAttrSelector = subselector.some((segment, index) => {
if (traversed) {
if (index === subselector.length - 1) {
return false;
}
const isNextTraversal = csswhat.isTraversal(subselector[index + 1]);
if (!isNextTraversal) {
return false;
}
}
if (segment.type !== 'attribute' || segment.name !== name) {
return false;
}
return value == null ? true : segment.value === value;
});
if (hasAttrSelector) {
return true;
}
}
return false;
};
exports.includesAttrSelector = includesAttrSelector;
+85
View File
@@ -0,0 +1,85 @@
'use strict';
const os = require('os');
const fs = require('fs');
const { pathToFileURL } = require('url');
const path = require('path');
const { optimize: optimizeAgnostic } = require('./svgo.js');
const importConfig = async (configFile) => {
let config;
// at the moment dynamic import may randomly fail with segfault
// to workaround this for some users .cjs extension is loaded
// exclusively with require
if (configFile.endsWith('.cjs')) {
config = require(configFile);
} else {
// dynamic import expects file url instead of path and may fail
// when windows path is provided
const { default: imported } = await import(pathToFileURL(configFile));
config = imported;
}
if (config == null || typeof config !== 'object' || Array.isArray(config)) {
throw Error(`Invalid config file "${configFile}"`);
}
return config;
};
const isFile = async (file) => {
try {
const stats = await fs.promises.stat(file);
return stats.isFile();
} catch {
return false;
}
};
const loadConfig = async (configFile, cwd = process.cwd()) => {
if (configFile != null) {
if (path.isAbsolute(configFile)) {
return await importConfig(configFile);
} else {
return await importConfig(path.join(cwd, configFile));
}
}
let dir = cwd;
// eslint-disable-next-line no-constant-condition
while (true) {
const js = path.join(dir, 'svgo.config.js');
if (await isFile(js)) {
return await importConfig(js);
}
const mjs = path.join(dir, 'svgo.config.mjs');
if (await isFile(mjs)) {
return await importConfig(mjs);
}
const cjs = path.join(dir, 'svgo.config.cjs');
if (await isFile(cjs)) {
return await importConfig(cjs);
}
const parent = path.dirname(dir);
if (dir === parent) {
return null;
}
dir = parent;
}
};
exports.loadConfig = loadConfig;
const optimize = (input, config) => {
if (config == null) {
config = {};
}
if (typeof config !== 'object') {
throw Error('Config should be an object');
}
return optimizeAgnostic(input, {
...config,
js2svg: {
// platform specific default for end of line
eol: os.EOL === '\r\n' ? 'crlf' : 'lf',
...config.js2svg,
},
});
};
exports.optimize = optimize;
+102
View File
@@ -0,0 +1,102 @@
'use strict';
const { parseSvg } = require('./parser.js');
const { stringifySvg } = require('./stringifier.js');
const { builtin } = require('./builtin.js');
const { invokePlugins } = require('./svgo/plugins.js');
const { encodeSVGDatauri } = require('./svgo/tools.js');
const pluginsMap = {};
for (const plugin of builtin) {
pluginsMap[plugin.name] = plugin;
}
const resolvePluginConfig = (plugin) => {
if (typeof plugin === 'string') {
// resolve builtin plugin specified as string
const builtinPlugin = pluginsMap[plugin];
if (builtinPlugin == null) {
throw Error(`Unknown builtin plugin "${plugin}" specified.`);
}
return {
name: plugin,
params: {},
fn: builtinPlugin.fn,
};
}
if (typeof plugin === 'object' && plugin != null) {
if (plugin.name == null) {
throw Error(`Plugin name should be specified`);
}
// use custom plugin implementation
let fn = plugin.fn;
if (fn == null) {
// resolve builtin plugin implementation
const builtinPlugin = pluginsMap[plugin.name];
if (builtinPlugin == null) {
throw Error(`Unknown builtin plugin "${plugin.name}" specified.`);
}
fn = builtinPlugin.fn;
}
return {
name: plugin.name,
params: plugin.params,
fn,
};
}
return null;
};
const optimize = (input, config) => {
if (config == null) {
config = {};
}
if (typeof config !== 'object') {
throw Error('Config should be an object');
}
const maxPassCount = config.multipass ? 10 : 1;
let prevResultSize = Number.POSITIVE_INFINITY;
let output = '';
const info = {};
if (config.path != null) {
info.path = config.path;
}
for (let i = 0; i < maxPassCount; i += 1) {
info.multipassCount = i;
const ast = parseSvg(input, config.path);
const plugins = config.plugins || ['preset-default'];
if (!Array.isArray(plugins)) {
throw Error(
'malformed config, `plugins` property must be an array.\nSee more info here: https://github.com/svg/svgo#configuration',
);
}
const resolvedPlugins = plugins
.filter((plugin) => plugin != null)
.map(resolvePluginConfig);
if (resolvedPlugins.length < plugins.length) {
console.warn(
'Warning: plugins list includes null or undefined elements, these will be ignored.',
);
}
const globalOverrides = {};
if (config.floatPrecision != null) {
globalOverrides.floatPrecision = config.floatPrecision;
}
invokePlugins(ast, info, resolvedPlugins, null, globalOverrides);
output = stringifySvg(ast, config.js2svg);
if (output.length < prevResultSize) {
input = output;
prevResultSize = output.length;
} else {
break;
}
}
if (config.datauri) {
output = encodeSVGDatauri(output, config.datauri);
}
return {
data: output,
};
};
exports.optimize = optimize;
+120
View File
@@ -0,0 +1,120 @@
'use strict';
const isTag = (node) => {
return node.type === 'element';
};
const existsOne = (test, elems) => {
return elems.some((elem) => {
if (isTag(elem)) {
return test(elem) || existsOne(test, getChildren(elem));
} else {
return false;
}
});
};
const getAttributeValue = (elem, name) => {
return elem.attributes[name];
};
const getChildren = (node) => {
return node.children || [];
};
const getName = (elemAst) => {
return elemAst.name;
};
const getParent = (node) => {
return node.parentNode || null;
};
const getSiblings = (elem) => {
var parent = getParent(elem);
return parent ? getChildren(parent) : [];
};
const getText = (node) => {
if (node.children[0].type === 'text' && node.children[0].type === 'cdata') {
return node.children[0].value;
}
return '';
};
const hasAttrib = (elem, name) => {
return elem.attributes[name] !== undefined;
};
const removeSubsets = (nodes) => {
let idx = nodes.length;
let node;
let ancestor;
let replace;
// Check if each node (or one of its ancestors) is already contained in the
// array.
while (--idx > -1) {
node = ancestor = nodes[idx];
// Temporarily remove the node under consideration
nodes[idx] = null;
replace = true;
while (ancestor) {
if (nodes.includes(ancestor)) {
replace = false;
nodes.splice(idx, 1);
break;
}
ancestor = getParent(ancestor);
}
// If the node has been found to be unique, re-insert it.
if (replace) {
nodes[idx] = node;
}
}
return nodes;
};
const findAll = (test, elems) => {
const result = [];
for (const elem of elems) {
if (isTag(elem)) {
if (test(elem)) {
result.push(elem);
}
result.push(...findAll(test, getChildren(elem)));
}
}
return result;
};
const findOne = (test, elems) => {
for (const elem of elems) {
if (isTag(elem)) {
if (test(elem)) {
return elem;
}
const result = findOne(test, getChildren(elem));
if (result) {
return result;
}
}
}
return null;
};
const svgoCssSelectAdapter = {
isTag,
existsOne,
getAttributeValue,
getChildren,
getName,
getParent,
getSiblings,
getText,
hasAttrib,
removeSubsets,
findAll,
findOne,
};
module.exports = svgoCssSelectAdapter;
+61
View File
@@ -0,0 +1,61 @@
'use strict';
const { visit } = require('../xast.js');
/**
* Plugins engine.
*
* @module plugins
*
* @param {Object} ast input ast
* @param {Object} info extra information
* @param {Array} plugins plugins object from config
* @return {Object} output ast
*/
const invokePlugins = (ast, info, plugins, overrides, globalOverrides) => {
for (const plugin of plugins) {
const override = overrides?.[plugin.name];
if (override === false) {
continue;
}
const params = { ...plugin.params, ...globalOverrides, ...override };
const visitor = plugin.fn(ast, params, info);
if (visitor != null) {
visit(ast, visitor);
}
}
};
exports.invokePlugins = invokePlugins;
const createPreset = ({ name, plugins }) => {
return {
name,
fn: (ast, params, info) => {
const { floatPrecision, overrides } = params;
const globalOverrides = {};
if (floatPrecision != null) {
globalOverrides.floatPrecision = floatPrecision;
}
if (overrides) {
const pluginNames = plugins.map(({ name }) => name);
for (const pluginName of Object.keys(overrides)) {
if (!pluginNames.includes(pluginName)) {
console.warn(
`You are trying to configure ${pluginName} which is not part of ${name}.\n` +
`Try to put it before or after, for example\n\n` +
`plugins: [\n` +
` {\n` +
` name: '${name}',\n` +
` },\n` +
` '${pluginName}'\n` +
`]\n`,
);
}
}
}
invokePlugins(ast, info, plugins, overrides, globalOverrides);
},
};
};
exports.createPreset = createPreset;
+245
View File
@@ -0,0 +1,245 @@
'use strict';
/**
* @typedef {import('../../lib/types').XastElement} XastElement
* @typedef {import('../types').PathDataCommand} PathDataCommand
* @typedef {import('../types').DataUri} DataUri
*/
const { attrsGroups, referencesProps } = require('../../plugins/_collections');
const regReferencesUrl = /\burl\((["'])?#(.+?)\1\)/g;
const regReferencesHref = /^#(.+?)$/;
const regReferencesBegin = /(\w+)\.[a-zA-Z]/;
/**
* Encode plain SVG data string into Data URI string.
*
* @type {(str: string, type?: DataUri) => string}
*/
exports.encodeSVGDatauri = (str, type) => {
var 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.
*
* @type {(str: string) => string}
*/
exports.decodeSVGDatauri = (str) => {
var regexp = /data:image\/svg\+xml(;charset=[^;,]*)?(;base64)?,(.*)/;
var match = regexp.exec(str);
// plain string
if (!match) return str;
var 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;
};
/**
* @typedef {{
* noSpaceAfterFlags?: boolean,
* leadingZero?: boolean,
* negativeExtraSpace?: boolean
* }} CleanupOutDataParams
*/
/**
* Convert a row of numbers to an optimized string view.
*
* @example
* [0, -1, .5, .5] → "0-1 .5.5"
*
* @type {(data: number[], params: CleanupOutDataParams, command?: PathDataCommand) => string}
*/
exports.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 'arcto' 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')) {
var 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
*/
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;
};
exports.removeLeadingZero = removeLeadingZero;
/**
* 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 {XastElement} node Current node to check against.
* @returns {boolean} If the current node contains scripts.
*/
const hasScripts = (node) => {
if (node.name === 'script' && node.children.length !== 0) {
return true;
}
if (node.name === 'a') {
const hasJsLinks = Object.entries(node.attributes).some(
([attrKey, attrValue]) =>
(attrKey === 'href' || attrKey.endsWith(':href')) &&
attrValue != null &&
attrValue.trimStart().startsWith('javascript:'),
);
if (hasJsLinks) {
return true;
}
}
const eventAttrs = [
...attrsGroups.animationEvent,
...attrsGroups.documentEvent,
...attrsGroups.documentElementEvent,
...attrsGroups.globalEvent,
...attrsGroups.graphicalEvent,
];
return eventAttrs.some((attr) => node.attributes[attr] != null);
};
exports.hasScripts = hasScripts;
/**
* 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.
*/
const includesUrlReference = (body) => {
return new RegExp(regReferencesUrl).test(body);
};
exports.includesUrlReference = includesUrlReference;
/**
* @param {string} attribute
* @param {string} value
* @returns {string[]}
*/
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));
};
exports.findReferences = findReferences;
/**
* Does the same as {@link Number.toFixed} but without casting
* the return value to a string.
*
* @param {number} num
* @param {number} precision
* @returns {number}
*/
const toFixed = (num, precision) => {
const pow = 10 ** precision;
return Math.round(num * pow) / pow;
};
exports.toFixed = toFixed;
+87
View File
@@ -0,0 +1,87 @@
'use strict';
/**
* @typedef {import('./types').XastNode} XastNode
* @typedef {import('./types').XastChild} XastChild
* @typedef {import('./types').XastParent} XastParent
* @typedef {import('./types').Visitor} Visitor
*/
const { selectAll, selectOne, is } = require('css-select');
const xastAdaptor = require('./svgo/css-select-adapter.js');
const cssSelectOptions = {
xmlMode: true,
adapter: xastAdaptor,
};
/**
* @type {(node: XastNode, selector: string) => XastChild[]}
*/
const querySelectorAll = (node, selector) => {
return selectAll(selector, node, cssSelectOptions);
};
exports.querySelectorAll = querySelectorAll;
/**
* @type {(node: XastNode, selector: string) => ?XastChild}
*/
const querySelector = (node, selector) => {
return selectOne(selector, node, cssSelectOptions);
};
exports.querySelector = querySelector;
/**
* @type {(node: XastChild, selector: string) => boolean}
*/
const matches = (node, selector) => {
return is(node, selector, cssSelectOptions);
};
exports.matches = matches;
const visitSkip = Symbol();
exports.visitSkip = visitSkip;
/**
* @type {(node: XastNode, visitor: Visitor, parentNode?: any) => void}
*/
const visit = (node, visitor, parentNode) => {
const callbacks = visitor[node.type];
if (callbacks && callbacks.enter) {
// @ts-ignore hard to infer
const symbol = callbacks.enter(node, parentNode);
if (symbol === visitSkip) {
return;
}
}
// visit root children
if (node.type === 'root') {
// copy children array to not loose cursor when children is spliced
for (const child of node.children) {
visit(child, visitor, node);
}
}
// visit element children if still attached to parent
if (node.type === 'element') {
if (parentNode.children.includes(node)) {
for (const child of node.children) {
visit(child, visitor, node);
}
}
}
if (callbacks && callbacks.exit) {
// @ts-ignore hard to infer
callbacks.exit(node, parentNode);
}
};
exports.visit = visit;
/**
* @param {XastChild} node
* @param {XastParent} parentNode
*/
const detachNodeFromParent = (node, parentNode) => {
// avoid splice to not break for loops
parentNode.children = parentNode.children.filter((child) => child !== node);
};
exports.detachNodeFromParent = detachNodeFromParent;
+108
View File
@@ -0,0 +1,108 @@
{
"packageManager": "yarn@2.4.3",
"name": "svgo",
"version": "3.3.2",
"description": "Nodejs-based tool for optimizing SVG vector graphics files",
"license": "MIT",
"keywords": [
"svgo",
"svg",
"optimize",
"minify"
],
"homepage": "https://svgo.dev",
"bugs": {
"url": "https://github.com/svg/svgo/issues"
},
"author": {
"name": "Kir Belevich",
"email": "kir@belevi.ch",
"url": "https://github.com/deepsweet"
},
"contributors": [
{
"name": "Sergey Belov",
"email": "peimei@ya.ru",
"url": "https://github.com/arikon"
},
{
"name": "Lev Solntsev",
"email": "lev.sun@ya.ru",
"url": "https://github.com/GreLI"
},
{
"name": "Bogdan Chadkin",
"email": "trysound@yandex.ru",
"url": "https://github.com/TrySound"
},
{
"name": "Seth Falco",
"email": "seth@falco.fun",
"url": "https://falco.fun/"
}
],
"repository": {
"type": "git",
"url": "git://github.com/svg/svgo.git"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/svgo"
},
"main": "./lib/svgo-node.js",
"bin": "./bin/svgo",
"types": "./lib/svgo.d.ts",
"files": [
"bin",
"lib",
"plugins",
"dist",
"!**/*.test.js"
],
"engines": {
"node": ">=14.0.0"
},
"scripts": {
"test": "NODE_OPTIONS=--experimental-vm-modules jest --maxWorkers=4 --coverage",
"lint": "eslint --ignore-path .gitignore . && prettier --check . --ignore-path .gitignore",
"fix": "eslint --ignore-path .gitignore --fix . && prettier --write . --ignore-path .gitignore",
"typecheck": "tsc",
"test-browser": "rollup -c && node ./test/browser.js",
"test-regression": "node ./test/regression-extract.js && NO_DIFF=1 node ./test/regression.js",
"prepublishOnly": "rm -rf dist && rollup -c",
"qa": "yarn lint && yarn typecheck && yarn test && yarn test-browser && yarn test-regression"
},
"jest": {
"coveragePathIgnorePatterns": [
"fixtures"
]
},
"dependencies": {
"@trysound/sax": "0.2.0",
"commander": "^7.2.0",
"css-select": "^5.1.0",
"css-tree": "^2.3.1",
"css-what": "^6.1.0",
"csso": "^5.0.5",
"picocolors": "^1.0.0"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^22.0.2",
"@rollup/plugin-node-resolve": "^14.1.0",
"@types/css-tree": "^2.3.4",
"@types/csso": "^5.0.4",
"@types/jest": "^29.5.5",
"del": "^6.0.0",
"eslint": "^8.55.0",
"jest": "^29.5.5",
"node-fetch": "^2.7.0",
"pixelmatch": "^5.3.0",
"playwright": "^1.40.1",
"pngjs": "^7.0.0",
"prettier": "^3.1.1",
"rollup": "^2.79.1",
"rollup-plugin-terser": "^7.0.2",
"tar-stream": "^3.1.6",
"typescript": "^5.3.3"
}
}
File diff suppressed because it is too large Load Diff
+815
View File
@@ -0,0 +1,815 @@
'use strict';
/**
* @typedef {import('../lib/types').XastElement} XastElement
* @typedef {import('../lib/types').PathDataItem} PathDataItem
*/
const { parsePathData, stringifyPathData } = require('../lib/path.js');
/**
* @type {[number, number]}
*/
var prevCtrlPoint;
/**
* Convert path string to JS representation.
*
* @type {(path: XastElement) => PathDataItem[]}
*/
const path2js = (path) => {
// @ts-ignore legacy
if (path.pathJS) return path.pathJS;
/**
* @type {PathDataItem[]}
*/
const pathData = []; // JS representation of the path data
const newPathData = parsePathData(path.attributes.d);
for (const { command, args } of newPathData) {
pathData.push({ command, args });
}
// First moveto is actually absolute. Subsequent coordinates were separated above.
if (pathData.length && pathData[0].command == 'm') {
pathData[0].command = 'M';
}
// @ts-ignore legacy
path.pathJS = pathData;
return pathData;
};
exports.path2js = path2js;
/**
* Convert relative Path data to absolute.
*
* @type {(data: PathDataItem[]) => PathDataItem[]}
*
*/
const convertRelativeToAbsolute = (data) => {
/**
* @type {PathDataItem[]}
*/
const newData = [];
let start = [0, 0];
let cursor = [0, 0];
for (let { command, args } of data) {
args = args.slice();
// moveto (x y)
if (command === 'm') {
args[0] += cursor[0];
args[1] += cursor[1];
command = 'M';
}
if (command === 'M') {
cursor[0] = args[0];
cursor[1] = args[1];
start[0] = cursor[0];
start[1] = cursor[1];
}
// horizontal lineto (x)
if (command === 'h') {
args[0] += cursor[0];
command = 'H';
}
if (command === 'H') {
cursor[0] = args[0];
}
// vertical lineto (y)
if (command === 'v') {
args[0] += cursor[1];
command = 'V';
}
if (command === 'V') {
cursor[1] = args[0];
}
// lineto (x y)
if (command === 'l') {
args[0] += cursor[0];
args[1] += cursor[1];
command = 'L';
}
if (command === 'L') {
cursor[0] = args[0];
cursor[1] = args[1];
}
// curveto (x1 y1 x2 y2 x y)
if (command === 'c') {
args[0] += cursor[0];
args[1] += cursor[1];
args[2] += cursor[0];
args[3] += cursor[1];
args[4] += cursor[0];
args[5] += cursor[1];
command = 'C';
}
if (command === 'C') {
cursor[0] = args[4];
cursor[1] = args[5];
}
// smooth curveto (x2 y2 x y)
if (command === 's') {
args[0] += cursor[0];
args[1] += cursor[1];
args[2] += cursor[0];
args[3] += cursor[1];
command = 'S';
}
if (command === 'S') {
cursor[0] = args[2];
cursor[1] = args[3];
}
// quadratic Bézier curveto (x1 y1 x y)
if (command === 'q') {
args[0] += cursor[0];
args[1] += cursor[1];
args[2] += cursor[0];
args[3] += cursor[1];
command = 'Q';
}
if (command === 'Q') {
cursor[0] = args[2];
cursor[1] = args[3];
}
// smooth quadratic Bézier curveto (x y)
if (command === 't') {
args[0] += cursor[0];
args[1] += cursor[1];
command = 'T';
}
if (command === 'T') {
cursor[0] = args[0];
cursor[1] = args[1];
}
// elliptical arc (rx ry x-axis-rotation large-arc-flag sweep-flag x y)
if (command === 'a') {
args[5] += cursor[0];
args[6] += cursor[1];
command = 'A';
}
if (command === 'A') {
cursor[0] = args[5];
cursor[1] = args[6];
}
// closepath
if (command === 'z' || command === 'Z') {
cursor[0] = start[0];
cursor[1] = start[1];
command = 'z';
}
newData.push({ command, args });
}
return newData;
};
/**
* @typedef {{ floatPrecision?: number, noSpaceAfterFlags?: boolean }} Js2PathParams
*/
/**
* Convert path array to string.
*
* @type {(path: XastElement, data: PathDataItem[], params: Js2PathParams) => void}
*/
exports.js2path = function (path, data, params) {
// @ts-ignore legacy
path.pathJS = data;
const pathData = [];
for (const item of data) {
// remove moveto commands which are followed by moveto commands
if (
pathData.length !== 0 &&
(item.command === 'M' || item.command === 'm')
) {
const last = pathData[pathData.length - 1];
if (last.command === 'M' || last.command === 'm') {
pathData.pop();
}
}
pathData.push({
command: item.command,
args: item.args,
});
}
path.attributes.d = stringifyPathData({
pathData,
precision: params.floatPrecision,
disableSpaceAfterFlags: params.noSpaceAfterFlags,
});
};
/**
* @type {(dest: number[], source: number[]) => number[]}
*/
function set(dest, source) {
dest[0] = source[source.length - 2];
dest[1] = source[source.length - 1];
return dest;
}
/**
* Checks if two paths have an intersection by checking convex hulls
* collision using Gilbert-Johnson-Keerthi distance algorithm
* https://web.archive.org/web/20180822200027/http://entropyinteractive.com/2011/04/gjk-algorithm/
*
* @type {(path1: PathDataItem[], path2: PathDataItem[]) => boolean}
*/
exports.intersects = function (path1, path2) {
// Collect points of every subpath.
const points1 = gatherPoints(convertRelativeToAbsolute(path1));
const points2 = gatherPoints(convertRelativeToAbsolute(path2));
// Axis-aligned bounding box check.
if (
points1.maxX <= points2.minX ||
points2.maxX <= points1.minX ||
points1.maxY <= points2.minY ||
points2.maxY <= points1.minY ||
points1.list.every((set1) => {
return points2.list.every((set2) => {
return (
set1.list[set1.maxX][0] <= set2.list[set2.minX][0] ||
set2.list[set2.maxX][0] <= set1.list[set1.minX][0] ||
set1.list[set1.maxY][1] <= set2.list[set2.minY][1] ||
set2.list[set2.maxY][1] <= set1.list[set1.minY][1]
);
});
})
)
return false;
// Get a convex hull from points of each subpath. Has the most complexity O(n·log n).
const hullNest1 = points1.list.map(convexHull);
const hullNest2 = points2.list.map(convexHull);
// Check intersection of every subpath of the first path with every subpath of the second.
return hullNest1.some(function (hull1) {
if (hull1.list.length < 3) return false;
return hullNest2.some(function (hull2) {
if (hull2.list.length < 3) return false;
var simplex = [getSupport(hull1, hull2, [1, 0])], // create the initial simplex
direction = minus(simplex[0]); // set the direction to point towards the origin
var iterations = 1e4; // infinite loop protection, 10 000 iterations is more than enough
// eslint-disable-next-line no-constant-condition
while (true) {
if (iterations-- == 0) {
console.error(
'Error: infinite loop while processing mergePaths plugin.',
);
return true; // true is the safe value that means “do nothing with paths”
}
// add a new point
simplex.push(getSupport(hull1, hull2, direction));
// see if the new point was on the correct side of the origin
if (dot(direction, simplex[simplex.length - 1]) <= 0) return false;
// process the simplex
if (processSimplex(simplex, direction)) return true;
}
});
});
/**
* @type {(a: Point, b: Point, direction: number[]) => number[]}
*/
function getSupport(a, b, direction) {
return sub(supportPoint(a, direction), supportPoint(b, minus(direction)));
}
// Computes farthest polygon point in particular direction.
// Thanks to knowledge of min/max x and y coordinates we can choose a quadrant to search in.
// Since we're working on convex hull, the dot product is increasing until we find the farthest point.
/**
* @type {(polygon: Point, direction: number[]) => number[]}
*/
function supportPoint(polygon, direction) {
var index =
direction[1] >= 0
? direction[0] < 0
? polygon.maxY
: polygon.maxX
: direction[0] < 0
? polygon.minX
: polygon.minY,
max = -Infinity,
value;
while ((value = dot(polygon.list[index], direction)) > max) {
max = value;
index = ++index % polygon.list.length;
}
return polygon.list[(index || polygon.list.length) - 1];
}
};
/**
* @type {(simplex: number[][], direction: number[]) => boolean}
*/
function processSimplex(simplex, direction) {
// we only need to handle to 1-simplex and 2-simplex
if (simplex.length == 2) {
// 1-simplex
let a = simplex[1],
b = simplex[0],
AO = minus(simplex[1]),
AB = sub(b, a);
// AO is in the same direction as AB
if (dot(AO, AB) > 0) {
// get the vector perpendicular to AB facing O
set(direction, orth(AB, a));
} else {
set(direction, AO);
// only A remains in the simplex
simplex.shift();
}
} else {
// 2-simplex
let a = simplex[2], // [a, b, c] = simplex
b = simplex[1],
c = simplex[0],
AB = sub(b, a),
AC = sub(c, a),
AO = minus(a),
ACB = orth(AB, AC), // the vector perpendicular to AB facing away from C
ABC = orth(AC, AB); // the vector perpendicular to AC facing away from B
if (dot(ACB, AO) > 0) {
if (dot(AB, AO) > 0) {
// region 4
set(direction, ACB);
simplex.shift(); // simplex = [b, a]
} else {
// region 5
set(direction, AO);
simplex.splice(0, 2); // simplex = [a]
}
} else if (dot(ABC, AO) > 0) {
if (dot(AC, AO) > 0) {
// region 6
set(direction, ABC);
simplex.splice(1, 1); // simplex = [c, a]
} else {
// region 5 (again)
set(direction, AO);
simplex.splice(0, 2); // simplex = [a]
}
} // region 7
else return true;
}
return false;
}
/**
* @type {(v: number[]) => number[]}
*/
function minus(v) {
return [-v[0], -v[1]];
}
/**
* @type {(v1: number[], v2: number[]) => number[]}
*/
function sub(v1, v2) {
return [v1[0] - v2[0], v1[1] - v2[1]];
}
/**
* @type {(v1: number[], v2: number[]) => number}
*/
function dot(v1, v2) {
return v1[0] * v2[0] + v1[1] * v2[1];
}
/**
* @type {(v1: number[], v2: number[]) => number[]}
*/
function orth(v, from) {
var o = [-v[1], v[0]];
return dot(o, minus(from)) < 0 ? minus(o) : o;
}
/**
* @typedef {{
* list: number[][],
* minX: number,
* minY: number,
* maxX: number,
* maxY: number
* }} Point
*/
/**
* @typedef {{
* list: Point[],
* minX: number,
* minY: number,
* maxX: number,
* maxY: number
* }} Points
*/
/**
* @type {(pathData: PathDataItem[]) => Points}
*/
function gatherPoints(pathData) {
/**
* @type {Points}
*/
const points = { list: [], minX: 0, minY: 0, maxX: 0, maxY: 0 };
// Writes data about the extreme points on each axle
/**
* @type {(path: Point, point: number[]) => void}
*/
const addPoint = (path, point) => {
if (!path.list.length || point[1] > path.list[path.maxY][1]) {
path.maxY = path.list.length;
points.maxY = points.list.length
? Math.max(point[1], points.maxY)
: point[1];
}
if (!path.list.length || point[0] > path.list[path.maxX][0]) {
path.maxX = path.list.length;
points.maxX = points.list.length
? Math.max(point[0], points.maxX)
: point[0];
}
if (!path.list.length || point[1] < path.list[path.minY][1]) {
path.minY = path.list.length;
points.minY = points.list.length
? Math.min(point[1], points.minY)
: point[1];
}
if (!path.list.length || point[0] < path.list[path.minX][0]) {
path.minX = path.list.length;
points.minX = points.list.length
? Math.min(point[0], points.minX)
: point[0];
}
path.list.push(point);
};
for (let i = 0; i < pathData.length; i += 1) {
const pathDataItem = pathData[i];
let subPath =
points.list.length === 0
? { list: [], minX: 0, minY: 0, maxX: 0, maxY: 0 }
: points.list[points.list.length - 1];
let prev = i === 0 ? null : pathData[i - 1];
let basePoint =
subPath.list.length === 0 ? null : subPath.list[subPath.list.length - 1];
let data = pathDataItem.args;
let ctrlPoint = basePoint;
/**
* @type {(n: number, i: number) => number}
* TODO fix null hack
*/
const toAbsolute = (n, i) => n + (basePoint == null ? 0 : basePoint[i % 2]);
switch (pathDataItem.command) {
case 'M':
subPath = { list: [], minX: 0, minY: 0, maxX: 0, maxY: 0 };
points.list.push(subPath);
break;
case 'H':
if (basePoint != null) {
addPoint(subPath, [data[0], basePoint[1]]);
}
break;
case 'V':
if (basePoint != null) {
addPoint(subPath, [basePoint[0], data[0]]);
}
break;
case 'Q':
addPoint(subPath, data.slice(0, 2));
prevCtrlPoint = [data[2] - data[0], data[3] - data[1]]; // Save control point for shorthand
break;
case 'T':
if (
basePoint != null &&
prev != null &&
(prev.command == 'Q' || prev.command == 'T')
) {
ctrlPoint = [
basePoint[0] + prevCtrlPoint[0],
basePoint[1] + prevCtrlPoint[1],
];
addPoint(subPath, ctrlPoint);
prevCtrlPoint = [data[0] - ctrlPoint[0], data[1] - ctrlPoint[1]];
}
break;
case 'C':
if (basePoint != null) {
// Approximate quibic Bezier curve with middle points between control points
addPoint(subPath, [
0.5 * (basePoint[0] + data[0]),
0.5 * (basePoint[1] + data[1]),
]);
}
addPoint(subPath, [
0.5 * (data[0] + data[2]),
0.5 * (data[1] + data[3]),
]);
addPoint(subPath, [
0.5 * (data[2] + data[4]),
0.5 * (data[3] + data[5]),
]);
prevCtrlPoint = [data[4] - data[2], data[5] - data[3]]; // Save control point for shorthand
break;
case 'S':
if (
basePoint != null &&
prev != null &&
(prev.command == 'C' || prev.command == 'S')
) {
addPoint(subPath, [
basePoint[0] + 0.5 * prevCtrlPoint[0],
basePoint[1] + 0.5 * prevCtrlPoint[1],
]);
ctrlPoint = [
basePoint[0] + prevCtrlPoint[0],
basePoint[1] + prevCtrlPoint[1],
];
}
if (ctrlPoint != null) {
addPoint(subPath, [
0.5 * (ctrlPoint[0] + data[0]),
0.5 * (ctrlPoint[1] + data[1]),
]);
}
addPoint(subPath, [
0.5 * (data[0] + data[2]),
0.5 * (data[1] + data[3]),
]);
prevCtrlPoint = [data[2] - data[0], data[3] - data[1]];
break;
case 'A':
if (basePoint != null) {
// Convert the arc to bezier curves and use the same approximation
// @ts-ignore no idea what's going on here
var curves = a2c.apply(0, basePoint.concat(data));
for (
var cData;
(cData = curves.splice(0, 6).map(toAbsolute)).length;
) {
if (basePoint != null) {
addPoint(subPath, [
0.5 * (basePoint[0] + cData[0]),
0.5 * (basePoint[1] + cData[1]),
]);
}
addPoint(subPath, [
0.5 * (cData[0] + cData[2]),
0.5 * (cData[1] + cData[3]),
]);
addPoint(subPath, [
0.5 * (cData[2] + cData[4]),
0.5 * (cData[3] + cData[5]),
]);
if (curves.length) addPoint(subPath, (basePoint = cData.slice(-2)));
}
}
break;
}
// Save final command coordinates
if (data.length >= 2) addPoint(subPath, data.slice(-2));
}
return points;
}
/**
* Forms a convex hull from set of points of every subpath using monotone chain convex hull algorithm.
* https://en.wikibooks.org/wiki/Algorithm_Implementation/Geometry/Convex_hull/Monotone_chain
*
* @type {(points: Point) => Point}
*/
function convexHull(points) {
points.list.sort(function (a, b) {
return a[0] == b[0] ? a[1] - b[1] : a[0] - b[0];
});
var lower = [],
minY = 0,
bottom = 0;
for (let i = 0; i < points.list.length; i++) {
while (
lower.length >= 2 &&
cross(lower[lower.length - 2], lower[lower.length - 1], points.list[i]) <=
0
) {
lower.pop();
}
if (points.list[i][1] < points.list[minY][1]) {
minY = i;
bottom = lower.length;
}
lower.push(points.list[i]);
}
var upper = [],
maxY = points.list.length - 1,
top = 0;
for (let i = points.list.length; i--; ) {
while (
upper.length >= 2 &&
cross(upper[upper.length - 2], upper[upper.length - 1], points.list[i]) <=
0
) {
upper.pop();
}
if (points.list[i][1] > points.list[maxY][1]) {
maxY = i;
top = upper.length;
}
upper.push(points.list[i]);
}
// last points are equal to starting points of the other part
upper.pop();
lower.pop();
const hullList = lower.concat(upper);
/**
* @type {Point}
*/
const hull = {
list: hullList,
minX: 0, // by sorting
maxX: lower.length,
minY: bottom,
maxY: (lower.length + top) % hullList.length,
};
return hull;
}
/**
* @type {(o: number[], a: number[], b: number[]) => number}
*/
function cross(o, a, b) {
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
}
/**
* Based on code from Snap.svg (Apache 2 license). http://snapsvg.io/
* Thanks to Dmitry Baranovskiy for his great work!
*
* @type {(
* x1: number,
* y1: number,
* rx: number,
* ry: number,
* angle: number,
* large_arc_flag: number,
* sweep_flag: number,
* x2: number,
* y2: number,
* recursive: number[]
* ) => number[]}
*/
const a2c = (
x1,
y1,
rx,
ry,
angle,
large_arc_flag,
sweep_flag,
x2,
y2,
recursive,
) => {
// for more information of where this Math came from visit:
// https://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes
const _120 = (Math.PI * 120) / 180;
const rad = (Math.PI / 180) * (+angle || 0);
/**
* @type {number[]}
*/
let res = [];
/**
* @type {(x: number, y: number, rad: number) => number}
*/
const rotateX = (x, y, rad) => {
return x * Math.cos(rad) - y * Math.sin(rad);
};
/**
* @type {(x: number, y: number, rad: number) => number}
*/
const rotateY = (x, y, rad) => {
return x * Math.sin(rad) + y * Math.cos(rad);
};
if (!recursive) {
x1 = rotateX(x1, y1, -rad);
y1 = rotateY(x1, y1, -rad);
x2 = rotateX(x2, y2, -rad);
y2 = rotateY(x2, y2, -rad);
var x = (x1 - x2) / 2,
y = (y1 - y2) / 2;
var h = (x * x) / (rx * rx) + (y * y) / (ry * ry);
if (h > 1) {
h = Math.sqrt(h);
rx = h * rx;
ry = h * ry;
}
var rx2 = rx * rx;
var ry2 = ry * ry;
var k =
(large_arc_flag == sweep_flag ? -1 : 1) *
Math.sqrt(
Math.abs(
(rx2 * ry2 - rx2 * y * y - ry2 * x * x) / (rx2 * y * y + ry2 * x * x),
),
);
var cx = (k * rx * y) / ry + (x1 + x2) / 2;
var cy = (k * -ry * x) / rx + (y1 + y2) / 2;
var f1 = Math.asin(Number(((y1 - cy) / ry).toFixed(9)));
var f2 = Math.asin(Number(((y2 - cy) / ry).toFixed(9)));
f1 = x1 < cx ? Math.PI - f1 : f1;
f2 = x2 < cx ? Math.PI - f2 : f2;
f1 < 0 && (f1 = Math.PI * 2 + f1);
f2 < 0 && (f2 = Math.PI * 2 + f2);
if (sweep_flag && f1 > f2) {
f1 = f1 - Math.PI * 2;
}
if (!sweep_flag && f2 > f1) {
f2 = f2 - Math.PI * 2;
}
} else {
f1 = recursive[0];
f2 = recursive[1];
cx = recursive[2];
cy = recursive[3];
}
var df = f2 - f1;
if (Math.abs(df) > _120) {
var f2old = f2,
x2old = x2,
y2old = y2;
f2 = f1 + _120 * (sweep_flag && f2 > f1 ? 1 : -1);
x2 = cx + rx * Math.cos(f2);
y2 = cy + ry * Math.sin(f2);
res = a2c(x2, y2, rx, ry, angle, 0, sweep_flag, x2old, y2old, [
f2,
f2old,
cx,
cy,
]);
}
df = f2 - f1;
var c1 = Math.cos(f1),
s1 = Math.sin(f1),
c2 = Math.cos(f2),
s2 = Math.sin(f2),
t = Math.tan(df / 4),
hx = (4 / 3) * rx * t,
hy = (4 / 3) * ry * t,
m = [
-hx * s1,
hy * c1,
x2 + hx * s2 - x1,
y2 - hy * c2 - y1,
x2 - x1,
y2 - y1,
];
if (recursive) {
return m.concat(res);
} else {
res = m.concat(res);
var newres = [];
for (var i = 0, n = res.length; i < n; i++) {
newres[i] =
i % 2
? rotateY(res[i - 1], res[i], rad)
: rotateX(res[i], res[i + 1], rad);
}
return newres;
}
};
+410
View File
@@ -0,0 +1,410 @@
'use strict';
const { toFixed } = require('../lib/svgo/tools');
/**
* @typedef {{ name: string, data: number[] }} TransformItem
* @typedef {{
* convertToShorts: boolean,
* floatPrecision: number,
* transformPrecision: number,
* matrixToTransform: boolean,
* shortTranslate: boolean,
* shortScale: boolean,
* shortRotate: boolean,
* removeUseless: boolean,
* collapseIntoOne: boolean,
* leadingZero: boolean,
* negativeExtraSpace: boolean,
* }} TransformParams
*/
const transformTypes = new Set([
'matrix',
'rotate',
'scale',
'skewX',
'skewY',
'translate',
]);
const regTransformSplit =
/\s*(matrix|translate|scale|rotate|skewX|skewY)\s*\(\s*(.+?)\s*\)[\s,]*/;
const regNumericValues = /[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g;
/**
* Convert transform string to JS representation.
*
* @param {string} transformString
* @returns {TransformItem[]} Object representation of transform, or an empty array if it was malformed.
*/
exports.transform2js = (transformString) => {
/** @type {TransformItem[]} */
const transforms = [];
/** @type {?TransformItem} */
let currentTransform = null;
// split value into ['', 'translate', '10 50', '', 'scale', '2', '', 'rotate', '-45', '']
for (const item of transformString.split(regTransformSplit)) {
if (!item) {
continue;
}
if (transformTypes.has(item)) {
currentTransform = { name: item, data: [] };
transforms.push(currentTransform);
} else {
let num;
// then split it into [10, 50] and collect as context.data
while ((num = regNumericValues.exec(item))) {
num = Number(num);
if (currentTransform != null) {
currentTransform.data.push(num);
}
}
}
}
return currentTransform == null || currentTransform.data.length == 0
? []
: transforms;
};
/**
* Multiply transforms into one.
*
* @param {TransformItem[]} transforms
* @returns {TransformItem}
*/
exports.transformsMultiply = (transforms) => {
const matrixData = transforms.map((transform) => {
if (transform.name === 'matrix') {
return transform.data;
}
return transformToMatrix(transform);
});
const matrixTransform = {
name: 'matrix',
data:
matrixData.length > 0 ? matrixData.reduce(multiplyTransformMatrices) : [],
};
return matrixTransform;
};
/**
* Math utilities in radians.
*/
const mth = {
/**
* @param {number} deg
* @returns {number}
*/
rad: (deg) => {
return (deg * Math.PI) / 180;
},
/**
* @param {number} rad
* @returns {number}
*/
deg: (rad) => {
return (rad * 180) / Math.PI;
},
/**
* @param {number} deg
* @returns {number}
*/
cos: (deg) => {
return Math.cos(mth.rad(deg));
},
/**
* @param {number} val
* @param {number} floatPrecision
* @returns {number}
*/
acos: (val, floatPrecision) => {
return toFixed(mth.deg(Math.acos(val)), floatPrecision);
},
/**
* @param {number} deg
* @returns {number}
*/
sin: (deg) => {
return Math.sin(mth.rad(deg));
},
/**
* @param {number} val
* @param {number} floatPrecision
* @returns {number}
*/
asin: (val, floatPrecision) => {
return toFixed(mth.deg(Math.asin(val)), floatPrecision);
},
/**
* @param {number} deg
* @returns {number}
*/
tan: (deg) => {
return Math.tan(mth.rad(deg));
},
/**
* @param {number} val
* @param {number} floatPrecision
* @returns {number}
*/
atan: (val, floatPrecision) => {
return toFixed(mth.deg(Math.atan(val)), floatPrecision);
},
};
/**
* Decompose matrix into simple transforms.
*
* @param {TransformItem} transform
* @param {TransformParams} params
* @returns {TransformItem[]}
* @see https://frederic-wang.fr/decomposition-of-2d-transform-matrices.html
*/
exports.matrixToTransform = (transform, params) => {
const floatPrecision = params.floatPrecision;
const data = transform.data;
const transforms = [];
// [..., ..., ..., ..., tx, ty] → translate(tx, ty)
if (data[4] || data[5]) {
transforms.push({
name: 'translate',
data: data.slice(4, data[5] ? 6 : 5),
});
}
let sx = toFixed(Math.hypot(data[0], data[1]), params.transformPrecision);
let sy = toFixed(
(data[0] * data[3] - data[1] * data[2]) / sx,
params.transformPrecision,
);
const colsSum = data[0] * data[2] + data[1] * data[3];
const rowsSum = data[0] * data[1] + data[2] * data[3];
const scaleBefore = rowsSum !== 0 || sx === sy;
// [sx, 0, tan(a)·sy, sy, 0, 0] → skewX(a)·scale(sx, sy)
if (!data[1] && data[2]) {
transforms.push({
name: 'skewX',
data: [mth.atan(data[2] / sy, floatPrecision)],
});
// [sx, sx·tan(a), 0, sy, 0, 0] → skewY(a)·scale(sx, sy)
} else if (data[1] && !data[2]) {
transforms.push({
name: 'skewY',
data: [mth.atan(data[1] / data[0], floatPrecision)],
});
sx = data[0];
sy = data[3];
// [sx·cos(a), sx·sin(a), sy·-sin(a), sy·cos(a), x, y] → rotate(a[, cx, cy])·(scale or skewX) or
// [sx·cos(a), sy·sin(a), sx·-sin(a), sy·cos(a), x, y] → scale(sx, sy)·rotate(a[, cx, cy]) (if !scaleBefore)
} else if (!colsSum || (sx === 1 && sy === 1) || !scaleBefore) {
if (!scaleBefore) {
sx = Math.hypot(data[0], data[2]);
sy = Math.hypot(data[1], data[3]);
if (toFixed(data[0], params.transformPrecision) < 0) {
sx = -sx;
}
if (
data[3] < 0 ||
(Math.sign(data[1]) === Math.sign(data[2]) &&
toFixed(data[3], params.transformPrecision) === 0)
) {
sy = -sy;
}
transforms.push({ name: 'scale', data: [sx, sy] });
}
const angle = Math.min(Math.max(-1, data[0] / sx), 1);
const rotate = [
mth.acos(angle, floatPrecision) *
((scaleBefore ? 1 : sy) * data[1] < 0 ? -1 : 1),
];
if (rotate[0]) {
transforms.push({ name: 'rotate', data: rotate });
}
if (rowsSum && colsSum)
transforms.push({
name: 'skewX',
data: [mth.atan(colsSum / (sx * sx), floatPrecision)],
});
// rotate(a, cx, cy) can consume translate() within optional arguments cx, cy (rotation point)
if (rotate[0] && (data[4] || data[5])) {
transforms.shift();
const oneOverCos = 1 - data[0] / sx;
const sin = data[1] / (scaleBefore ? sx : sy);
const x = data[4] * (scaleBefore ? 1 : sy);
const y = data[5] * (scaleBefore ? 1 : sx);
const denom = (oneOverCos ** 2 + sin ** 2) * (scaleBefore ? 1 : sx * sy);
rotate.push(
(oneOverCos * x - sin * y) / denom,
(oneOverCos * y + sin * x) / denom,
);
}
// Too many transformations, return original matrix if it isn't just a scale/translate
} else if (data[1] || data[2]) {
return [transform];
}
if ((scaleBefore && (sx != 1 || sy != 1)) || !transforms.length) {
transforms.push({
name: 'scale',
data: sx == sy ? [sx] : [sx, sy],
});
}
return transforms;
};
/**
* Convert transform to the matrix data.
*
* @type {(transform: TransformItem) => number[] }
*/
const transformToMatrix = (transform) => {
if (transform.name === 'matrix') {
return transform.data;
}
switch (transform.name) {
case 'translate':
// [1, 0, 0, 1, tx, ty]
return [1, 0, 0, 1, transform.data[0], transform.data[1] || 0];
case 'scale':
// [sx, 0, 0, sy, 0, 0]
return [
transform.data[0],
0,
0,
transform.data[1] || transform.data[0],
0,
0,
];
case 'rotate':
// [cos(a), sin(a), -sin(a), cos(a), x, y]
var cos = mth.cos(transform.data[0]),
sin = mth.sin(transform.data[0]),
cx = transform.data[1] || 0,
cy = transform.data[2] || 0;
return [
cos,
sin,
-sin,
cos,
(1 - cos) * cx + sin * cy,
(1 - cos) * cy - sin * cx,
];
case 'skewX':
// [1, 0, tan(a), 1, 0, 0]
return [1, 0, mth.tan(transform.data[0]), 1, 0, 0];
case 'skewY':
// [1, tan(a), 0, 1, 0, 0]
return [1, mth.tan(transform.data[0]), 0, 1, 0, 0];
default:
throw Error(`Unknown transform ${transform.name}`);
}
};
/**
* Applies transformation to an arc. To do so, we represent ellipse as a matrix, multiply it
* by the transformation matrix and use a singular value decomposition to represent in a form
* rotate(θ)·scale(a b)·rotate(φ). This gives us new ellipse params a, b and θ.
* SVD is being done with the formulae provided by Wolffram|Alpha (svd {{m0, m2}, {m1, m3}})
*
* @type {(
* cursor: [x: number, y: number],
* arc: number[],
* transform: number[]
* ) => number[]}
*/
exports.transformArc = (cursor, arc, transform) => {
const x = arc[5] - cursor[0];
const y = arc[6] - cursor[1];
let a = arc[0];
let b = arc[1];
const rot = (arc[2] * Math.PI) / 180;
const cos = Math.cos(rot);
const sin = Math.sin(rot);
// skip if radius is 0
if (a > 0 && b > 0) {
let h =
Math.pow(x * cos + y * sin, 2) / (4 * a * a) +
Math.pow(y * cos - x * sin, 2) / (4 * b * b);
if (h > 1) {
h = Math.sqrt(h);
a *= h;
b *= h;
}
}
const ellipse = [a * cos, a * sin, -b * sin, b * cos, 0, 0];
const m = multiplyTransformMatrices(transform, ellipse);
// Decompose the new ellipse matrix
const lastCol = m[2] * m[2] + m[3] * m[3];
const squareSum = m[0] * m[0] + m[1] * m[1] + lastCol;
const root =
Math.hypot(m[0] - m[3], m[1] + m[2]) * Math.hypot(m[0] + m[3], m[1] - m[2]);
if (!root) {
// circle
arc[0] = arc[1] = Math.sqrt(squareSum / 2);
arc[2] = 0;
} else {
const majorAxisSqr = (squareSum + root) / 2;
const minorAxisSqr = (squareSum - root) / 2;
const major = Math.abs(majorAxisSqr - lastCol) > 1e-6;
const sub = (major ? majorAxisSqr : minorAxisSqr) - lastCol;
const rowsSum = m[0] * m[2] + m[1] * m[3];
const term1 = m[0] * sub + m[2] * rowsSum;
const term2 = m[1] * sub + m[3] * rowsSum;
arc[0] = Math.sqrt(majorAxisSqr);
arc[1] = Math.sqrt(minorAxisSqr);
arc[2] =
(((major ? term2 < 0 : term1 > 0) ? -1 : 1) *
Math.acos((major ? term1 : term2) / Math.hypot(term1, term2)) *
180) /
Math.PI;
}
if (transform[0] < 0 !== transform[3] < 0) {
// Flip the sweep flag if coordinates are being flipped horizontally XOR vertically
arc[4] = 1 - arc[4];
}
return arc;
};
/**
* Multiply transformation matrices.
*
* @type {(a: number[], b: number[]) => number[]}
*/
const multiplyTransformMatrices = (a, b) => {
return [
a[0] * b[0] + a[2] * b[1],
a[1] * b[0] + a[3] * b[1],
a[0] * b[2] + a[2] * b[3],
a[1] * b[2] + a[3] * b[3],
a[0] * b[4] + a[2] * b[5] + a[4],
a[1] * b[4] + a[3] * b[5] + a[5],
];
};
+82
View File
@@ -0,0 +1,82 @@
'use strict';
exports.name = 'addAttributesToSVGElement';
exports.description = 'adds attributes to an outer <svg> element';
var ENOCLS = `Error in plugin "addAttributesToSVGElement": absent parameters.
It should have a list of "attributes" or one "attribute".
Config example:
plugins: [
{
name: 'addAttributesToSVGElement',
params: {
attribute: "mySvg"
}
}
]
plugins: [
{
name: 'addAttributesToSVGElement',
params: {
attributes: ["mySvg", "size-big"]
}
}
]
plugins: [
{
name: 'addAttributesToSVGElement',
params: {
attributes: [
{
focusable: false
},
{
'data-image': icon
}
]
}
}
]
`;
/**
* Add attributes to an outer <svg> element. Example config:
*
* @author April Arcus
*
* @type {import('./plugins-types').Plugin<'addAttributesToSVGElement'>}
*/
exports.fn = (root, params) => {
if (!Array.isArray(params.attributes) && !params.attribute) {
console.error(ENOCLS);
return null;
}
const attributes = params.attributes || [params.attribute];
return {
element: {
enter: (node, parentNode) => {
if (node.name === 'svg' && parentNode.type === 'root') {
for (const attribute of attributes) {
if (typeof attribute === 'string') {
if (node.attributes[attribute] == null) {
// @ts-ignore disallow explicit nullable attribute value
node.attributes[attribute] = undefined;
}
}
if (typeof attribute === 'object') {
for (const key of Object.keys(attribute)) {
if (node.attributes[key] == null) {
// @ts-ignore disallow explicit nullable attribute value
node.attributes[key] = attribute[key];
}
}
}
}
}
},
},
};
};
+82
View File
@@ -0,0 +1,82 @@
'use strict';
exports.name = 'addClassesToSVGElement';
exports.description = 'adds classnames to an outer <svg> element';
var ENOCLS = `Error in plugin "addClassesToSVGElement": absent parameters.
It should have a list of classes in "classNames" or one "className".
Config example:
plugins: [
{
name: "addClassesToSVGElement",
params: {
className: "mySvg"
}
}
]
plugins: [
{
name: "addClassesToSVGElement",
params: {
classNames: ["mySvg", "size-big"]
}
}
]
`;
/**
* Add classnames to an outer <svg> element. Example config:
*
* plugins: [
* {
* name: "addClassesToSVGElement",
* params: {
* className: "mySvg"
* }
* }
* ]
*
* plugins: [
* {
* name: "addClassesToSVGElement",
* params: {
* classNames: ["mySvg", "size-big"]
* }
* }
* ]
*
* @author April Arcus
*
* @type {import('./plugins-types').Plugin<'addClassesToSVGElement'>}
*/
exports.fn = (root, params) => {
if (
!(Array.isArray(params.classNames) && params.classNames.some(String)) &&
!params.className
) {
console.error(ENOCLS);
return null;
}
const classNames = params.classNames || [params.className];
return {
element: {
enter: (node, parentNode) => {
if (node.name === 'svg' && parentNode.type === 'root') {
const classList = new Set(
node.attributes.class == null
? null
: node.attributes.class.split(' '),
);
for (const className of classNames) {
if (className != null) {
classList.add(className);
}
}
node.attributes.class = Array.from(classList).join(' ');
}
},
},
};
};
+388
View File
@@ -0,0 +1,388 @@
'use strict';
/**
* @typedef {import('../lib/types').PathDataItem} PathDataItem
* @typedef {import('../lib/types').XastElement} XastElement
*/
const { collectStylesheet, computeStyle } = require('../lib/style.js');
const {
transformsMultiply,
transform2js,
transformArc,
} = require('./_transforms.js');
const { path2js } = require('./_path.js');
const {
removeLeadingZero,
includesUrlReference,
} = require('../lib/svgo/tools.js');
const { referencesProps, attrsGroupsDefaults } = require('./_collections.js');
/**
* @typedef {PathDataItem[]} PathData
* @typedef {number[]} Matrix
*/
const regNumericValues = /[-+]?(\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g;
/**
* Apply transformation(s) to the Path data.
*
* @type {import('../lib/types').Plugin<{
* transformPrecision: number,
* applyTransformsStroked: boolean,
* }>}
*/
const applyTransforms = (root, params) => {
const stylesheet = collectStylesheet(root);
return {
element: {
enter: (node) => {
if (node.attributes.d == null) {
return;
}
// stroke and stroke-width can be redefined with <use>
if (node.attributes.id != null) {
return;
}
// if there are no 'stroke' attr and references to other objects such as
// gradients or clip-path which are also subjects to transform.
if (
node.attributes.transform == null ||
node.attributes.transform === '' ||
// styles are not considered when applying transform
// can be fixed properly with new style engine
node.attributes.style != null ||
Object.entries(node.attributes).some(
([name, value]) =>
referencesProps.has(name) && includesUrlReference(value),
)
) {
return;
}
const computedStyle = computeStyle(stylesheet, node);
const transformStyle = computedStyle.transform;
// Transform overridden in <style> tag which is not considered
if (
transformStyle.type === 'static' &&
transformStyle.value !== node.attributes.transform
) {
return;
}
const matrix = transformsMultiply(
transform2js(node.attributes.transform),
);
const stroke =
computedStyle.stroke?.type === 'static'
? computedStyle.stroke.value
: null;
const strokeWidth =
computedStyle['stroke-width']?.type === 'static'
? computedStyle['stroke-width'].value
: null;
const transformPrecision = params.transformPrecision;
if (
computedStyle.stroke?.type === 'dynamic' ||
computedStyle['stroke-width']?.type === 'dynamic'
) {
return;
}
const scale = Number(
Math.sqrt(
matrix.data[0] * matrix.data[0] + matrix.data[1] * matrix.data[1],
).toFixed(transformPrecision),
);
if (stroke && stroke != 'none') {
if (!params.applyTransformsStroked) {
return;
}
// stroke cannot be transformed with different vertical and horizontal scale or skew
if (
(matrix.data[0] !== matrix.data[3] ||
matrix.data[1] !== -matrix.data[2]) &&
(matrix.data[0] !== -matrix.data[3] ||
matrix.data[1] !== matrix.data[2])
) {
return;
}
// apply transform to stroke-width, stroke-dashoffset and stroke-dasharray
if (scale !== 1) {
if (node.attributes['vector-effect'] !== 'non-scaling-stroke') {
node.attributes['stroke-width'] = (
strokeWidth || attrsGroupsDefaults.presentation['stroke-width']
)
.trim()
.replace(regNumericValues, (num) =>
removeLeadingZero(Number(num) * scale),
);
if (node.attributes['stroke-dashoffset'] != null) {
node.attributes['stroke-dashoffset'] = node.attributes[
'stroke-dashoffset'
]
.trim()
.replace(regNumericValues, (num) =>
removeLeadingZero(Number(num) * scale),
);
}
if (node.attributes['stroke-dasharray'] != null) {
node.attributes['stroke-dasharray'] = node.attributes[
'stroke-dasharray'
]
.trim()
.replace(regNumericValues, (num) =>
removeLeadingZero(Number(num) * scale),
);
}
}
}
}
const pathData = path2js(node);
applyMatrixToPathData(pathData, matrix.data);
// remove transform attr
delete node.attributes.transform;
},
},
};
};
exports.applyTransforms = applyTransforms;
/**
* @type {(matrix: Matrix, x: number, y: number) => [number, number]}
*/
const transformAbsolutePoint = (matrix, x, y) => {
const newX = matrix[0] * x + matrix[2] * y + matrix[4];
const newY = matrix[1] * x + matrix[3] * y + matrix[5];
return [newX, newY];
};
/**
* @type {(matrix: Matrix, x: number, y: number) => [number, number]}
*/
const transformRelativePoint = (matrix, x, y) => {
const newX = matrix[0] * x + matrix[2] * y;
const newY = matrix[1] * x + matrix[3] * y;
return [newX, newY];
};
/**
* @type {(pathData: PathData, matrix: Matrix) => void}
*/
const applyMatrixToPathData = (pathData, matrix) => {
/**
* @type {[number, number]}
*/
const start = [0, 0];
/**
* @type {[number, number]}
*/
const cursor = [0, 0];
for (const pathItem of pathData) {
let { command, args } = pathItem;
// moveto (x y)
if (command === 'M') {
cursor[0] = args[0];
cursor[1] = args[1];
start[0] = cursor[0];
start[1] = cursor[1];
const [x, y] = transformAbsolutePoint(matrix, args[0], args[1]);
args[0] = x;
args[1] = y;
}
if (command === 'm') {
cursor[0] += args[0];
cursor[1] += args[1];
start[0] = cursor[0];
start[1] = cursor[1];
const [x, y] = transformRelativePoint(matrix, args[0], args[1]);
args[0] = x;
args[1] = y;
}
// horizontal lineto (x)
// convert to lineto to handle two-dimentional transforms
if (command === 'H') {
command = 'L';
args = [args[0], cursor[1]];
}
if (command === 'h') {
command = 'l';
args = [args[0], 0];
}
// vertical lineto (y)
// convert to lineto to handle two-dimentional transforms
if (command === 'V') {
command = 'L';
args = [cursor[0], args[0]];
}
if (command === 'v') {
command = 'l';
args = [0, args[0]];
}
// lineto (x y)
if (command === 'L') {
cursor[0] = args[0];
cursor[1] = args[1];
const [x, y] = transformAbsolutePoint(matrix, args[0], args[1]);
args[0] = x;
args[1] = y;
}
if (command === 'l') {
cursor[0] += args[0];
cursor[1] += args[1];
const [x, y] = transformRelativePoint(matrix, args[0], args[1]);
args[0] = x;
args[1] = y;
}
// curveto (x1 y1 x2 y2 x y)
if (command === 'C') {
cursor[0] = args[4];
cursor[1] = args[5];
const [x1, y1] = transformAbsolutePoint(matrix, args[0], args[1]);
const [x2, y2] = transformAbsolutePoint(matrix, args[2], args[3]);
const [x, y] = transformAbsolutePoint(matrix, args[4], args[5]);
args[0] = x1;
args[1] = y1;
args[2] = x2;
args[3] = y2;
args[4] = x;
args[5] = y;
}
if (command === 'c') {
cursor[0] += args[4];
cursor[1] += args[5];
const [x1, y1] = transformRelativePoint(matrix, args[0], args[1]);
const [x2, y2] = transformRelativePoint(matrix, args[2], args[3]);
const [x, y] = transformRelativePoint(matrix, args[4], args[5]);
args[0] = x1;
args[1] = y1;
args[2] = x2;
args[3] = y2;
args[4] = x;
args[5] = y;
}
// smooth curveto (x2 y2 x y)
if (command === 'S') {
cursor[0] = args[2];
cursor[1] = args[3];
const [x2, y2] = transformAbsolutePoint(matrix, args[0], args[1]);
const [x, y] = transformAbsolutePoint(matrix, args[2], args[3]);
args[0] = x2;
args[1] = y2;
args[2] = x;
args[3] = y;
}
if (command === 's') {
cursor[0] += args[2];
cursor[1] += args[3];
const [x2, y2] = transformRelativePoint(matrix, args[0], args[1]);
const [x, y] = transformRelativePoint(matrix, args[2], args[3]);
args[0] = x2;
args[1] = y2;
args[2] = x;
args[3] = y;
}
// quadratic Bézier curveto (x1 y1 x y)
if (command === 'Q') {
cursor[0] = args[2];
cursor[1] = args[3];
const [x1, y1] = transformAbsolutePoint(matrix, args[0], args[1]);
const [x, y] = transformAbsolutePoint(matrix, args[2], args[3]);
args[0] = x1;
args[1] = y1;
args[2] = x;
args[3] = y;
}
if (command === 'q') {
cursor[0] += args[2];
cursor[1] += args[3];
const [x1, y1] = transformRelativePoint(matrix, args[0], args[1]);
const [x, y] = transformRelativePoint(matrix, args[2], args[3]);
args[0] = x1;
args[1] = y1;
args[2] = x;
args[3] = y;
}
// smooth quadratic Bézier curveto (x y)
if (command === 'T') {
cursor[0] = args[0];
cursor[1] = args[1];
const [x, y] = transformAbsolutePoint(matrix, args[0], args[1]);
args[0] = x;
args[1] = y;
}
if (command === 't') {
cursor[0] += args[0];
cursor[1] += args[1];
const [x, y] = transformRelativePoint(matrix, args[0], args[1]);
args[0] = x;
args[1] = y;
}
// elliptical arc (rx ry x-axis-rotation large-arc-flag sweep-flag x y)
if (command === 'A') {
transformArc(cursor, args, matrix);
cursor[0] = args[5];
cursor[1] = args[6];
// reduce number of digits in rotation angle
if (Math.abs(args[2]) > 80) {
const a = args[0];
const rotation = args[2];
args[0] = args[1];
args[1] = a;
args[2] = rotation + (rotation > 0 ? -90 : 90);
}
const [x, y] = transformAbsolutePoint(matrix, args[5], args[6]);
args[5] = x;
args[6] = y;
}
if (command === 'a') {
transformArc([0, 0], args, matrix);
cursor[0] += args[5];
cursor[1] += args[6];
// reduce number of digits in rotation angle
if (Math.abs(args[2]) > 80) {
const a = args[0];
const rotation = args[2];
args[0] = args[1];
args[1] = a;
args[2] = rotation + (rotation > 0 ? -90 : 90);
}
const [x, y] = transformRelativePoint(matrix, args[5], args[6]);
args[5] = x;
args[6] = y;
}
// closepath
if (command === 'z' || command === 'Z') {
cursor[0] = start[0];
cursor[1] = start[1];
}
pathItem.command = command;
pathItem.args = args;
}
};
+49
View File
@@ -0,0 +1,49 @@
'use strict';
exports.name = 'cleanupAttrs';
exports.description =
'cleanups attributes from newlines, trailing and repeating spaces';
const regNewlinesNeedSpace = /(\S)\r?\n(\S)/g;
const regNewlines = /\r?\n/g;
const regSpaces = /\s{2,}/g;
/**
* Cleanup attributes values from newlines, trailing and repeating spaces.
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'cleanupAttrs'>}
*/
exports.fn = (root, params) => {
const { newlines = true, trim = true, spaces = true } = params;
return {
element: {
enter: (node) => {
for (const name of Object.keys(node.attributes)) {
if (newlines) {
// new line which requires a space instead of themself
node.attributes[name] = node.attributes[name].replace(
regNewlinesNeedSpace,
(match, p1, p2) => p1 + ' ' + p2,
);
// simple new line
node.attributes[name] = node.attributes[name].replace(
regNewlines,
'',
);
}
if (trim) {
node.attributes[name] = node.attributes[name].trim();
}
if (spaces) {
node.attributes[name] = node.attributes[name].replace(
regSpaces,
' ',
);
}
}
},
},
};
};
+165
View File
@@ -0,0 +1,165 @@
'use strict';
const csstree = require('css-tree');
const { visit } = require('../lib/xast.js');
exports.name = 'cleanupEnableBackground';
exports.description =
'remove or cleanup enable-background attribute when possible';
const regEnableBackground =
/^new\s0\s0\s([-+]?\d*\.?\d+([eE][-+]?\d+)?)\s([-+]?\d*\.?\d+([eE][-+]?\d+)?)$/;
/**
* Remove or cleanup enable-background attr which coincides with a width/height box.
*
* @see https://www.w3.org/TR/SVG11/filters.html#EnableBackgroundProperty
* @example
* <svg width="100" height="50" enable-background="new 0 0 100 50">
* ⬇
* <svg width="100" height="50">
* @author Kir Belevich
* @type {import('./plugins-types').Plugin<'cleanupEnableBackground'>}
*/
exports.fn = (root) => {
let hasFilter = false;
visit(root, {
element: {
enter: (node) => {
if (node.name === 'filter') {
hasFilter = true;
}
},
},
});
return {
element: {
enter: (node) => {
/** @type {?csstree.CssNode} */
let newStyle = null;
/** @type {?csstree.ListItem<csstree.CssNode>} */
let enableBackgroundDeclaration = null;
if (node.attributes.style != null) {
newStyle = csstree.parse(node.attributes.style, {
context: 'declarationList',
});
if (newStyle.type === 'DeclarationList') {
/** @type {csstree.ListItem<csstree.CssNode>[]} */
const enableBackgroundDeclarations = [];
csstree.walk(newStyle, (node, nodeItem) => {
if (
node.type === 'Declaration' &&
node.property === 'enable-background'
) {
enableBackgroundDeclarations.push(nodeItem);
enableBackgroundDeclaration = nodeItem;
}
});
for (let i = 0; i < enableBackgroundDeclarations.length - 1; i++) {
newStyle.children.remove(enableBackgroundDeclarations[i]);
}
}
}
if (!hasFilter) {
delete node.attributes['enable-background'];
if (newStyle?.type === 'DeclarationList') {
if (enableBackgroundDeclaration) {
newStyle.children.remove(enableBackgroundDeclaration);
}
if (newStyle.children.isEmpty) {
delete node.attributes.style;
} else {
node.attributes.style = csstree.generate(newStyle);
}
}
return;
}
const hasDimensions =
node.attributes.width != null && node.attributes.height != null;
if (
(node.name === 'svg' ||
node.name === 'mask' ||
node.name === 'pattern') &&
hasDimensions
) {
const attrValue = node.attributes['enable-background'];
const attrCleaned = cleanupValue(
attrValue,
node.name,
node.attributes.width,
node.attributes.height,
);
if (attrCleaned) {
node.attributes['enable-background'] = attrCleaned;
} else {
delete node.attributes['enable-background'];
}
if (
newStyle?.type === 'DeclarationList' &&
enableBackgroundDeclaration
) {
const styleValue = csstree.generate(
// @ts-ignore
enableBackgroundDeclaration.data.value,
);
const styleCleaned = cleanupValue(
styleValue,
node.name,
node.attributes.width,
node.attributes.height,
);
if (styleCleaned) {
// @ts-ignore
enableBackgroundDeclaration.data.value = {
type: 'Raw',
value: styleCleaned,
};
} else {
newStyle.children.remove(enableBackgroundDeclaration);
}
}
}
if (newStyle?.type === 'DeclarationList') {
if (newStyle.children.isEmpty) {
delete node.attributes.style;
} else {
node.attributes.style = csstree.generate(newStyle);
}
}
},
},
};
};
/**
* @param {string} value Value of a enable-background attribute or style declaration.
* @param {string} nodeName Name of the node the value was assigned to.
* @param {string} width Width of the node the value was assigned to.
* @param {string} height Height of the node the value was assigned to.
* @returns {string | undefined} Cleaned up value, or undefined if it's redundant.
*/
const cleanupValue = (value, nodeName, width, height) => {
const match = regEnableBackground.exec(value);
if (match != null && width === match[1] && height === match[3]) {
return nodeName === 'svg' ? undefined : 'new';
}
return value;
};
+265
View File
@@ -0,0 +1,265 @@
'use strict';
/**
* @typedef {import('../lib/types').XastElement} XastElement
*/
const { visitSkip } = require('../lib/xast.js');
const { hasScripts, findReferences } = require('../lib/svgo/tools');
exports.name = 'cleanupIds';
exports.description = 'removes unused IDs and minifies used';
const generateIdChars = [
'a',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'i',
'j',
'k',
'l',
'm',
'n',
'o',
'p',
'q',
'r',
's',
't',
'u',
'v',
'w',
'x',
'y',
'z',
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
];
const maxIdIndex = generateIdChars.length - 1;
/**
* Check if an ID starts with any one of a list of strings.
*
* @type {(string: string, prefixes: string[]) => boolean}
*/
const hasStringPrefix = (string, prefixes) => {
for (const prefix of prefixes) {
if (string.startsWith(prefix)) {
return true;
}
}
return false;
};
/**
* Generate unique minimal ID.
*
* @param {?number[]} currentId
* @returns {number[]}
*/
const generateId = (currentId) => {
if (currentId == null) {
return [0];
}
currentId[currentId.length - 1] += 1;
for (let i = currentId.length - 1; i > 0; i--) {
if (currentId[i] > maxIdIndex) {
currentId[i] = 0;
if (currentId[i - 1] !== undefined) {
currentId[i - 1]++;
}
}
}
if (currentId[0] > maxIdIndex) {
currentId[0] = 0;
currentId.unshift(0);
}
return currentId;
};
/**
* Get string from generated ID array.
*
* @type {(arr: number[]) => string}
*/
const getIdString = (arr) => {
return arr.map((i) => generateIdChars[i]).join('');
};
/**
* Remove unused and minify used IDs
* (only if there are no any <style> or <script>).
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'cleanupIds'>}
*/
exports.fn = (_root, params) => {
const {
remove = true,
minify = true,
preserve = [],
preservePrefixes = [],
force = false,
} = params;
const preserveIds = new Set(
Array.isArray(preserve) ? preserve : preserve ? [preserve] : [],
);
const preserveIdPrefixes = Array.isArray(preservePrefixes)
? preservePrefixes
: preservePrefixes
? [preservePrefixes]
: [];
/**
* @type {Map<string, XastElement>}
*/
const nodeById = new Map();
/**
* @type {Map<string, {element: XastElement, name: string }[]>}
*/
const referencesById = new Map();
let deoptimized = false;
return {
element: {
enter: (node) => {
if (!force) {
// deoptimize if style or scripts are present
if (
(node.name === 'style' && node.children.length !== 0) ||
hasScripts(node)
) {
deoptimized = true;
return;
}
// avoid removing IDs if the whole SVG consists only of defs
if (node.name === 'svg') {
let hasDefsOnly = true;
for (const child of node.children) {
if (child.type !== 'element' || child.name !== 'defs') {
hasDefsOnly = false;
break;
}
}
if (hasDefsOnly) {
return visitSkip;
}
}
}
for (const [name, value] of Object.entries(node.attributes)) {
if (name === 'id') {
// collect all ids
const id = value;
if (nodeById.has(id)) {
delete node.attributes.id; // remove repeated id
} else {
nodeById.set(id, node);
}
} else {
const ids = findReferences(name, value);
for (const id of ids) {
let refs = referencesById.get(id);
if (refs == null) {
refs = [];
referencesById.set(id, refs);
}
refs.push({ element: node, name });
}
}
}
},
},
root: {
exit: () => {
if (deoptimized) {
return;
}
/**
* @param {string} id
* @returns {boolean}
*/
const isIdPreserved = (id) =>
preserveIds.has(id) || hasStringPrefix(id, preserveIdPrefixes);
/** @type {?number[]} */
let currentId = null;
for (const [id, refs] of referencesById) {
const node = nodeById.get(id);
if (node != null) {
// replace referenced IDs with the minified ones
if (minify && isIdPreserved(id) === false) {
/** @type {?string} */
let currentIdString = null;
do {
currentId = generateId(currentId);
currentIdString = getIdString(currentId);
} while (
isIdPreserved(currentIdString) ||
(referencesById.has(currentIdString) &&
nodeById.get(currentIdString) == null)
);
node.attributes.id = currentIdString;
for (const { element, name } of refs) {
const value = element.attributes[name];
if (value.includes('#')) {
// replace id in href and url()
element.attributes[name] = value.replace(
`#${encodeURI(id)}`,
`#${currentIdString}`,
);
} else {
// replace id in begin attribute
element.attributes[name] = value.replace(
`${id}.`,
`${currentIdString}.`,
);
}
}
}
// keep referenced node
nodeById.delete(id);
}
}
// remove non-referenced IDs attributes from elements
if (remove) {
for (const [id, node] of nodeById) {
if (isIdPreserved(id) === false) {
delete node.attributes.id;
}
}
}
},
},
};
};
+147
View File
@@ -0,0 +1,147 @@
'use strict';
const { removeLeadingZero } = require('../lib/svgo/tools.js');
exports.name = 'cleanupListOfValues';
exports.description = 'rounds list of values to the fixed precision';
const regNumericValues =
/^([-+]?\d*\.?\d+([eE][-+]?\d+)?)(px|pt|pc|mm|cm|m|in|ft|em|ex|%)?$/;
const regSeparator = /\s+,?\s*|,\s*/;
const absoluteLengths = {
// relative to px
cm: 96 / 2.54,
mm: 96 / 25.4,
in: 96,
pt: 4 / 3,
pc: 16,
px: 1,
};
/**
* Round list of values to the fixed precision.
*
* @example
* <svg viewBox="0 0 200.28423 200.28423" enable-background="new 0 0 200.28423 200.28423">
* ⬇
* <svg viewBox="0 0 200.284 200.284" enable-background="new 0 0 200.284 200.284">
*
* <polygon points="208.250977 77.1308594 223.069336 ... "/>
* ⬇
* <polygon points="208.251 77.131 223.069 ... "/>
*
* @author kiyopikko
*
* @type {import('./plugins-types').Plugin<'cleanupListOfValues'>}
*/
exports.fn = (_root, params) => {
const {
floatPrecision = 3,
leadingZero = true,
defaultPx = true,
convertToPx = true,
} = params;
/**
* @type {(lists: string) => string}
*/
const roundValues = (lists) => {
const roundedList = [];
for (const elem of lists.split(regSeparator)) {
const match = elem.match(regNumericValues);
const matchNew = elem.match(/new/);
// if attribute value matches regNumericValues
if (match) {
// round it to the fixed precision
let num = Number(Number(match[1]).toFixed(floatPrecision));
/**
* @type {any}
*/
let matchedUnit = match[3] || '';
/**
* @type{'' | keyof typeof absoluteLengths}
*/
let units = matchedUnit;
// convert absolute values to pixels
if (convertToPx && units && units in absoluteLengths) {
const pxNum = Number(
(absoluteLengths[units] * Number(match[1])).toFixed(floatPrecision),
);
if (pxNum.toString().length < match[0].length) {
num = pxNum;
units = 'px';
}
}
// and remove leading zero
let str;
if (leadingZero) {
str = removeLeadingZero(num);
} else {
str = num.toString();
}
// remove default 'px' units
if (defaultPx && units === 'px') {
units = '';
}
roundedList.push(str + units);
}
// if attribute value is "new"(only enable-background).
else if (matchNew) {
roundedList.push('new');
} else if (elem) {
roundedList.push(elem);
}
}
return roundedList.join(' ');
};
return {
element: {
enter: (node) => {
if (node.attributes.points != null) {
node.attributes.points = roundValues(node.attributes.points);
}
if (node.attributes['enable-background'] != null) {
node.attributes['enable-background'] = roundValues(
node.attributes['enable-background'],
);
}
if (node.attributes.viewBox != null) {
node.attributes.viewBox = roundValues(node.attributes.viewBox);
}
if (node.attributes['stroke-dasharray'] != null) {
node.attributes['stroke-dasharray'] = roundValues(
node.attributes['stroke-dasharray'],
);
}
if (node.attributes.dx != null) {
node.attributes.dx = roundValues(node.attributes.dx);
}
if (node.attributes.dy != null) {
node.attributes.dy = roundValues(node.attributes.dy);
}
if (node.attributes.x != null) {
node.attributes.x = roundValues(node.attributes.x);
}
if (node.attributes.y != null) {
node.attributes.y = roundValues(node.attributes.y);
}
},
},
};
};
+106
View File
@@ -0,0 +1,106 @@
'use strict';
const { removeLeadingZero } = require('../lib/svgo/tools');
exports.name = 'cleanupNumericValues';
exports.description =
'rounds numeric values to the fixed precision, removes default ‘px’ units';
const regNumericValues =
/^([-+]?\d*\.?\d+([eE][-+]?\d+)?)(px|pt|pc|mm|cm|m|in|ft|em|ex|%)?$/;
const absoluteLengths = {
// relative to px
cm: 96 / 2.54,
mm: 96 / 25.4,
in: 96,
pt: 4 / 3,
pc: 16,
px: 1,
};
/**
* Round numeric values to the fixed precision,
* remove default 'px' units.
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'cleanupNumericValues'>}
*/
exports.fn = (_root, params) => {
const {
floatPrecision = 3,
leadingZero = true,
defaultPx = true,
convertToPx = true,
} = params;
return {
element: {
enter: (node) => {
if (node.attributes.viewBox != null) {
const nums = node.attributes.viewBox.split(/\s,?\s*|,\s*/g);
node.attributes.viewBox = nums
.map((value) => {
const num = Number(value);
return Number.isNaN(num)
? value
: Number(num.toFixed(floatPrecision));
})
.join(' ');
}
for (const [name, value] of Object.entries(node.attributes)) {
// The `version` attribute is a text string and cannot be rounded
if (name === 'version') {
continue;
}
const match = value.match(regNumericValues);
// if attribute value matches regNumericValues
if (match) {
// round it to the fixed precision
let num = Number(Number(match[1]).toFixed(floatPrecision));
/**
* @type {any}
*/
let matchedUnit = match[3] || '';
/**
* @type{'' | keyof typeof absoluteLengths}
*/
let units = matchedUnit;
// convert absolute values to pixels
if (convertToPx && units !== '' && units in absoluteLengths) {
const pxNum = Number(
(absoluteLengths[units] * Number(match[1])).toFixed(
floatPrecision,
),
);
if (pxNum.toString().length < match[0].length) {
num = pxNum;
units = 'px';
}
}
// and remove leading zero
let str;
if (leadingZero) {
str = removeLeadingZero(num);
} else {
str = num.toString();
}
// remove default 'px' units
if (defaultPx && units === 'px') {
units = '';
}
node.attributes[name] = str + units;
}
}
},
},
};
};
+134
View File
@@ -0,0 +1,134 @@
'use strict';
/**
* @typedef {import('../lib/types').XastNode} XastNode
*/
const { inheritableAttrs, elemsGroups } = require('./_collections.js');
exports.name = 'collapseGroups';
exports.description = 'collapses useless groups';
/**
* @type {(node: XastNode, name: string) => boolean}
*/
const hasAnimatedAttr = (node, name) => {
if (node.type === 'element') {
if (
elemsGroups.animation.has(node.name) &&
node.attributes.attributeName === name
) {
return true;
}
for (const child of node.children) {
if (hasAnimatedAttr(child, name)) {
return true;
}
}
}
return false;
};
/**
* Collapse useless groups.
*
* @example
* <g>
* <g attr1="val1">
* <path d="..."/>
* </g>
* </g>
* ⬇
* <g>
* <g>
* <path attr1="val1" d="..."/>
* </g>
* </g>
* ⬇
* <path attr1="val1" d="..."/>
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'collapseGroups'>}
*/
exports.fn = () => {
return {
element: {
exit: (node, parentNode) => {
if (parentNode.type === 'root' || parentNode.name === 'switch') {
return;
}
// non-empty groups
if (node.name !== 'g' || node.children.length === 0) {
return;
}
// move group attributes to the single child element
if (
Object.keys(node.attributes).length !== 0 &&
node.children.length === 1
) {
const firstChild = node.children[0];
// TODO untangle this mess
if (
firstChild.type === 'element' &&
firstChild.attributes.id == null &&
node.attributes.filter == null &&
(node.attributes.class == null ||
firstChild.attributes.class == null) &&
((node.attributes['clip-path'] == null &&
node.attributes.mask == null) ||
(firstChild.name === 'g' &&
node.attributes.transform == null &&
firstChild.attributes.transform == null))
) {
for (const [name, value] of Object.entries(node.attributes)) {
// avoid copying to not conflict with animated attribute
if (hasAnimatedAttr(firstChild, name)) {
return;
}
if (firstChild.attributes[name] == null) {
firstChild.attributes[name] = value;
} else if (name === 'transform') {
firstChild.attributes[name] =
value + ' ' + firstChild.attributes[name];
} else if (firstChild.attributes[name] === 'inherit') {
firstChild.attributes[name] = value;
} else if (
inheritableAttrs.has(name) === false &&
firstChild.attributes[name] !== value
) {
return;
}
delete node.attributes[name];
}
}
}
// collapse groups without attributes
if (Object.keys(node.attributes).length === 0) {
// animation elements "add" attributes to group
// group should be preserved
for (const child of node.children) {
if (
child.type === 'element' &&
elemsGroups.animation.has(child.name)
) {
return;
}
}
// replace current node with all its children
const index = parentNode.children.indexOf(node);
parentNode.children.splice(index, 1, ...node.children);
// TODO remove legacy parentNode in v4
for (const child of node.children) {
Object.defineProperty(child, 'parentNode', {
writable: true,
value: parentNode,
});
}
}
},
},
};
};
+144
View File
@@ -0,0 +1,144 @@
'use strict';
const collections = require('./_collections.js');
exports.name = 'convertColors';
exports.description = 'converts colors: rgb() to #rrggbb and #rrggbb to #rgb';
const rNumber = '([+-]?(?:\\d*\\.\\d+|\\d+\\.?)%?)';
const rComma = '\\s*,\\s*';
const regRGB = new RegExp(
'^rgb\\(\\s*' + rNumber + rComma + rNumber + rComma + rNumber + '\\s*\\)$',
);
const regHEX = /^#(([a-fA-F0-9])\2){3}$/;
/**
* Convert [r, g, b] to #rrggbb.
*
* @see https://gist.github.com/983535
*
* @example
* rgb2hex([255, 255, 255]) // '#ffffff'
*
* @author Jed Schmidt
*
* @type {(rgb: number[]) => string}
*/
const convertRgbToHex = ([r, g, b]) => {
// combine the octets into a 32-bit integer as: [1][r][g][b]
const hexNumber =
// operator precedence is (+) > (<<) > (|)
((((256 + // [1][0]
r) << // [1][r]
8) | // [1][r][0]
g) << // [1][r][g]
8) | // [1][r][g][0]
b;
// serialize [1][r][g][b] to a hex string, and
// remove the 1 to get the number with 0s intact
return '#' + hexNumber.toString(16).slice(1).toUpperCase();
};
/**
* Convert different colors formats in element attributes to hex.
*
* @see https://www.w3.org/TR/SVG11/types.html#DataTypeColor
* @see https://www.w3.org/TR/SVG11/single-page.html#types-ColorKeywords
*
* @example
* Convert color name keyword to long hex:
* fuchsia ➡ #ff00ff
*
* Convert rgb() to long hex:
* rgb(255, 0, 255) ➡ #ff00ff
* rgb(50%, 100, 100%) ➡ #7f64ff
*
* Convert long hex to short hex:
* #aabbcc ➡ #abc
*
* Convert hex to short name
* #000080 ➡ navy
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'convertColors'>}
*/
exports.fn = (_root, params) => {
const {
currentColor = false,
names2hex = true,
rgb2hex = true,
shorthex = true,
shortname = true,
} = params;
return {
element: {
enter: (node) => {
for (const [name, value] of Object.entries(node.attributes)) {
if (collections.colorsProps.has(name)) {
let val = value;
// convert colors to currentColor
if (currentColor) {
let matched;
if (typeof currentColor === 'string') {
matched = val === currentColor;
} else if (currentColor instanceof RegExp) {
matched = currentColor.exec(val) != null;
} else {
matched = val !== 'none';
}
if (matched) {
val = 'currentColor';
}
}
// convert color name keyword to long hex
if (names2hex) {
const colorName = val.toLowerCase();
if (collections.colorsNames[colorName] != null) {
val = collections.colorsNames[colorName];
}
}
// convert rgb() to long hex
if (rgb2hex) {
let match = val.match(regRGB);
if (match != null) {
let nums = match.slice(1, 4).map((m) => {
let n;
if (m.indexOf('%') > -1) {
n = Math.round(parseFloat(m) * 2.55);
} else {
n = Number(m);
}
return Math.max(0, Math.min(n, 255));
});
val = convertRgbToHex(nums);
}
}
// convert long hex to short hex
if (shorthex) {
let match = val.match(regHEX);
if (match != null) {
val = '#' + match[0][1] + match[0][3] + match[0][5];
}
}
// convert hex to short name
if (shortname) {
const colorName = val.toLowerCase();
if (collections.colorsShortNames[colorName] != null) {
val = collections.colorsShortNames[colorName];
}
}
node.attributes[name] = val;
}
}
},
},
};
};
+37
View File
@@ -0,0 +1,37 @@
'use strict';
exports.name = 'convertEllipseToCircle';
exports.description = 'converts non-eccentric <ellipse>s to <circle>s';
/**
* Converts non-eccentric <ellipse>s to <circle>s.
*
* @see https://www.w3.org/TR/SVG11/shapes.html
*
* @author Taylor Hunt
*
* @type {import('./plugins-types').Plugin<'convertEllipseToCircle'>}
*/
exports.fn = () => {
return {
element: {
enter: (node) => {
if (node.name === 'ellipse') {
const rx = node.attributes.rx || '0';
const ry = node.attributes.ry || '0';
if (
rx === ry ||
rx === 'auto' ||
ry === 'auto' // SVG2
) {
node.name = 'circle';
const radius = rx === 'auto' ? ry : rx;
delete node.attributes.rx;
delete node.attributes.ry;
node.attributes.r = radius;
}
}
},
},
};
};
+168
View File
@@ -0,0 +1,168 @@
'use strict';
/**
* @typedef {import('../lib/types').XastElement} XastElement
* @typedef {import('../lib/types').XastParent} XastParent
*/
const { attrsGroupsDefaults, colorsProps } = require('./_collections');
const {
detachNodeFromParent,
querySelectorAll,
querySelector,
} = require('../lib/xast');
const { computeStyle, collectStylesheet } = require('../lib/style');
exports.name = 'convertOneStopGradients';
exports.description =
'converts one-stop (single color) gradients to a plain color';
/**
* Converts one-stop (single color) gradients to a plain color.
*
* @author Seth Falco <seth@falco.fun>
* @type {import('./plugins-types').Plugin<'convertOneStopGradients'>}
* @see https://developer.mozilla.org/docs/Web/SVG/Element/linearGradient
* @see https://developer.mozilla.org/docs/Web/SVG/Element/radialGradient
*/
exports.fn = (root) => {
const stylesheet = collectStylesheet(root);
/**
* Parent defs that had gradients elements removed from them.
*
* @type {Set<XastElement>}
*/
const effectedDefs = new Set();
/**
* @type {Map<XastElement, XastParent>}
*/
const allDefs = new Map();
/**
* @type {Map<XastElement, XastParent>}
*/
const gradientsToDetach = new Map();
/** Number of references to the xlink:href attribute. */
let xlinkHrefCount = 0;
return {
element: {
enter: (node, parentNode) => {
if (node.attributes['xlink:href'] != null) {
xlinkHrefCount++;
}
if (node.name === 'defs') {
allDefs.set(node, parentNode);
return;
}
if (node.name !== 'linearGradient' && node.name !== 'radialGradient') {
return;
}
const stops = node.children.filter((child) => {
return child.type === 'element' && child.name === 'stop';
});
const href = node.attributes['xlink:href'] || node.attributes['href'];
let effectiveNode =
stops.length === 0 && href != null && href.startsWith('#')
? querySelector(root, href)
: node;
if (effectiveNode == null || effectiveNode.type !== 'element') {
gradientsToDetach.set(node, parentNode);
return;
}
const effectiveStops = effectiveNode.children.filter((child) => {
return child.type === 'element' && child.name === 'stop';
});
if (
effectiveStops.length !== 1 ||
effectiveStops[0].type !== 'element'
) {
return;
}
if (parentNode.type === 'element' && parentNode.name === 'defs') {
effectedDefs.add(parentNode);
}
gradientsToDetach.set(node, parentNode);
let color;
const style = computeStyle(stylesheet, effectiveStops[0])['stop-color'];
if (style != null && style.type === 'static') {
color = style.value;
}
const selectorVal = `url(#${node.attributes.id})`;
const selector = [...colorsProps]
.map((attr) => `[${attr}="${selectorVal}"]`)
.join(',');
const elements = querySelectorAll(root, selector);
for (const element of elements) {
if (element.type !== 'element') {
continue;
}
for (const attr of colorsProps) {
if (element.attributes[attr] !== selectorVal) {
continue;
}
if (color != null) {
element.attributes[attr] = color;
} else {
delete element.attributes[attr];
}
}
}
const styledElements = querySelectorAll(
root,
`[style*=${selectorVal}]`,
);
for (const element of styledElements) {
if (element.type !== 'element') {
continue;
}
element.attributes.style = element.attributes.style.replace(
selectorVal,
color || attrsGroupsDefaults.presentation['stop-color'],
);
}
},
exit: (node) => {
if (node.name === 'svg') {
for (const [gradient, parent] of gradientsToDetach.entries()) {
if (gradient.attributes['xlink:href'] != null) {
xlinkHrefCount--;
}
detachNodeFromParent(gradient, parent);
}
if (xlinkHrefCount === 0) {
delete node.attributes['xmlns:xlink'];
}
for (const [defs, parent] of allDefs.entries()) {
if (effectedDefs.has(defs) && defs.children.length === 0) {
detachNodeFromParent(defs, parent);
}
}
}
},
},
};
};
File diff suppressed because it is too large Load Diff
+170
View File
@@ -0,0 +1,170 @@
'use strict';
/**
* @typedef {import('../lib/types').PathDataItem} PathDataItem
*/
const { stringifyPathData } = require('../lib/path.js');
const { detachNodeFromParent } = require('../lib/xast.js');
exports.name = 'convertShapeToPath';
exports.description = 'converts basic shapes to more compact path form';
const regNumber = /[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g;
/**
* Converts basic shape to more compact path.
* It also allows further optimizations like
* combining paths with similar attributes.
*
* @see https://www.w3.org/TR/SVG11/shapes.html
*
* @author Lev Solntsev
*
* @type {import('./plugins-types').Plugin<'convertShapeToPath'>}
*/
exports.fn = (root, params) => {
const { convertArcs = false, floatPrecision: precision } = params;
return {
element: {
enter: (node, parentNode) => {
// convert rect to path
if (
node.name === 'rect' &&
node.attributes.width != null &&
node.attributes.height != null &&
node.attributes.rx == null &&
node.attributes.ry == null
) {
const x = Number(node.attributes.x || '0');
const y = Number(node.attributes.y || '0');
const width = Number(node.attributes.width);
const height = Number(node.attributes.height);
// Values like '100%' compute to NaN, thus running after
// cleanupNumericValues when 'px' units has already been removed.
// TODO: Calculate sizes from % and non-px units if possible.
if (Number.isNaN(x - y + width - height)) return;
/**
* @type {PathDataItem[]}
*/
const pathData = [
{ command: 'M', args: [x, y] },
{ command: 'H', args: [x + width] },
{ command: 'V', args: [y + height] },
{ command: 'H', args: [x] },
{ command: 'z', args: [] },
];
node.name = 'path';
node.attributes.d = stringifyPathData({ pathData, precision });
delete node.attributes.x;
delete node.attributes.y;
delete node.attributes.width;
delete node.attributes.height;
}
// convert line to path
if (node.name === 'line') {
const x1 = Number(node.attributes.x1 || '0');
const y1 = Number(node.attributes.y1 || '0');
const x2 = Number(node.attributes.x2 || '0');
const y2 = Number(node.attributes.y2 || '0');
if (Number.isNaN(x1 - y1 + x2 - y2)) return;
/**
* @type {PathDataItem[]}
*/
const pathData = [
{ command: 'M', args: [x1, y1] },
{ command: 'L', args: [x2, y2] },
];
node.name = 'path';
node.attributes.d = stringifyPathData({ pathData, precision });
delete node.attributes.x1;
delete node.attributes.y1;
delete node.attributes.x2;
delete node.attributes.y2;
}
// convert polyline and polygon to path
if (
(node.name === 'polyline' || node.name === 'polygon') &&
node.attributes.points != null
) {
const coords = (node.attributes.points.match(regNumber) || []).map(
Number,
);
if (coords.length < 4) {
detachNodeFromParent(node, parentNode);
return;
}
/**
* @type {PathDataItem[]}
*/
const pathData = [];
for (let i = 0; i < coords.length; i += 2) {
pathData.push({
command: i === 0 ? 'M' : 'L',
args: coords.slice(i, i + 2),
});
}
if (node.name === 'polygon') {
pathData.push({ command: 'z', args: [] });
}
node.name = 'path';
node.attributes.d = stringifyPathData({ pathData, precision });
delete node.attributes.points;
}
// optionally convert circle
if (node.name === 'circle' && convertArcs) {
const cx = Number(node.attributes.cx || '0');
const cy = Number(node.attributes.cy || '0');
const r = Number(node.attributes.r || '0');
if (Number.isNaN(cx - cy + r)) {
return;
}
/**
* @type {PathDataItem[]}
*/
const pathData = [
{ command: 'M', args: [cx, cy - r] },
{ command: 'A', args: [r, r, 0, 1, 0, cx, cy + r] },
{ command: 'A', args: [r, r, 0, 1, 0, cx, cy - r] },
{ command: 'z', args: [] },
];
node.name = 'path';
node.attributes.d = stringifyPathData({ pathData, precision });
delete node.attributes.cx;
delete node.attributes.cy;
delete node.attributes.r;
}
// optionally convert ellipse
if (node.name === 'ellipse' && convertArcs) {
const ecx = Number(node.attributes.cx || '0');
const ecy = Number(node.attributes.cy || '0');
const rx = Number(node.attributes.rx || '0');
const ry = Number(node.attributes.ry || '0');
if (Number.isNaN(ecx - ecy + rx - ry)) {
return;
}
/**
* @type {PathDataItem[]}
*/
const pathData = [
{ command: 'M', args: [ecx, ecy - ry] },
{ command: 'A', args: [rx, ry, 0, 1, 0, ecx, ecy + ry] },
{ command: 'A', args: [rx, ry, 0, 1, 0, ecx, ecy - ry] },
{ command: 'z', args: [] },
];
node.name = 'path';
node.attributes.d = stringifyPathData({ pathData, precision });
delete node.attributes.cx;
delete node.attributes.cy;
delete node.attributes.rx;
delete node.attributes.ry;
}
},
},
};
};
+136
View File
@@ -0,0 +1,136 @@
'use strict';
const { attrsGroups } = require('./_collections');
exports.name = 'convertStyleToAttrs';
exports.description = 'converts style to attributes';
/**
* @type {(...args: string[]) => string}
*/
const g = (...args) => {
return '(?:' + args.join('|') + ')';
};
const stylingProps = attrsGroups.presentation;
const rEscape = '\\\\(?:[0-9a-f]{1,6}\\s?|\\r\\n|.)'; // Like \" or \2051. Code points consume one space.
const rAttr = '\\s*(' + g('[^:;\\\\]', rEscape) + '*?)\\s*'; // attribute name like ‘fill’
const rSingleQuotes = "'(?:[^'\\n\\r\\\\]|" + rEscape + ")*?(?:'|$)"; // string in single quotes: 'smth'
const rQuotes = '"(?:[^"\\n\\r\\\\]|' + rEscape + ')*?(?:"|$)'; // string in double quotes: "smth"
const rQuotedString = new RegExp('^' + g(rSingleQuotes, rQuotes) + '$');
// Parentheses, E.g.: url(data:image/png;base64,iVBO...).
// ':' and ';' inside of it should be treated as is. (Just like in strings.)
const rParenthesis =
'\\(' + g('[^\'"()\\\\]+', rEscape, rSingleQuotes, rQuotes) + '*?' + '\\)';
// The value. It can have strings and parentheses (see above). Fallbacks to anything in case of unexpected input.
const rValue =
'\\s*(' +
g(
'[^!\'"();\\\\]+?',
rEscape,
rSingleQuotes,
rQuotes,
rParenthesis,
'[^;]*?',
) +
'*?' +
')';
// End of declaration. Spaces outside of capturing groups help to do natural trimming.
const rDeclEnd = '\\s*(?:;\\s*|$)';
// Important rule
const rImportant = '(\\s*!important(?![-(\\w]))?';
// Final RegExp to parse CSS declarations.
const regDeclarationBlock = new RegExp(
rAttr + ':' + rValue + rImportant + rDeclEnd,
'ig',
);
// Comments expression. Honors escape sequences and strings.
const regStripComments = new RegExp(
g(rEscape, rSingleQuotes, rQuotes, '/\\*[^]*?\\*/'),
'ig',
);
/**
* Convert style in attributes. Cleanups comments and illegal declarations (without colon) as a side effect.
*
* @example
* <g style="fill:#000; color: #fff;">
* ⬇
* <g fill="#000" color="#fff">
*
* @example
* <g style="fill:#000; color: #fff; -webkit-blah: blah">
* ⬇
* <g fill="#000" color="#fff" style="-webkit-blah: blah">
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'convertStyleToAttrs'>}
*/
exports.fn = (_root, params) => {
const { keepImportant = false } = params;
return {
element: {
enter: (node) => {
if (node.attributes.style != null) {
// ['opacity: 1', 'color: #000']
let styles = [];
/**
* @type {Record<string, string>}
*/
const newAttributes = {};
// Strip CSS comments preserving escape sequences and strings.
const styleValue = node.attributes.style.replace(
regStripComments,
(match) => {
return match[0] == '/'
? ''
: match[0] == '\\' && /[-g-z]/i.test(match[1])
? match[1]
: match;
},
);
regDeclarationBlock.lastIndex = 0;
for (var rule; (rule = regDeclarationBlock.exec(styleValue)); ) {
if (!keepImportant || !rule[3]) {
styles.push([rule[1], rule[2]]);
}
}
if (styles.length) {
styles = styles.filter(function (style) {
if (style[0]) {
var prop = style[0].toLowerCase(),
val = style[1];
if (rQuotedString.test(val)) {
val = val.slice(1, -1);
}
if (stylingProps.has(prop)) {
newAttributes[prop] = val;
return false;
}
}
return true;
});
Object.assign(node.attributes, newAttributes);
if (styles.length) {
node.attributes.style = styles
.map((declaration) => declaration.join(':'))
.join(';');
} else {
delete node.attributes.style;
}
}
}
},
},
};
};
+418
View File
@@ -0,0 +1,418 @@
'use strict';
/**
* @typedef {import('../lib/types').XastChild} XastChild
* @typedef {import('../lib/types').XastElement} XastElement
* @typedef {import('../lib/types').XastParent} XastParent
*/
const { cleanupOutData, toFixed } = require('../lib/svgo/tools.js');
const {
transform2js,
transformsMultiply,
matrixToTransform,
} = require('./_transforms.js');
exports.name = 'convertTransform';
exports.description = 'collapses multiple transformations and optimizes it';
/**
* Convert matrices to the short aliases,
* convert long translate, scale or rotate transform notations to the shorts ones,
* convert transforms to the matrices and multiply them all into one,
* remove useless transforms.
*
* @see https://www.w3.org/TR/SVG11/coords.html#TransformMatrixDefined
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'convertTransform'>}
*/
exports.fn = (_root, params) => {
const {
convertToShorts = true,
// degPrecision = 3, // transformPrecision (or matrix precision) - 2 by default
degPrecision,
floatPrecision = 3,
transformPrecision = 5,
matrixToTransform = true,
shortTranslate = true,
shortScale = true,
shortRotate = true,
removeUseless = true,
collapseIntoOne = true,
leadingZero = true,
negativeExtraSpace = false,
} = params;
const newParams = {
convertToShorts,
degPrecision,
floatPrecision,
transformPrecision,
matrixToTransform,
shortTranslate,
shortScale,
shortRotate,
removeUseless,
collapseIntoOne,
leadingZero,
negativeExtraSpace,
};
return {
element: {
enter: (node) => {
if (node.attributes.transform != null) {
convertTransform(node, 'transform', newParams);
}
if (node.attributes.gradientTransform != null) {
convertTransform(node, 'gradientTransform', newParams);
}
if (node.attributes.patternTransform != null) {
convertTransform(node, 'patternTransform', newParams);
}
},
},
};
};
/**
* @typedef {{
* convertToShorts: boolean,
* degPrecision?: number,
* floatPrecision: number,
* transformPrecision: number,
* matrixToTransform: boolean,
* shortTranslate: boolean,
* shortScale: boolean,
* shortRotate: boolean,
* removeUseless: boolean,
* collapseIntoOne: boolean,
* leadingZero: boolean,
* negativeExtraSpace: boolean,
* }} TransformParams
*/
/**
* @typedef {{ name: string, data: number[] }} TransformItem
*/
/**
* @param {XastElement} item
* @param {string} attrName
* @param {TransformParams} params
*/
const convertTransform = (item, attrName, params) => {
let data = transform2js(item.attributes[attrName]);
params = definePrecision(data, params);
if (params.collapseIntoOne && data.length > 1) {
data = [transformsMultiply(data)];
}
if (params.convertToShorts) {
data = convertToShorts(data, params);
} else {
data.forEach((item) => roundTransform(item, params));
}
if (params.removeUseless) {
data = removeUseless(data);
}
if (data.length) {
item.attributes[attrName] = js2transform(data, params);
} else {
delete item.attributes[attrName];
}
};
/**
* Defines precision to work with certain parts.
* transformPrecision - for scale and four first matrix parameters (needs a better precision due to multiplying),
* floatPrecision - for translate including two last matrix and rotate parameters,
* degPrecision - for rotate and skew. By default it's equal to (roughly)
* transformPrecision - 2 or floatPrecision whichever is lower. Can be set in params.
*
* @type {(data: TransformItem[], params: TransformParams) => TransformParams}
*
* clone params so it don't affect other elements transformations.
*/
const definePrecision = (data, { ...newParams }) => {
const matrixData = [];
for (const item of data) {
if (item.name == 'matrix') {
matrixData.push(...item.data.slice(0, 4));
}
}
let numberOfDigits = newParams.transformPrecision;
// Limit transform precision with matrix one. Calculating with larger precision doesn't add any value.
if (matrixData.length) {
newParams.transformPrecision = Math.min(
newParams.transformPrecision,
Math.max.apply(Math, matrixData.map(floatDigits)) ||
newParams.transformPrecision,
);
numberOfDigits = Math.max.apply(
Math,
matrixData.map(
(n) => n.toString().replace(/\D+/g, '').length, // Number of digits in a number. 123.45 → 5
),
);
}
// No sense in angle precision more then number of significant digits in matrix.
if (newParams.degPrecision == null) {
newParams.degPrecision = Math.max(
0,
Math.min(newParams.floatPrecision, numberOfDigits - 2),
);
}
return newParams;
};
/**
* @type {(data: number[], params: TransformParams) => number[]}
*/
const degRound = (data, params) => {
if (
params.degPrecision != null &&
params.degPrecision >= 1 &&
params.floatPrecision < 20
) {
return smartRound(params.degPrecision, data);
} else {
return round(data);
}
};
/**
* @type {(data: number[], params: TransformParams) => number[]}
*/
const floatRound = (data, params) => {
if (params.floatPrecision >= 1 && params.floatPrecision < 20) {
return smartRound(params.floatPrecision, data);
} else {
return round(data);
}
};
/**
* @type {(data: number[], params: TransformParams) => number[]}
*/
const transformRound = (data, params) => {
if (params.transformPrecision >= 1 && params.floatPrecision < 20) {
return smartRound(params.transformPrecision, data);
} else {
return round(data);
}
};
/**
* Returns number of digits after the point. 0.125 → 3
*
* @type {(n: number) => number}
*/
const floatDigits = (n) => {
const str = n.toString();
return str.slice(str.indexOf('.')).length - 1;
};
/**
* Convert transforms to the shorthand alternatives.
*
* @param {TransformItem[]} transforms
* @param {TransformParams} params
* @returns {TransformItem[]}
*/
const convertToShorts = (transforms, params) => {
for (var i = 0; i < transforms.length; i++) {
let transform = transforms[i];
// convert matrix to the short aliases
if (params.matrixToTransform && transform.name === 'matrix') {
var decomposed = matrixToTransform(transform, params);
if (
js2transform(decomposed, params).length <=
js2transform([transform], params).length
) {
transforms.splice(i, 1, ...decomposed);
}
transform = transforms[i];
}
// fixed-point numbers
// 12.754997 → 12.755
roundTransform(transform, params);
// convert long translate transform notation to the shorts one
// translate(10 0) → translate(10)
if (
params.shortTranslate &&
transform.name === 'translate' &&
transform.data.length === 2 &&
!transform.data[1]
) {
transform.data.pop();
}
// convert long scale transform notation to the shorts one
// scale(2 2) → scale(2)
if (
params.shortScale &&
transform.name === 'scale' &&
transform.data.length === 2 &&
transform.data[0] === transform.data[1]
) {
transform.data.pop();
}
// convert long rotate transform notation to the short one
// translate(cx cy) rotate(a) translate(-cx -cy) → rotate(a cx cy)
if (
params.shortRotate &&
transforms[i - 2]?.name === 'translate' &&
transforms[i - 1].name === 'rotate' &&
transforms[i].name === 'translate' &&
transforms[i - 2].data[0] === -transforms[i].data[0] &&
transforms[i - 2].data[1] === -transforms[i].data[1]
) {
transforms.splice(i - 2, 3, {
name: 'rotate',
data: [
transforms[i - 1].data[0],
transforms[i - 2].data[0],
transforms[i - 2].data[1],
],
});
// splice compensation
i -= 2;
}
}
return transforms;
};
/**
* Remove useless transforms.
*
* @type {(transforms: TransformItem[]) => TransformItem[]}
*/
const removeUseless = (transforms) => {
return transforms.filter((transform) => {
// translate(0), rotate(0[, cx, cy]), skewX(0), skewY(0)
if (
(['translate', 'rotate', 'skewX', 'skewY'].indexOf(transform.name) > -1 &&
(transform.data.length == 1 || transform.name == 'rotate') &&
!transform.data[0]) ||
// translate(0, 0)
(transform.name == 'translate' &&
!transform.data[0] &&
!transform.data[1]) ||
// scale(1)
(transform.name == 'scale' &&
transform.data[0] == 1 &&
(transform.data.length < 2 || transform.data[1] == 1)) ||
// matrix(1 0 0 1 0 0)
(transform.name == 'matrix' &&
transform.data[0] == 1 &&
transform.data[3] == 1 &&
!(
transform.data[1] ||
transform.data[2] ||
transform.data[4] ||
transform.data[5]
))
) {
return false;
}
return true;
});
};
/**
* Convert transforms JS representation to string.
*
* @param {TransformItem[]} transformJS
* @param {TransformParams} params
* @returns {string}
*/
const js2transform = (transformJS, params) => {
const transformString = transformJS
.map((transform) => {
roundTransform(transform, params);
return `${transform.name}(${cleanupOutData(transform.data, params)})`;
})
.join('');
return transformString;
};
/**
* @type {(transform: TransformItem, params: TransformParams) => TransformItem}
*/
const roundTransform = (transform, params) => {
switch (transform.name) {
case 'translate':
transform.data = floatRound(transform.data, params);
break;
case 'rotate':
transform.data = [
...degRound(transform.data.slice(0, 1), params),
...floatRound(transform.data.slice(1), params),
];
break;
case 'skewX':
case 'skewY':
transform.data = degRound(transform.data, params);
break;
case 'scale':
transform.data = transformRound(transform.data, params);
break;
case 'matrix':
transform.data = [
...transformRound(transform.data.slice(0, 4), params),
...floatRound(transform.data.slice(4), params),
];
break;
}
return transform;
};
/**
* Rounds numbers in array.
*
* @type {(data: number[]) => number[]}
*/
const round = (data) => {
return data.map(Math.round);
};
/**
* Decrease accuracy of floating-point numbers
* in transforms keeping a specified number of decimals.
* Smart rounds values like 2.349 to 2.35.
*
* @param {number} precision
* @param {number[]} data
* @returns {number[]}
*/
const smartRound = (precision, data) => {
for (
var i = data.length,
tolerance = +Math.pow(0.1, precision).toFixed(precision);
i--;
) {
if (toFixed(data[i], precision) !== data[i]) {
var rounded = +data[i].toFixed(precision - 1);
data[i] =
+Math.abs(rounded - data[i]).toFixed(precision + 1) >= tolerance
? +data[i].toFixed(precision)
: rounded;
}
}
return data;
};
+390
View File
@@ -0,0 +1,390 @@
'use strict';
/**
* @typedef {import('../lib/types').XastElement} XastElement
* @typedef {import('../lib/types').XastParent} XastParent
*/
const csstree = require('css-tree');
const {
syntax: { specificity },
} = require('csso');
const {
visitSkip,
querySelectorAll,
detachNodeFromParent,
} = require('../lib/xast.js');
const { compareSpecificity, includesAttrSelector } = require('../lib/style');
const { attrsGroups, pseudoClasses } = require('./_collections');
exports.name = 'inlineStyles';
exports.description = 'inline styles (additional options)';
/**
* Some pseudo-classes can only be calculated by clients, like :visited,
* :future, or :hover, but there are other pseudo-classes that we can evaluate
* during optimization.
*
* The list of pseudo-classes that we can evaluate during optimization, and so
* shouldn't be toggled conditionally through the `usePseudos` parameter.
*
* @see https://developer.mozilla.org/docs/Web/CSS/Pseudo-classes
*/
const preservedPseudos = [
...pseudoClasses.functional,
...pseudoClasses.treeStructural,
];
/**
* Merges styles from style nodes into inline styles.
*
* @type {import('./plugins-types').Plugin<'inlineStyles'>}
* @author strarsis <strarsis@gmail.com>
*/
exports.fn = (root, params) => {
const {
onlyMatchedOnce = true,
removeMatchedSelectors = true,
useMqs = ['', 'screen'],
usePseudos = [''],
} = params;
/**
* @type {{ node: XastElement, parentNode: XastParent, cssAst: csstree.StyleSheet }[]}
*/
const styles = [];
/**
* @type {{
* node: csstree.Selector,
* item: csstree.ListItem<csstree.CssNode>,
* rule: csstree.Rule,
* matchedElements?: XastElement[]
* }[]}
*/
let selectors = [];
return {
element: {
enter: (node, parentNode) => {
if (node.name === 'foreignObject') {
return visitSkip;
}
if (node.name !== 'style' || node.children.length === 0) {
return;
}
if (
node.attributes.type != null &&
node.attributes.type !== '' &&
node.attributes.type !== 'text/css'
) {
return;
}
const cssText = node.children
.filter((child) => child.type === 'text' || child.type === 'cdata')
// @ts-ignore
.map((child) => child.value)
.join('');
/** @type {?csstree.CssNode} */
let cssAst = null;
try {
cssAst = csstree.parse(cssText, {
parseValue: false,
parseCustomProperty: false,
});
} catch {
return;
}
if (cssAst.type === 'StyleSheet') {
styles.push({ node, parentNode, cssAst });
}
// collect selectors
csstree.walk(cssAst, {
visit: 'Rule',
enter(node) {
const atrule = this.atrule;
// skip media queries not included into useMqs param
let mediaQuery = '';
if (atrule != null) {
mediaQuery = atrule.name;
if (atrule.prelude != null) {
mediaQuery += ` ${csstree.generate(atrule.prelude)}`;
}
}
if (!useMqs.includes(mediaQuery)) {
return;
}
if (node.prelude.type === 'SelectorList') {
node.prelude.children.forEach((childNode, item) => {
if (childNode.type === 'Selector') {
/**
* @type {{
* item: csstree.ListItem<csstree.CssNode>,
* list: csstree.List<csstree.CssNode>
* }[]}
*/
const pseudos = [];
childNode.children.forEach(
(grandchildNode, grandchildItem, grandchildList) => {
const isPseudo =
grandchildNode.type === 'PseudoClassSelector' ||
grandchildNode.type === 'PseudoElementSelector';
if (
isPseudo &&
!preservedPseudos.includes(grandchildNode.name)
) {
pseudos.push({
item: grandchildItem,
list: grandchildList,
});
}
},
);
const pseudoSelectors = csstree.generate({
type: 'Selector',
children: new csstree.List().fromArray(
pseudos.map((pseudo) => pseudo.item.data),
),
});
if (usePseudos.includes(pseudoSelectors)) {
for (const pseudo of pseudos) {
pseudo.list.remove(pseudo.item);
}
}
selectors.push({ node: childNode, rule: node, item: item });
}
});
}
},
});
},
},
root: {
exit: () => {
if (styles.length === 0) {
return;
}
const sortedSelectors = selectors
.slice()
.sort((a, b) => {
const aSpecificity = specificity(a.item.data);
const bSpecificity = specificity(b.item.data);
return compareSpecificity(aSpecificity, bSpecificity);
})
.reverse();
for (const selector of sortedSelectors) {
// match selectors
const selectorText = csstree.generate(selector.item.data);
/** @type {XastElement[]} */
const matchedElements = [];
try {
for (const node of querySelectorAll(root, selectorText)) {
if (node.type === 'element') {
matchedElements.push(node);
}
}
} catch (selectError) {
continue;
}
// nothing selected
if (matchedElements.length === 0) {
continue;
}
// apply styles to matched elements
// skip selectors that match more than once if option onlyMatchedOnce is enabled
if (onlyMatchedOnce && matchedElements.length > 1) {
continue;
}
// apply <style/> to matched elements
for (const selectedEl of matchedElements) {
const styleDeclarationList = csstree.parse(
selectedEl.attributes.style ?? '',
{
context: 'declarationList',
parseValue: false,
},
);
if (styleDeclarationList.type !== 'DeclarationList') {
continue;
}
const styleDeclarationItems = new Map();
/** @type {csstree.ListItem<csstree.CssNode>} */
let firstListItem;
csstree.walk(styleDeclarationList, {
visit: 'Declaration',
enter(node, item) {
if (firstListItem == null) {
firstListItem = item;
}
styleDeclarationItems.set(node.property.toLowerCase(), item);
},
});
// merge declarations
csstree.walk(selector.rule, {
visit: 'Declaration',
enter(ruleDeclaration) {
// existing inline styles have higher priority
// no inline styles, external styles, external styles used
// inline styles, external styles same priority as inline styles, inline styles used
// inline styles, external styles higher priority than inline styles, external styles used
const property = ruleDeclaration.property;
if (
attrsGroups.presentation.has(property) &&
!selectors.some((selector) =>
includesAttrSelector(selector.item, property),
)
) {
delete selectedEl.attributes[property];
}
const matchedItem = styleDeclarationItems.get(property);
const ruleDeclarationItem =
styleDeclarationList.children.createItem(ruleDeclaration);
if (matchedItem == null) {
styleDeclarationList.children.insert(
ruleDeclarationItem,
firstListItem,
);
} else if (
matchedItem.data.important !== true &&
ruleDeclaration.important === true
) {
styleDeclarationList.children.replace(
matchedItem,
ruleDeclarationItem,
);
styleDeclarationItems.set(property, ruleDeclarationItem);
}
},
});
const newStyles = csstree.generate(styleDeclarationList);
if (newStyles.length !== 0) {
selectedEl.attributes.style = newStyles;
}
}
if (
removeMatchedSelectors &&
matchedElements.length !== 0 &&
selector.rule.prelude.type === 'SelectorList'
) {
// clean up matching simple selectors if option removeMatchedSelectors is enabled
selector.rule.prelude.children.remove(selector.item);
}
selector.matchedElements = matchedElements;
}
// no further processing required
if (!removeMatchedSelectors) {
return;
}
// clean up matched class + ID attribute values
for (const selector of sortedSelectors) {
if (selector.matchedElements == null) {
continue;
}
if (onlyMatchedOnce && selector.matchedElements.length > 1) {
// skip selectors that match more than once if option onlyMatchedOnce is enabled
continue;
}
for (const selectedEl of selector.matchedElements) {
// class
const classList = new Set(
selectedEl.attributes.class == null
? null
: selectedEl.attributes.class.split(' '),
);
for (const child of selector.node.children) {
if (
child.type === 'ClassSelector' &&
!selectors.some((selector) =>
includesAttrSelector(
selector.item,
'class',
child.name,
true,
),
)
) {
classList.delete(child.name);
}
}
if (classList.size === 0) {
delete selectedEl.attributes.class;
} else {
selectedEl.attributes.class = Array.from(classList).join(' ');
}
// ID
const firstSubSelector = selector.node.children.first;
if (
firstSubSelector?.type === 'IdSelector' &&
selectedEl.attributes.id === firstSubSelector.name &&
!selectors.some((selector) =>
includesAttrSelector(
selector.item,
'id',
firstSubSelector.name,
true,
),
)
) {
delete selectedEl.attributes.id;
}
}
}
for (const style of styles) {
csstree.walk(style.cssAst, {
visit: 'Rule',
enter: function (node, item, list) {
// clean up <style/> rulesets without any css selectors left
if (
node.type === 'Rule' &&
node.prelude.type === 'SelectorList' &&
node.prelude.children.isEmpty
) {
list.remove(item);
}
},
});
// csstree v2 changed this type
if (style.cssAst.children.isEmpty) {
// remove empty style element
detachNodeFromParent(style.node, style.parentNode);
} else {
// update style element if any styles left
const firstChild = style.node.children[0];
if (firstChild.type === 'text' || firstChild.type === 'cdata') {
firstChild.value = csstree.generate(style.cssAst);
}
}
}
},
},
};
};
+148
View File
@@ -0,0 +1,148 @@
'use strict';
/**
* @typedef {import("../lib/types").PathDataItem} PathDataItem
* @typedef {import('../lib/types').XastChild} XastChild
* @typedef {import('../lib/types').XastElement} XastElement
*/
const { collectStylesheet, computeStyle } = require('../lib/style.js');
const { path2js, js2path, intersects } = require('./_path.js');
exports.name = 'mergePaths';
exports.description = 'merges multiple paths in one if possible';
/**
* Merge multiple Paths into one.
*
* @author Kir Belevich, Lev Solntsev
*
* @type {import('./plugins-types').Plugin<'mergePaths'>}
*/
exports.fn = (root, params) => {
const {
force = false,
floatPrecision,
noSpaceAfterFlags = false, // a20 60 45 0 1 30 20 → a20 60 45 0130 20
} = params;
const stylesheet = collectStylesheet(root);
return {
element: {
enter: (node) => {
if (node.children.length <= 1) {
return;
}
/** @type {XastChild[]} */
const elementsToRemove = [];
let prevChild = node.children[0];
let prevPathData = null;
/**
* @param {XastElement} child
* @param {PathDataItem[]} pathData
*/
const updatePreviousPath = (child, pathData) => {
js2path(child, pathData, {
floatPrecision,
noSpaceAfterFlags,
});
prevPathData = null;
};
for (let i = 1; i < node.children.length; i++) {
const child = node.children[i];
if (
prevChild.type !== 'element' ||
prevChild.name !== 'path' ||
prevChild.children.length !== 0 ||
prevChild.attributes.d == null
) {
if (prevPathData && prevChild.type === 'element') {
updatePreviousPath(prevChild, prevPathData);
}
prevChild = child;
continue;
}
if (
child.type !== 'element' ||
child.name !== 'path' ||
child.children.length !== 0 ||
child.attributes.d == null
) {
if (prevPathData) {
updatePreviousPath(prevChild, prevPathData);
}
prevChild = child;
continue;
}
const computedStyle = computeStyle(stylesheet, child);
if (
computedStyle['marker-start'] ||
computedStyle['marker-mid'] ||
computedStyle['marker-end']
) {
if (prevPathData) {
updatePreviousPath(prevChild, prevPathData);
}
prevChild = child;
continue;
}
const childAttrs = Object.keys(child.attributes);
if (childAttrs.length !== Object.keys(prevChild.attributes).length) {
if (prevPathData) {
updatePreviousPath(prevChild, prevPathData);
}
prevChild = child;
continue;
}
const areAttrsEqual = childAttrs.some((attr) => {
return (
attr !== 'd' &&
prevChild.type === 'element' &&
prevChild.attributes[attr] !== child.attributes[attr]
);
});
if (areAttrsEqual) {
if (prevPathData) {
updatePreviousPath(prevChild, prevPathData);
}
prevChild = child;
continue;
}
const hasPrevPath = prevPathData != null;
const currentPathData = path2js(child);
prevPathData = prevPathData ?? path2js(prevChild);
if (force || !intersects(prevPathData, currentPathData)) {
prevPathData.push(...currentPathData);
elementsToRemove.push(child);
continue;
}
if (hasPrevPath) {
updatePreviousPath(prevChild, prevPathData);
}
prevChild = child;
prevPathData = null;
}
if (prevPathData && prevChild.type === 'element') {
updatePreviousPath(prevChild, prevPathData);
}
node.children = node.children.filter(
(child) => !elementsToRemove.includes(child),
);
},
},
};
};
+98
View File
@@ -0,0 +1,98 @@
'use strict';
/**
* @typedef {import('../lib/types').XastElement} XastElement
* @typedef {import('../lib/types').XastChild} XastChild
*/
const { visitSkip, detachNodeFromParent } = require('../lib/xast.js');
exports.name = 'mergeStyles';
exports.description = 'merge multiple style elements into one';
/**
* Merge multiple style elements into one.
*
* @author strarsis <strarsis@gmail.com>
*
* @type {import('./plugins-types').Plugin<'mergeStyles'>}
*/
exports.fn = () => {
/**
* @type {?XastElement}
*/
let firstStyleElement = null;
let collectedStyles = '';
/**
* @type {'text' | 'cdata'}
*/
let styleContentType = 'text';
return {
element: {
enter: (node, parentNode) => {
// skip <foreignObject> content
if (node.name === 'foreignObject') {
return visitSkip;
}
// collect style elements
if (node.name !== 'style') {
return;
}
// skip <style> with invalid type attribute
if (
node.attributes.type != null &&
node.attributes.type !== '' &&
node.attributes.type !== 'text/css'
) {
return;
}
// extract style element content
let css = '';
for (const child of node.children) {
if (child.type === 'text') {
css += child.value;
}
if (child.type === 'cdata') {
styleContentType = 'cdata';
css += child.value;
}
}
// remove empty style elements
if (css.trim().length === 0) {
detachNodeFromParent(node, parentNode);
return;
}
// collect css and wrap with media query if present in attribute
if (node.attributes.media == null) {
collectedStyles += css;
} else {
collectedStyles += `@media ${node.attributes.media}{${css}}`;
delete node.attributes.media;
}
// combine collected styles in the first style element
if (firstStyleElement == null) {
firstStyleElement = node;
} else {
detachNodeFromParent(node, parentNode);
/**
* @type {XastChild}
*/
const child = { type: styleContentType, value: collectedStyles };
// TODO remove legacy parentNode in v4
Object.defineProperty(child, 'parentNode', {
writable: true,
value: firstStyleElement,
});
firstStyleElement.children = [child];
}
},
},
};
};
+141
View File
@@ -0,0 +1,141 @@
'use strict';
/**
* @typedef {import('../lib/types').XastElement} XastElement
* @typedef {import('../lib/types').XastParent} XastParent
*/
const csso = require('csso');
const { detachNodeFromParent } = require('../lib/xast');
const { hasScripts } = require('../lib/svgo/tools');
exports.name = 'minifyStyles';
exports.description = 'minifies styles and removes unused styles';
/**
* Minifies styles (<style> element + style attribute) using CSSO.
*
* @author strarsis <strarsis@gmail.com>
* @type {import('./plugins-types').Plugin<'minifyStyles'>}
*/
exports.fn = (_root, { usage, ...params }) => {
/** @type {Map<XastElement, XastParent>} */
const styleElements = new Map();
/** @type {XastElement[]} */
const elementsWithStyleAttributes = [];
/** @type {Set<string>} */
const tagsUsage = new Set();
/** @type {Set<string>} */
const idsUsage = new Set();
/** @type {Set<string>} */
const classesUsage = new Set();
let enableTagsUsage = true;
let enableIdsUsage = true;
let enableClassesUsage = true;
/**
* Force to use usage data even if it unsafe. For example, the document
* contains scripts or in attributes..
*/
let forceUsageDeoptimized = false;
if (typeof usage === 'boolean') {
enableTagsUsage = usage;
enableIdsUsage = usage;
enableClassesUsage = usage;
} else if (usage) {
enableTagsUsage = usage.tags == null ? true : usage.tags;
enableIdsUsage = usage.ids == null ? true : usage.ids;
enableClassesUsage = usage.classes == null ? true : usage.classes;
forceUsageDeoptimized = usage.force == null ? false : usage.force;
}
let deoptimized = false;
return {
element: {
enter: (node, parentNode) => {
// detect deoptimisations
if (hasScripts(node)) {
deoptimized = true;
}
// collect tags, ids and classes usage
tagsUsage.add(node.name);
if (node.attributes.id != null) {
idsUsage.add(node.attributes.id);
}
if (node.attributes.class != null) {
for (const className of node.attributes.class.split(/\s+/)) {
classesUsage.add(className);
}
}
// collect style elements or elements with style attribute
if (node.name === 'style' && node.children.length !== 0) {
styleElements.set(node, parentNode);
} else if (node.attributes.style != null) {
elementsWithStyleAttributes.push(node);
}
},
},
root: {
exit: () => {
/** @type {csso.Usage} */
const cssoUsage = {};
if (!deoptimized || forceUsageDeoptimized) {
if (enableTagsUsage) {
cssoUsage.tags = Array.from(tagsUsage);
}
if (enableIdsUsage) {
cssoUsage.ids = Array.from(idsUsage);
}
if (enableClassesUsage) {
cssoUsage.classes = Array.from(classesUsage);
}
}
// minify style elements
for (const [styleNode, styleNodeParent] of styleElements.entries()) {
if (
styleNode.children[0].type === 'text' ||
styleNode.children[0].type === 'cdata'
) {
const cssText = styleNode.children[0].value;
const minified = csso.minify(cssText, {
...params,
usage: cssoUsage,
}).css;
if (minified.length === 0) {
detachNodeFromParent(styleNode, styleNodeParent);
continue;
}
// preserve cdata if necessary
// TODO split cdata -> text optimisation into separate plugin
if (cssText.indexOf('>') >= 0 || cssText.indexOf('<') >= 0) {
styleNode.children[0].type = 'cdata';
styleNode.children[0].value = minified;
} else {
styleNode.children[0].type = 'text';
styleNode.children[0].value = minified;
}
}
}
// minify style attributes
for (const node of elementsWithStyleAttributes) {
// style attribute
const elemStyle = node.attributes.style;
node.attributes.style = csso.minifyBlock(elemStyle, {
...params,
}).css;
}
},
},
};
};
+128
View File
@@ -0,0 +1,128 @@
'use strict';
const { visit } = require('../lib/xast.js');
const { inheritableAttrs, pathElems } = require('./_collections.js');
exports.name = 'moveElemsAttrsToGroup';
exports.description = 'Move common attributes of group children to the group';
/**
* Move common attributes of group children to the group
*
* @example
* <g attr1="val1">
* <g attr2="val2">
* text
* </g>
* <circle attr2="val2" attr3="val3"/>
* </g>
* ⬇
* <g attr1="val1" attr2="val2">
* <g>
* text
* </g>
* <circle attr3="val3"/>
* </g>
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'moveElemsAttrsToGroup'>}
*/
exports.fn = (root) => {
// find if any style element is present
let deoptimizedWithStyles = false;
visit(root, {
element: {
enter: (node) => {
if (node.name === 'style') {
deoptimizedWithStyles = true;
}
},
},
});
return {
element: {
exit: (node) => {
// process only groups with more than 1 children
if (node.name !== 'g' || node.children.length <= 1) {
return;
}
// deoptimize the plugin when style elements are present
// selectors may rely on id, classes or tag names
if (deoptimizedWithStyles) {
return;
}
/**
* find common attributes in group children
* @type {Map<string, string>}
*/
const commonAttributes = new Map();
let initial = true;
let everyChildIsPath = true;
for (const child of node.children) {
if (child.type === 'element') {
if (!pathElems.has(child.name)) {
everyChildIsPath = false;
}
if (initial) {
initial = false;
// collect all inheritable attributes from first child element
for (const [name, value] of Object.entries(child.attributes)) {
// consider only inheritable attributes
if (inheritableAttrs.has(name)) {
commonAttributes.set(name, value);
}
}
} else {
// exclude uncommon attributes from initial list
for (const [name, value] of commonAttributes) {
if (child.attributes[name] !== value) {
commonAttributes.delete(name);
}
}
}
}
}
// preserve transform on children when group has clip-path or mask
if (
node.attributes['clip-path'] != null ||
node.attributes.mask != null
) {
commonAttributes.delete('transform');
}
// preserve transform when all children are paths
// so the transform could be applied to path data by other plugins
if (everyChildIsPath) {
commonAttributes.delete('transform');
}
// add common children attributes to group
for (const [name, value] of commonAttributes) {
if (name === 'transform') {
if (node.attributes.transform != null) {
node.attributes.transform = `${node.attributes.transform} ${value}`;
} else {
node.attributes.transform = value;
}
} else {
node.attributes[name] = value;
}
}
// delete common attributes from children
for (const child of node.children) {
if (child.type === 'element') {
for (const [name] of commonAttributes) {
delete child.attributes[name];
}
}
}
},
},
};
};
+65
View File
@@ -0,0 +1,65 @@
'use strict';
const { pathElems, referencesProps } = require('./_collections.js');
const { includesUrlReference } = require('../lib/svgo/tools.js');
exports.name = 'moveGroupAttrsToElems';
exports.description = 'moves some group attributes to the content elements';
const pathElemsWithGroupsAndText = [...pathElems, 'g', 'text'];
/**
* Move group attrs to the content elements.
*
* @example
* <g transform="scale(2)">
* <path transform="rotate(45)" d="M0,0 L10,20"/>
* <path transform="translate(10, 20)" d="M0,10 L20,30"/>
* </g>
* ⬇
* <g>
* <path transform="scale(2) rotate(45)" d="M0,0 L10,20"/>
* <path transform="scale(2) translate(10, 20)" d="M0,10 L20,30"/>
* </g>
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'moveGroupAttrsToElems'>}
*/
exports.fn = () => {
return {
element: {
enter: (node) => {
// move group transform attr to content's pathElems
if (
node.name === 'g' &&
node.children.length !== 0 &&
node.attributes.transform != null &&
Object.entries(node.attributes).some(
([name, value]) =>
referencesProps.has(name) && includesUrlReference(value),
) === false &&
node.children.every(
(child) =>
child.type === 'element' &&
pathElemsWithGroupsAndText.includes(child.name) &&
child.attributes.id == null,
)
) {
for (const child of node.children) {
const value = node.attributes.transform;
if (child.type === 'element') {
if (child.attributes.transform != null) {
child.attributes.transform = `${value} ${child.attributes.transform}`;
} else {
child.attributes.transform = value;
}
}
}
delete node.attributes.transform;
}
},
},
};
};
+268
View File
@@ -0,0 +1,268 @@
'use strict';
/**
* @typedef {import('../lib/types.js').PluginInfo} PluginInfo
* @typedef {import('../lib/types').XastElement} XastElement
*/
const csstree = require('css-tree');
const { referencesProps } = require('./_collections.js');
exports.name = 'prefixIds';
exports.description = 'prefix IDs';
/**
* extract basename from path
* @type {(path: string) => string}
*/
const getBasename = (path) => {
// extract everything after latest slash or backslash
const matched = /[/\\]?([^/\\]+)$/.exec(path);
if (matched) {
return matched[1];
}
return '';
};
/**
* escapes a string for being used as ID
* @type {(string: string) => string}
*/
const escapeIdentifierName = (str) => {
return str.replace(/[. ]/g, '_');
};
/**
* @type {(string: string) => string}
*/
const unquote = (string) => {
if (
(string.startsWith('"') && string.endsWith('"')) ||
(string.startsWith("'") && string.endsWith("'"))
) {
return string.slice(1, -1);
}
return string;
};
/**
* Prefix the given string, unless it already starts with the generated prefix.
*
* @param {(id: string) => string} prefixGenerator Function to generate a prefix.
* @param {string} body An arbitrary string.
* @returns {string} The given string with a prefix prepended to it.
*/
const prefixId = (prefixGenerator, body) => {
const prefix = prefixGenerator(body);
if (body.startsWith(prefix)) {
return body;
}
return prefix + body;
};
/**
* Insert the prefix in a reference string. A reference string is already
* prefixed with #, so the prefix is inserted after the first character.
*
* @param {(id: string) => string} prefixGenerator Function to generate a prefix.
* @param {string} reference An arbitrary string, should start with "#".
* @returns {?string} The given string with a prefix inserted, or null if the string did not start with "#".
*/
const prefixReference = (prefixGenerator, reference) => {
if (reference.startsWith('#')) {
return '#' + prefixId(prefixGenerator, reference.slice(1));
}
return null;
};
/**
* Generates a prefix for the given string.
*
* @param {string} body An arbitrary string.
* @param {XastElement} node XML node that the identifier belongs to.
* @param {PluginInfo} info
* @param {((node: XastElement, info: PluginInfo) => string)|string|boolean|undefined} prefixGenerator Some way of obtaining a prefix.
* @param {string} delim Content to insert between the prefix and original value.
* @param {Map<string, string>} history Map of previously generated prefixes to IDs.
* @returns {string} A generated prefix.
*/
const generatePrefix = (body, node, info, prefixGenerator, delim, history) => {
if (typeof prefixGenerator === 'function') {
let prefix = history.get(body);
if (prefix != null) {
return prefix;
}
prefix = prefixGenerator(node, info) + delim;
history.set(body, prefix);
return prefix;
}
if (typeof prefixGenerator === 'string') {
return prefixGenerator + delim;
}
if (prefixGenerator === false) {
return '';
}
if (info.path != null && info.path.length > 0) {
return escapeIdentifierName(getBasename(info.path)) + delim;
}
return 'prefix' + delim;
};
/**
* Prefixes identifiers
*
* @author strarsis <strarsis@gmail.com>
* @type {import('./plugins-types').Plugin<'prefixIds'>}
*/
exports.fn = (_root, params, info) => {
const {
delim = '__',
prefix,
prefixIds = true,
prefixClassNames = true,
} = params;
/** @type {Map<string, string>} */
const prefixMap = new Map();
return {
element: {
enter: (node) => {
/**
* @param {string} id A node identifier or class.
* @returns {string} Given string with a prefix inserted, or null if the string did not start with "#".
*/
const prefixGenerator = (id) =>
generatePrefix(id, node, info, prefix, delim, prefixMap);
// prefix id/class selectors and url() references in styles
if (node.name === 'style') {
// skip empty <style/> elements
if (node.children.length === 0) {
return;
}
for (const child of node.children) {
if (child.type !== 'text' && child.type !== 'cdata') {
continue;
}
const cssText = child.value;
/** @type {?csstree.CssNode} */
let cssAst = null;
try {
cssAst = csstree.parse(cssText, {
parseValue: true,
parseCustomProperty: false,
});
} catch {
return;
}
csstree.walk(cssAst, (node) => {
if (
(prefixIds && node.type === 'IdSelector') ||
(prefixClassNames && node.type === 'ClassSelector')
) {
node.name = prefixId(prefixGenerator, node.name);
return;
}
if (node.type === 'Url' && node.value.length > 0) {
const prefixed = prefixReference(
prefixGenerator,
unquote(node.value),
);
if (prefixed != null) {
node.value = prefixed;
}
}
});
child.value = csstree.generate(cssAst);
return;
}
}
// prefix an ID attribute value
if (
prefixIds &&
node.attributes.id != null &&
node.attributes.id.length !== 0
) {
node.attributes.id = prefixId(prefixGenerator, node.attributes.id);
}
// prefix a class attribute value
if (
prefixClassNames &&
node.attributes.class != null &&
node.attributes.class.length !== 0
) {
node.attributes.class = node.attributes.class
.split(/\s+/)
.map((name) => prefixId(prefixGenerator, name))
.join(' ');
}
// prefix a href attribute value
// xlink:href is deprecated, must be still supported
for (const name of ['href', 'xlink:href']) {
if (
node.attributes[name] != null &&
node.attributes[name].length !== 0
) {
const prefixed = prefixReference(
prefixGenerator,
node.attributes[name],
);
if (prefixed != null) {
node.attributes[name] = prefixed;
}
}
}
// prefix a URL attribute value
for (const name of referencesProps) {
if (
node.attributes[name] != null &&
node.attributes[name].length !== 0
) {
node.attributes[name] = node.attributes[name].replace(
/\burl\((["'])?(#.+?)\1\)/gi,
(match, _, url) => {
const prefixed = prefixReference(prefixGenerator, url);
if (prefixed == null) {
return match;
}
return `url(${prefixed})`;
},
);
}
}
// prefix begin/end attribute value
for (const name of ['begin', 'end']) {
if (
node.attributes[name] != null &&
node.attributes[name].length !== 0
) {
const parts = node.attributes[name].split(/\s*;\s+/).map((val) => {
if (val.endsWith('.end') || val.endsWith('.start')) {
const [id, postfix] = val.split('.');
return `${prefixId(prefixGenerator, id)}.${postfix}`;
}
return val;
});
node.attributes[name] = parts.join('; ');
}
}
},
},
};
};
+82
View File
@@ -0,0 +1,82 @@
'use strict';
const { createPreset } = require('../lib/svgo/plugins.js');
const removeDoctype = require('./removeDoctype.js');
const removeXMLProcInst = require('./removeXMLProcInst.js');
const removeComments = require('./removeComments.js');
const removeMetadata = require('./removeMetadata.js');
const removeEditorsNSData = require('./removeEditorsNSData.js');
const cleanupAttrs = require('./cleanupAttrs.js');
const mergeStyles = require('./mergeStyles.js');
const inlineStyles = require('./inlineStyles.js');
const minifyStyles = require('./minifyStyles.js');
const cleanupIds = require('./cleanupIds.js');
const removeUselessDefs = require('./removeUselessDefs.js');
const cleanupNumericValues = require('./cleanupNumericValues.js');
const convertColors = require('./convertColors.js');
const removeUnknownsAndDefaults = require('./removeUnknownsAndDefaults.js');
const removeNonInheritableGroupAttrs = require('./removeNonInheritableGroupAttrs.js');
const removeUselessStrokeAndFill = require('./removeUselessStrokeAndFill.js');
const removeViewBox = require('./removeViewBox.js');
const cleanupEnableBackground = require('./cleanupEnableBackground.js');
const removeHiddenElems = require('./removeHiddenElems.js');
const removeEmptyText = require('./removeEmptyText.js');
const convertShapeToPath = require('./convertShapeToPath.js');
const convertEllipseToCircle = require('./convertEllipseToCircle.js');
const moveElemsAttrsToGroup = require('./moveElemsAttrsToGroup.js');
const moveGroupAttrsToElems = require('./moveGroupAttrsToElems.js');
const collapseGroups = require('./collapseGroups.js');
const convertPathData = require('./convertPathData.js');
const convertTransform = require('./convertTransform.js');
const removeEmptyAttrs = require('./removeEmptyAttrs.js');
const removeEmptyContainers = require('./removeEmptyContainers.js');
const mergePaths = require('./mergePaths.js');
const removeUnusedNS = require('./removeUnusedNS.js');
const sortAttrs = require('./sortAttrs.js');
const sortDefsChildren = require('./sortDefsChildren.js');
const removeTitle = require('./removeTitle.js');
const removeDesc = require('./removeDesc.js');
const presetDefault = createPreset({
name: 'preset-default',
plugins: [
removeDoctype,
removeXMLProcInst,
removeComments,
removeMetadata,
removeEditorsNSData,
cleanupAttrs,
mergeStyles,
inlineStyles,
minifyStyles,
cleanupIds,
removeUselessDefs,
cleanupNumericValues,
convertColors,
removeUnknownsAndDefaults,
removeNonInheritableGroupAttrs,
removeUselessStrokeAndFill,
removeViewBox,
cleanupEnableBackground,
removeHiddenElems,
removeEmptyText,
convertShapeToPath,
convertEllipseToCircle,
moveElemsAttrsToGroup,
moveGroupAttrsToElems,
collapseGroups,
convertPathData,
convertTransform,
removeEmptyAttrs,
removeEmptyContainers,
mergePaths,
removeUnusedNS,
sortAttrs,
sortDefsChildren,
removeTitle,
removeDesc,
],
});
module.exports = presetDefault;
+97
View File
@@ -0,0 +1,97 @@
'use strict';
const { querySelectorAll } = require('../lib/xast.js');
exports.name = 'removeAttributesBySelector';
exports.description =
'removes attributes of elements that match a css selector';
/**
* Removes attributes of elements that match a css selector.
*
* @example
* <caption>A selector removing a single attribute</caption>
* plugins: [
* {
* name: "removeAttributesBySelector",
* params: {
* selector: "[fill='#00ff00']"
* attributes: "fill"
* }
* }
* ]
*
* <rect x="0" y="0" width="100" height="100" fill="#00ff00" stroke="#00ff00"/>
* ↓
* <rect x="0" y="0" width="100" height="100" stroke="#00ff00"/>
*
* <caption>A selector removing multiple attributes</caption>
* plugins: [
* {
* name: "removeAttributesBySelector",
* params: {
* selector: "[fill='#00ff00']",
* attributes: [
* "fill",
* "stroke"
* ]
* }
* }
* ]
*
* <rect x="0" y="0" width="100" height="100" fill="#00ff00" stroke="#00ff00"/>
* ↓
* <rect x="0" y="0" width="100" height="100"/>
*
* <caption>Multiple selectors removing attributes</caption>
* plugins: [
* {
* name: "removeAttributesBySelector",
* params: {
* selectors: [
* {
* selector: "[fill='#00ff00']",
* attributes: "fill"
* },
* {
* selector: "#remove",
* attributes: [
* "stroke",
* "id"
* ]
* }
* ]
* }
* }
* ]
*
* <rect x="0" y="0" width="100" height="100" fill="#00ff00" stroke="#00ff00"/>
* ↓
* <rect x="0" y="0" width="100" height="100"/>
*
* @link https://developer.mozilla.org/docs/Web/CSS/CSS_Selectors|MDN CSS Selectors
*
* @author Bradley Mease
*
* @type {import('./plugins-types').Plugin<'removeAttributesBySelector'>}
*/
exports.fn = (root, params) => {
const selectors = Array.isArray(params.selectors)
? params.selectors
: [params];
for (const { selector, attributes } of selectors) {
const nodes = querySelectorAll(root, selector);
for (const node of nodes) {
if (node.type === 'element') {
if (Array.isArray(attributes)) {
for (const name of attributes) {
delete node.attributes[name];
}
} else {
delete node.attributes[attributes];
}
}
}
}
return {};
};
+151
View File
@@ -0,0 +1,151 @@
'use strict';
exports.name = 'removeAttrs';
exports.description = 'removes specified attributes';
const DEFAULT_SEPARATOR = ':';
const ENOATTRS = `Warning: The plugin "removeAttrs" requires the "attrs" parameter.
It should have a pattern to remove, otherwise the plugin is a noop.
Config example:
plugins: [
{
name: "removeAttrs",
params: {
attrs: "(fill|stroke)"
}
}
]
`;
/**
* Remove attributes
*
* @example elemSeparator
* format: string
*
* @example preserveCurrentColor
* format: boolean
*
* @example attrs:
*
* format: [ element* : attribute* : value* ]
*
* element : regexp (wrapped into ^...$), single * or omitted > all elements (must be present when value is used)
* attribute : regexp (wrapped into ^...$)
* value : regexp (wrapped into ^...$), single * or omitted > all values
*
* examples:
*
* > basic: remove fill attribute
* ---
* removeAttrs:
* attrs: 'fill'
*
* > remove fill attribute on path element
* ---
* attrs: 'path:fill'
*
* > remove fill attribute on path element where value is none
* ---
* attrs: 'path:fill:none'
*
*
* > remove all fill and stroke attribute
* ---
* attrs:
* - 'fill'
* - 'stroke'
*
* [is same as]
*
* attrs: '(fill|stroke)'
*
* [is same as]
*
* attrs: '*:(fill|stroke)'
*
* [is same as]
*
* attrs: '.*:(fill|stroke)'
*
* [is same as]
*
* attrs: '.*:(fill|stroke):.*'
*
*
* > remove all stroke related attributes
* ----
* attrs: 'stroke.*'
*
*
* @author Benny Schudel
*
* @type {import('./plugins-types').Plugin<'removeAttrs'>}
*/
exports.fn = (root, params) => {
if (typeof params.attrs == 'undefined') {
console.warn(ENOATTRS);
return null;
}
const elemSeparator =
typeof params.elemSeparator == 'string'
? params.elemSeparator
: DEFAULT_SEPARATOR;
const preserveCurrentColor =
typeof params.preserveCurrentColor == 'boolean'
? params.preserveCurrentColor
: false;
const attrs = Array.isArray(params.attrs) ? params.attrs : [params.attrs];
return {
element: {
enter: (node) => {
for (let pattern of attrs) {
// if no element separators (:), assume it's attribute name, and apply to all elements *regardless of value*
if (!pattern.includes(elemSeparator)) {
pattern = ['.*', pattern, '.*'].join(elemSeparator);
// if only 1 separator, assume it's element and attribute name, and apply regardless of attribute value
} else if (pattern.split(elemSeparator).length < 3) {
pattern = [pattern, '.*'].join(elemSeparator);
}
// create regexps for element, attribute name, and attribute value
const list = pattern.split(elemSeparator).map((value) => {
// adjust single * to match anything
if (value === '*') {
value = '.*';
}
return new RegExp(['^', value, '$'].join(''), 'i');
});
// matches element
if (list[0].test(node.name)) {
// loop attributes
for (const [name, value] of Object.entries(node.attributes)) {
const isFillCurrentColor =
preserveCurrentColor &&
name == 'fill' &&
value == 'currentColor';
const isStrokeCurrentColor =
preserveCurrentColor &&
name == 'stroke' &&
value == 'currentColor';
if (
!isFillCurrentColor &&
!isStrokeCurrentColor &&
// matches attribute name
list[1].test(name) &&
// matches attribute value
list[2].test(value)
) {
delete node.attributes[name];
}
}
}
}
},
},
};
};
+51
View File
@@ -0,0 +1,51 @@
'use strict';
const { detachNodeFromParent } = require('../lib/xast.js');
exports.name = 'removeComments';
exports.description = 'removes comments';
/**
* If a comment matches one of the following patterns, it will be
* preserved by default. Particularly for copyright/license information.
*/
const DEFAULT_PRESERVE_PATTERNS = [/^!/];
/**
* Remove comments.
*
* @example
* <!-- Generator: Adobe Illustrator 15.0.0, SVG Export
* Plug-In . SVG Version: 6.00 Build 0) -->
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'removeComments'>}
*/
exports.fn = (_root, params) => {
const { preservePatterns = DEFAULT_PRESERVE_PATTERNS } = params;
return {
comment: {
enter: (node, parentNode) => {
if (preservePatterns) {
if (!Array.isArray(preservePatterns)) {
throw Error(
`Expected array in removeComments preservePatterns parameter but received ${preservePatterns}`,
);
}
const matches = preservePatterns.some((pattern) => {
return new RegExp(pattern).test(node.value);
});
if (matches) {
return;
}
}
detachNodeFromParent(node, parentNode);
},
},
};
};
+39
View File
@@ -0,0 +1,39 @@
'use strict';
const { detachNodeFromParent } = require('../lib/xast.js');
exports.name = 'removeDesc';
exports.description = 'removes <desc>';
const standardDescs = /^(Created with|Created using)/;
/**
* Removes <desc>.
* Removes only standard editors content or empty elements 'cause it can be used for accessibility.
* Enable parameter 'removeAny' to remove any description.
*
* https://developer.mozilla.org/docs/Web/SVG/Element/desc
*
* @author Daniel Wabyick
*
* @type {import('./plugins-types').Plugin<'removeDesc'>}
*/
exports.fn = (root, params) => {
const { removeAny = false } = params;
return {
element: {
enter: (node, parentNode) => {
if (node.name === 'desc') {
if (
removeAny ||
node.children.length === 0 ||
(node.children[0].type === 'text' &&
standardDescs.test(node.children[0].value))
) {
detachNodeFromParent(node, parentNode);
}
}
},
},
};
};
+43
View File
@@ -0,0 +1,43 @@
'use strict';
exports.name = 'removeDimensions';
exports.description =
'removes width and height in presence of viewBox (opposite to removeViewBox, disable it first)';
/**
* Remove width/height attributes and add the viewBox attribute if it's missing
*
* @example
* <svg width="100" height="50" />
* ↓
* <svg viewBox="0 0 100 50" />
*
* @author Benny Schudel
*
* @type {import('./plugins-types').Plugin<'removeDimensions'>}
*/
exports.fn = () => {
return {
element: {
enter: (node) => {
if (node.name === 'svg') {
if (node.attributes.viewBox != null) {
delete node.attributes.width;
delete node.attributes.height;
} else if (
node.attributes.width != null &&
node.attributes.height != null &&
Number.isNaN(Number(node.attributes.width)) === false &&
Number.isNaN(Number(node.attributes.height)) === false
) {
const width = Number(node.attributes.width);
const height = Number(node.attributes.height);
node.attributes.viewBox = `0 0 ${width} ${height}`;
delete node.attributes.width;
delete node.attributes.height;
}
}
},
},
};
};
+40
View File
@@ -0,0 +1,40 @@
'use strict';
const { detachNodeFromParent } = require('../lib/xast.js');
exports.name = 'removeDoctype';
exports.description = 'removes doctype declaration';
/**
* Remove DOCTYPE declaration.
*
* "Unfortunately the SVG DTDs are a source of so many
* issues that the SVG WG has decided not to write one
* for the upcoming SVG 1.2 standard. In fact SVG WG
* members are even telling people not to use a DOCTYPE
* declaration in SVG 1.0 and 1.1 documents"
* https://jwatt.org/svg/authoring/#doctype-declaration
*
* @example
* <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
* q"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
*
* @example
* <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
* "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd" [
* <!-- an internal subset can be embedded here -->
* ]>
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'removeDoctype'>}
*/
exports.fn = () => {
return {
doctype: {
enter: (node, parentNode) => {
detachNodeFromParent(node, parentNode);
},
},
};
};
+64
View File
@@ -0,0 +1,64 @@
'use strict';
const { detachNodeFromParent } = require('../lib/xast.js');
const { editorNamespaces } = require('./_collections.js');
exports.name = 'removeEditorsNSData';
exports.description = 'removes editors namespaces, elements and attributes';
/**
* Remove editors namespaces, elements and attributes.
*
* @example
* <svg xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd">
* <sodipodi:namedview/>
* <path sodipodi:nodetypes="cccc"/>
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'removeEditorsNSData'>}
*/
exports.fn = (_root, params) => {
let namespaces = [...editorNamespaces];
if (Array.isArray(params.additionalNamespaces)) {
namespaces = [...editorNamespaces, ...params.additionalNamespaces];
}
/**
* @type {string[]}
*/
const prefixes = [];
return {
element: {
enter: (node, parentNode) => {
// collect namespace prefixes from svg element
if (node.name === 'svg') {
for (const [name, value] of Object.entries(node.attributes)) {
if (name.startsWith('xmlns:') && namespaces.includes(value)) {
prefixes.push(name.slice('xmlns:'.length));
// <svg xmlns:sodipodi="">
delete node.attributes[name];
}
}
}
// remove editor attributes, for example
// <* sodipodi:*="">
for (const name of Object.keys(node.attributes)) {
if (name.includes(':')) {
const [prefix] = name.split(':');
if (prefixes.includes(prefix)) {
delete node.attributes[name];
}
}
}
// remove editor elements, for example
// <sodipodi:*>
if (node.name.includes(':')) {
const [prefix] = node.name.split(':');
if (prefixes.includes(prefix)) {
detachNodeFromParent(node, parentNode);
}
}
},
},
};
};
+73
View File
@@ -0,0 +1,73 @@
'use strict';
const { detachNodeFromParent } = require('../lib/xast.js');
exports.name = 'removeElementsByAttr';
exports.description =
'removes arbitrary elements by ID or className (disabled by default)';
/**
* Remove arbitrary SVG elements by ID or className.
*
* @example id
* > single: remove element with ID of `elementID`
* ---
* removeElementsByAttr:
* id: 'elementID'
*
* > list: remove multiple elements by ID
* ---
* removeElementsByAttr:
* id:
* - 'elementID'
* - 'anotherID'
*
* @example class
* > single: remove all elements with class of `elementClass`
* ---
* removeElementsByAttr:
* class: 'elementClass'
*
* > list: remove all elements with class of `elementClass` or `anotherClass`
* ---
* removeElementsByAttr:
* class:
* - 'elementClass'
* - 'anotherClass'
*
* @author Eli Dupuis (@elidupuis)
*
* @type {import('./plugins-types').Plugin<'removeElementsByAttr'>}
*/
exports.fn = (root, params) => {
const ids =
params.id == null ? [] : Array.isArray(params.id) ? params.id : [params.id];
const classes =
params.class == null
? []
: Array.isArray(params.class)
? params.class
: [params.class];
return {
element: {
enter: (node, parentNode) => {
// remove element if it's `id` matches configured `id` params
if (node.attributes.id != null && ids.length !== 0) {
if (ids.includes(node.attributes.id)) {
detachNodeFromParent(node, parentNode);
}
}
// remove element if it's `class` contains any of the configured `class` params
if (node.attributes.class && classes.length !== 0) {
const classList = node.attributes.class.split(' ');
for (const item of classes) {
if (classList.includes(item)) {
detachNodeFromParent(node, parentNode);
break;
}
}
}
},
},
};
};
+31
View File
@@ -0,0 +1,31 @@
'use strict';
const { attrsGroups } = require('./_collections.js');
exports.name = 'removeEmptyAttrs';
exports.description = 'removes empty attributes';
/**
* Remove attributes with empty values.
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'removeEmptyAttrs'>}
*/
exports.fn = () => {
return {
element: {
enter: (node) => {
for (const [name, value] of Object.entries(node.attributes)) {
if (
value === '' &&
// empty conditional processing attributes prevents elements from rendering
!attrsGroups.conditionalProcessing.has(name)
) {
delete node.attributes[name];
}
}
},
},
};
};
+59
View File
@@ -0,0 +1,59 @@
'use strict';
const { detachNodeFromParent } = require('../lib/xast.js');
const { elemsGroups } = require('./_collections.js');
exports.name = 'removeEmptyContainers';
exports.description = 'removes empty container elements';
/**
* Remove empty containers.
*
* @see https://www.w3.org/TR/SVG11/intro.html#TermContainerElement
*
* @example
* <defs/>
*
* @example
* <g><marker><a/></marker></g>
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'removeEmptyContainers'>}
*/
exports.fn = () => {
return {
element: {
exit: (node, parentNode) => {
// remove only empty non-svg containers
if (
node.name === 'svg' ||
!elemsGroups.container.has(node.name) ||
node.children.length !== 0
) {
return;
}
// empty patterns may contain reusable configuration
if (
node.name === 'pattern' &&
Object.keys(node.attributes).length !== 0
) {
return;
}
// The <g> may not have content, but the filter may cause a rectangle
// to be created and filled with pattern.
if (node.name === 'g' && node.attributes.filter != null) {
return;
}
// empty <mask> hides masked element
if (node.name === 'mask' && node.attributes.id != null) {
return;
}
if (parentNode.type === 'element' && parentNode.name === 'switch') {
return;
}
detachNodeFromParent(node, parentNode);
},
},
};
};
+51
View File
@@ -0,0 +1,51 @@
'use strict';
const { detachNodeFromParent } = require('../lib/xast.js');
exports.name = 'removeEmptyText';
exports.description = 'removes empty <text> elements';
/**
* Remove empty Text elements.
*
* @see https://www.w3.org/TR/SVG11/text.html
*
* @example
* Remove empty text element:
* <text/>
*
* Remove empty tspan element:
* <tspan/>
*
* Remove tref with empty xlink:href attribute:
* <tref xlink:href=""/>
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'removeEmptyText'>}
*/
exports.fn = (root, params) => {
const { text = true, tspan = true, tref = true } = params;
return {
element: {
enter: (node, parentNode) => {
// Remove empty text element
if (text && node.name === 'text' && node.children.length === 0) {
detachNodeFromParent(node, parentNode);
}
// Remove empty tspan element
if (tspan && node.name === 'tspan' && node.children.length === 0) {
detachNodeFromParent(node, parentNode);
}
// Remove tref with empty xlink:href attribute
if (
tref &&
node.name === 'tref' &&
node.attributes['xlink:href'] == null
) {
detachNodeFromParent(node, parentNode);
}
},
},
};
};
+440
View File
@@ -0,0 +1,440 @@
'use strict';
/**
* @typedef {import('../lib/types').XastChild} XastChild
* @typedef {import('../lib/types').XastElement} XastElement
* @typedef {import('../lib/types').XastParent} XastParent
*/
const { elemsGroups } = require('./_collections.js');
const {
visit,
visitSkip,
querySelector,
detachNodeFromParent,
} = require('../lib/xast.js');
const { collectStylesheet, computeStyle } = require('../lib/style.js');
const { parsePathData } = require('../lib/path.js');
const { hasScripts, findReferences } = require('../lib/svgo/tools.js');
const nonRendering = elemsGroups.nonRendering;
exports.name = 'removeHiddenElems';
exports.description =
'removes hidden elements (zero sized, with absent attributes)';
/**
* Remove hidden elements with disabled rendering:
* - display="none"
* - opacity="0"
* - circle with zero radius
* - ellipse with zero x-axis or y-axis radius
* - rectangle with zero width or height
* - pattern with zero width or height
* - image with zero width or height
* - path with empty data
* - polyline with empty points
* - polygon with empty points
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'removeHiddenElems'>}
*/
exports.fn = (root, params) => {
const {
isHidden = true,
displayNone = true,
opacity0 = true,
circleR0 = true,
ellipseRX0 = true,
ellipseRY0 = true,
rectWidth0 = true,
rectHeight0 = true,
patternWidth0 = true,
patternHeight0 = true,
imageWidth0 = true,
imageHeight0 = true,
pathEmptyD = true,
polylineEmptyPoints = true,
polygonEmptyPoints = true,
} = params;
const stylesheet = collectStylesheet(root);
/**
* Skip non-rendered nodes initially, and only detach if they have no ID, or
* their ID is not referenced by another node.
*
* @type {Map<XastElement, XastParent>}
*/
const nonRenderedNodes = new Map();
/**
* IDs for removed hidden definitions.
*
* @type {Set<string>}
*/
const removedDefIds = new Set();
/**
* @type {Map<XastElement, XastParent>}
*/
const allDefs = new Map();
/** @type {Set<string>} */
const allReferences = new Set();
/**
* @type {Map<string, Array<{ node: XastElement, parentNode: XastParent }>>}
*/
const referencesById = new Map();
/**
* If styles are present, we can't be sure if a definition is unused or not
*/
let deoptimized = false;
/**
* @param {XastChild} node
* @param {XastParent} parentNode
*/
function removeElement(node, parentNode) {
if (
node.type === 'element' &&
node.attributes.id != null &&
parentNode.type === 'element' &&
parentNode.name === 'defs'
) {
removedDefIds.add(node.attributes.id);
}
detachNodeFromParent(node, parentNode);
}
visit(root, {
element: {
enter: (node, parentNode) => {
// transparent non-rendering elements still apply where referenced
if (nonRendering.has(node.name)) {
if (node.attributes.id == null) {
detachNodeFromParent(node, parentNode);
return visitSkip;
}
nonRenderedNodes.set(node, parentNode);
return visitSkip;
}
const computedStyle = computeStyle(stylesheet, node);
// opacity="0"
//
// https://www.w3.org/TR/SVG11/masking.html#ObjectAndGroupOpacityProperties
if (
opacity0 &&
computedStyle.opacity &&
computedStyle.opacity.type === 'static' &&
computedStyle.opacity.value === '0'
) {
removeElement(node, parentNode);
}
},
},
});
return {
element: {
enter: (node, parentNode) => {
if (
(node.name === 'style' && node.children.length !== 0) ||
hasScripts(node)
) {
deoptimized = true;
return;
}
if (node.name === 'defs') {
allDefs.set(node, parentNode);
}
if (node.name === 'use') {
for (const attr of Object.keys(node.attributes)) {
if (attr !== 'href' && !attr.endsWith(':href')) continue;
const value = node.attributes[attr];
const id = value.slice(1);
let refs = referencesById.get(id);
if (!refs) {
refs = [];
referencesById.set(id, refs);
}
refs.push({ node, parentNode });
}
}
// Removes hidden elements
// https://www.w3schools.com/cssref/pr_class_visibility.asp
const computedStyle = computeStyle(stylesheet, node);
if (
isHidden &&
computedStyle.visibility &&
computedStyle.visibility.type === 'static' &&
computedStyle.visibility.value === 'hidden' &&
// keep if any descendant enables visibility
querySelector(node, '[visibility=visible]') == null
) {
removeElement(node, parentNode);
return;
}
// display="none"
//
// https://www.w3.org/TR/SVG11/painting.html#DisplayProperty
// "A value of display: none indicates that the given element
// and its children shall not be rendered directly"
if (
displayNone &&
computedStyle.display &&
computedStyle.display.type === 'static' &&
computedStyle.display.value === 'none' &&
// markers with display: none still rendered
node.name !== 'marker'
) {
removeElement(node, parentNode);
return;
}
// Circles with zero radius
//
// https://www.w3.org/TR/SVG11/shapes.html#CircleElementRAttribute
// "A value of zero disables rendering of the element"
//
// <circle r="0">
if (
circleR0 &&
node.name === 'circle' &&
node.children.length === 0 &&
node.attributes.r === '0'
) {
removeElement(node, parentNode);
return;
}
// Ellipse with zero x-axis radius
//
// https://www.w3.org/TR/SVG11/shapes.html#EllipseElementRXAttribute
// "A value of zero disables rendering of the element"
//
// <ellipse rx="0">
if (
ellipseRX0 &&
node.name === 'ellipse' &&
node.children.length === 0 &&
node.attributes.rx === '0'
) {
removeElement(node, parentNode);
return;
}
// Ellipse with zero y-axis radius
//
// https://www.w3.org/TR/SVG11/shapes.html#EllipseElementRYAttribute
// "A value of zero disables rendering of the element"
//
// <ellipse ry="0">
if (
ellipseRY0 &&
node.name === 'ellipse' &&
node.children.length === 0 &&
node.attributes.ry === '0'
) {
removeElement(node, parentNode);
return;
}
// Rectangle with zero width
//
// https://www.w3.org/TR/SVG11/shapes.html#RectElementWidthAttribute
// "A value of zero disables rendering of the element"
//
// <rect width="0">
if (
rectWidth0 &&
node.name === 'rect' &&
node.children.length === 0 &&
node.attributes.width === '0'
) {
removeElement(node, parentNode);
return;
}
// Rectangle with zero height
//
// https://www.w3.org/TR/SVG11/shapes.html#RectElementHeightAttribute
// "A value of zero disables rendering of the element"
//
// <rect height="0">
if (
rectHeight0 &&
rectWidth0 &&
node.name === 'rect' &&
node.children.length === 0 &&
node.attributes.height === '0'
) {
removeElement(node, parentNode);
return;
}
// Pattern with zero width
//
// https://www.w3.org/TR/SVG11/pservers.html#PatternElementWidthAttribute
// "A value of zero disables rendering of the element (i.e., no paint is applied)"
//
// <pattern width="0">
if (
patternWidth0 &&
node.name === 'pattern' &&
node.attributes.width === '0'
) {
removeElement(node, parentNode);
return;
}
// Pattern with zero height
//
// https://www.w3.org/TR/SVG11/pservers.html#PatternElementHeightAttribute
// "A value of zero disables rendering of the element (i.e., no paint is applied)"
//
// <pattern height="0">
if (
patternHeight0 &&
node.name === 'pattern' &&
node.attributes.height === '0'
) {
removeElement(node, parentNode);
return;
}
// Image with zero width
//
// https://www.w3.org/TR/SVG11/struct.html#ImageElementWidthAttribute
// "A value of zero disables rendering of the element"
//
// <image width="0">
if (
imageWidth0 &&
node.name === 'image' &&
node.attributes.width === '0'
) {
removeElement(node, parentNode);
return;
}
// Image with zero height
//
// https://www.w3.org/TR/SVG11/struct.html#ImageElementHeightAttribute
// "A value of zero disables rendering of the element"
//
// <image height="0">
if (
imageHeight0 &&
node.name === 'image' &&
node.attributes.height === '0'
) {
removeElement(node, parentNode);
return;
}
// Path with empty data
//
// https://www.w3.org/TR/SVG11/paths.html#DAttribute
//
// <path d=""/>
if (pathEmptyD && node.name === 'path') {
if (node.attributes.d == null) {
removeElement(node, parentNode);
return;
}
const pathData = parsePathData(node.attributes.d);
if (pathData.length === 0) {
removeElement(node, parentNode);
return;
}
// keep single point paths for markers
if (
pathData.length === 1 &&
computedStyle['marker-start'] == null &&
computedStyle['marker-end'] == null
) {
removeElement(node, parentNode);
return;
}
}
// Polyline with empty points
//
// https://www.w3.org/TR/SVG11/shapes.html#PolylineElementPointsAttribute
//
// <polyline points="">
if (
polylineEmptyPoints &&
node.name === 'polyline' &&
node.attributes.points == null
) {
removeElement(node, parentNode);
return;
}
// Polygon with empty points
//
// https://www.w3.org/TR/SVG11/shapes.html#PolygonElementPointsAttribute
//
// <polygon points="">
if (
polygonEmptyPoints &&
node.name === 'polygon' &&
node.attributes.points == null
) {
removeElement(node, parentNode);
return;
}
for (const [name, value] of Object.entries(node.attributes)) {
const ids = findReferences(name, value);
for (const id of ids) {
allReferences.add(id);
}
}
},
},
root: {
exit: () => {
for (const id of removedDefIds) {
const refs = referencesById.get(id);
if (refs) {
for (const { node, parentNode } of refs) {
detachNodeFromParent(node, parentNode);
}
}
}
if (!deoptimized) {
for (const [
nonRenderedNode,
nonRenderedParent,
] of nonRenderedNodes.entries()) {
const id = nonRenderedNode.attributes.id;
if (!allReferences.has(id)) {
detachNodeFromParent(nonRenderedNode, nonRenderedParent);
}
}
}
for (const [node, parentNode] of allDefs.entries()) {
if (node.children.length === 0) {
detachNodeFromParent(node, parentNode);
}
}
},
},
};
};
+27
View File
@@ -0,0 +1,27 @@
'use strict';
const { detachNodeFromParent } = require('../lib/xast.js');
exports.name = 'removeMetadata';
exports.description = 'removes <metadata>';
/**
* Remove <metadata>.
*
* https://www.w3.org/TR/SVG11/metadata.html
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'removeMetadata'>}
*/
exports.fn = () => {
return {
element: {
enter: (node, parentNode) => {
if (node.name === 'metadata') {
detachNodeFromParent(node, parentNode);
}
},
},
};
};
@@ -0,0 +1,38 @@
'use strict';
const {
inheritableAttrs,
attrsGroups,
presentationNonInheritableGroupAttrs,
} = require('./_collections');
exports.name = 'removeNonInheritableGroupAttrs';
exports.description =
'removes non-inheritable group’s presentational attributes';
/**
* Remove non-inheritable group's "presentation" attributes.
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'removeNonInheritableGroupAttrs'>}
*/
exports.fn = () => {
return {
element: {
enter: (node) => {
if (node.name === 'g') {
for (const name of Object.keys(node.attributes)) {
if (
attrsGroups.presentation.has(name) &&
!inheritableAttrs.has(name) &&
!presentationNonInheritableGroupAttrs.has(name)
) {
delete node.attributes[name];
}
}
}
},
},
};
};
+136
View File
@@ -0,0 +1,136 @@
'use strict';
/**
* @typedef {import('../lib/types').PathDataItem} PathDataItem
*/
const { visitSkip, detachNodeFromParent } = require('../lib/xast.js');
const { parsePathData } = require('../lib/path.js');
const { intersects } = require('./_path.js');
exports.name = 'removeOffCanvasPaths';
exports.description =
'removes elements that are drawn outside of the viewbox (disabled by default)';
/**
* Remove elements that are drawn outside of the viewbox.
*
* @author JoshyPHP
*
* @type {import('./plugins-types').Plugin<'removeOffCanvasPaths'>}
*/
exports.fn = () => {
/**
* @type {?{
* top: number,
* right: number,
* bottom: number,
* left: number,
* width: number,
* height: number
* }}
*/
let viewBoxData = null;
return {
element: {
enter: (node, parentNode) => {
if (node.name === 'svg' && parentNode.type === 'root') {
let viewBox = '';
// find viewbox
if (node.attributes.viewBox != null) {
// remove commas and plus signs, normalize and trim whitespace
viewBox = node.attributes.viewBox;
} else if (
node.attributes.height != null &&
node.attributes.width != null
) {
viewBox = `0 0 ${node.attributes.width} ${node.attributes.height}`;
}
// parse viewbox
// remove commas and plus signs, normalize and trim whitespace
viewBox = viewBox
.replace(/[,+]|px/g, ' ')
.replace(/\s+/g, ' ')
.replace(/^\s*|\s*$/g, '');
// ensure that the dimensions are 4 values separated by space
const m =
/^(-?\d*\.?\d+) (-?\d*\.?\d+) (\d*\.?\d+) (\d*\.?\d+)$/.exec(
viewBox,
);
if (m == null) {
return;
}
const left = Number.parseFloat(m[1]);
const top = Number.parseFloat(m[2]);
const width = Number.parseFloat(m[3]);
const height = Number.parseFloat(m[4]);
// store the viewBox boundaries
viewBoxData = {
left,
top,
right: left + width,
bottom: top + height,
width,
height,
};
}
// consider that any item with a transform attribute is visible
if (node.attributes.transform != null) {
return visitSkip;
}
if (
node.name === 'path' &&
node.attributes.d != null &&
viewBoxData != null
) {
const pathData = parsePathData(node.attributes.d);
// consider that a M command within the viewBox is visible
let visible = false;
for (const pathDataItem of pathData) {
if (pathDataItem.command === 'M') {
const [x, y] = pathDataItem.args;
if (
x >= viewBoxData.left &&
x <= viewBoxData.right &&
y >= viewBoxData.top &&
y <= viewBoxData.bottom
) {
visible = true;
}
}
}
if (visible) {
return;
}
if (pathData.length === 2) {
// close the path too short for intersects()
pathData.push({ command: 'z', args: [] });
}
const { left, top, width, height } = viewBoxData;
/**
* @type {PathDataItem[]}
*/
const viewBoxPathData = [
{ command: 'M', args: [left, top] },
{ command: 'h', args: [width] },
{ command: 'v', args: [height] },
{ command: 'H', args: [left] },
{ command: 'z', args: [] },
];
if (intersects(viewBoxPathData, pathData) === false) {
detachNodeFromParent(node, parentNode);
}
}
},
},
};
};
+31
View File
@@ -0,0 +1,31 @@
'use strict';
const { detachNodeFromParent } = require('../lib/xast.js');
exports.name = 'removeRasterImages';
exports.description = 'removes raster images (disabled by default)';
/**
* Remove raster images references in <image>.
*
* @see https://bugs.webkit.org/show_bug.cgi?id=63548
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'removeRasterImages'>}
*/
exports.fn = () => {
return {
element: {
enter: (node, parentNode) => {
if (
node.name === 'image' &&
node.attributes['xlink:href'] != null &&
/(\.|image\/)(jpe?g|png|gif)/.test(node.attributes['xlink:href'])
) {
detachNodeFromParent(node, parentNode);
}
},
},
};
};
+70
View File
@@ -0,0 +1,70 @@
'use strict';
const { detachNodeFromParent } = require('../lib/xast.js');
const { attrsGroups } = require('./_collections.js');
exports.name = 'removeScriptElement';
exports.description = 'removes scripts (disabled by default)';
/** Union of all event attributes. */
const eventAttrs = [
...attrsGroups.animationEvent,
...attrsGroups.documentEvent,
...attrsGroups.documentElementEvent,
...attrsGroups.globalEvent,
...attrsGroups.graphicalEvent,
];
/**
* Remove scripts.
*
* https://www.w3.org/TR/SVG11/script.html
*
* @author Patrick Klingemann
* @type {import('./plugins-types').Plugin<'removeScriptElement'>}
*/
exports.fn = () => {
return {
element: {
enter: (node, parentNode) => {
if (node.name === 'script') {
detachNodeFromParent(node, parentNode);
return;
}
for (const attr of eventAttrs) {
if (node.attributes[attr] != null) {
delete node.attributes[attr];
}
}
},
exit: (node, parentNode) => {
if (node.name !== 'a') {
return;
}
for (const attr of Object.keys(node.attributes)) {
if (attr === 'href' || attr.endsWith(':href')) {
if (
node.attributes[attr] == null ||
!node.attributes[attr].trimStart().startsWith('javascript:')
) {
continue;
}
const index = parentNode.children.indexOf(node);
parentNode.children.splice(index, 1, ...node.children);
// TODO remove legacy parentNode in v4
for (const child of node.children) {
Object.defineProperty(child, 'parentNode', {
writable: true,
value: parentNode,
});
}
}
}
},
},
};
};
+27
View File
@@ -0,0 +1,27 @@
'use strict';
const { detachNodeFromParent } = require('../lib/xast.js');
exports.name = 'removeStyleElement';
exports.description = 'removes <style> element (disabled by default)';
/**
* Remove <style>.
*
* https://www.w3.org/TR/SVG11/styling.html#StyleElement
*
* @author Betsy Dupuis
*
* @type {import('./plugins-types').Plugin<'removeStyleElement'>}
*/
exports.fn = () => {
return {
element: {
enter: (node, parentNode) => {
if (node.name === 'style') {
detachNodeFromParent(node, parentNode);
}
},
},
};
};
+27
View File
@@ -0,0 +1,27 @@
'use strict';
const { detachNodeFromParent } = require('../lib/xast.js');
exports.name = 'removeTitle';
exports.description = 'removes <title>';
/**
* Remove <title>.
*
* https://developer.mozilla.org/docs/Web/SVG/Element/title
*
* @author Igor Kalashnikov
*
* @type {import('./plugins-types').Plugin<'removeTitle'>}
*/
exports.fn = () => {
return {
element: {
enter: (node, parentNode) => {
if (node.name === 'title') {
detachNodeFromParent(node, parentNode);
}
},
},
};
};
+212
View File
@@ -0,0 +1,212 @@
'use strict';
const { visitSkip, detachNodeFromParent } = require('../lib/xast.js');
const { collectStylesheet, computeStyle } = require('../lib/style.js');
const {
elems,
attrsGroups,
elemsGroups,
attrsGroupsDefaults,
presentationNonInheritableGroupAttrs,
} = require('./_collections');
exports.name = 'removeUnknownsAndDefaults';
exports.description =
'removes unknown elements content and attributes, removes attrs with default values';
// resolve all groups references
/**
* @type {Map<string, Set<string>>}
*/
const allowedChildrenPerElement = new Map();
/**
* @type {Map<string, Set<string>>}
*/
const allowedAttributesPerElement = new Map();
/**
* @type {Map<string, Map<string, string>>}
*/
const attributesDefaultsPerElement = new Map();
for (const [name, config] of Object.entries(elems)) {
/**
* @type {Set<string>}
*/
const allowedChildren = new Set();
if (config.content) {
for (const elementName of config.content) {
allowedChildren.add(elementName);
}
}
if (config.contentGroups) {
for (const contentGroupName of config.contentGroups) {
const elemsGroup = elemsGroups[contentGroupName];
if (elemsGroup) {
for (const elementName of elemsGroup) {
allowedChildren.add(elementName);
}
}
}
}
/**
* @type {Set<string>}
*/
const allowedAttributes = new Set();
if (config.attrs) {
for (const attrName of config.attrs) {
allowedAttributes.add(attrName);
}
}
/**
* @type {Map<string, string>}
*/
const attributesDefaults = new Map();
if (config.defaults) {
for (const [attrName, defaultValue] of Object.entries(config.defaults)) {
attributesDefaults.set(attrName, defaultValue);
}
}
for (const attrsGroupName of config.attrsGroups) {
const attrsGroup = attrsGroups[attrsGroupName];
if (attrsGroup) {
for (const attrName of attrsGroup) {
allowedAttributes.add(attrName);
}
}
const groupDefaults = attrsGroupsDefaults[attrsGroupName];
if (groupDefaults) {
for (const [attrName, defaultValue] of Object.entries(groupDefaults)) {
attributesDefaults.set(attrName, defaultValue);
}
}
}
allowedChildrenPerElement.set(name, allowedChildren);
allowedAttributesPerElement.set(name, allowedAttributes);
attributesDefaultsPerElement.set(name, attributesDefaults);
}
/**
* Remove unknown elements content and attributes,
* remove attributes with default values.
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'removeUnknownsAndDefaults'>}
*/
exports.fn = (root, params) => {
const {
unknownContent = true,
unknownAttrs = true,
defaultAttrs = true,
defaultMarkupDeclarations = true,
uselessOverrides = true,
keepDataAttrs = true,
keepAriaAttrs = true,
keepRoleAttr = false,
} = params;
const stylesheet = collectStylesheet(root);
return {
instruction: {
enter: (node) => {
if (defaultMarkupDeclarations) {
node.value = node.value.replace(/\s*standalone\s*=\s*(["'])no\1/, '');
}
},
},
element: {
enter: (node, parentNode) => {
// skip namespaced elements
if (node.name.includes(':')) {
return;
}
// skip visiting foreignObject subtree
if (node.name === 'foreignObject') {
return visitSkip;
}
// remove unknown element's content
if (unknownContent && parentNode.type === 'element') {
const allowedChildren = allowedChildrenPerElement.get(
parentNode.name,
);
if (allowedChildren == null || allowedChildren.size === 0) {
// remove unknown elements
if (allowedChildrenPerElement.get(node.name) == null) {
detachNodeFromParent(node, parentNode);
return;
}
} else {
// remove not allowed children
if (allowedChildren.has(node.name) === false) {
detachNodeFromParent(node, parentNode);
return;
}
}
}
const allowedAttributes = allowedAttributesPerElement.get(node.name);
const attributesDefaults = attributesDefaultsPerElement.get(node.name);
const computedParentStyle =
parentNode.type === 'element'
? computeStyle(stylesheet, parentNode)
: null;
// remove element's unknown attrs and attrs with default values
for (const [name, value] of Object.entries(node.attributes)) {
if (keepDataAttrs && name.startsWith('data-')) {
continue;
}
if (keepAriaAttrs && name.startsWith('aria-')) {
continue;
}
if (keepRoleAttr && name === 'role') {
continue;
}
// skip xmlns attribute
if (name === 'xmlns') {
continue;
}
// skip namespaced attributes except xml:* and xlink:*
if (name.includes(':')) {
const [prefix] = name.split(':');
if (prefix !== 'xml' && prefix !== 'xlink') {
continue;
}
}
if (
unknownAttrs &&
allowedAttributes &&
allowedAttributes.has(name) === false
) {
delete node.attributes[name];
}
if (
defaultAttrs &&
node.attributes.id == null &&
attributesDefaults &&
attributesDefaults.get(name) === value
) {
// keep defaults if parent has own or inherited style
if (computedParentStyle?.[name] == null) {
delete node.attributes[name];
}
}
if (uselessOverrides && node.attributes.id == null) {
const style = computedParentStyle?.[name];
if (
presentationNonInheritableGroupAttrs.has(name) === false &&
style != null &&
style.type === 'static' &&
style.value === value
) {
delete node.attributes[name];
}
}
}
},
},
};
};
+59
View File
@@ -0,0 +1,59 @@
'use strict';
exports.name = 'removeUnusedNS';
exports.description = 'removes unused namespaces declaration';
/**
* Remove unused namespaces declaration from svg element
* which are not used in elements or attributes
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'removeUnusedNS'>}
*/
exports.fn = () => {
/**
* @type {Set<string>}
*/
const unusedNamespaces = new Set();
return {
element: {
enter: (node, parentNode) => {
// collect all namespaces from svg element
// (such as xmlns:xlink="http://www.w3.org/1999/xlink")
if (node.name === 'svg' && parentNode.type === 'root') {
for (const name of Object.keys(node.attributes)) {
if (name.startsWith('xmlns:')) {
const local = name.slice('xmlns:'.length);
unusedNamespaces.add(local);
}
}
}
if (unusedNamespaces.size !== 0) {
// preserve namespace used in nested elements names
if (node.name.includes(':')) {
const [ns] = node.name.split(':');
if (unusedNamespaces.has(ns)) {
unusedNamespaces.delete(ns);
}
}
// preserve namespace used in nested elements attributes
for (const name of Object.keys(node.attributes)) {
if (name.includes(':')) {
const [ns] = name.split(':');
unusedNamespaces.delete(ns);
}
}
}
},
exit: (node, parentNode) => {
// remove unused namespace attributes from svg element
if (node.name === 'svg' && parentNode.type === 'root') {
for (const name of unusedNamespaces) {
delete node.attributes[`xmlns:${name}`];
}
}
},
},
};
};
+65
View File
@@ -0,0 +1,65 @@
'use strict';
/**
* @typedef {import('../lib/types').XastElement} XastElement
*/
const { detachNodeFromParent } = require('../lib/xast.js');
const { elemsGroups } = require('./_collections.js');
exports.name = 'removeUselessDefs';
exports.description = 'removes elements in <defs> without id';
/**
* Removes content of defs and properties that aren't rendered directly without ids.
*
* @author Lev Solntsev
*
* @type {import('./plugins-types').Plugin<'removeUselessDefs'>}
*/
exports.fn = () => {
return {
element: {
enter: (node, parentNode) => {
if (node.name === 'defs') {
/**
* @type {XastElement[]}
*/
const usefulNodes = [];
collectUsefulNodes(node, usefulNodes);
if (usefulNodes.length === 0) {
detachNodeFromParent(node, parentNode);
}
// TODO remove legacy parentNode in v4
for (const usefulNode of usefulNodes) {
Object.defineProperty(usefulNode, 'parentNode', {
writable: true,
value: node,
});
}
node.children = usefulNodes;
} else if (
elemsGroups.nonRendering.has(node.name) &&
node.attributes.id == null
) {
detachNodeFromParent(node, parentNode);
}
},
},
};
};
/**
* @type {(node: XastElement, usefulNodes: XastElement[]) => void}
*/
const collectUsefulNodes = (node, usefulNodes) => {
for (const child of node.children) {
if (child.type === 'element') {
if (child.attributes.id != null || child.name === 'style') {
usefulNodes.push(child);
} else {
collectUsefulNodes(child, usefulNodes);
}
}
}
};
+139
View File
@@ -0,0 +1,139 @@
'use strict';
const { visit, visitSkip, detachNodeFromParent } = require('../lib/xast.js');
const { collectStylesheet, computeStyle } = require('../lib/style.js');
const { hasScripts } = require('../lib/svgo/tools.js');
const { elemsGroups } = require('./_collections.js');
exports.name = 'removeUselessStrokeAndFill';
exports.description = 'removes useless stroke and fill attributes';
/**
* Remove useless stroke and fill attrs.
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'removeUselessStrokeAndFill'>}
*/
exports.fn = (root, params) => {
const {
stroke: removeStroke = true,
fill: removeFill = true,
removeNone = false,
} = params;
// style and script elements deoptimise this plugin
let hasStyleOrScript = false;
visit(root, {
element: {
enter: (node) => {
if (node.name === 'style' || hasScripts(node)) {
hasStyleOrScript = true;
}
},
},
});
if (hasStyleOrScript) {
return null;
}
const stylesheet = collectStylesheet(root);
return {
element: {
enter: (node, parentNode) => {
// id attribute deoptimise the whole subtree
if (node.attributes.id != null) {
return visitSkip;
}
if (!elemsGroups.shape.has(node.name)) {
return;
}
const computedStyle = computeStyle(stylesheet, node);
const stroke = computedStyle.stroke;
const strokeOpacity = computedStyle['stroke-opacity'];
const strokeWidth = computedStyle['stroke-width'];
const markerEnd = computedStyle['marker-end'];
const fill = computedStyle.fill;
const fillOpacity = computedStyle['fill-opacity'];
const computedParentStyle =
parentNode.type === 'element'
? computeStyle(stylesheet, parentNode)
: null;
const parentStroke =
computedParentStyle == null ? null : computedParentStyle.stroke;
// remove stroke*
if (removeStroke) {
if (
stroke == null ||
(stroke.type === 'static' && stroke.value == 'none') ||
(strokeOpacity != null &&
strokeOpacity.type === 'static' &&
strokeOpacity.value === '0') ||
(strokeWidth != null &&
strokeWidth.type === 'static' &&
strokeWidth.value === '0')
) {
// stroke-width may affect the size of marker-end
// marker is not visible when stroke-width is 0
if (
(strokeWidth != null &&
strokeWidth.type === 'static' &&
strokeWidth.value === '0') ||
markerEnd == null
) {
for (const name of Object.keys(node.attributes)) {
if (name.startsWith('stroke')) {
delete node.attributes[name];
}
}
// set explicit none to not inherit from parent
if (
parentStroke != null &&
parentStroke.type === 'static' &&
parentStroke.value !== 'none'
) {
node.attributes.stroke = 'none';
}
}
}
}
// remove fill*
if (removeFill) {
if (
(fill != null && fill.type === 'static' && fill.value === 'none') ||
(fillOpacity != null &&
fillOpacity.type === 'static' &&
fillOpacity.value === '0')
) {
for (const name of Object.keys(node.attributes)) {
if (name.startsWith('fill-')) {
delete node.attributes[name];
}
}
if (
fill == null ||
(fill.type === 'static' && fill.value !== 'none')
) {
node.attributes.fill = 'none';
}
}
}
if (removeNone) {
if (
(stroke == null || node.attributes.stroke === 'none') &&
((fill != null &&
fill.type === 'static' &&
fill.value === 'none') ||
node.attributes.fill === 'none')
) {
detachNodeFromParent(node, parentNode);
}
}
},
},
};
};
+49
View File
@@ -0,0 +1,49 @@
'use strict';
exports.name = 'removeViewBox';
exports.description = 'removes viewBox attribute when possible';
const viewBoxElems = new Set(['pattern', 'svg', 'symbol']);
/**
* Remove viewBox attr which coincides with a width/height box.
*
* @see https://www.w3.org/TR/SVG11/coords.html#ViewBoxAttribute
*
* @example
* <svg width="100" height="50" viewBox="0 0 100 50">
* ⬇
* <svg width="100" height="50">
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'removeViewBox'>}
*/
exports.fn = () => {
return {
element: {
enter: (node, parentNode) => {
if (
viewBoxElems.has(node.name) &&
node.attributes.viewBox != null &&
node.attributes.width != null &&
node.attributes.height != null
) {
// TODO remove width/height for such case instead
if (node.name === 'svg' && parentNode.type !== 'root') {
return;
}
const nums = node.attributes.viewBox.split(/[ ,]+/g);
if (
nums[0] === '0' &&
nums[1] === '0' &&
node.attributes.width.replace(/px$/, '') === nums[2] && // could use parseFloat too
node.attributes.height.replace(/px$/, '') === nums[3]
) {
delete node.attributes.viewBox;
}
}
},
},
};
};
+29
View File
@@ -0,0 +1,29 @@
'use strict';
exports.name = 'removeXMLNS';
exports.description =
'removes xmlns attribute (for inline svg, disabled by default)';
/**
* Remove the xmlns attribute when present.
*
* @example
* <svg viewBox="0 0 100 50" xmlns="http://www.w3.org/2000/svg">
* ↓
* <svg viewBox="0 0 100 50">
*
* @author Ricardo Tomasi
*
* @type {import('./plugins-types').Plugin<'removeXMLNS'>}
*/
exports.fn = () => {
return {
element: {
enter: (node) => {
if (node.name === 'svg') {
delete node.attributes.xmlns;
}
},
},
};
};
+28
View File
@@ -0,0 +1,28 @@
'use strict';
const { detachNodeFromParent } = require('../lib/xast.js');
exports.name = 'removeXMLProcInst';
exports.description = 'removes XML processing instructions';
/**
* Remove XML Processing Instruction.
*
* @example
* <?xml version="1.0" encoding="utf-8"?>
*
* @author Kir Belevich
*
* @type {import('./plugins-types').Plugin<'removeXMLProcInst'>}
*/
exports.fn = () => {
return {
instruction: {
enter: (node, parentNode) => {
if (node.name === 'xml') {
detachNodeFromParent(node, parentNode);
}
},
},
};
};
+226
View File
@@ -0,0 +1,226 @@
'use strict';
const { elems } = require('./_collections');
/**
* @typedef {import('../lib/types').XastElement} XastElement
*/
exports.name = 'removeXlink';
exports.description =
'remove xlink namespace and replaces attributes with the SVG 2 equivalent where applicable';
/** URI indicating the Xlink namespace. */
const XLINK_NAMESPACE = 'http://www.w3.org/1999/xlink';
/**
* Map of `xlink:show` values to the SVG 2 `target` attribute values.
*
* @type {Record<string, string>}
* @see https://developer.mozilla.org/docs/Web/SVG/Attribute/xlink:show#usage_notes
*/
const SHOW_TO_TARGET = {
new: '_blank',
replace: '_self',
};
/**
* Elements that use xlink:href, but were deprecated in SVG 2 and therefore
* don't support the SVG 2 href attribute.
*
* @type {Set<string>}
* @see https://developer.mozilla.org/docs/Web/SVG/Attribute/xlink:href
* @see https://developer.mozilla.org/docs/Web/SVG/Attribute/href
*/
const LEGACY_ELEMENTS = new Set([
'cursor',
'filter',
'font-face-uri',
'glyphRef',
'tref',
]);
/**
* @param {XastElement} node
* @param {string[]} prefixes
* @param {string} attr
* @returns {string[]}
*/
const findPrefixedAttrs = (node, prefixes, attr) => {
return prefixes
.map((prefix) => `${prefix}:${attr}`)
.filter((attr) => node.attributes[attr] != null);
};
/**
* Removes XLink namespace prefixes and converts references to XLink attributes
* to the native SVG equivalent.
*
* The XLink namespace is deprecated in SVG 2.
*
* @type {import('./plugins-types').Plugin<'removeXlink'>}
* @see https://developer.mozilla.org/docs/Web/SVG/Attribute/xlink:href
*/
exports.fn = (_, params) => {
const { includeLegacy } = params;
/**
* XLink namespace prefixes that are currently in the stack.
*
* @type {string[]}
*/
const xlinkPrefixes = [];
/**
* Namespace prefixes that exist in {@link xlinkPrefixes} but were overridden
* in a child element to point to another namespace, and so is not treated as
* an XLink attribute.
*
* @type {string[]}
*/
const overriddenPrefixes = [];
/**
* Namespace prefixes that were used in one of the {@link LEGACY_ELEMENTS}.
*
* @type {string[]}
*/
const usedInLegacyElement = [];
return {
element: {
enter: (node) => {
for (const [key, value] of Object.entries(node.attributes)) {
if (key.startsWith('xmlns:')) {
const prefix = key.split(':', 2)[1];
if (value === XLINK_NAMESPACE) {
xlinkPrefixes.push(prefix);
continue;
}
if (xlinkPrefixes.includes(prefix)) {
overriddenPrefixes.push(prefix);
}
}
}
if (
overriddenPrefixes.some((prefix) => xlinkPrefixes.includes(prefix))
) {
return;
}
const showAttrs = findPrefixedAttrs(node, xlinkPrefixes, 'show');
let showHandled = node.attributes.target != null;
for (let i = showAttrs.length - 1; i >= 0; i--) {
const attr = showAttrs[i];
const value = node.attributes[attr];
const mapping = SHOW_TO_TARGET[value];
if (showHandled || mapping == null) {
delete node.attributes[attr];
continue;
}
if (mapping !== elems[node.name]?.defaults?.target) {
node.attributes.target = mapping;
}
delete node.attributes[attr];
showHandled = true;
}
const titleAttrs = findPrefixedAttrs(node, xlinkPrefixes, 'title');
for (let i = titleAttrs.length - 1; i >= 0; i--) {
const attr = titleAttrs[i];
const value = node.attributes[attr];
const hasTitle = node.children.filter(
(child) => child.type === 'element' && child.name === 'title',
);
if (hasTitle.length > 0) {
delete node.attributes[attr];
continue;
}
/** @type {XastElement} */
const titleTag = {
type: 'element',
name: 'title',
attributes: {},
children: [
{
type: 'text',
value,
},
],
};
Object.defineProperty(titleTag, 'parentNode', {
writable: true,
value: node,
});
node.children.unshift(titleTag);
delete node.attributes[attr];
}
const hrefAttrs = findPrefixedAttrs(node, xlinkPrefixes, 'href');
if (
hrefAttrs.length > 0 &&
LEGACY_ELEMENTS.has(node.name) &&
!includeLegacy
) {
hrefAttrs
.map((attr) => attr.split(':', 1)[0])
.forEach((prefix) => usedInLegacyElement.push(prefix));
return;
}
for (let i = hrefAttrs.length - 1; i >= 0; i--) {
const attr = hrefAttrs[i];
const value = node.attributes[attr];
if (node.attributes.href != null) {
delete node.attributes[attr];
continue;
}
node.attributes.href = value;
delete node.attributes[attr];
}
},
exit: (node) => {
for (const [key, value] of Object.entries(node.attributes)) {
const [prefix, attr] = key.split(':', 2);
if (
xlinkPrefixes.includes(prefix) &&
!overriddenPrefixes.includes(prefix) &&
!usedInLegacyElement.includes(prefix) &&
!includeLegacy
) {
delete node.attributes[key];
continue;
}
if (key.startsWith('xmlns:') && !usedInLegacyElement.includes(attr)) {
if (value === XLINK_NAMESPACE) {
const index = xlinkPrefixes.indexOf(attr);
xlinkPrefixes.splice(index, 1);
delete node.attributes[key];
continue;
}
if (overriddenPrefixes.includes(prefix)) {
const index = overriddenPrefixes.indexOf(attr);
overriddenPrefixes.splice(index, 1);
}
}
}
},
},
};
};
+195
View File
@@ -0,0 +1,195 @@
'use strict';
const { collectStylesheet } = require('../lib/style');
const { detachNodeFromParent, querySelectorAll } = require('../lib/xast');
/**
* @typedef {import('../lib/types').XastElement} XastElement
* @typedef {import('../lib/types').XastParent} XastParent
* @typedef {import('../lib/types').XastNode} XastNode
*/
exports.name = 'reusePaths';
exports.description =
'Finds <path> elements with the same d, fill, and ' +
'stroke, and converts them to <use> elements ' +
'referencing a single <path> def.';
/**
* Finds <path> elements with the same d, fill, and stroke, and converts them to
* <use> elements referencing a single <path> def.
*
* @author Jacob Howcroft
*
* @type {import('./plugins-types').Plugin<'reusePaths'>}
*/
exports.fn = (root) => {
const stylesheet = collectStylesheet(root);
/**
* @type {Map<string, XastElement[]>}
*/
const paths = new Map();
/**
* Reference to the first defs element that is a direct child of the svg
* element if one exists.
*
* @type {XastElement}
* @see https://developer.mozilla.org/docs/Web/SVG/Element/defs
*/
let svgDefs;
/**
* Set of hrefs that reference the id of another node.
*
* @type {Set<string>}
*/
const hrefs = new Set();
return {
element: {
enter: (node, parentNode) => {
if (node.name === 'path' && node.attributes.d != null) {
const d = node.attributes.d;
const fill = node.attributes.fill || '';
const stroke = node.attributes.stroke || '';
const key = d + ';s:' + stroke + ';f:' + fill;
let list = paths.get(key);
if (list == null) {
list = [];
paths.set(key, list);
}
list.push(node);
}
if (
svgDefs == null &&
node.name === 'defs' &&
parentNode.type === 'element' &&
parentNode.name === 'svg'
) {
svgDefs = node;
}
if (node.name === 'use') {
for (const name of ['href', 'xlink:href']) {
const href = node.attributes[name];
if (href != null && href.startsWith('#') && href.length > 1) {
hrefs.add(href.slice(1));
}
}
}
},
exit: (node, parentNode) => {
if (node.name === 'svg' && parentNode.type === 'root') {
let defsTag = svgDefs;
if (defsTag == null) {
defsTag = {
type: 'element',
name: 'defs',
attributes: {},
children: [],
};
// TODO remove legacy parentNode in v4
Object.defineProperty(defsTag, 'parentNode', {
writable: true,
value: node,
});
}
let index = 0;
for (const list of paths.values()) {
if (list.length > 1) {
/** @type {XastElement} */
const reusablePath = {
type: 'element',
name: 'path',
attributes: {},
children: [],
};
for (const attr of ['fill', 'stroke', 'd']) {
if (list[0].attributes[attr] != null) {
reusablePath.attributes[attr] = list[0].attributes[attr];
}
}
const originalId = list[0].attributes.id;
if (
originalId == null ||
hrefs.has(originalId) ||
stylesheet.rules.some(
(rule) => rule.selector === `#${originalId}`,
)
) {
reusablePath.attributes.id = 'reuse-' + index++;
} else {
reusablePath.attributes.id = originalId;
delete list[0].attributes.id;
}
// TODO remove legacy parentNode in v4
Object.defineProperty(reusablePath, 'parentNode', {
writable: true,
value: defsTag,
});
defsTag.children.push(reusablePath);
// convert paths to <use>
for (const pathNode of list) {
delete pathNode.attributes.d;
delete pathNode.attributes.stroke;
delete pathNode.attributes.fill;
if (
defsTag.children.includes(pathNode) &&
pathNode.children.length === 0
) {
if (Object.keys(pathNode.attributes).length === 0) {
detachNodeFromParent(pathNode, defsTag);
continue;
}
if (
Object.keys(pathNode.attributes).length === 1 &&
pathNode.attributes.id != null
) {
detachNodeFromParent(pathNode, defsTag);
const selector = `[xlink\\:href=#${pathNode.attributes.id}], [href=#${pathNode.attributes.id}]`;
for (const child of querySelectorAll(node, selector)) {
if (child.type !== 'element') {
continue;
}
for (const name of ['href', 'xlink:href']) {
if (child.attributes[name] != null) {
child.attributes[name] =
'#' + reusablePath.attributes.id;
}
}
}
continue;
}
}
pathNode.name = 'use';
pathNode.attributes['xlink:href'] =
'#' + reusablePath.attributes.id;
}
}
}
if (defsTag.children.length !== 0) {
if (node.attributes['xmlns:xlink'] == null) {
node.attributes['xmlns:xlink'] = 'http://www.w3.org/1999/xlink';
}
if (svgDefs == null) {
node.children.unshift(defsTag);
}
}
}
},
},
};
};
+108
View File
@@ -0,0 +1,108 @@
'use strict';
exports.name = 'sortAttrs';
exports.description = 'Sort element attributes for better compression';
/**
* Sort element attributes for better compression
*
* @author Nikolay Frantsev
*
* @type {import('./plugins-types').Plugin<'sortAttrs'>}
*/
exports.fn = (_root, params) => {
const {
order = [
'id',
'width',
'height',
'x',
'x1',
'x2',
'y',
'y1',
'y2',
'cx',
'cy',
'r',
'fill',
'stroke',
'marker',
'd',
'points',
],
xmlnsOrder = 'front',
} = params;
/**
* @type {(name: string) => number}
*/
const getNsPriority = (name) => {
if (xmlnsOrder === 'front') {
// put xmlns first
if (name === 'xmlns') {
return 3;
}
// xmlns:* attributes second
if (name.startsWith('xmlns:')) {
return 2;
}
}
// other namespaces after and sort them alphabetically
if (name.includes(':')) {
return 1;
}
// other attributes
return 0;
};
/**
* @type {(a: [string, string], b: [string, string]) => number}
*/
const compareAttrs = ([aName], [bName]) => {
// sort namespaces
const aPriority = getNsPriority(aName);
const bPriority = getNsPriority(bName);
const priorityNs = bPriority - aPriority;
if (priorityNs !== 0) {
return priorityNs;
}
// extract the first part from attributes
// for example "fill" from "fill" and "fill-opacity"
const [aPart] = aName.split('-');
const [bPart] = bName.split('-');
// rely on alphabetical sort when the first part is the same
if (aPart !== bPart) {
const aInOrderFlag = order.includes(aPart) ? 1 : 0;
const bInOrderFlag = order.includes(bPart) ? 1 : 0;
// sort by position in order param
if (aInOrderFlag === 1 && bInOrderFlag === 1) {
return order.indexOf(aPart) - order.indexOf(bPart);
}
// put attributes from order param before others
const priorityOrder = bInOrderFlag - aInOrderFlag;
if (priorityOrder !== 0) {
return priorityOrder;
}
}
// sort alphabetically
return aName < bName ? -1 : 1;
};
return {
element: {
enter: (node) => {
const attrs = Object.entries(node.attributes);
attrs.sort(compareAttrs);
/**
* @type {Record<string, string>}
*/
const sortedAttributes = {};
for (const [name, value] of attrs) {
sortedAttributes[name] = value;
}
node.attributes = sortedAttributes;
},
},
};
};
+58
View File
@@ -0,0 +1,58 @@
'use strict';
exports.name = 'sortDefsChildren';
exports.description = 'Sorts children of <defs> to improve compression';
/**
* Sorts children of defs in order to improve compression.
* Sorted first by frequency then by element name length then by element name (to ensure grouping).
*
* @author David Leston
*
* @type {import('./plugins-types').Plugin<'sortDefsChildren'>}
*/
exports.fn = () => {
return {
element: {
enter: (node) => {
if (node.name === 'defs') {
/**
* @type {Map<string, number>}
*/
const frequencies = new Map();
for (const child of node.children) {
if (child.type === 'element') {
const frequency = frequencies.get(child.name);
if (frequency == null) {
frequencies.set(child.name, 1);
} else {
frequencies.set(child.name, frequency + 1);
}
}
}
node.children.sort((a, b) => {
if (a.type !== 'element' || b.type !== 'element') {
return 0;
}
const aFrequency = frequencies.get(a.name);
const bFrequency = frequencies.get(b.name);
if (aFrequency != null && bFrequency != null) {
const frequencyComparison = bFrequency - aFrequency;
if (frequencyComparison !== 0) {
return frequencyComparison;
}
}
const lengthComparison = b.name.length - a.name.length;
if (lengthComparison !== 0) {
return lengthComparison;
}
if (a.name !== b.name) {
return a.name > b.name ? -1 : 1;
}
return 0;
});
}
},
},
};
};