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
+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);
});
});