feat: introduce local-first SVG workbench
This commit is contained in:
@@ -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