Files
svg-tools/tests/export/export.test.ts
T

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