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
+184
View File
@@ -0,0 +1,184 @@
import type { SemanticSvgDocument } from "../document/document.types";
import { createEditingProjection } from "../security/sanitize-svg";
import { assertExportableSvg } from "./svg-export";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const INFRASTRUCTURE_ELEMENTS = new Set(["defs", "style"]);
export interface DerivedSvgExport {
source: string;
warnings: string[];
}
function ancestorsOf(semantic: SemanticSvgDocument, key: string): string[] {
const ancestors: string[] = [];
let current = semantic.nodes.get(key)?.parentKey ?? null;
while (current) {
ancestors.push(current);
current = semantic.nodes.get(current)?.parentKey ?? null;
}
return ancestors;
}
function descendantsOf(semantic: SemanticSvgDocument, key: string): string[] {
const descendants: string[] = [];
const pending = [...(semantic.nodes.get(key)?.childKeys ?? [])];
while (pending.length > 0) {
const current = pending.pop()!;
descendants.push(current);
pending.push(...(semantic.nodes.get(current)?.childKeys ?? []));
}
return descendants;
}
function projectionDocument(semantic: SemanticSvgDocument): XMLDocument {
const projection = createEditingProjection(semantic);
const document = new DOMParser().parseFromString(
projection.source,
"image/svg+xml",
);
if (document.documentElement.localName !== "svg") {
throw new Error("The sanitized projection did not produce an SVG document");
}
return document;
}
function stripEditorMetadata(root: Element): void {
root.removeAttribute("data-svg-tools-node");
for (const element of Array.from(
root.querySelectorAll("[data-svg-tools-node]"),
)) {
element.removeAttribute("data-svg-tools-node");
}
}
function serialize(root: Element): string {
stripEditorMetadata(root);
const source = new XMLSerializer().serializeToString(root);
assertExportableSvg(source);
return source;
}
function selectedKeysThatExist(
semantic: SemanticSvgDocument,
selectedKeys: readonly string[],
): string[] {
return [...new Set(selectedKeys)].filter((key) => semantic.nodes.has(key));
}
export function createSelectedSvgSource(
semantic: SemanticSvgDocument,
selectedKeys: readonly string[],
): DerivedSvgExport {
const selected = selectedKeysThatExist(semantic, selectedKeys);
if (selected.length === 0)
throw new Error("Select at least one SVG element to export");
const keep = new Set<string>([semantic.rootKey]);
for (const key of selected) {
keep.add(key);
for (const ancestor of ancestorsOf(semantic, key)) keep.add(ancestor);
for (const descendant of descendantsOf(semantic, key)) keep.add(descendant);
}
for (const key of semantic.order) {
const node = semantic.nodes.get(key)!;
if (!INFRASTRUCTURE_ELEMENTS.has(node.localName)) continue;
keep.add(key);
for (const ancestor of ancestorsOf(semantic, key)) keep.add(ancestor);
for (const descendant of descendantsOf(semantic, key)) keep.add(descendant);
}
const document = projectionDocument(semantic);
const byKey = new Map<string, Element>();
for (const element of [
document.documentElement,
...Array.from(document.documentElement.querySelectorAll("*")),
]) {
const key = element.getAttribute("data-svg-tools-node");
if (key) byKey.set(key, element);
}
for (const key of [...semantic.order].reverse()) {
if (keep.has(key)) continue;
byKey.get(key)?.remove();
}
return {
source: serialize(document.documentElement),
warnings: [
"Selected-object export uses the sanitized editing projection.",
"The original document viewport is retained; crop-to-selection is not applied.",
],
};
}
function copyRootViewport(from: Element, to: Element): void {
for (const name of ["viewBox", "width", "height", "preserveAspectRatio"]) {
const value = from.getAttribute(name);
if (value !== null) to.setAttribute(name, value);
}
}
function appendSharedDefinitions(
sourceRoot: Element,
targetRoot: Element,
): void {
const definitions = Array.from(sourceRoot.children).filter(
(element) => element.localName === "defs",
);
for (const definition of definitions) {
const clone = definition.cloneNode(true) as Element;
for (const symbol of Array.from(clone.querySelectorAll("symbol")))
symbol.remove();
if (clone.children.length > 0) targetRoot.append(clone);
}
}
export function createSymbolSpriteSource(
semantic: SemanticSvgDocument,
selectedKeys: readonly string[],
): DerivedSvgExport {
const document = projectionDocument(semantic);
const sourceRoot = document.documentElement;
const outputDocument = document.implementation.createDocument(
SVG_NAMESPACE,
"svg",
null,
);
const outputRoot = outputDocument.documentElement;
copyRootViewport(sourceRoot, outputRoot);
appendSharedDefinitions(sourceRoot, outputRoot);
const symbols = Array.from(sourceRoot.querySelectorAll("symbol"));
if (symbols.length > 0) {
for (const symbol of symbols)
outputRoot.append(outputDocument.importNode(symbol, true));
} else {
const selected = createSelectedSvgSource(semantic, selectedKeys);
const selectedDocument = new DOMParser().parseFromString(
selected.source,
"image/svg+xml",
);
const symbol = outputDocument.createElementNS(SVG_NAMESPACE, "symbol");
const firstSelected = selectedKeysThatExist(semantic, selectedKeys)[0]!;
const requestedId = semantic.nodes.get(firstSelected)?.id ?? "selection";
symbol.setAttribute(
"id",
`symbol-${requestedId.replace(/[^A-Za-z0-9_.:-]+/gu, "-")}`,
);
const viewBox = selectedDocument.documentElement.getAttribute("viewBox");
if (viewBox) symbol.setAttribute("viewBox", viewBox);
for (const child of Array.from(selectedDocument.documentElement.children)) {
if (child.localName === "defs") continue;
symbol.append(outputDocument.importNode(child, true));
}
outputRoot.append(symbol);
}
return {
source: serialize(outputRoot),
warnings: [
"Sprite export uses the sanitized editing projection.",
symbols.length > 0
? `${symbols.length} existing symbol element(s) were exported.`
: "No existing symbol was found; the current selection was wrapped in a symbol.",
],
};
}
+54
View File
@@ -0,0 +1,54 @@
export type ExportKind = "svg" | "svgz" | "project" | "png" | "jpeg" | "webp";
// eslint-disable-next-line no-control-regex -- all control characters are invalid in download names.
const UNSAFE = /[\u0000-\u001f\u007f<>:"/\\|?*\u202a-\u202e\u2066-\u2069]/gu;
const KNOWN_EXTENSION = /(?:\.svgtools\.json|\.svgz?|\.png|\.jpe?g|\.webp)$/iu;
const RESERVED = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu;
function truncate(value: string, maximumBytes: number): string {
const encoder = new TextEncoder();
let result = "";
for (const character of value) {
if (encoder.encode(result + character).byteLength > maximumBytes) break;
result += character;
}
return result;
}
export function sanitizeFileName(input: string, maximumBytes = 180): string {
let value = input
.normalize("NFKC")
.replace(UNSAFE, "-")
.replace(/\s+/gu, " ")
.replace(/-{2,}/gu, "-")
.replace(/^[. -]+|[. ]+$/gu, "");
if (!value || value === "." || value === "..") value = "drawing";
if (RESERVED.test(value)) value = `_${value}`;
return truncate(value, maximumBytes).replace(/[. ]+$/u, "") || "drawing";
}
export function exportFileName(
input: string | undefined,
kind: ExportKind,
): string {
const extension: Record<ExportKind, string> = {
svg: ".svg",
svgz: ".svgz",
project: ".svgtools.json",
png: ".png",
jpeg: ".jpg",
webp: ".webp",
};
const suffix = extension[kind];
return `${sanitizeFileName((input ?? "drawing").replace(KNOWN_EXTENSION, ""), 180 - suffix.length)}${suffix}`;
}
export function downloadBlob(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = sanitizeFileName(fileName);
anchor.rel = "noopener";
anchor.click();
globalThis.setTimeout(() => URL.revokeObjectURL(url), 0);
}
+223
View File
@@ -0,0 +1,223 @@
import { defaultSvgLimits } from "../app/limits";
import { exportFileName } from "./file-name";
export type RasterFormat = "png" | "jpeg" | "webp";
export interface RasterOptions {
format: RasterFormat;
width?: number;
height?: number;
scale?: number;
quality?: number;
background?: string;
fileName?: string;
signal?: AbortSignal;
}
export interface RasterSize {
width: number;
height: number;
aspectRatio: number;
}
const SAFE_DATA = /^data:image\/(?:png|jpeg|gif|webp|avif);base64,/iu;
const URL_PATTERN = /url\(\s*(["']?)(.*?)\1\s*\)/giu;
function parseSafeProjection(source: string): SVGSVGElement {
if (/<!doctype\b|<!entity\b|<\?xml-stylesheet\b/iu.test(source)) {
throw new Error(
"Raster export does not allow DTDs, entities or XML stylesheets",
);
}
const document = new DOMParser().parseFromString(source, "image/svg+xml");
if (
document.documentElement.localName !== "svg" ||
document.querySelector("parsererror")
) {
throw new Error("Raster export requires a well-formed SVG projection");
}
for (const element of Array.from(document.querySelectorAll("*"))) {
if (
["script", "foreignobject", "iframe", "object", "embed"].includes(
element.localName.toLowerCase(),
)
) {
throw new Error(`Raster export blocked <${element.localName}>`);
}
for (const attribute of Array.from(element.attributes)) {
const name = attribute.name.toLowerCase();
if (name.startsWith("on"))
throw new Error(`Raster export blocked ${attribute.name}`);
if (["href", "xlink:href", "src"].includes(name)) {
const value = attribute.value.trim();
if (!value.startsWith("#") && !SAFE_DATA.test(value)) {
throw new Error("Raster export blocked an external resource");
}
}
for (const match of attribute.value.matchAll(URL_PATTERN)) {
if (!(match[2] ?? "").trim().startsWith("#")) {
throw new Error("Raster export blocked an external CSS resource");
}
}
}
}
return document.documentElement as unknown as SVGSVGElement;
}
function absoluteLength(value: string | null): number | undefined {
const match = /^\s*(\d+(?:\.\d+)?|\.\d+)\s*(px|in|cm|mm|pt|pc)?\s*$/iu.exec(
value ?? "",
);
if (!match) return undefined;
const numeric = Number(match[1]);
switch ((match[2] ?? "px").toLowerCase()) {
case "in":
return numeric * 96;
case "cm":
return (numeric * 96) / 2.54;
case "mm":
return (numeric * 96) / 25.4;
case "pt":
return (numeric * 96) / 72;
case "pc":
return numeric * 16;
default:
return numeric;
}
}
export function resolveRasterSize(
source: string,
request: Pick<RasterOptions, "width" | "height" | "scale"> = {},
): RasterSize {
const root = parseSafeProjection(source);
const viewBox = (root.getAttribute("viewBox") ?? "")
.trim()
.split(/[\s,]+/u)
.map(Number);
const viewWidth =
viewBox.length === 4 && viewBox[2]! > 0 ? viewBox[2] : undefined;
const viewHeight =
viewBox.length === 4 && viewBox[3]! > 0 ? viewBox[3] : undefined;
const intrinsicWidth = absoluteLength(root.getAttribute("width"));
const intrinsicHeight = absoluteLength(root.getAttribute("height"));
const aspectRatio =
viewWidth && viewHeight
? viewWidth / viewHeight
: intrinsicWidth && intrinsicHeight
? intrinsicWidth / intrinsicHeight
: 1;
const requestedWidth = request.width;
const requestedHeight = request.height;
const scale = request.scale ?? 1;
if (
[requestedWidth, requestedHeight]
.filter((value): value is number => value !== undefined)
.some((value) => !Number.isFinite(value) || value <= 0) ||
!Number.isFinite(scale) ||
scale <= 0 ||
scale > 16
) {
throw new Error(
"Raster dimensions and scale must be positive finite values",
);
}
let width = requestedWidth;
let height = requestedHeight;
if (width && !height) height = width / aspectRatio;
else if (height && !width) width = height * aspectRatio;
else if (!width && !height) {
width = intrinsicWidth ?? viewWidth ?? 1024;
height = intrinsicHeight ?? viewHeight ?? width / aspectRatio;
}
const finalWidth = Math.max(1, Math.round(width! * scale));
const finalHeight = Math.max(1, Math.round(height! * scale));
if (
finalWidth > 16_384 ||
finalHeight > 16_384 ||
finalWidth * finalHeight > defaultSvgLimits.maximumRasterPixels
) {
throw new Error(
`The requested ${finalWidth} × ${finalHeight} raster exceeds the canvas safety limit`,
);
}
return { width: finalWidth, height: finalHeight, aspectRatio };
}
function mimeType(format: RasterFormat): string {
return format === "png"
? "image/png"
: format === "jpeg"
? "image/jpeg"
: "image/webp";
}
export async function rasterizeProjection(
sanitizedProjection: string,
options: RasterOptions,
): Promise<{ blob: Blob; fileName: string; size: RasterSize }> {
if (options.signal?.aborted)
throw new DOMException("Raster export cancelled", "AbortError");
parseSafeProjection(sanitizedProjection);
const size = resolveRasterSize(sanitizedProjection, options);
const imageUrl = URL.createObjectURL(
new Blob([sanitizedProjection], { type: "image/svg+xml" }),
);
try {
const image = new Image();
image.decoding = "async";
await new Promise<void>((resolve, reject) => {
const timeout = globalThis.setTimeout(
() =>
reject(new Error("SVG decoding exceeded the 15-second safety limit")),
15_000,
);
const abort = () =>
reject(new DOMException("Raster export cancelled", "AbortError"));
options.signal?.addEventListener("abort", abort, { once: true });
image.onload = () => {
globalThis.clearTimeout(timeout);
options.signal?.removeEventListener("abort", abort);
resolve();
};
image.onerror = () => {
globalThis.clearTimeout(timeout);
options.signal?.removeEventListener("abort", abort);
reject(new Error("The browser could not decode the sanitized SVG"));
};
image.src = imageUrl;
});
const canvas = document.createElement("canvas");
canvas.width = size.width;
canvas.height = size.height;
const context = canvas.getContext("2d");
if (!context) throw new Error("The browser could not create a 2D canvas");
const background =
options.background ?? (options.format === "jpeg" ? "#ffffff" : undefined);
if (background) {
if (/url\s*\(/iu.test(background) || background.length > 128)
throw new Error("Unsafe raster background value");
context.fillStyle = background;
context.fillRect(0, 0, size.width, size.height);
}
context.drawImage(image, 0, 0, size.width, size.height);
const type = mimeType(options.format);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) =>
result
? resolve(result)
: reject(new Error(`The browser could not encode ${type}`)),
type,
options.format === "png" ? undefined : (options.quality ?? 0.92),
);
});
return {
blob,
fileName: exportFileName(options.fileName, options.format),
size,
};
} finally {
URL.revokeObjectURL(imageUrl);
}
}
+122
View File
@@ -0,0 +1,122 @@
import { Gunzip, gzipSync } from "fflate";
import { defaultSvgLimits, utf8ByteLength } from "../app/limits";
import { parseSvgSource } from "../document/source-parser";
import { exportFileName } from "./file-name";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
export function assertExportableSvg(source: string): void {
const parsed = parseSvgSource(source, 0);
if (
parsed.diagnostics.some((diagnostic) => diagnostic.code === "non-svg-root")
) {
throw new Error(
"The document root must be an SVG element in the SVG namespace",
);
}
if (!parsed.valid || !parsed.semantic) {
throw new Error("The current source is not well-formed XML");
}
const root = parsed.semantic.document.documentElement;
if (root.localName !== "svg" || root.namespaceURI !== SVG_NAMESPACE) {
throw new Error(
"The document root must be an SVG element in the SVG namespace",
);
}
}
export function gunzipWithLimit(
bytes: Uint8Array,
maximumBytes: number,
): Uint8Array<ArrayBuffer> {
const chunks: Uint8Array[] = [];
let total = 0;
const gunzip = new Gunzip((chunk) => {
if (total + chunk.byteLength > maximumBytes) {
throw new RangeError("Decompressed SVG exceeds the processing limit");
}
total += chunk.byteLength;
chunks.push(Uint8Array.from(chunk));
});
const inputChunkBytes = 16 * 1024;
for (let offset = 0; offset < bytes.byteLength; offset += inputChunkBytes) {
const to = Math.min(bytes.byteLength, offset + inputChunkBytes);
gunzip.push(bytes.subarray(offset, to), to === bytes.byteLength);
}
if (bytes.byteLength === 0) gunzip.push(bytes, true);
const result = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return result;
}
export function createSvgExport(
source: string,
format: "svg" | "svgz",
requestedName?: string,
): { blob: Blob; bytes: Uint8Array; fileName: string } {
if (utf8ByteLength(source) > defaultSvgLimits.sourceHardBytes) {
throw new Error("SVG source exceeds the export limit");
}
const sourceBytes = new TextEncoder().encode(source);
assertExportableSvg(source);
const bytes =
format === "svg"
? sourceBytes
: gzipSync(sourceBytes, { level: 9, mtime: 0 });
const stableBytes = Uint8Array.from(bytes);
return {
bytes: stableBytes,
blob: new Blob([stableBytes], {
type: format === "svg" ? "image/svg+xml" : "application/gzip",
}),
fileName: exportFileName(requestedName, format),
};
}
export async function readSvgFile(
file: File,
): Promise<{ source: string; fileName: string }> {
if (file.size > defaultSvgLimits.sourceHardBytes * 2) {
throw new Error("Selected file exceeds the processing limit");
}
const bytes = new Uint8Array(await file.arrayBuffer());
const compressed =
/\.svgz$/iu.test(file.name) || (bytes[0] === 0x1f && bytes[1] === 0x8b);
let decoded = bytes;
if (compressed) {
try {
decoded = gunzipWithLimit(bytes, defaultSvgLimits.sourceHardBytes);
} catch (error) {
if (
error instanceof RangeError &&
error.message.includes("processing limit")
) {
throw error;
}
throw new Error("The SVGZ file could not be decompressed", {
cause: error,
});
}
}
let source: string;
try {
source = new TextDecoder("utf-8", { fatal: true })
.decode(decoded)
.replace(/^\uFEFF/u, "");
} catch {
throw new Error(
compressed
? "The decompressed SVG is not valid UTF-8"
: "The SVG file is not valid UTF-8",
);
}
if (utf8ByteLength(source) > defaultSvgLimits.sourceHardBytes) {
throw new Error("Decompressed SVG exceeds the processing limit");
}
assertExportableSvg(source);
return { source, fileName: file.name.replace(/\.svgz?$/iu, ".svg") };
}