1
0
mirror of https://github.com/svg/svgo.git synced 2025-07-31 07:44:22 +03:00

Refactor svg stringifier (#1593)

- rewrote prototype class with functions
- covered with tsdoc
- added a few TODOs for v3
This commit is contained in:
Bogdan Chadkin
2021-10-07 14:06:55 +03:00
committed by GitHub
parent 5fb42ab47a
commit 65b6bf4c16
5 changed files with 227 additions and 209 deletions

View File

@ -1,8 +1,44 @@
'use strict'; '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.js'); const { textElems } = require('../plugins/_collections.js');
var defaults = { /**
* @typedef {{
* width: void | string,
* height: void | string,
* indent: string,
* textContext: null | XastElement,
* indentLevel: number,
* }} State
*/
/**
* @typedef {Required<StringifyOptions>} Options
*/
/**
* @type {(char: string) => string}
*/
const encodeEntity = (char) => {
return entities[char];
};
/**
* @type {Options}
*/
const defaults = {
doctypeStart: '<!DOCTYPE', doctypeStart: '<!DOCTYPE',
doctypeEnd: '>', doctypeEnd: '>',
procInstStart: '<?', procInstStart: '<?',
@ -31,7 +67,10 @@ var defaults = {
finalNewline: false, finalNewline: false,
}; };
var entities = { /**
* @type {Record<string, string>}
*/
const entities = {
'&': '&amp;', '&': '&amp;',
"'": '&apos;', "'": '&apos;',
'"': '&quot;', '"': '&quot;',
@ -40,278 +79,233 @@ var entities = {
}; };
/** /**
* Convert SVG-as-JS object to SVG (XML) string. * convert XAST to SVG string
* *
* @param {Object} data input data * @type {(data: XastRoot, config: StringifyOptions) => {
* @param {Object} config config * data: string,
* * info: {
* @return {Object} output data * width: void | string,
* height: void | string
* }
* }}
*/ */
module.exports = function (data, config) { const stringifySvg = (data, userOptions = {}) => {
return new JS2SVG(config).convert(data); /**
}; * @type {Options}
*/
function JS2SVG(config) { const config = { ...defaults, ...userOptions };
if (config) { const indent = config.indent;
this.config = Object.assign({}, defaults, config); let newIndent = ' ';
} else { if (typeof indent === 'number' && Number.isNaN(indent) === false) {
this.config = Object.assign({}, defaults); newIndent = indent < 0 ? '\t' : ' '.repeat(indent);
} else if (typeof indent === 'string') {
newIndent = indent;
} }
/**
var indent = this.config.indent; * @type {State}
if (typeof indent == 'number' && !isNaN(indent)) { */
this.config.indent = indent < 0 ? '\t' : ' '.repeat(indent); const state = {
} else if (typeof indent != 'string') { // TODO remove width and height in v3
this.config.indent = ' '; width: undefined,
height: undefined,
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 (this.config.eol === 'crlf') { if (config.finalNewline && svg.length > 0 && svg[svg.length - 1] !== '\n') {
this.eol = '\r\n'; svg += eol;
} else {
this.eol = '\n';
} }
if (this.config.pretty) {
this.config.doctypeEnd += this.eol;
this.config.procInstEnd += this.eol;
this.config.commentEnd += this.eol;
this.config.cdataEnd += this.eol;
this.config.tagShortEnd += this.eol;
this.config.tagOpenEnd += this.eol;
this.config.tagCloseEnd += this.eol;
this.config.textEnd += this.eol;
}
this.indentLevel = 0;
this.textContext = null;
}
function encodeEntity(char) {
return entities[char];
}
/**
* Start conversion.
*
* @param {Object} data input data
*
* @return {String}
*/
JS2SVG.prototype.convert = function (data) {
var svg = '';
this.indentLevel++;
for (const item of data.children) {
if (item.type === 'element') {
svg += this.createElem(item);
}
if (item.type === 'text') {
svg += this.createText(item);
}
if (item.type === 'doctype') {
svg += this.createDoctype(item);
}
if (item.type === 'instruction') {
svg += this.createProcInst(item);
}
if (item.type === 'comment') {
svg += this.createComment(item);
}
if (item.type === 'cdata') {
svg += this.createCDATA(item);
}
}
this.indentLevel--;
if (
this.config.finalNewline &&
this.indentLevel === 0 &&
svg.length > 0 &&
svg[svg.length - 1] !== '\n'
) {
svg += this.eol;
}
return { return {
data: svg, data: svg,
info: { info: {
width: this.width, width: state.width,
height: this.height, height: state.height,
}, },
}; };
}; };
exports.stringifySvg = stringifySvg;
/** /**
* Create indent string in accordance with the current node level. * @type {(node: XastParent, config: Options, state: State) => string}
*
* @return {String}
*/ */
JS2SVG.prototype.createIndent = function () { const stringifyNode = (data, config, state) => {
var indent = ''; let svg = '';
state.indentLevel += 1;
if (this.config.pretty && !this.textContext) { for (const item of data.children) {
indent = this.config.indent.repeat(this.indentLevel - 1); 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; return indent;
}; };
/** /**
* Create doctype tag. * @type {(node: XastDoctype, config: Options) => string}
*
* @param {String} doctype doctype body string
*
* @return {String}
*/ */
JS2SVG.prototype.createDoctype = function (node) { const stringifyDoctype = (node, config) => {
const { doctype } = node.data; return config.doctypeStart + node.data.doctype + config.doctypeEnd;
return this.config.doctypeStart + doctype + this.config.doctypeEnd;
}; };
/** /**
* Create XML Processing Instruction tag. * @type {(node: XastInstruction, config: Options) => string}
*
* @param {Object} instruction instruction object
*
* @return {String}
*/ */
JS2SVG.prototype.createProcInst = function (node) { const stringifyInstruction = (node, config) => {
const { name, value } = node;
return ( return (
this.config.procInstStart + name + ' ' + value + this.config.procInstEnd config.procInstStart + node.name + ' ' + node.value + config.procInstEnd
); );
}; };
/** /**
* Create comment tag. * @type {(node: XastComment, config: Options) => string}
*
* @param {String} comment comment body
*
* @return {String}
*/ */
JS2SVG.prototype.createComment = function (node) { const stringifyComment = (node, config) => {
const { value } = node; return config.commentStart + node.value + config.commentEnd;
return this.config.commentStart + value + this.config.commentEnd;
}; };
/** /**
* Create CDATA section. * @type {(node: XastCdata, config: Options, state: State) => string}
*
* @param {String} cdata CDATA body
*
* @return {String}
*/ */
JS2SVG.prototype.createCDATA = function (node) { const stringifyCdata = (node, config, state) => {
const { value } = node;
return ( return (
this.createIndent() + this.config.cdataStart + value + this.config.cdataEnd createIndent(config, state) +
config.cdataStart +
node.value +
config.cdataEnd
); );
}; };
/** /**
* Create element tag. * @type {(node: XastElement, config: Options, state: State) => string}
*
* @param {Object} data element object
*
* @return {String}
*/ */
JS2SVG.prototype.createElem = function (data) { const stringifyElement = (node, config, state) => {
// beautiful injection for obtaining SVG information :) // beautiful injection for obtaining SVG information :)
if ( if (
data.name === 'svg' && node.name === 'svg' &&
data.attributes.width != null && node.attributes.width != null &&
data.attributes.height != null node.attributes.height != null
) { ) {
this.width = data.attributes.width; state.width = node.attributes.width;
this.height = data.attributes.height; state.height = node.attributes.height;
} }
// empty element and short tag // empty element and short tag
if (data.children.length === 0) { if (node.children.length === 0) {
if (this.config.useShortTags) { if (config.useShortTags) {
return ( return (
this.createIndent() + createIndent(config, state) +
this.config.tagShortStart + config.tagShortStart +
data.name + node.name +
this.createAttrs(data) + stringifyAttributes(node, config) +
this.config.tagShortEnd config.tagShortEnd
); );
} else { } else {
return ( return (
this.createIndent() + createIndent(config, state) +
this.config.tagShortStart + config.tagShortStart +
data.name + node.name +
this.createAttrs(data) + stringifyAttributes(node, config) +
this.config.tagOpenEnd + config.tagOpenEnd +
this.config.tagCloseStart + config.tagCloseStart +
data.name + node.name +
this.config.tagCloseEnd config.tagCloseEnd
); );
} }
// non-empty element // non-empty element
} else { } else {
var tagOpenStart = this.config.tagOpenStart, let tagOpenStart = config.tagOpenStart;
tagOpenEnd = this.config.tagOpenEnd, let tagOpenEnd = config.tagOpenEnd;
tagCloseStart = this.config.tagCloseStart, let tagCloseStart = config.tagCloseStart;
tagCloseEnd = this.config.tagCloseEnd, let tagCloseEnd = config.tagCloseEnd;
openIndent = this.createIndent(), let openIndent = createIndent(config, state);
closeIndent = this.createIndent(), let closeIndent = createIndent(config, state);
processedData = '',
dataEnd = '';
if (this.textContext) { if (state.textContext) {
tagOpenStart = defaults.tagOpenStart; tagOpenStart = defaults.tagOpenStart;
tagOpenEnd = defaults.tagOpenEnd; tagOpenEnd = defaults.tagOpenEnd;
tagCloseStart = defaults.tagCloseStart; tagCloseStart = defaults.tagCloseStart;
tagCloseEnd = defaults.tagCloseEnd; tagCloseEnd = defaults.tagCloseEnd;
openIndent = ''; openIndent = '';
} else if (data.isElem(textElems)) { } else if (textElems.includes(node.name)) {
tagOpenEnd = defaults.tagOpenEnd; tagOpenEnd = defaults.tagOpenEnd;
tagCloseStart = defaults.tagCloseStart; tagCloseStart = defaults.tagCloseStart;
closeIndent = ''; closeIndent = '';
this.textContext = data; state.textContext = node;
} }
processedData += this.convert(data).data; const children = stringifyNode(node, config, state);
if (this.textContext == data) { if (state.textContext === node) {
this.textContext = null; state.textContext = null;
} }
return ( return (
openIndent + openIndent +
tagOpenStart + tagOpenStart +
data.name + node.name +
this.createAttrs(data) + stringifyAttributes(node, config) +
tagOpenEnd + tagOpenEnd +
processedData + children +
dataEnd +
closeIndent + closeIndent +
tagCloseStart + tagCloseStart +
data.name + node.name +
tagCloseEnd tagCloseEnd
); );
} }
}; };
/** /**
* Create element attributes. * @type {(node: XastElement, config: Options) => string}
*
* @param {Object} elem attributes object
*
* @return {String}
*/ */
JS2SVG.prototype.createAttrs = function (element) { const stringifyAttributes = (node, config) => {
let attrs = ''; let attrs = '';
for (const [name, value] of Object.entries(element.attributes)) { for (const [name, value] of Object.entries(node.attributes)) {
// TODO remove attributes without values support in v3
if (value !== undefined) { if (value !== undefined) {
const encodedValue = value const encodedValue = value
.toString() .toString()
.replace(this.config.regValEntities, this.config.encodeEntity); .replace(config.regValEntities, config.encodeEntity);
attrs += attrs += ' ' + name + config.attrStart + encodedValue + config.attrEnd;
' ' + name + this.config.attrStart + encodedValue + this.config.attrEnd;
} else { } else {
attrs += ' ' + name; attrs += ' ' + name;
} }
@ -320,18 +314,13 @@ JS2SVG.prototype.createAttrs = function (element) {
}; };
/** /**
* Create text node. * @type {(node: XastText, config: Options, state: State) => string}
*
* @param {String} text text
*
* @return {String}
*/ */
JS2SVG.prototype.createText = function (node) { const stringifyText = (node, config, state) => {
const { value } = node;
return ( return (
this.createIndent() + createIndent(config, state) +
this.config.textStart + config.textStart +
value.replace(this.config.regEntities, this.config.encodeEntity) + node.value.replace(config.regEntities, config.encodeEntity) +
(this.textContext ? '' : this.config.textEnd) (state.textContext ? '' : config.textEnd)
); );
}; };

View File

@ -6,7 +6,7 @@ const {
extendDefaultPlugins, extendDefaultPlugins,
} = require('./svgo/config.js'); } = require('./svgo/config.js');
const { parseSvg } = require('./parser.js'); const { parseSvg } = require('./parser.js');
const js2svg = require('./stringifier.js'); const { stringifySvg } = require('./stringifier.js');
const { invokePlugins } = require('./svgo/plugins.js'); const { invokePlugins } = require('./svgo/plugins.js');
const JSAPI = require('./svgo/jsAPI.js'); const JSAPI = require('./svgo/jsAPI.js');
const { encodeSVGDatauri } = require('./svgo/tools.js'); const { encodeSVGDatauri } = require('./svgo/tools.js');
@ -53,10 +53,7 @@ const optimize = (input, config) => {
globalOverrides.floatPrecision = config.floatPrecision; globalOverrides.floatPrecision = config.floatPrecision;
} }
svgjs = invokePlugins(svgjs, info, resolvedPlugins, null, globalOverrides); svgjs = invokePlugins(svgjs, info, resolvedPlugins, null, globalOverrides);
svgjs = js2svg(svgjs, config.js2svg); svgjs = stringifySvg(svgjs, config.js2svg);
if (svgjs.error) {
throw Error(svgjs.error);
}
if (svgjs.data.length < prevResultSize) { if (svgjs.data.length < prevResultSize) {
input = svgjs.data; input = svgjs.data;
prevResultSize = svgjs.data.length; prevResultSize = svgjs.data.length;

View File

@ -51,6 +51,35 @@ export type XastParent = XastRoot | XastElement;
export type XastNode = XastRoot | XastChild; export type XastNode = XastRoot | XastChild;
export type StringifyOptions = {
doctypeStart?: string;
doctypeEnd?: string;
procInstStart?: string;
procInstEnd?: string;
tagOpenStart?: string;
tagOpenEnd?: string;
tagCloseStart?: string;
tagCloseEnd?: string;
tagShortStart?: string;
tagShortEnd?: string;
attrStart?: string;
attrEnd?: string;
commentStart?: string;
commentEnd?: string;
cdataStart?: string;
cdataEnd?: string;
textStart?: string;
textEnd?: string;
indent?: number | string;
regEntities?: RegExp;
regValEntities?: RegExp;
encodeEntity?: (char: string) => string;
pretty?: boolean;
useShortTags?: boolean;
eol?: 'lf' | 'crlf';
finalNewline?: boolean;
};
type VisitorNode<Node> = { type VisitorNode<Node> = {
enter?: (node: Node, parentNode: XastParent) => void | symbol; enter?: (node: Node, parentNode: XastParent) => void | symbol;
exit?: (node: Node, parentNode: XastParent) => void; exit?: (node: Node, parentNode: XastParent) => void;

View File

@ -53,7 +53,7 @@
"node": ">=10.13.0" "node": ">=10.13.0"
}, },
"scripts": { "scripts": {
"test": "NODE_OPTIONS=--experimental-vm-modules jest --maxWorkers=3 --coverage", "test": "NODE_OPTIONS=--experimental-vm-modules jest --maxWorkers=4 --coverage",
"lint": "eslint --ignore-path .gitignore . && prettier --check \"**/*.js\" --ignore-path .gitignore", "lint": "eslint --ignore-path .gitignore . && prettier --check \"**/*.js\" --ignore-path .gitignore",
"fix": "eslint --ignore-path .gitignore --fix . && prettier --write \"**/*.js\" --ignore-path .gitignore", "fix": "eslint --ignore-path .gitignore --fix . && prettier --write \"**/*.js\" --ignore-path .gitignore",
"typecheck": "tsc", "typecheck": "tsc",

View File

@ -10,14 +10,17 @@
"resolveJsonModule": true "resolveJsonModule": true
}, },
"include": [ "include": [
"lib/**/*.js",
"plugins/**/*", "plugins/**/*",
"lib/svg-parser.js",
"lib/xast.test.js",
"lib/path.test.js",
"lib/style.test.js",
"test/cli/**/*" "test/cli/**/*"
], ],
"exclude": [ "exclude": [
"lib/svgo-node.js",
"lib/svgo-node.test.js",
"lib/svgo.js",
"lib/svgo.test.js",
"lib/svgo/**/*.js",
"lib/css-tools.js",
"plugins/_applyTransforms.js", "plugins/_applyTransforms.js",
"plugins/convertPathData.js", "plugins/convertPathData.js",
"plugins/convertStyleToAttrs.js", "plugins/convertStyleToAttrs.js",