From 65b6bf4c16a8c0a0faa1dac24157a746857f5cd7 Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Thu, 7 Oct 2021 14:06:55 +0300 Subject: [PATCH] Refactor svg stringifier (#1593) - rewrote prototype class with functions - covered with tsdoc - added a few TODOs for v3 --- lib/stringifier.js | 387 ++++++++++++++++++++++----------------------- lib/svgo.js | 7 +- lib/types.ts | 29 ++++ package.json | 2 +- tsconfig.json | 11 +- 5 files changed, 227 insertions(+), 209 deletions(-) diff --git a/lib/stringifier.js b/lib/stringifier.js index aa9c2502..e490fd48 100644 --- a/lib/stringifier.js +++ b/lib/stringifier.js @@ -1,8 +1,44 @@ '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'); -var defaults = { +/** + * @typedef {{ + * width: void | string, + * height: void | string, + * indent: string, + * textContext: null | XastElement, + * indentLevel: number, + * }} State + */ + +/** + * @typedef {Required} Options + */ + +/** + * @type {(char: string) => string} + */ +const encodeEntity = (char) => { + return entities[char]; +}; + +/** + * @type {Options} + */ +const defaults = { doctypeStart: '', procInstStart: '} + */ +const entities = { '&': '&', "'": ''', '"': '"', @@ -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 - * @param {Object} config config - * - * @return {Object} output data + * @type {(data: XastRoot, config: StringifyOptions) => { + * data: string, + * info: { + * width: void | string, + * height: void | string + * } + * }} */ -module.exports = function (data, config) { - return new JS2SVG(config).convert(data); -}; - -function JS2SVG(config) { - if (config) { - this.config = Object.assign({}, defaults, config); - } else { - this.config = Object.assign({}, defaults); +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; } - - var indent = this.config.indent; - if (typeof indent == 'number' && !isNaN(indent)) { - this.config.indent = indent < 0 ? '\t' : ' '.repeat(indent); - } else if (typeof indent != 'string') { - this.config.indent = ' '; + /** + * @type {State} + */ + const state = { + // TODO remove width and height in v3 + 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; } - - if (this.config.eol === 'crlf') { - this.eol = '\r\n'; - } else { - this.eol = '\n'; + let svg = stringifyNode(data, config, state); + if (config.finalNewline && svg.length > 0 && svg[svg.length - 1] !== '\n') { + svg += eol; } - - 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 { data: svg, info: { - width: this.width, - height: this.height, + width: state.width, + height: state.height, }, }; }; +exports.stringifySvg = stringifySvg; /** - * Create indent string in accordance with the current node level. - * - * @return {String} + * @type {(node: XastParent, config: Options, state: State) => string} */ -JS2SVG.prototype.createIndent = function () { - var indent = ''; - - if (this.config.pretty && !this.textContext) { - indent = this.config.indent.repeat(this.indentLevel - 1); +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; }; /** - * Create doctype tag. - * - * @param {String} doctype doctype body string - * - * @return {String} + * @type {(node: XastDoctype, config: Options) => string} */ -JS2SVG.prototype.createDoctype = function (node) { - const { doctype } = node.data; - return this.config.doctypeStart + doctype + this.config.doctypeEnd; +const stringifyDoctype = (node, config) => { + return config.doctypeStart + node.data.doctype + config.doctypeEnd; }; /** - * Create XML Processing Instruction tag. - * - * @param {Object} instruction instruction object - * - * @return {String} + * @type {(node: XastInstruction, config: Options) => string} */ -JS2SVG.prototype.createProcInst = function (node) { - const { name, value } = node; +const stringifyInstruction = (node, config) => { return ( - this.config.procInstStart + name + ' ' + value + this.config.procInstEnd + config.procInstStart + node.name + ' ' + node.value + config.procInstEnd ); }; /** - * Create comment tag. - * - * @param {String} comment comment body - * - * @return {String} + * @type {(node: XastComment, config: Options) => string} */ -JS2SVG.prototype.createComment = function (node) { - const { value } = node; - return this.config.commentStart + value + this.config.commentEnd; +const stringifyComment = (node, config) => { + return config.commentStart + node.value + config.commentEnd; }; /** - * Create CDATA section. - * - * @param {String} cdata CDATA body - * - * @return {String} + * @type {(node: XastCdata, config: Options, state: State) => string} */ -JS2SVG.prototype.createCDATA = function (node) { - const { value } = node; +const stringifyCdata = (node, config, state) => { return ( - this.createIndent() + this.config.cdataStart + value + this.config.cdataEnd + createIndent(config, state) + + config.cdataStart + + node.value + + config.cdataEnd ); }; /** - * Create element tag. - * - * @param {Object} data element object - * - * @return {String} + * @type {(node: XastElement, config: Options, state: State) => string} */ -JS2SVG.prototype.createElem = function (data) { +const stringifyElement = (node, config, state) => { // beautiful injection for obtaining SVG information :) if ( - data.name === 'svg' && - data.attributes.width != null && - data.attributes.height != null + node.name === 'svg' && + node.attributes.width != null && + node.attributes.height != null ) { - this.width = data.attributes.width; - this.height = data.attributes.height; + state.width = node.attributes.width; + state.height = node.attributes.height; } // empty element and short tag - if (data.children.length === 0) { - if (this.config.useShortTags) { + if (node.children.length === 0) { + if (config.useShortTags) { return ( - this.createIndent() + - this.config.tagShortStart + - data.name + - this.createAttrs(data) + - this.config.tagShortEnd + createIndent(config, state) + + config.tagShortStart + + node.name + + stringifyAttributes(node, config) + + config.tagShortEnd ); } else { return ( - this.createIndent() + - this.config.tagShortStart + - data.name + - this.createAttrs(data) + - this.config.tagOpenEnd + - this.config.tagCloseStart + - data.name + - this.config.tagCloseEnd + createIndent(config, state) + + config.tagShortStart + + node.name + + stringifyAttributes(node, config) + + config.tagOpenEnd + + config.tagCloseStart + + node.name + + config.tagCloseEnd ); } // non-empty element } else { - var tagOpenStart = this.config.tagOpenStart, - tagOpenEnd = this.config.tagOpenEnd, - tagCloseStart = this.config.tagCloseStart, - tagCloseEnd = this.config.tagCloseEnd, - openIndent = this.createIndent(), - closeIndent = this.createIndent(), - processedData = '', - dataEnd = ''; + 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 (this.textContext) { + if (state.textContext) { tagOpenStart = defaults.tagOpenStart; tagOpenEnd = defaults.tagOpenEnd; tagCloseStart = defaults.tagCloseStart; tagCloseEnd = defaults.tagCloseEnd; openIndent = ''; - } else if (data.isElem(textElems)) { + } else if (textElems.includes(node.name)) { tagOpenEnd = defaults.tagOpenEnd; tagCloseStart = defaults.tagCloseStart; closeIndent = ''; - this.textContext = data; + state.textContext = node; } - processedData += this.convert(data).data; + const children = stringifyNode(node, config, state); - if (this.textContext == data) { - this.textContext = null; + if (state.textContext === node) { + state.textContext = null; } return ( openIndent + tagOpenStart + - data.name + - this.createAttrs(data) + + node.name + + stringifyAttributes(node, config) + tagOpenEnd + - processedData + - dataEnd + + children + closeIndent + tagCloseStart + - data.name + + node.name + tagCloseEnd ); } }; /** - * Create element attributes. - * - * @param {Object} elem attributes object - * - * @return {String} + * @type {(node: XastElement, config: Options) => string} */ -JS2SVG.prototype.createAttrs = function (element) { +const stringifyAttributes = (node, config) => { 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) { const encodedValue = value .toString() - .replace(this.config.regValEntities, this.config.encodeEntity); - attrs += - ' ' + name + this.config.attrStart + encodedValue + this.config.attrEnd; + .replace(config.regValEntities, config.encodeEntity); + attrs += ' ' + name + config.attrStart + encodedValue + config.attrEnd; } else { attrs += ' ' + name; } @@ -320,18 +314,13 @@ JS2SVG.prototype.createAttrs = function (element) { }; /** - * Create text node. - * - * @param {String} text text - * - * @return {String} + * @type {(node: XastText, config: Options, state: State) => string} */ -JS2SVG.prototype.createText = function (node) { - const { value } = node; +const stringifyText = (node, config, state) => { return ( - this.createIndent() + - this.config.textStart + - value.replace(this.config.regEntities, this.config.encodeEntity) + - (this.textContext ? '' : this.config.textEnd) + createIndent(config, state) + + config.textStart + + node.value.replace(config.regEntities, config.encodeEntity) + + (state.textContext ? '' : config.textEnd) ); }; diff --git a/lib/svgo.js b/lib/svgo.js index 03e70d30..a2e1017d 100644 --- a/lib/svgo.js +++ b/lib/svgo.js @@ -6,7 +6,7 @@ const { extendDefaultPlugins, } = require('./svgo/config.js'); const { parseSvg } = require('./parser.js'); -const js2svg = require('./stringifier.js'); +const { stringifySvg } = require('./stringifier.js'); const { invokePlugins } = require('./svgo/plugins.js'); const JSAPI = require('./svgo/jsAPI.js'); const { encodeSVGDatauri } = require('./svgo/tools.js'); @@ -53,10 +53,7 @@ const optimize = (input, config) => { globalOverrides.floatPrecision = config.floatPrecision; } svgjs = invokePlugins(svgjs, info, resolvedPlugins, null, globalOverrides); - svgjs = js2svg(svgjs, config.js2svg); - if (svgjs.error) { - throw Error(svgjs.error); - } + svgjs = stringifySvg(svgjs, config.js2svg); if (svgjs.data.length < prevResultSize) { input = svgjs.data; prevResultSize = svgjs.data.length; diff --git a/lib/types.ts b/lib/types.ts index b62145a3..38d9f9f2 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -51,6 +51,35 @@ export type XastParent = XastRoot | XastElement; 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 = { enter?: (node: Node, parentNode: XastParent) => void | symbol; exit?: (node: Node, parentNode: XastParent) => void; diff --git a/package.json b/package.json index 85129b60..0f4a891b 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "node": ">=10.13.0" }, "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", "fix": "eslint --ignore-path .gitignore --fix . && prettier --write \"**/*.js\" --ignore-path .gitignore", "typecheck": "tsc", diff --git a/tsconfig.json b/tsconfig.json index 98b32ede..0a374868 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,14 +10,17 @@ "resolveJsonModule": true }, "include": [ + "lib/**/*.js", "plugins/**/*", - "lib/svg-parser.js", - "lib/xast.test.js", - "lib/path.test.js", - "lib/style.test.js", "test/cli/**/*" ], "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/convertPathData.js", "plugins/convertStyleToAttrs.js",