import { SVG_COMPRESS_CODES, SvgCompressError, } from "../../error-codes/error-codes.js"; import type { SvgNode } from "../../types/common.js"; const HTML_ENTITIES: Record = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", }; function decodeEntities(s: string): string { return s.replace(/&([^;]+);/g, (_match, name: string) => { if (name.startsWith("#x")) { return String.fromCodePoint(Number.parseInt(name.slice(2), 16)); } if (name.startsWith("#")) { return String.fromCodePoint(Number.parseInt(name.slice(1), 10)); } return HTML_ENTITIES[name] ?? /* istanbul ignore next */ `&${name};`; }); } function parseAttrs(raw: string): Record { const attrs: Record = {}; const re = /([^\s=/"'<>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'`<>=]*)))?/g; let m: RegExpExecArray | null; while ((m = re.exec(raw)) !== null) { const name = m[1]!; const val = m[2] ?? m[3] ?? m[4] ?? ""; attrs[name] = decodeEntities(val); } return attrs; } export function parseSvg(svg: string): SvgNode { if (!svg.trim()) { throw new SvgCompressError( SVG_COMPRESS_CODES.PARSE_FAILED, "Empty input string" ); } const stack: SvgNode[] = []; let pos = 0; let root: SvgNode | null = null; function pushNode(node: SvgNode): void { const parent = stack[stack.length - 1]; if (parent) { (parent.children ??= []).push(node); } else if (!root) { root = node; } } while (pos < svg.length) { if (svg[pos] !== "<") { const end = svg.indexOf("<", pos); const text = end === -1 ? svg.slice(pos) : svg.slice(pos, end); const parent = stack[stack.length - 1]; if (parent) { (parent.children ??= []).push({ type: "text", value: text }); } pos = end === -1 ? svg.length : end; continue; } // XML/processing instruction: if (svg.startsWith("", pos + 2); if (end === -1) { throw new SvgCompressError( SVG_COMPRESS_CODES.PARSE_FAILED, "Unclosed processing instruction" ); } pos = end + 2; continue; } // Comment: if (svg.startsWith("", pos + 4); if (end === -1) { throw new SvgCompressError( SVG_COMPRESS_CODES.PARSE_FAILED, "Unclosed comment" ); } const value = svg.slice(pos + 4, end); pushNode({ type: "comment", value }); pos = end + 3; continue; } // CDATA: if (svg.startsWith("", pos + 9); if (end === -1) { throw new SvgCompressError( SVG_COMPRESS_CODES.PARSE_FAILED, "Unclosed CDATA section" ); } const value = svg.slice(pos + 9, end); pushNode({ type: "cdata", value }); pos = end + 3; continue; } // DOCTYPE: if (svg.startsWith("", pos + 2); if (end === -1) { throw new SvgCompressError( SVG_COMPRESS_CODES.PARSE_FAILED, "Unclosed DOCTYPE" ); } const inner = svg.slice(pos + 2, end).trim(); if (inner.toUpperCase().startsWith("DOCTYPE")) { const value = inner.slice(7).trim(); pushNode({ type: "doctype", value }); } pos = end + 1; continue; } // Closing tag: if (svg.startsWith("", pos + 2); if (end === -1) { throw new SvgCompressError( SVG_COMPRESS_CODES.PARSE_FAILED, "Unclosed closing tag" ); } const name = svg.slice(pos + 2, end).trim(); const top = stack[stack.length - 1]; if (!top || top.name !== name) { throw new SvgCompressError( SVG_COMPRESS_CODES.PARSE_FAILED, `Mismatched closing tag: , expected ` ); } const closed = stack.pop()!; if (stack.length === 0) { root = closed; } pos = end + 1; continue; } // Opening tag: or const tagEnd = findTagEnd(svg, pos + 1); if (tagEnd === -1) { throw new SvgCompressError( SVG_COMPRESS_CODES.PARSE_FAILED, "Unclosed opening tag" ); } const tagContent = svg.slice(pos + 1, tagEnd); const selfClosing = tagContent.endsWith("/"); const inner = selfClosing ? tagContent.slice(0, -1) : tagContent; const spaceIdx = inner.search(/[\s]/); const name = spaceIdx === -1 ? inner.trim() : inner.slice(0, spaceIdx).trim(); const attrStr = spaceIdx === -1 ? "" : inner.slice(spaceIdx + 1); const attributes = parseAttrs(attrStr); const node: SvgNode = { type: "element", name, attributes, children: [], }; if (selfClosing) { pushNode(node); } else { const parent = stack[stack.length - 1]; if (parent) { (parent.children ??= []).push(node); } stack.push(node); } pos = tagEnd + 1; } if (stack.length > 0) { throw new SvgCompressError( SVG_COMPRESS_CODES.PARSE_FAILED, `Unclosed elements: ${stack.map((n) => n.name).join(", ")}` ); } if (!root) { throw new SvgCompressError( SVG_COMPRESS_CODES.PARSE_FAILED, "No root element found" ); } return root; } function findTagEnd(svg: string, start: number): number { let i = start; let inSingle = false; let inDouble = false; while (i < svg.length) { const ch = svg[i]; if (ch === '"' && !inSingle) inDouble = !inDouble; else if (ch === "'" && !inDouble) inSingle = !inSingle; else if (ch === ">" && !inSingle && !inDouble) return i; i++; } return -1; } export function serializeSvg(node: SvgNode): string { switch (node.type) { case "text": return node.value ?? /* istanbul ignore next */ ""; case "comment": return ``; case "cdata": return ``; case "doctype": return ``; case "element": { const name = node.name ?? /* istanbul ignore next */ "unknown"; const attrs = node.attributes ? Object.entries(node.attributes) .map(([k, v]) => `${k}="${v}"`) .join(" ") : ""; const attrStr = attrs ? ` ${attrs}` : ""; const children = node.children ?? /* istanbul ignore next */ []; if (children.length === 0) { return `<${name}${attrStr}/>`; } const childStr = children.map(serializeSvg).join(""); return `<${name}${attrStr}>${childStr}`; } } }