121 lines
4.4 KiB
TypeScript
121 lines
4.4 KiB
TypeScript
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);
|
|
});
|
|
});
|