feat: introduce local-first SVG workbench
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user