1
0
mirror of https://github.com/svg/svgo.git synced 2025-07-29 20:21:14 +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';
/**
* @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<StringifyOptions>} Options
*/
/**
* @type {(char: string) => string}
*/
const encodeEntity = (char) => {
return entities[char];
};
/**
* @type {Options}
*/
const defaults = {
doctypeStart: '<!DOCTYPE',
doctypeEnd: '>',
procInstStart: '<?',
@ -31,7 +67,10 @@ var defaults = {
finalNewline: false,
};
var entities = {
/**
* @type {Record<string, string>}
*/
const entities = {
'&': '&amp;',
"'": '&apos;',
'"': '&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
* @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)
);
};