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
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import type { AnimationDefinition } from "../../src/animation/animation.types";
import {
animationStyle,
withAnimationPreview,
} from "../../src/animation/preview";
const fade: AnimationDefinition = {
id: "fade",
name: "Fade",
targetNodeKey: "id:shape",
property: "opacity",
kind: "style",
enabled: true,
keyframes: [
{ offset: 0, value: "0" },
{ offset: 1, value: "1" },
],
timing: {
durationMs: 500,
delayMs: 20,
iterations: "infinite",
direction: "alternate",
fillMode: "both",
easing: "ease-in-out",
},
};
describe("isolated animation preview", () => {
it("creates project-side CSS targeting only projection metadata", () => {
const css = animationStyle([fade]);
expect(css).toContain("@keyframes svg-tools-animation-0");
expect(css).toContain('[data-svg-tools-node="id:shape"]');
expect(css).toContain("500ms ease-in-out 20ms infinite alternate both");
});
it("injects preview metadata without mutating the input projection", () => {
const source =
'<svg xmlns="http://www.w3.org/2000/svg"><circle data-svg-tools-node="id:shape"/></svg>';
const result = withAnimationPreview(source, [fade]);
expect(source).not.toContain("data-svg-tools-preview");
expect(result).toContain('data-svg-tools-preview="animation"');
});
it("rejects unsafe properties and CSS rule or URL injection", () => {
expect(() =>
animationStyle([{ ...fade, property: "background-image" }]),
).toThrow(/not application-safe/u);
expect(() =>
animationStyle([
{
...fade,
keyframes: [{ offset: 0, value: "url(https://evil.test)" }],
},
]),
).toThrow(/URLs or external resources/u);
expect(() =>
animationStyle([
{
...fade,
timing: { ...fade.timing, easing: "linear; color:red" },
},
]),
).toThrow(/unsafe animation easing/u);
});
it("omits disabled animations", () => {
expect(animationStyle([{ ...fade, enabled: false }])).toBe("");
});
});
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import type { AnimationDefinition } from "../../src/animation/animation.types";
import {
AnimationValidationError,
buildAnimationCss,
validateAnimationDefinitions,
} from "../../src/animation/validation";
const animation: AnimationDefinition = {
id: "pulse",
name: "Pulse",
targetNodeKey: "id:shape",
property: "opacity",
kind: "style",
enabled: true,
keyframes: [
{ offset: 0, value: "0.25", easing: "cubic-bezier(0.2, 0, 0.8, 1)" },
{ offset: 1, value: "calc(1 - 0.1)" },
],
timing: {
durationMs: 500,
delayMs: -20,
iterations: 2.5,
direction: "alternate",
fillMode: "both",
easing: "steps(4, jump-end)",
},
};
function invalid(mutator: (value: AnimationDefinition) => void): () => void {
const value = structuredClone(animation);
mutator(value);
return () => validateAnimationDefinitions([value]);
}
describe("application-owned animation validation", () => {
it("builds source-safe CSS through the same validated serializer", () => {
const css = buildAnimationCss([animation], {
keyframeNamePrefix: "svg-tools",
selectorFor: () => "#shape",
});
expect(css).toContain("@keyframes svg-tools-0");
expect(css).toContain("#shape { animation: svg-tools-0 500ms");
expect(css).toContain(
"animation-timing-function: cubic-bezier(0.2, 0, 0.8, 1)",
);
});
it.each([
[
"NaN offset",
invalid((value) => (value.keyframes[0]!.offset = Number.NaN)),
],
["large offset", invalid((value) => (value.keyframes[0]!.offset = 1.1))],
[
"infinite duration",
invalid((value) => (value.timing.durationMs = Number.POSITIVE_INFINITY)),
],
[
"infinite delay",
invalid((value) => (value.timing.delayMs = Number.NEGATIVE_INFINITY)),
],
[
"infinite iterations",
invalid((value) => (value.timing.iterations = Number.POSITIVE_INFINITY)),
],
["negative iterations", invalid((value) => (value.timing.iterations = -1))],
])("rejects non-finite or out-of-range timing: %s", (_label, action) => {
expect(action).toThrow(AnimationValidationError);
});
it.each([
["property", invalid((value) => (value.property = "opacity; stroke: red"))],
[
"declaration",
invalid((value) => (value.keyframes[0]!.value = "0; stroke: red")),
],
[
"closing style element",
invalid(
(value) =>
(value.keyframes[0]!.value = "0</style><script>alert(1)</script>"),
),
],
[
"external URL",
invalid(
(value) =>
(value.keyframes[0]!.value = "url(https://attacker.invalid/a.svg)"),
),
],
[
"escaped external URL",
invalid(
(value) =>
(value.keyframes[0]!.value = String.raw`u\72 l("https://attacker.invalid/a.svg")`),
),
],
[
"timing easing",
invalid((value) => (value.timing.easing = "linear; stroke: red")),
],
[
"keyframe easing",
invalid(
(value) => (value.keyframes[0]!.easing = "linear } body { color:red"),
),
],
])("rejects CSS injection through %s", (_label, action) => {
expect(action).toThrow(AnimationValidationError);
});
it("validates disabled definitions instead of retaining dormant payloads", () => {
const value = structuredClone(animation);
value.enabled = false;
value.keyframes[0]!.value = "url(https://attacker.invalid/payload)";
expect(() =>
buildAnimationCss([value], {
keyframeNamePrefix: "svg-tools",
selectorFor: () => "#shape",
}),
).toThrow(AnimationValidationError);
});
});
+148
View File
@@ -0,0 +1,148 @@
import { expect, test } from "@playwright/test";
test("loads from a deep path and keeps the last valid projection while source is invalid", async ({
page,
}) => {
const pageErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
await page.goto("/deep/nested/svg/");
await expect(page).toHaveTitle("SVG Tools");
await expect(
page.getByText("Source synchronized", { exact: true }),
).toBeVisible();
await expect(
page.getByRole("tree", { name: "SVG element structure" }),
).toBeVisible();
await expect(page.getByTitle("Sanitized SVG preview")).toHaveAttribute(
"sandbox",
"allow-scripts",
);
const treeItems = page.getByRole("treeitem");
const initialTreeCount = await treeItems.count();
expect(initialTreeCount).toBeGreaterThan(1);
await page
.frameLocator('iframe[title="Sanitized SVG preview"]')
.locator("#sun")
.click();
await expect(page.locator('[data-node-key="id:sun"]')).toHaveAttribute(
"aria-selected",
"true",
);
const source = page.locator(".cm-content");
await source.click();
await page.keyboard.press("Control+A");
// insertText bypasses CodeMirror's helpful XML close-tag completion so this
// exercise really leaves the canonical source in an incomplete state.
await page.keyboard.insertText('<svg xmlns="http://www.w3.org/2000/svg"><g>');
await expect(page.getByText("Source invalid", { exact: true })).toBeVisible();
await expect(
page.getByText("Showing the last valid canvas revision."),
).toBeVisible();
expect(await treeItems.count()).toBe(initialTreeCount);
await expect(page.getByRole("treeitem").first()).toBeVisible();
await expect(
page.getByRole("button", { name: "Download SVG" }).last(),
).toBeDisabled();
await source.click();
await page.keyboard.press("Control+z");
await expect(
page.getByText("Source synchronized", { exact: true }),
).toBeVisible();
expect(pageErrors).toEqual([]);
});
test("synchronizes tree selection, source patches, undo and redo", async ({
page,
}) => {
await page.goto("/deep/nested/svg/");
await expect(
page.getByText("Source synchronized", { exact: true }),
).toBeVisible();
await page.getByPlaceholder("Filter elements…").fill("path");
const pathRow = page.getByRole("treeitem").filter({ hasText: "path" }).last();
await pathRow.click();
await expect(pathRow).toHaveAttribute("aria-selected", "true");
await page.getByRole("tab", { name: "Element" }).click();
const fill = page.getByLabel("Fill", { exact: true });
await fill.fill("#123456");
await fill.press("Enter");
await expect(page.locator(".cm-content")).toContainText('fill="#123456"');
await page.getByRole("button", { name: "Undo" }).click();
await expect(page.locator(".cm-content")).not.toContainText('fill="#123456"');
await page.getByRole("button", { name: "Redo" }).click();
await expect(page.locator(".cm-content")).toContainText('fill="#123456"');
});
test("connects to a valid same-origin Toolbox catalogue from the nested build", async ({
page,
}) => {
await page.goto("/deep/nested/svg/?toolbox=%2Ftoolbox.catalog.json");
await expect(
page.getByText("Source synchronized", { exact: true }),
).toBeVisible();
await expect(page.locator(".toolbox-shell")).toHaveAttribute(
"data-toolbox-context",
"connected",
);
await page.getByRole("button", { name: "Apps" }).click();
const switcher = page.getByRole("navigation", {
name: "Toolbox applications",
});
await expect(switcher).toBeVisible();
await expect(
switcher.getByRole("link", { name: "SVG Tools" }),
).toHaveAttribute("aria-current", "page");
});
test("opens hostile SVG locally without executing scripts or fetching external URLs", async ({
page,
}) => {
const forbiddenRequests: string[] = [];
let executed = false;
page.on("request", (request) => {
if (request.url().includes("invalid.example"))
forbiddenRequests.push(request.url());
});
await page.exposeFunction("svgToolsExecuted", () => {
executed = true;
});
await page.goto("/deep/nested/svg/");
const hostile = `
<!DOCTYPE svg SYSTEM "https://invalid.example/tracker.dtd">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<script>parent.svgToolsExecuted()</script>
<image href="https://invalid.example/tracker.png" width="20" height="20"/>
<rect width="20" height="20" onclick="parent.svgToolsExecuted()"/>
</svg>`;
await page.locator('input[type="file"]').evaluate((element, contents) => {
const transfer = new DataTransfer();
transfer.items.add(
new File([contents], "hostile.svg", { type: "image/svg+xml" }),
);
(element as HTMLInputElement).files = transfer.files;
element.dispatchEvent(new Event("change", { bubbles: true }));
}, hostile);
await expect(
page.getByText("Source synchronized", { exact: true }),
).toBeVisible();
await expect(
page.getByText(/blocked-script|event-handler|unsafe-url/).first(),
).toBeVisible();
const frame = page.frameLocator('iframe[title="Sanitized SVG preview"]');
await expect(frame.locator("script")).toHaveCount(0);
await expect(frame.locator("image")).not.toHaveAttribute(
"href",
/invalid\.example/u,
);
expect(executed).toBe(false);
expect(forbiddenRequests).toEqual([]);
});
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from "vitest";
import { CommandHistory, createTransaction } from "../../src/commands/history";
describe("source transaction history", () => {
it("undoes and redoes exact source without normalization", () => {
const history = new CommandHistory();
const transaction = createTransaction({
label: "Set fill",
baseRevision: 1,
sourceBefore: "<svg><path/></svg>\r\n",
sourceAfter: '<svg><path fill="red"/></svg>\r\n',
});
history.commit(transaction);
expect(history.undo(transaction.sourceAfter)?.sourceBefore).toBe(
transaction.sourceBefore,
);
expect(history.redo(transaction.sourceBefore)?.sourceAfter).toBe(
transaction.sourceAfter,
);
});
it("coalesces rapid source-editor transactions with a shared merge key", () => {
vi.spyOn(Date, "now").mockReturnValueOnce(100).mockReturnValueOnce(200);
const history = new CommandHistory();
history.commit(
createTransaction({
label: "Type",
baseRevision: 0,
sourceBefore: "a",
sourceAfter: "ab",
mergeKey: "typing",
}),
);
history.commit(
createTransaction({
label: "Type",
baseRevision: 1,
sourceBefore: "ab",
sourceAfter: "abc",
mergeKey: "typing",
}),
);
expect(history.snapshot.past).toHaveLength(1);
expect(history.snapshot.past[0]).toMatchObject({
sourceBefore: "a",
sourceAfter: "abc",
});
});
it("rejects stale undo and bounds history", () => {
const history = new CommandHistory(2, 10_000);
history.commit(
createTransaction({
label: "one",
baseRevision: 0,
sourceBefore: "a",
sourceAfter: "b",
}),
);
expect(() => history.undo("not-b")).toThrow(/stale/u);
history.commit(
createTransaction({
label: "two",
baseRevision: 1,
sourceBefore: "b",
sourceAfter: "c",
}),
);
history.commit(
createTransaction({
label: "three",
baseRevision: 2,
sourceBefore: "c",
sourceAfter: "d",
}),
);
expect(history.snapshot.past).toHaveLength(2);
});
});
+112
View File
@@ -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);
});
});
+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" }),
);
});
});
+92
View File
@@ -0,0 +1,92 @@
import fc from "fast-check";
import { describe, expect, it } from "vitest";
import {
IDENTITY,
applyToPoint,
composeTransformList,
decomposeCanonical,
diagnoseTransform,
invert,
matrixNearlyEqual,
multiply,
parseTransformList,
recomposeCanonical,
rotation,
scaling,
translation,
} from "../../src/domain/affine";
describe("SVG affine transforms", () => {
it("parses and composes source order according to SVG matrix semantics", () => {
const matrix = composeTransformList(
parseTransformList("translate(10 20) scale(2)"),
);
expect(matrix).toEqual({ a: 2, b: 0, c: 0, d: 2, e: 10, f: 20 });
expect(applyToPoint(matrix, { x: 1, y: 1 })).toEqual({ x: 12, y: 22 });
});
it("inverts and canonically decomposes a reflected shear", () => {
const matrix = composeTransformList(
parseTransformList("translate(8 -2) rotate(23) scale(3 -2) skewX(14)"),
);
expect(matrixNearlyEqual(multiply(matrix, invert(matrix)!), IDENTITY)).toBe(
true,
);
const decomposition = decomposeCanonical(matrix);
expect(decomposition.reflected).toBe(true);
expect(matrixNearlyEqual(recomposeCanonical(decomposition), matrix)).toBe(
true,
);
expect(decomposition.residual).toBeLessThan(1e-10);
});
it("rejects malformed arities and diagnoses singular matrices", () => {
expect(() => parseTransformList("rotate(10 20)")).toThrow(
/expects 1 or 3/u,
);
expect(diagnoseTransform("scale(1 0)").diagnostics).toContainEqual(
expect.objectContaining({
code: "singular-transform",
severity: "error",
}),
);
});
it("round-trips bounded points through generated nonsingular transform chains", () => {
const coordinate = fc
.integer({ min: -10_000, max: 10_000 })
.map((value) => value / 10);
const nonzeroScale = fc
.integer({ min: -100, max: 100 })
.filter((value) => value !== 0)
.map((value) => value / 10);
fc.assert(
fc.property(
coordinate,
coordinate,
fc.integer({ min: -720, max: 720 }),
nonzeroScale,
nonzeroScale,
coordinate,
coordinate,
(tx, ty, angle, sx, sy, x, y) => {
const matrix = multiply(
translation(tx, ty),
multiply(rotation(angle), scaling(sx, sy)),
);
const inverse = invert(matrix);
if (!inverse) return false;
const recovered = applyToPoint(
inverse,
applyToPoint(matrix, { x, y }),
);
return (
Math.abs(recovered.x - x) < 1e-8 && Math.abs(recovered.y - y) < 1e-8
);
},
),
{ numRuns: 250 },
);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import { applySourcePatches } from "../../src/document/source-patcher";
import { parseSvgSource } from "../../src/document/source-parser";
import {
composeTransformList,
parseTransformList,
} from "../../src/domain/affine";
import { bakeElementTransform } from "../../src/domain/bake-transform";
function bake(source: string, id: string) {
const parsed = parseSvgSource(source, 1);
expect(parsed.semantic).not.toBeNull();
const semantic = parsed.semantic!;
const node = [...semantic.nodes.values()].find(
(candidate) => candidate.id === id,
)!;
const matrix = composeTransformList(
parseTransformList(node.attributes.transform ?? ""),
);
const result = bakeElementTransform(
source,
node,
matrix,
semantic.preferences,
);
return { result, source: applySourcePatches(source, result.patches) };
}
describe("element transform baking", () => {
it("retains lines, polygons, and safe rectangles as native elements", () => {
const line = bake(
'<svg xmlns="http://www.w3.org/2000/svg"><line id="shape" x1="1" y1="2" x2="3" y2="4" transform="translate(10 20)"/></svg>',
"shape",
);
expect(line.source).toContain(
'<line id="shape" x1="11" y1="22" x2="13" y2="24"',
);
expect(line.source).not.toContain("transform=");
expect(line.result.warnings).toEqual([]);
const polygon = bake(
'<svg xmlns="http://www.w3.org/2000/svg"><polygon id="shape" points="0,0 2,0 2,2" transform="scale(2 3)"/></svg>',
"shape",
);
expect(polygon.source).toContain('points="0,0 4,0 4,6"');
const rectangle = bake(
'<svg xmlns="http://www.w3.org/2000/svg"><rect id="shape" x="2" y="3" width="4" height="5" rx="1" transform="translate(1 2) scale(2 3)"/></svg>',
"shape",
);
expect(rectangle.result.convertedToPath).toBe(false);
expect(rectangle.source).toContain(
'<rect id="shape" x="5" y="11" width="8" height="15" rx="2"',
);
expect(rectangle.source).toContain('ry="3"');
});
it("converts general rectangles to paths and preserves ordinary attributes", () => {
const baked = bake(
'<svg xmlns="http://www.w3.org/2000/svg"><rect id="shape" class="important" x="0" y="0" width="10" height="5" rx="2" transform="rotate(30)"/></svg>',
"shape",
);
expect(baked.result).toMatchObject({
outputElement: "path",
convertedToPath: true,
});
expect(baked.source).toContain('<path id="shape" class="important"');
expect(baked.source).toContain(' d="M ');
expect(baked.source).not.toMatch(/\s(?:x|y|width|height|rx|transform)=/u);
});
it("retains uniform circles and converts axis-scaled circles to ellipses", () => {
const circle = bake(
'<svg xmlns="http://www.w3.org/2000/svg"><circle id="shape" cx="4" cy="5" r="3" transform="rotate(40) scale(2)"/></svg>',
"shape",
);
expect(circle.result.outputElement).toBe("circle");
expect(circle.source).toContain('r="6"');
const ellipse = bake(
'<svg xmlns="http://www.w3.org/2000/svg"><circle id="shape" cx="4" cy="5" r="3" transform="scale(2 4)"/></svg>',
"shape",
);
expect(ellipse.result.outputElement).toBe("ellipse");
expect(ellipse.source).toContain('<ellipse id="shape"');
expect(ellipse.source).toContain('rx="6"');
expect(ellipse.source).toContain('ry="12"');
expect(ellipse.source).not.toContain(' r="');
});
it("bakes paths with high precision and reports stroke consequences", () => {
const baked = bake(
'<svg xmlns="http://www.w3.org/2000/svg"><path id="shape" d="M0 0A10 4 20 0 1 20 0" stroke="red" transform="matrix(-2 .5 .25 3 4 8)"/></svg>',
"shape",
);
expect(baked.source).toContain('<path id="shape" d="M 4 8 A ');
expect(baked.source).not.toContain("transform=");
expect(baked.result.warnings).toContainEqual(
expect.stringContaining("stroke-width"),
);
});
it("refuses unsupported text baking instead of partially applying", () => {
const source =
'<svg xmlns="http://www.w3.org/2000/svg"><text id="shape" transform="rotate(2)">Text</text></svg>';
expect(() => bake(source, "shape")).toThrow(/not deterministic/u);
});
it("refuses non-SVG numeric spellings instead of coercing geometry", () => {
const source =
'<svg xmlns="http://www.w3.org/2000/svg"><rect id="shape" width="0x10" height="4" transform="translate(1)"/></svg>';
expect(() => bake(source, "shape")).toThrow(/finite width/u);
});
});
+168
View File
@@ -0,0 +1,168 @@
import { describe, expect, it } from "vitest";
import {
arcEndpointToCenter,
describePathCommand,
movePathHandle,
parsePathData,
pathHandles,
pointOnArc,
reversePath,
serializePathData,
splitSegment,
transformPath,
} from "../../src/domain/path";
describe("application-owned SVG path model", () => {
it("normalizes every command family, relatives, repeats and shorthand controls", () => {
const model = parsePathData(
"m 10 10 5 5 h 10 v 10 c 1 2 3 4 5 6 s 7 8 9 10 q 2 3 4 5 t 6 7 a 8 9 30 0 1 10 11 z",
);
expect(model.segments.map((segment) => segment.kind)).toEqual([
"M",
"L",
"L",
"L",
"C",
"C",
"Q",
"Q",
"A",
"Z",
]);
const firstCubic = model.segments[4];
const smoothCubic = model.segments[5];
expect(firstCubic?.kind).toBe("C");
expect(smoothCubic?.kind).toBe("C");
if (firstCubic?.kind === "C" && smoothCubic?.kind === "C") {
expect(smoothCubic.control1).toEqual({
x: 2 * smoothCubic.from.x - firstCubic.control2.x,
y: 2 * smoothCubic.from.y - firstCubic.control2.y,
});
expect(smoothCubic).toMatchObject({
derivedControl1: true,
sourceForm: { command: "s", relative: true },
});
const derived = pathHandles(model).find(
(handle) => handle.segmentIndex === 5 && handle.role === "control-1",
)!;
expect(derived.derived).toBe(true);
const explicit = movePathHandle(model, derived, {
x: derived.point.x + 1,
y: derived.point.y,
});
expect(explicit.segments[5]).toMatchObject({ derivedControl1: false });
}
expect(model.segments[7]).toMatchObject({
derivedControl: true,
sourceForm: { command: "t" },
});
expect(
model.segments.every(
({ sourceForm }) =>
sourceForm !== undefined &&
sourceForm.sourceRange.to > sourceForm.sourceRange.from,
),
).toBe(true);
});
it("supports compact numbers, exponent notation and packed arc flags", () => {
const model = parsePathData("M.5.6L10-5e-1A5 6 0 0110 20");
expect(model.segments).toHaveLength(3);
expect(model.segments[0]).toMatchObject({ to: { x: 0.5, y: 0.6 } });
expect(model.segments[1]).toMatchObject({ to: { x: 10, y: -0.5 } });
expect(model.segments[2]).toMatchObject({
kind: "A",
largeArc: false,
sweep: true,
to: { x: 10, y: 20 },
});
});
it("round-trips normalized geometry and rejects malformed data", () => {
const model = parsePathData(
"M0 0 C1 2 3 4 5 6 Q7 8 9 10 A4 3 20 1 0 12 13 Z",
);
expect(serializePathData(parsePathData(serializePathData(model)))).toBe(
serializePathData(model),
);
expect(() => parsePathData("L 1 2")).toThrow(/begin with a moveto/u);
expect(() => parsePathData("M 0 0 A 5 5 0 2 0 10 10")).toThrow(
/flag must be 0 or 1/u,
);
expect(() => parsePathData("M 0,")).toThrow(/comma/iu);
expect(() => parsePathData("M0 0 1 1 2 2", 2)).toThrow(/segment limit/u);
});
it("splits cubic geometry with de Casteljau and reverses arc sweep", () => {
const cubic = parsePathData("M0 0 C10 0 10 10 20 10");
const split = splitSegment(cubic, 1, 0.5);
expect(split.segments).toHaveLength(3);
expect(split.segments[1]).toMatchObject({ kind: "C", to: { x: 10, y: 5 } });
expect(split.segments[2]).toMatchObject({
kind: "C",
from: { x: 10, y: 5 },
});
const arc = parsePathData("M0 0 A10 5 20 0 1 20 0");
const reversed = reversePath(arc);
expect(reversed.segments[1]).toMatchObject({ kind: "A", sweep: false });
expect(serializePathData(reversePath(reversed))).toBe(
serializePathData(arc),
);
});
it("derives arc center/radius controls and moves path handles", () => {
const model = parsePathData("M0 0 A4 3 30 0 1 12 0");
const segment = model.segments[1];
expect(segment?.kind).toBe("A");
if (segment?.kind !== "A") return;
const center = arcEndpointToCenter(segment)!;
expect(pointOnArc(center, center.startAngle).x).toBeCloseTo(segment.from.x);
const anchor = pathHandles(model).find(
(handle) => handle.role === "anchor" && handle.segmentIndex === 1,
)!;
expect(
movePathHandle(model, anchor, { x: 14, y: 2 }).segments[1],
).toMatchObject({ to: { x: 14, y: 2 } });
});
it("bakes nonsingular affine matrices and flips arc sweep under reflection", () => {
const model = parsePathData("M0 0 A10 5 20 0 1 20 0");
const transformed = transformPath(model, {
a: -2,
b: 0.5,
c: 0.25,
d: 3,
e: 4,
f: 8,
});
expect(transformed.segments[1]).toMatchObject({ kind: "A", sweep: false });
expect(() =>
transformPath(model, { a: 1, b: 0, c: 0, d: 0, e: 0, f: 0 }),
).toThrow(/singular/u);
});
it("describes source form, endpoints, controls, derived shorthand and arc flags", () => {
const source = "m 1 2 3 4 s 5 6 7 8 t 9 10 a 11 12 30 1 0 13 14";
const segments = parsePathData(source).segments;
expect(describePathCommand(segments[1]!, source)).toMatchObject({
sourceCommand: "m",
normalizedCommand: "L",
sourceFragment: "3 4",
form: "relative · implicit repeat",
endpoint: "4, 6",
});
expect(describePathCommand(segments[2]!, source).details).toEqual([
{ label: "C1", value: "4, 6", derived: true },
{ label: "C2", value: "9, 12", derived: false },
]);
expect(describePathCommand(segments[3]!, source).details[0]).toMatchObject({
label: "C",
derived: true,
});
expect(describePathCommand(segments[4]!, source).details).toEqual([
{ label: "Radii", value: "11 × 12", derived: false },
{ label: "Rotation", value: "30°", derived: false },
{ label: "Flags", value: "large 1 · sweep 0", derived: false },
]);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { parseSvgSource } from "../../src/document/source-parser";
import { applyToPoint } from "../../src/domain/affine";
import { resolveTransformChain } from "../../src/domain/transform-chain";
function semantic(source: string) {
const result = parseSvgSource(source, 1);
expect(result.semantic).not.toBeNull();
return result.semantic!;
}
describe("ancestor transform chains", () => {
it("composes root, ancestor and local transforms in SVG order", () => {
const document = semantic(
'<svg xmlns="http://www.w3.org/2000/svg" transform="translate(2 3)"><g id="parent" transform="rotate(90)"><path id="child" transform="scale(2 3)" d="M0 0L1 1"/></g></svg>',
);
const child = [...document.nodes.values()].find(
(node) => node.id === "child",
)!;
const chain = resolveTransformChain(document, child.key);
expect(chain.entries).toHaveLength(3);
expect(applyToPoint(chain.matrix, { x: 1, y: 1 })).toMatchObject({
x: -1,
y: 5,
});
const restored = applyToPoint(
chain.inverse!,
applyToPoint(chain.matrix, { x: 7, y: -4 }),
);
expect(restored.x).toBeCloseTo(7);
expect(restored.y).toBeCloseTo(-4);
});
it("refuses deterministic coordinate editing through singular or CSS transforms", () => {
const document = semantic(
'<svg xmlns="http://www.w3.org/2000/svg"><g transform="scale(1 0)"><path id="p" style="transform: rotate(2deg)" d="M0 0L1 1"/></g></svg>',
);
const path = [...document.nodes.values()].find((node) => node.id === "p")!;
const chain = resolveTransformChain(document, path.key);
expect(chain.inverse).toBeNull();
expect(chain.diagnostics).toContainEqual(
expect.stringContaining("CSS transforms"),
);
});
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { parseSvgSource } from "../../src/document/source-parser";
import {
createSelectedSvgSource,
createSymbolSpriteSource,
} from "../../src/export/derived-svg-export";
function semantic(source: string) {
const parsed = parseSvgSource(source, 1);
if (!parsed.semantic) throw new Error("Fixture did not parse");
return parsed.semantic;
}
describe("derived SVG exports", () => {
it("retains selected ancestry and definitions while using a safe projection", () => {
const document = semantic(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<defs><linearGradient id="paint"><stop offset="1"/></linearGradient></defs>
<g transform="translate(2 3)"><path id="keep" d="M0 0L1 1" fill="url(#paint)"/></g>
<circle id="remove" r="4"/>
<script>alert(1)</script>
</svg>`);
const selected = document.order.find(
(key) => document.nodes.get(key)?.id === "keep",
)!;
const result = createSelectedSvgSource(document, [selected]);
expect(result.source).toContain("translate(2 3)");
expect(result.source).toContain("linearGradient");
expect(result.source).toContain('id="keep"');
expect(result.source).not.toContain('id="remove"');
expect(result.source).not.toContain("script");
expect(result.source).not.toContain("data-svg-tools-node");
});
it("exports existing symbols and shared definitions as a sprite", () => {
const document = semantic(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs>
<linearGradient id="paint"><stop offset="1"/></linearGradient>
<symbol id="mark" viewBox="0 0 4 4"><path d="M0 0L4 4"/></symbol>
</defs>
</svg>`);
const result = createSymbolSpriteSource(document, []);
expect(result.source).toContain('<symbol id="mark"');
expect(result.source).toContain("linearGradient");
expect(result.source.match(/<symbol/gu)).toHaveLength(1);
expect(result.source).not.toContain("data-svg-tools-node");
});
it("wraps a selection in a symbol when the source has no symbols", () => {
const document = semantic(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">
<path id="arrow" d="M0 5L10 5"/>
</svg>`);
const selected = document.order.find(
(key) => document.nodes.get(key)?.id === "arrow",
)!;
const result = createSymbolSpriteSource(document, [selected]);
expect(result.source).toContain('id="symbol-arrow"');
expect(result.source).toContain('id="arrow"');
});
});
+120
View File
@@ -0,0 +1,120 @@
import { gzipSync, gunzipSync, strFromU8 } from "fflate";
import { describe, expect, it } from "vitest";
import { exportFileName, sanitizeFileName } from "../../src/export/file-name";
import { resolveRasterSize } from "../../src/export/raster-export";
import {
createSvgExport,
gunzipWithLimit,
readSvgFile,
} from "../../src/export/svg-export";
const source =
'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 200 100">\r\n <text>ä😀</text>\r\n</svg>\r\n';
describe("SVG, SVGZ and filename export", () => {
it("exports exact UTF-8 SVG bytes without formatting or editor metadata", () => {
const artifact = createSvgExport(source, "svg", "../CON: diagram.svgz");
expect(new TextDecoder().decode(artifact.bytes)).toBe(source);
expect(artifact.fileName).toBe("CON- diagram.svg");
expect(artifact.blob.type).toBe("image/svg+xml");
});
it("creates deterministic SVGZ and decompresses to the exact source", () => {
const first = createSvgExport(source, "svgz", "drawing.svg");
const second = createSvgExport(source, "svgz", "drawing.svg");
expect(first.bytes).toEqual(second.bytes);
expect(strFromU8(gunzipSync(first.bytes))).toBe(source);
expect(first.fileName).toBe("drawing.svgz");
expect(first.blob.type).toBe("application/gzip");
});
it("reads plain SVG and magic-byte SVGZ locally", async () => {
const plain = await readSvgFile(
new File([source], "drawing.svg", { type: "image/svg+xml" }),
);
expect(plain.source).toBe(source);
const compressed = createSvgExport(source, "svgz").bytes;
const zipped = await readSvgFile(
new File([compressed.buffer as ArrayBuffer], "mystery.bin"),
);
expect(zipped.source).toBe(source);
});
it("stops SVGZ expansion at the configured output boundary", () => {
const compressed = gzipSync(new TextEncoder().encode("x".repeat(512)));
expect(() => gunzipWithLimit(compressed, 64)).toThrow(/processing limit/u);
});
it("rejects malformed XML and non-SVG roots", () => {
expect(() => createSvgExport("<svg><g", "svg")).toThrow(/well-formed XML/u);
expect(() =>
createSvgExport('<root xmlns="http://www.w3.org/2000/svg"/>', "svg"),
).toThrow(/root must be an SVG/u);
});
it("normalizes dangerous names and preserves the requested extension", () => {
expect(sanitizeFileName("../../\u202eaux:<x>?*.svg")).not.toMatch(
/[<>:"/\\|?*\u202e]/u,
);
expect(exportFileName("image.jpeg", "webp")).toBe("image.webp");
expect(exportFileName("diagram.svgtools.json", "project")).toBe(
"diagram.svgtools.json",
);
expect(
new TextEncoder().encode(exportFileName("😀".repeat(100), "project"))
.byteLength,
).toBeLessThanOrEqual(180);
});
});
describe("raster safety and sizing", () => {
it("preserves aspect ratio and applies scale after resolving size", () => {
expect(resolveRasterSize(source, { height: 300 })).toEqual({
width: 600,
height: 300,
aspectRatio: 2,
});
expect(resolveRasterSize(source, { scale: 2 })).toEqual({
width: 400,
height: 200,
aspectRatio: 2,
});
});
it("rejects active content and external resources at the raster boundary", () => {
expect(() =>
resolveRasterSize(
'<svg xmlns="http://www.w3.org/2000/svg"><script/></svg>',
),
).toThrow(/blocked/u);
expect(() =>
resolveRasterSize(
'<svg xmlns="http://www.w3.org/2000/svg" onload="go()"/>',
),
).toThrow(/blocked/u);
expect(() =>
resolveRasterSize(
'<svg xmlns="http://www.w3.org/2000/svg"><image href="https://example.test/x.png"/></svg>',
),
).toThrow(/external resource/u);
expect(() =>
resolveRasterSize(
'<svg xmlns="http://www.w3.org/2000/svg"><rect fill="url(https://example.test/p)"/></svg>',
),
).toThrow(/external CSS/u);
});
it("allows local references and bounded image data in the sanitized projection", () => {
expect(() =>
resolveRasterSize(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><defs><linearGradient id="p"/></defs><rect fill="url(#p)"/><image href="data:image/png;base64,iVBORw0KGgo="/></svg>',
),
).not.toThrow();
});
it("enforces canvas safety limits", () => {
expect(() =>
resolveRasterSize(source, { width: 16_000, height: 16_000 }),
).toThrow(/canvas safety limit/u);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { lineDiff } from "../../src/format/diff";
import { formatSvgSource } from "../../src/format/formatter";
describe("explicit formatting and source diff", () => {
it("formats only when explicitly requested and retains CRLF style", () => {
const source =
'<svg xmlns="http://www.w3.org/2000/svg" z="2" a="1">\r\n<g><title>A &amp; B</title></g>\r\n</svg>';
const formatted = formatSvgSource(source);
expect(formatted).toContain("\r\n <g>\r\n <title>A &amp; B</title>");
expect(formatted).toContain(' a="1" z="2"');
expect(formatted.endsWith("\r\n")).toBe(true);
});
it("refuses to format malformed XML", () => {
expect(() => formatSvgSource("<svg><g")).toThrow(/well-formed/u);
});
it("produces stable line additions, removals and context", () => {
expect(lineDiff("a\nb\nc", "a\nx\nc")).toEqual([
{ kind: "same", text: "a", oldLine: 1, newLine: 1 },
{ kind: "add", text: "x", newLine: 2 },
{ kind: "remove", text: "b", oldLine: 2 },
{ kind: "same", text: "c", oldLine: 3, newLine: 3 },
]);
});
it("falls back to bounded summaries for large comparisons", () => {
expect(lineDiff("a\nb\nc", "d\ne\nf", 2)).toEqual([
{ kind: "remove", text: "3 lines (5 characters)", oldLine: 1 },
{ kind: "add", text: "3 lines (5 characters)", newLine: 1 },
]);
});
});
+129
View File
@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
OptimizationCancelledError,
OptimizerClient,
} from "../../src/optimization/optimizer-client";
import type {
OptimizationRequest,
OptimizationResult,
} from "../../src/optimization/optimization.types";
class TestWorker extends EventTarget {
static instances: TestWorker[] = [];
readonly messages: unknown[] = [];
terminateCount = 0;
constructor() {
super();
TestWorker.instances.push(this);
}
postMessage(message: unknown): void {
this.messages.push(message);
}
terminate(): void {
this.terminateCount += 1;
}
respond(response: OptimizationResult): void {
this.dispatchEvent(new MessageEvent("message", { data: response }));
}
}
function resultFor(worker: TestWorker, source = "<svg/>"): OptimizationResult {
const request = worker.messages[0] as OptimizationRequest;
return {
type: "result",
jobId: request.jobId,
source,
profile: request.profile,
optionalPlugins: request.optionalPlugins,
inputBytes: source.length,
outputBytes: source.length,
elapsedMs: 1,
};
}
describe("OptimizerClient cancellation", () => {
beforeEach(() => {
TestWorker.instances = [];
vi.useFakeTimers();
vi.stubGlobal("Worker", TestWorker);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("rejects a cancelled job immediately and cleans up exactly once", async () => {
const client = new OptimizerClient();
const pending = client.optimize("<svg/>", "conservative");
const rejected = expect(pending).rejects.toBeInstanceOf(
OptimizationCancelledError,
);
const worker = TestWorker.instances[0]!;
expect(vi.getTimerCount()).toBe(1);
client.cancel();
client.cancel();
await rejected;
expect(worker.terminateCount).toBe(1);
expect(vi.getTimerCount()).toBe(0);
worker.respond(resultFor(worker, '<svg id="late"/>'));
expect(worker.terminateCount).toBe(1);
});
it("promptly rejects a superseded job while the replacement can finish", async () => {
const client = new OptimizerClient();
const first = client.optimize('<svg id="first"/>', "conservative");
const firstRejected = expect(first).rejects.toBeInstanceOf(
OptimizationCancelledError,
);
const firstWorker = TestWorker.instances[0]!;
const second = client.optimize('<svg id="second"/>', "standard");
const secondWorker = TestWorker.instances[1]!;
await firstRejected;
expect(firstWorker.terminateCount).toBe(1);
expect(secondWorker.terminateCount).toBe(0);
expect(vi.getTimerCount()).toBe(1);
const expected = resultFor(secondWorker, '<svg id="optimized"/>');
secondWorker.respond(expected);
await expect(second).resolves.toEqual(expected);
expect(secondWorker.terminateCount).toBe(1);
expect(vi.getTimerCount()).toBe(0);
client.cancel();
expect(secondWorker.terminateCount).toBe(1);
});
it("uses the same race-safe cleanup for AbortSignal cancellation", async () => {
const controller = new AbortController();
const client = new OptimizerClient();
const pending = client.optimize(
"<svg/>",
"aggressive",
[],
controller.signal,
);
const rejected = expect(pending).rejects.toBeInstanceOf(
OptimizationCancelledError,
);
const worker = TestWorker.instances[0]!;
controller.abort();
await rejected;
expect(worker.terminateCount).toBe(1);
expect(vi.getTimerCount()).toBe(0);
client.cancel();
expect(worker.terminateCount).toBe(1);
});
});
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import {
configForProfile,
optimizationProfiles,
} from "../../src/optimization/profiles";
describe("explicit optimization profiles", () => {
it("exposes three clearly ordered risk levels", () => {
expect(optimizationProfiles.map((profile) => profile.id)).toEqual([
"conservative",
"standard",
"aggressive",
]);
expect(
optimizationProfiles.every(
(profile) => profile.description && profile.risk,
),
).toBe(true);
});
it("retains IDs and viewBox in every profile", () => {
for (const name of ["conservative", "standard", "aggressive"] as const) {
const config = configForProfile(name);
const preset = config.plugins?.find(
(plugin) =>
typeof plugin === "object" && plugin.name === "preset-default",
);
expect(preset).toMatchObject({
params: { overrides: { cleanupIds: false, removeViewBox: false } },
});
}
});
it("only enables multipass and structural rewrites in aggressive mode", () => {
expect(configForProfile("conservative").multipass).toBe(false);
expect(configForProfile("standard").multipass).toBe(false);
const aggressive = configForProfile("aggressive");
expect(aggressive.multipass).toBe(true);
expect(aggressive.plugins).toEqual(
expect.arrayContaining(["convertShapeToPath", "collapseGroups"]),
);
});
it("adds only explicitly allow-listed optional plugins", () => {
const config = configForProfile("conservative", [
"removeDimensions",
"reusePaths",
"removeDimensions",
]);
expect(config.plugins).toEqual(
expect.arrayContaining(["removeDimensions", "reusePaths"]),
);
expect(
config.plugins?.filter((plugin) => plugin === "removeDimensions"),
).toHaveLength(1);
expect(() =>
configForProfile("standard", ["unknown" as "reusePaths"]),
).toThrow("Unsupported optional SVGO plugin");
});
});
+165
View File
@@ -0,0 +1,165 @@
import { describe, expect, it } from "vitest";
import {
PROJECT_FORMAT,
PROJECT_SCHEMA_VERSION,
ProjectFormatError,
createProject,
parseProject,
readProject,
serializeProject,
validateProject,
} from "../../src/project/project-format";
function project(
source = '<svg xmlns="http://www.w3.org/2000/svg">\r\n <path d="M0 0"/>\r\n</svg>',
) {
return createProject({
appVersion: "0.1.0",
document: { source },
ui: {
selectedNodeKey: "id:path",
expandedNodeKeys: ["id:root"],
activePanel: "path",
zoom: 1.25,
pan: { x: 12, y: -4 },
showGrid: true,
sourceSelection: { anchor: 1, head: 4 },
},
animations: [
{
id: "fade",
name: "Fade",
targetNodeKey: "id:path",
property: "opacity",
kind: "style",
enabled: true,
keyframes: [
{ offset: 0, value: "0" },
{ offset: 1, value: "1", easing: "linear" },
],
timing: {
durationMs: 750,
delayMs: 0,
iterations: 1,
direction: "normal",
fillMode: "both",
easing: "ease-in-out",
},
},
],
metadata: {
title: "Exact source",
originalFileName: "drawing.svg",
createdAt: "2026-07-31T00:00:00.000Z",
updatedAt: "2026-07-31T00:00:00.000Z",
},
});
}
describe("SVG Tools project format", () => {
it("round-trips canonical source byte-for-byte, including temporarily invalid XML", () => {
const source =
'<svg xmlns="http://www.w3.org/2000/svg">\r\n <g data-x="&amp;">\r\n';
const original = project(source);
const restored = parseProject(serializeProject(original));
expect(restored.document.source).toBe(source);
expect(restored).toEqual(original);
});
it("serializes deterministically with the versioned identity", () => {
const value = project();
const first = serializeProject(value);
expect(serializeProject(value)).toBe(first);
expect(first.endsWith("\n")).toBe(true);
expect(JSON.parse(first)).toMatchObject({
format: PROJECT_FORMAT,
schemaVersion: PROJECT_SCHEMA_VERSION,
appVersion: "0.1.0",
});
});
it("accepts a UTF-8 BOM and rejects incompatible schemas", () => {
const serialized = serializeProject(project());
expect(parseProject(`\ufeff${serialized}`).document.source).toContain(
"<svg",
);
const unsupported = JSON.parse(serialized) as Record<string, unknown>;
unsupported.schemaVersion = 999;
expect(() => parseProject(JSON.stringify(unsupported))).toThrowError(
expect.objectContaining({ code: "UNSUPPORTED_SCHEMA_VERSION" }),
);
});
it("rejects invalid projects instead of silently repairing them", () => {
const invalid = JSON.parse(serializeProject(project())) as {
animations: Array<{ keyframes: Array<{ offset: number }> }>;
ui: { expandedNodeKeys: string[] };
};
invalid.animations[0]!.keyframes[0]!.offset = 1;
invalid.animations[0]!.keyframes[1]!.offset = 0;
expect(() => parseProject(JSON.stringify(invalid))).toThrowError(
ProjectFormatError,
);
invalid.animations[0]!.keyframes[0]!.offset = 0;
invalid.animations[0]!.keyframes[1]!.offset = 1;
invalid.ui.expandedNodeKeys = ["same", "same"];
expect(() => parseProject(JSON.stringify(invalid))).toThrow(
/Duplicate values/u,
);
expect(() => parseProject("not json")).toThrowError(
expect.objectContaining({ code: "INVALID_JSON" }),
);
});
it("applies the animation CSS policy to imported project definitions", () => {
type ImportedProject = {
animations: Array<{
property: string;
keyframes: Array<{ offset: number; value: string; easing?: string }>;
timing: {
durationMs: number;
delayMs: number;
iterations: number | "infinite";
easing: string;
};
}>;
};
const original = JSON.parse(serializeProject(project())) as ImportedProject;
const expectInvalid = (mutate: (input: ImportedProject) => void) => {
const input = structuredClone(original);
mutate(input);
expect(() => validateProject(input)).toThrowError(
expect.objectContaining({ code: "INVALID_PROJECT" }),
);
};
expectInvalid((input) => {
input.animations[0]!.property = "opacity; stroke: red";
});
expectInvalid((input) => {
input.animations[0]!.keyframes[0]!.value =
"0</style><script>alert(1)</script>";
});
expectInvalid((input) => {
input.animations[0]!.timing.easing = "linear; stroke: red";
});
expectInvalid((input) => {
input.animations[0]!.keyframes[0]!.offset = Number.NaN;
});
expectInvalid((input) => {
input.animations[0]!.timing.durationMs = Number.POSITIVE_INFINITY;
});
expectInvalid((input) => {
input.animations[0]!.timing.iterations = Number.POSITIVE_INFINITY;
});
});
it("rejects invalid UTF-8 project bytes", async () => {
await expect(
readProject(new Blob([Uint8Array.of(0xc3, 0x28)])),
).rejects.toMatchObject({
code: "INVALID_UTF8",
});
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { parseSvgSource } from "../../src/document/source-parser";
import {
createEditingProjection,
inspectSvgSecurity,
} from "../../src/security/sanitize-svg";
function parse(source: string) {
const result = parseSvgSource(source, 1);
expect(result.semantic).not.toBeNull();
return result.semantic!;
}
describe("application-owned adversarial SVG policy", () => {
it("allows only references that resolve to one unique local ID", () => {
const semantic = parse(`<svg xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="unique"/><linearGradient id="duplicate"/><linearGradient id="duplicate"/></defs>
<rect id="ok" fill="url(#unique)"/>
<rect id="missing" fill="url(#absent)"/>
<rect id="ambiguous" fill="url(#duplicate)"/>
</svg>`);
const projection = createEditingProjection(semantic).source;
expect(projection).toContain('id="ok" fill="url(#unique)"');
expect(projection).not.toContain("url(#absent)");
expect(projection).not.toContain('id="ambiguous" fill=');
});
it("parses CSS and removes imports, external URLs and behavior-like properties", () => {
const semantic = parse(`<svg xmlns="http://www.w3.org/2000/svg">
<style>@import "https://evil.example/theme.css"; rect { fill: red }</style>
<rect id="external" style="fill: url(https://evil.example/a); stroke: red"/>
<circle id="behavior" style="behavior: url(#x)"/>
</svg>`);
const findings = inspectSvgSecurity(semantic);
expect(findings.map((item) => item.code)).toEqual(
expect.arrayContaining(["unsafe-stylesheet", "unsafe-inline-css"]),
);
const projection = createEditingProjection(semantic).source;
expect(projection).not.toContain("evil.example");
expect(projection).not.toContain("behavior:");
expect(projection).not.toContain("<style");
});
it("keeps the normal editing canvas static by stripping source animations", () => {
const semantic = parse(`<svg xmlns="http://www.w3.org/2000/svg">
<circle id="dot" r="2"><animate attributeName="opacity" values="0;1" dur="1s"/></circle>
<set attributeName="fill" to="red" begin="click"/>
</svg>`);
const findings = inspectSvgSecurity(semantic);
expect(findings.map((item) => item.code)).toEqual(
expect.arrayContaining(["blocked-animate", "blocked-set"]),
);
const projection = createEditingProjection(semantic).source;
expect(projection).not.toMatch(/<animate|<set\b/iu);
expect(semantic.source).toContain("<animate");
});
it("neutralizes executable schemes, external use targets and non-SVG element namespaces", () => {
const semantic =
parse(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:x="urn:active">
<a href="javascript:alert(1)"><rect width="1" height="1"/></a>
<use href="https://evil.example/icons.svg#mark"/>
<x:widget x:run="yes"/>
<image href="data:text/html;base64,PHNjcmlwdD4="/>
</svg>`);
const projection = createEditingProjection(semantic).source;
expect(projection).not.toMatch(
/javascript:|evil\.example|data:text\/html|x:widget/iu,
);
expect(semantic.source).toContain("javascript:");
});
});
+148
View File
@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import { parseSvgSource } from "../../src/document/source-parser";
import {
createEditingProjection,
createSanitizedCandidate,
inspectSvgSecurity,
} from "../../src/security/sanitize-svg";
function parse(source: string) {
const result = parseSvgSource(source, 1);
expect(result.semantic).not.toBeNull();
return result.semantic!;
}
describe("hostile SVG editing projection", () => {
it("preserves active content in canonical source but removes it from projection", () => {
const semantic =
parse(`<svg xmlns="http://www.w3.org/2000/svg" onload="steal()">
<script>alert(1)</script>
<foreignObject><div xmlns="http://www.w3.org/1999/xhtml">HTML</div></foreignObject>
<a href="https://evil.example/"><rect width="10" height="10"/></a>
<image href="https://evil.example/tracker.png"/>
<path style="fill:url(https://evil.example/paint)" d="M0 0L1 1"/>
</svg>`);
const findings = inspectSvgSecurity(semantic);
const codes = findings.map((item) => item.code);
expect(codes).toEqual(
expect.arrayContaining([
"event-handler",
"blocked-script",
"blocked-foreignobject",
"unsafe-url",
]),
);
expect(codes.some((code) => code.startsWith("unsafe-inline-css"))).toBe(
true,
);
expect(semantic.source).toContain("steal()");
const projection = createEditingProjection(semantic);
expect(projection.source).not.toMatch(
/<script|foreignObject|steal\(\)|evil\.example/iu,
);
expect(projection.source).toContain("data-svg-tools-node");
});
it("allows local fragment paint and bounded embedded raster data", () => {
const semantic = parse(
`<svg xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="p"/></defs><rect fill="url(#p)"/><image href="data:image/png;base64,iVBORw0KGgo="/></svg>`,
);
const projection = createEditingProjection(semantic);
expect(projection.source).toContain("url(#p)");
expect(projection.source).toContain("data:image/png;base64");
});
it("removes internal mapping metadata from explicit sanitized source", () => {
const semantic = parse(
'<svg xmlns="http://www.w3.org/2000/svg"><circle id="c" r="2"/></svg>',
);
const candidate = createSanitizedCandidate(semantic);
expect(candidate.source).not.toContain("data-svg-tools-node");
expect(candidate.source).toContain('id="c"');
});
it("parses CSS and removes imports, external URLs and behavior-like properties", () => {
const semantic = parse(`<svg xmlns="http://www.w3.org/2000/svg">
<style>@import/**/url(https://evil.example/a.css); .x { fill: red }</style>
<path class="x" style="-moz-binding:url(https://evil.example/x);fill:red" d="M0 0L1 1"/>
</svg>`);
const findings = inspectSvgSecurity(semantic);
expect(
findings.some((item) => item.code.startsWith("unsafe-stylesheet")),
).toBe(true);
expect(
findings.some((item) => item.code.startsWith("unsafe-inline-css")),
).toBe(true);
const projection = createEditingProjection(semantic);
expect(projection.source).not.toContain("evil.example");
expect(projection.source).not.toContain("-moz-binding");
});
it("blocks CSS resource functions even when a URL is represented as a string", () => {
const semantic = parse(
'<svg xmlns="http://www.w3.org/2000/svg"><style>.x { background-image: image-set("tracker.png" 1x) }</style><rect class="x" width="2" height="2"/></svg>',
);
const findings = inspectSvgSecurity(semantic);
expect(findings).toContainEqual(
expect.objectContaining({
code: "unsafe-stylesheet",
description: expect.stringContaining("image-set()"),
}),
);
expect(createEditingProjection(semantic).source).not.toContain(
"tracker.png",
);
});
it("allows only references that resolve to one unique local ID", () => {
const semantic = parse(`<svg xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="unique"/><linearGradient id="duplicate"/><linearGradient id="duplicate"/></defs>
<rect id="ok" fill="url(#unique)"/>
<rect id="missing" fill="url(#absent)"/>
<rect id="ambiguous" fill="url(#duplicate)"/>
</svg>`);
const projection = createEditingProjection(semantic).source;
const document = new DOMParser().parseFromString(
projection,
"image/svg+xml",
);
expect(document.getElementById("ok")?.getAttribute("fill")).toBe(
"url(#unique)",
);
expect(document.getElementById("missing")?.hasAttribute("fill")).toBe(
false,
);
expect(document.getElementById("ambiguous")?.hasAttribute("fill")).toBe(
false,
);
});
it("keeps the normal canvas static by removing declarative animation elements", () => {
const semantic = parse(`<svg xmlns="http://www.w3.org/2000/svg">
<circle id="dot" r="4"><animate attributeName="opacity" values="0;1" begin="click"/></circle>
<set attributeName="fill" to="red"/>
</svg>`);
const findings = inspectSvgSecurity(semantic);
expect(findings.map((item) => item.code)).toEqual(
expect.arrayContaining(["blocked-animate", "blocked-set"]),
);
const projection = createEditingProjection(semantic).source;
expect(projection).not.toMatch(/<animate|<set/iu);
expect(projection).toContain('id="dot"');
});
it("reports and removes elements in unknown namespaces", () => {
const semantic =
parse(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:evil="urn:evil">
<evil:widget id="active"><circle r="2"/></evil:widget>
</svg>`);
expect(inspectSvgSecurity(semantic)).toContainEqual(
expect.objectContaining({
code: "unknown-namespace",
editingProjectionAction: "removed",
}),
);
expect(createEditingProjection(semantic).source).not.toContain("widget");
});
});
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import {
accessibilityFixPatch,
auditAccessibility,
} from "../../src/accessibility/audit";
import { parseSvgSource } from "../../src/document/source-parser";
import { applySourcePatches } from "../../src/document/source-patcher";
import {
buildReferenceIndex,
previewIdRename,
} from "../../src/structure/reference-index";
function semantic(source: string) {
return parseSvgSource(source, 1).semantic!;
}
describe("references and accessibility", () => {
it("indexes resolved, missing and cyclic local references", () => {
const document = semantic(
`<svg xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="paint" href="#paint"/></defs><rect id="shape" fill="url(#paint)" aria-labelledby="missing"/></svg>`,
);
const index = buildReferenceIndex(document);
expect(index.edges).toEqual(
expect.arrayContaining([
expect.objectContaining({ targetId: "paint", status: "cyclic" }),
expect.objectContaining({ targetId: "missing", status: "missing" }),
]),
);
});
it("marks cycle members without mislabelling an upstream reference", () => {
const document = semantic(
'<svg xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="upstream" href="#left"/><linearGradient id="left" href="#right"/><linearGradient id="right" href="#left"/></defs></svg>',
);
const edges = buildReferenceIndex(document).edges;
expect(
edges.find(
(edge) =>
edge.sourceKey === "id:upstream" &&
edge.targetId === "left" &&
edge.status === "resolved",
),
).toBeDefined();
expect(
edges
.filter((edge) => edge.status === "cyclic")
.map((edge) => edge.targetId),
).toEqual(expect.arrayContaining(["left", "right"]));
});
it("renames an ID and its typed references without unsafe global replacement", () => {
const source =
'<svg xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="old"/><linearGradient id="oldish"/></defs><rect fill="url( #old )" stroke="url(#oldish)" aria-describedby="old oldish old"/></svg>';
const document = semantic(source);
const target = [...document.nodes.values()].find(
(node) => node.id === "old",
)!;
const preview = previewIdRename(document, target.key, "newPaint");
const result = applySourcePatches(source, preview.patches);
expect(result).toContain('id="newPaint"');
expect(result).toContain("url( #newPaint )");
expect(result).toContain("url(#oldish)");
expect(result).toContain('aria-describedby="newPaint oldish newPaint"');
});
it("reports missing root naming and provides an exact title patch", () => {
const source =
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="4" height="4"/></svg>';
const document = semantic(source);
const findings = auditAccessibility(document);
expect(findings).toContainEqual(
expect.objectContaining({
rule: "svg-accessible-name",
automaticFix: "add-title",
}),
);
const fixed = applySourcePatches(source, [
accessibilityFixPatch(document, "add-title", "Small square"),
]);
expect(fixed).toContain("<title>Small square</title>");
});
});