'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 || ''}:${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 = //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}) => 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;