mirror of
https://github.com/svg/svgo.git
synced 2025-08-01 18:46:52 +03:00
Refactor cleanupIDs (#1556)
I still did get how id generation works but data flow is clear now at least. - covered with types - migrated to visitor plugin api - got rid of traverse api
This commit is contained in:
@ -1,259 +1,103 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const { traverse, traverseBreak } = require('../lib/xast.js');
|
/**
|
||||||
const { parseName } = require('../lib/svgo/tools.js');
|
* @typedef {import('../lib/types').XastElement} XastElement
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { visitSkip } = require('../lib/xast.js');
|
||||||
|
const { referencesProps } = require('./_collections.js');
|
||||||
|
|
||||||
|
exports.type = 'visitor';
|
||||||
exports.name = 'cleanupIDs';
|
exports.name = 'cleanupIDs';
|
||||||
|
|
||||||
exports.type = 'full';
|
|
||||||
|
|
||||||
exports.active = true;
|
exports.active = true;
|
||||||
|
|
||||||
exports.description = 'removes unused IDs and minifies used';
|
exports.description = 'removes unused IDs and minifies used';
|
||||||
|
|
||||||
exports.params = {
|
const regReferencesUrl = /\burl\(("|')?#(.+?)\1\)/;
|
||||||
remove: true,
|
const regReferencesHref = /^#(.+?)$/;
|
||||||
minify: true,
|
const regReferencesBegin = /(\w+)\./;
|
||||||
prefix: '',
|
const generateIDchars = [
|
||||||
preserve: [],
|
'a',
|
||||||
preservePrefixes: [],
|
'b',
|
||||||
force: false,
|
'c',
|
||||||
};
|
'd',
|
||||||
|
'e',
|
||||||
var referencesProps = new Set(require('./_collections').referencesProps),
|
'f',
|
||||||
regReferencesUrl = /\burl\(("|')?#(.+?)\1\)/,
|
'g',
|
||||||
regReferencesHref = /^#(.+?)$/,
|
'h',
|
||||||
regReferencesBegin = /(\w+)\./,
|
'i',
|
||||||
styleOrScript = ['style', 'script'],
|
'j',
|
||||||
generateIDchars = [
|
'k',
|
||||||
'a',
|
'l',
|
||||||
'b',
|
'm',
|
||||||
'c',
|
'n',
|
||||||
'd',
|
'o',
|
||||||
'e',
|
'p',
|
||||||
'f',
|
'q',
|
||||||
'g',
|
'r',
|
||||||
'h',
|
's',
|
||||||
'i',
|
't',
|
||||||
'j',
|
'u',
|
||||||
'k',
|
'v',
|
||||||
'l',
|
'w',
|
||||||
'm',
|
'x',
|
||||||
'n',
|
'y',
|
||||||
'o',
|
'z',
|
||||||
'p',
|
'A',
|
||||||
'q',
|
'B',
|
||||||
'r',
|
'C',
|
||||||
's',
|
'D',
|
||||||
't',
|
'E',
|
||||||
'u',
|
'F',
|
||||||
'v',
|
'G',
|
||||||
'w',
|
'H',
|
||||||
'x',
|
'I',
|
||||||
'y',
|
'J',
|
||||||
'z',
|
'K',
|
||||||
'A',
|
'L',
|
||||||
'B',
|
'M',
|
||||||
'C',
|
'N',
|
||||||
'D',
|
'O',
|
||||||
'E',
|
'P',
|
||||||
'F',
|
'Q',
|
||||||
'G',
|
'R',
|
||||||
'H',
|
'S',
|
||||||
'I',
|
'T',
|
||||||
'J',
|
'U',
|
||||||
'K',
|
'V',
|
||||||
'L',
|
'W',
|
||||||
'M',
|
'X',
|
||||||
'N',
|
'Y',
|
||||||
'O',
|
'Z',
|
||||||
'P',
|
];
|
||||||
'Q',
|
const maxIDindex = generateIDchars.length - 1;
|
||||||
'R',
|
|
||||||
'S',
|
|
||||||
'T',
|
|
||||||
'U',
|
|
||||||
'V',
|
|
||||||
'W',
|
|
||||||
'X',
|
|
||||||
'Y',
|
|
||||||
'Z',
|
|
||||||
],
|
|
||||||
maxIDindex = generateIDchars.length - 1;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove unused and minify used IDs
|
|
||||||
* (only if there are no any <style> or <script>).
|
|
||||||
*
|
|
||||||
* @param {Object} item current iteration item
|
|
||||||
* @param {Object} params plugin params
|
|
||||||
*
|
|
||||||
* @author Kir Belevich
|
|
||||||
*/
|
|
||||||
exports.fn = function (root, params) {
|
|
||||||
var currentID,
|
|
||||||
currentIDstring,
|
|
||||||
IDs = new Map(),
|
|
||||||
referencesIDs = new Map(),
|
|
||||||
hasStyleOrScript = false,
|
|
||||||
preserveIDs = new Set(
|
|
||||||
Array.isArray(params.preserve)
|
|
||||||
? params.preserve
|
|
||||||
: params.preserve
|
|
||||||
? [params.preserve]
|
|
||||||
: []
|
|
||||||
),
|
|
||||||
preserveIDPrefixes = new Set(
|
|
||||||
Array.isArray(params.preservePrefixes)
|
|
||||||
? params.preservePrefixes
|
|
||||||
: params.preservePrefixes
|
|
||||||
? [params.preservePrefixes]
|
|
||||||
: []
|
|
||||||
),
|
|
||||||
idValuePrefix = '#',
|
|
||||||
idValuePostfix = '.';
|
|
||||||
|
|
||||||
traverse(root, (node) => {
|
|
||||||
if (hasStyleOrScript === true) {
|
|
||||||
return traverseBreak;
|
|
||||||
}
|
|
||||||
|
|
||||||
// quit if <style> or <script> present ('force' param prevents quitting)
|
|
||||||
if (!params.force) {
|
|
||||||
if (node.isElem(styleOrScript) && node.children.length !== 0) {
|
|
||||||
hasStyleOrScript = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't remove IDs if the whole SVG consists only of defs.
|
|
||||||
if (node.type === 'element' && 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 traverseBreak;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// …and don't remove any ID if yes
|
|
||||||
if (node.type === 'element') {
|
|
||||||
for (const [name, value] of Object.entries(node.attributes)) {
|
|
||||||
let key;
|
|
||||||
let match;
|
|
||||||
|
|
||||||
// save IDs
|
|
||||||
if (name === 'id') {
|
|
||||||
key = value;
|
|
||||||
if (IDs.has(key)) {
|
|
||||||
delete node.attributes.id; // remove repeated id
|
|
||||||
} else {
|
|
||||||
IDs.set(key, node);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// save references
|
|
||||||
const { local } = parseName(name);
|
|
||||||
if (
|
|
||||||
referencesProps.has(name) &&
|
|
||||||
(match = value.match(regReferencesUrl))
|
|
||||||
) {
|
|
||||||
key = match[2]; // url() reference
|
|
||||||
} else if (
|
|
||||||
(local === 'href' && (match = value.match(regReferencesHref))) ||
|
|
||||||
(name === 'begin' && (match = value.match(regReferencesBegin)))
|
|
||||||
) {
|
|
||||||
key = match[1]; // href reference
|
|
||||||
}
|
|
||||||
if (key) {
|
|
||||||
const refs = referencesIDs.get(key) || [];
|
|
||||||
refs.push({ element: node, name, value });
|
|
||||||
referencesIDs.set(key, refs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasStyleOrScript) {
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
const idPreserved = (id) =>
|
|
||||||
preserveIDs.has(id) || idMatchesPrefix(preserveIDPrefixes, id);
|
|
||||||
|
|
||||||
for (const [key, refs] of referencesIDs) {
|
|
||||||
if (IDs.has(key)) {
|
|
||||||
// replace referenced IDs with the minified ones
|
|
||||||
if (params.minify && !idPreserved(key)) {
|
|
||||||
do {
|
|
||||||
currentIDstring = getIDstring(
|
|
||||||
(currentID = generateID(currentID)),
|
|
||||||
params
|
|
||||||
);
|
|
||||||
} while (idPreserved(currentIDstring));
|
|
||||||
|
|
||||||
IDs.get(key).attributes.id = currentIDstring;
|
|
||||||
|
|
||||||
for (const { element, name, value } of refs) {
|
|
||||||
element.attributes[name] = value.includes(idValuePrefix)
|
|
||||||
? value.replace(
|
|
||||||
idValuePrefix + key,
|
|
||||||
idValuePrefix + currentIDstring
|
|
||||||
)
|
|
||||||
: value.replace(
|
|
||||||
key + idValuePostfix,
|
|
||||||
currentIDstring + idValuePostfix
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// don't remove referenced IDs
|
|
||||||
IDs.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// remove non-referenced IDs attributes from elements
|
|
||||||
if (params.remove) {
|
|
||||||
for (var keyElem of IDs) {
|
|
||||||
if (!idPreserved(keyElem[0])) {
|
|
||||||
delete keyElem[1].attributes.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return root;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an ID starts with any one of a list of strings.
|
* Check if an ID starts with any one of a list of strings.
|
||||||
*
|
*
|
||||||
* @param {Array} of prefix strings
|
* @type {(string: string, prefixes: Array<string>) => boolean}
|
||||||
* @param {String} current ID
|
|
||||||
* @return {Boolean} if currentID starts with one of the strings in prefixArray
|
|
||||||
*/
|
*/
|
||||||
function idMatchesPrefix(prefixArray, currentID) {
|
const hasStringPrefix = (string, prefixes) => {
|
||||||
if (!currentID) return false;
|
for (const prefix of prefixes) {
|
||||||
|
if (string.startsWith(prefix)) {
|
||||||
for (var prefix of prefixArray) if (currentID.startsWith(prefix)) return true;
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate unique minimal ID.
|
* Generate unique minimal ID.
|
||||||
*
|
*
|
||||||
* @param {Array} [currentID] current ID
|
* @type {(currentID: null | Array<number>) => Array<number>}
|
||||||
* @return {Array} generated ID array
|
|
||||||
*/
|
*/
|
||||||
function generateID(currentID) {
|
const generateID = (currentID) => {
|
||||||
if (!currentID) return [0];
|
if (currentID == null) {
|
||||||
|
return [0];
|
||||||
currentID[currentID.length - 1]++;
|
}
|
||||||
|
currentID[currentID.length - 1] += 1;
|
||||||
for (var i = currentID.length - 1; i > 0; i--) {
|
for (let i = currentID.length - 1; i > 0; i--) {
|
||||||
if (currentID[i] > maxIDindex) {
|
if (currentID[i] > maxIDindex) {
|
||||||
currentID[i] = 0;
|
currentID[i] = 0;
|
||||||
|
|
||||||
if (currentID[i - 1] !== undefined) {
|
if (currentID[i - 1] !== undefined) {
|
||||||
currentID[i - 1]++;
|
currentID[i - 1]++;
|
||||||
}
|
}
|
||||||
@ -264,15 +108,190 @@ function generateID(currentID) {
|
|||||||
currentID.unshift(0);
|
currentID.unshift(0);
|
||||||
}
|
}
|
||||||
return currentID;
|
return currentID;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get string from generated ID array.
|
* Get string from generated ID array.
|
||||||
*
|
*
|
||||||
* @param {Array} arr input ID array
|
* @type {(arr: Array<number>, prefix: string) => string}
|
||||||
* @return {String} output ID string
|
|
||||||
*/
|
*/
|
||||||
function getIDstring(arr, params) {
|
const getIDstring = (arr, prefix) => {
|
||||||
var str = params.prefix;
|
return prefix + arr.map((i) => generateIDchars[i]).join('');
|
||||||
return str + 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('../lib/types').Plugin<{
|
||||||
|
* remove?: boolean,
|
||||||
|
* minify?: boolean,
|
||||||
|
* prefix?: string,
|
||||||
|
* preserve?: Array<string>,
|
||||||
|
* preservePrefixes?: Array<string>,
|
||||||
|
* force?: boolean,
|
||||||
|
* }>}
|
||||||
|
*/
|
||||||
|
exports.fn = (_root, params) => {
|
||||||
|
const {
|
||||||
|
remove = true,
|
||||||
|
minify = true,
|
||||||
|
prefix = '',
|
||||||
|
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, Array<{element: XastElement, name: string, value: string }>>}
|
||||||
|
*/
|
||||||
|
const referencesById = new Map();
|
||||||
|
let deoptimized = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
element: {
|
||||||
|
enter: (node) => {
|
||||||
|
if (force == false) {
|
||||||
|
// deoptimize if style or script elements are present
|
||||||
|
if (
|
||||||
|
(node.name === 'style' || node.name === 'script') &&
|
||||||
|
node.children.length !== 0
|
||||||
|
) {
|
||||||
|
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 {
|
||||||
|
// collect all references
|
||||||
|
/**
|
||||||
|
* @type {null | string}
|
||||||
|
*/
|
||||||
|
let id = null;
|
||||||
|
if (referencesProps.includes(name)) {
|
||||||
|
const match = value.match(regReferencesUrl);
|
||||||
|
if (match != null) {
|
||||||
|
id = match[2]; // url() reference
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (name === 'href' || name.endsWith(':href')) {
|
||||||
|
const match = value.match(regReferencesHref);
|
||||||
|
if (match != null) {
|
||||||
|
id = match[1]; // href reference
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (name === 'begin') {
|
||||||
|
const match = value.match(regReferencesBegin);
|
||||||
|
if (match != null) {
|
||||||
|
id = match[1]; // href reference
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (id != null) {
|
||||||
|
let refs = referencesById.get(id);
|
||||||
|
if (refs == null) {
|
||||||
|
refs = [];
|
||||||
|
referencesById.set(id, refs);
|
||||||
|
}
|
||||||
|
refs.push({ element: node, name, value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
root: {
|
||||||
|
exit: () => {
|
||||||
|
if (deoptimized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @type {(id: string) => boolean}
|
||||||
|
**/
|
||||||
|
const isIdPreserved = (id) =>
|
||||||
|
preserveIDs.has(id) || hasStringPrefix(id, preserveIDPrefixes);
|
||||||
|
/**
|
||||||
|
* @type {null | Array<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 {null | string}
|
||||||
|
*/
|
||||||
|
let currentIDString = null;
|
||||||
|
do {
|
||||||
|
currentID = generateID(currentID);
|
||||||
|
currentIDString = getIDstring(currentID, prefix);
|
||||||
|
} while (isIdPreserved(currentIDString));
|
||||||
|
node.attributes.id = currentIDString;
|
||||||
|
for (const { element, name, value } of refs) {
|
||||||
|
if (value.includes('#')) {
|
||||||
|
// replace id in href and url()
|
||||||
|
element.attributes[name] = value.replace(
|
||||||
|
`#${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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
@ -13,7 +13,6 @@
|
|||||||
"include": ["plugins/**/*", "lib/xast.test.js", "lib/path.test.js"],
|
"include": ["plugins/**/*", "lib/xast.test.js", "lib/path.test.js"],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"plugins/_applyTransforms.js",
|
"plugins/_applyTransforms.js",
|
||||||
"plugins/cleanupIDs.js",
|
|
||||||
"plugins/collapseGroups.js",
|
"plugins/collapseGroups.js",
|
||||||
"plugins/convertPathData.js",
|
"plugins/convertPathData.js",
|
||||||
"plugins/convertStyleToAttrs.js",
|
"plugins/convertStyleToAttrs.js",
|
||||||
|
Reference in New Issue
Block a user