feat: introduce local-first SVG workbench

This commit is contained in:
2026-08-02 16:31:49 +02:00
commit 39d9802daa
97 changed files with 20702 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest";
import { defaultSvgLimits } from "../../src/app/limits";
import { parseSvgSource } from "../../src/document/source-parser";
import {
applySourcePatches,
patchAttribute,
} from "../../src/document/source-patcher";
const source = `<?xml version="1.0"?>
<!--preserve me-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 80">
<vendor:thing xmlns:vendor="urn:vendor" unusual="yes"><circle id="dot" cx="10" cy="20" r="4"/></vendor:thing>
</svg>`;
describe("source-faithful SVG parsing", () => {
it("retains canonical source and maps unknown elements to exact ranges", () => {
const result = parseSvgSource(source, 7);
expect(result.valid).toBe(true);
expect(result.semantic?.source).toBe(source);
expect(result.semantic?.revision).toBe(7);
const vendor = [...result.semantic!.nodes.values()].find(
(node) => node.name === "vendor:thing",
)!;
expect(
source.slice(vendor.sourceRange.full.from, vendor.sourceRange.full.to),
).toContain("vendor:thing");
expect(vendor.attributes.unusual).toBe("yes");
});
it("rejects malformed XML without constructing a semantic model", () => {
const result = parseSvgSource(
'<svg xmlns="http://www.w3.org/2000/svg"><g>',
2,
);
expect(result.valid).toBe(false);
expect(result.semantic).toBeNull();
expect(result.diagnostics.some((item) => item.severity === "error")).toBe(
true,
);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "xml-unclosed-element" }),
);
});
it("preserves but refuses entity declarations before invoking the XML parser", () => {
const entitySource = `<!DOCTYPE svg [<!ENTITY local "blocked">]><svg xmlns="http://www.w3.org/2000/svg"><text>&local;</text></svg>`;
const result = parseSvgSource(entitySource, 8);
expect(result.valid).toBe(false);
expect(result.semantic).toBeNull();
expect(result.source).toBe(entitySource);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({
code: "entity-declaration-blocked",
severity: "error",
}),
);
});
it("preserves a simple doctype while excluding it from the editing parser", () => {
const doctypeSource = `<!DOCTYPE svg SYSTEM "https://invalid.example/svg.dtd"><svg xmlns="http://www.w3.org/2000/svg"><rect width="2" height="3"/></svg>`;
const result = parseSvgSource(doctypeSource, 9);
expect(result.valid).toBe(true);
expect(result.semantic?.source).toBe(doctypeSource);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({
code: "doctype-preserved",
severity: "warning",
}),
);
});
it("treats duplicate IDs as editable diagnostics, not XML invalidity", () => {
const result = parseSvgSource(
'<svg xmlns="http://www.w3.org/2000/svg"><g id="same"/><path id="same" d="M0 0L1 1"/></svg>',
3,
);
expect(result.valid).toBe(true);
expect(result.semantic).not.toBeNull();
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "duplicate-id", severity: "warning" }),
);
});
it("patches one attribute while preserving comments, quote style and unknown content", () => {
const result = parseSvgSource(source, 1);
const dot = [...result.semantic!.nodes.values()].find(
(node) => node.id === "dot",
)!;
const patch = patchAttribute(source, dot, "cx", "42", result.preferences)!;
const next = applySourcePatches(source, [patch]);
expect(next).toBe(source.replace('cx="10"', 'cx="42"'));
expect(next).toContain("<!--preserve me-->");
expect(next).toContain("vendor:thing");
});
it("rejects overlapping or stale patches", () => {
expect(() =>
applySourcePatches("abcdef", [
{ from: 1, to: 4, insert: "x", label: "first" },
{ from: 3, to: 5, insert: "y", label: "second" },
]),
).toThrow(/overlapping/u);
});
it("enforces each configurable structural resource limit", () => {
const constrained = {
...defaultSvgLimits,
maximumAttributeLength: 12,
maximumPathCommandsPerPath: 2,
maximumPathCommandsTotal: 2,
maximumCssRules: 1,
maximumReferences: 0,
maximumAnimations: 0,
maximumFilterPrimitives: 0,
maximumTextLength: 4,
maximumEmbeddedResourceBytes: 8,
};
const result = parseSvgSource(
`<svg xmlns="http://www.w3.org/2000/svg">
<style>.a{fill:red}.b{stroke:blue}</style>
<defs><filter id="fx"><feGaussianBlur stdDeviation="1"/></filter></defs>
<path d="M0 0L1 1L2 2" filter="url(#fx)"/>
<image href="data:image/png;base64,AAAAAAAAAAAA"/>
<animate attributeName="opacity" values="0;1"/>
<text>longer text</text>
</svg>`,
9,
constrained,
);
const codes = result.diagnostics.map((item) => item.code);
expect(result.valid).toBe(false);
expect(codes).toEqual(
expect.arrayContaining([
"attribute-length-limit",
"embedded-resource-limit",
"path-command-per-element-limit",
"path-command-limit",
"css-rule-limit",
"reference-limit",
"animation-limit",
"filter-primitive-limit",
"text-length-limit",
"total-text-limit",
]),
);
});
it("counts SVG path commands without mistaking exponent notation for commands", () => {
const result = parseSvgSource(
'<svg xmlns="http://www.w3.org/2000/svg"><path d="M1e2 0L2e2 1"/></svg>',
4,
);
expect(result.semantic?.metrics.pathCommandCount).toBe(2);
});
it("counts implicit path groups and packed arc flags as resolved commands", () => {
const result = parseSvgSource(
'<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0 1 1 2 2 A5 6 0 0110 20"/></svg>',
5,
{ ...defaultSvgLimits, maximumPathCommandsPerPath: 3 },
);
expect(result.semantic?.metrics.pathCommandCount).toBe(4);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "path-command-per-element-limit" }),
);
});
});