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