255 lines
7.9 KiB
TypeScript
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}>`;
|
|
}
|
|
}
|
|
}
|