feat: introduce local-first SVG workbench
This commit is contained in:
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 & B</title></g>\r\n</svg>';
|
||||
const formatted = formatSvgSource(source);
|
||||
expect(formatted).toContain("\r\n <g>\r\n <title>A & 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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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="&">\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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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:");
|
||||
});
|
||||
});
|
||||
@@ -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>");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user