Files
suaveplan/packages/devtools/docs/compress-svg/src/parser/svg/svg.ts
2026-05-27 23:29:53 +01:00

255 lines
7.9 KiB
TypeScript

import {
SVG_COMPRESS_CODES,
SvgCompressError,
} from "../../error-codes/error-codes.js";
import type { SvgNode } from "../../types/common.js";
const HTML_ENTITIES: Record<string, string> = {
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<string, string> {
const attrs: Record<string, string> = {};
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)) {
const end = svg.indexOf("?>", 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)) {
const end = svg.indexOf("-->", 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: <![CDATA[...]]>
if (svg.startsWith("<![CDATA[", pos)) {
const end = svg.indexOf("]]>", 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)) {
const end = svg.indexOf(">", 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: </name>
if (svg.startsWith("</", pos)) {
const end = svg.indexOf(">", 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: </${name}>, expected </${top?.name ?? "none"}>`
);
}
const closed = stack.pop()!;
if (stack.length === 0) {
root = closed;
}
pos = end + 1;
continue;
}
// Opening tag: <name attrs> or <name attrs/>
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 `<!--${node.value ?? /* istanbul ignore next */ ""}-->`;
case "cdata":
return `<![CDATA[${node.value ?? /* istanbul ignore next */ ""}]]>`;
case "doctype":
return `<!DOCTYPE ${node.value ?? /* istanbul ignore next */ ""}>`;
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}</${name}>`;
}
}
}