feat: introduce local-first SVG workbench
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
defaultSvgLimits,
|
||||
utf8ByteLength,
|
||||
type SvgResourceLimits,
|
||||
} from "../../src/app/limits";
|
||||
import { parseSvgSource } from "../../src/document/source-parser";
|
||||
|
||||
const mutableLimits = defaultSvgLimits as SvgResourceLimits;
|
||||
let original: SvgResourceLimits;
|
||||
|
||||
beforeEach(() => {
|
||||
original = { ...defaultSvgLimits };
|
||||
Object.assign(mutableLimits, {
|
||||
maximumAttributeLength: 30,
|
||||
maximumPathCommandsPerPath: 2,
|
||||
maximumPathCommandsTotal: 3,
|
||||
maximumCssRules: 1,
|
||||
maximumReferences: 1,
|
||||
maximumAnimations: 1,
|
||||
maximumFilterPrimitives: 1,
|
||||
maximumTextLength: 3,
|
||||
maximumEmbeddedResourceBytes: 10,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.assign(mutableLimits, original);
|
||||
});
|
||||
|
||||
describe("configured SVG resource limits", () => {
|
||||
it("counts UTF-8 bytes without allocating an encoded copy", () => {
|
||||
expect(utf8ByteLength("Aé🙂\ud800")).toBe(10);
|
||||
});
|
||||
|
||||
it("rejects a hard-limit document before XML or semantic processing", () => {
|
||||
const result = parseSvgSource(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
0,
|
||||
{ ...original, sourceHardBytes: 8, sourceSoftBytes: 4 },
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.semantic).toBeNull();
|
||||
expect(result.diagnostics).toEqual([
|
||||
expect.objectContaining({ code: "source-hard-limit" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports every bounded resource class with source-preserving diagnostics", () => {
|
||||
const result = parseSvgSource(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" data-long="1234567890123456789012345678901">
|
||||
<style>.a{} .b{}</style>
|
||||
<defs><filter id="f"><feBlend/><feGaussianBlur/></filter></defs>
|
||||
<path d="M0 0L1 1L2 2L3 3" fill="url(#a)" stroke="url(#b)"/>
|
||||
<animate attributeName="opacity"/><set attributeName="fill"/>
|
||||
<text>long</text>
|
||||
<image href="data:image/png;base64,abcdef"/>
|
||||
</svg>`,
|
||||
1,
|
||||
);
|
||||
const codes = result.diagnostics.map((diagnostic) => diagnostic.code);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.semantic).not.toBeNull();
|
||||
expect(codes).toEqual(
|
||||
expect.arrayContaining([
|
||||
"attribute-length-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",
|
||||
"embedded-resource-limit",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not count exponent markers as path commands", () => {
|
||||
Object.assign(mutableLimits, {
|
||||
maximumPathCommandsPerPath: 10,
|
||||
maximumPathCommandsTotal: 10,
|
||||
maximumTextLength: original.maximumTextLength,
|
||||
maximumAttributeLength: original.maximumAttributeLength,
|
||||
});
|
||||
const result = parseSvgSource(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><path d="M1e2 1e-2L3e1 4e0"/></svg>',
|
||||
1,
|
||||
);
|
||||
expect(result.semantic?.metrics.pathCommandCount).toBe(2);
|
||||
});
|
||||
|
||||
it("bounds aggregate embedded data even when each resource is below the limit", () => {
|
||||
const perResource = "data:image/png;base64,aaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const result = parseSvgSource(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg"><image href="${perResource}"/><image href="${perResource}"/></svg>`,
|
||||
2,
|
||||
{
|
||||
...original,
|
||||
maximumEmbeddedResourceBytes: 60,
|
||||
},
|
||||
);
|
||||
const codes = result.diagnostics.map((diagnostic) => diagnostic.code);
|
||||
|
||||
expect(codes).not.toContain("embedded-resource-limit");
|
||||
expect(codes).toContain("embedded-resource-total-limit");
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user