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
+42
View File
@@ -0,0 +1,42 @@
import { lazy, Suspense, useState } from "react";
import { AppShell } from "@add-ideas/toolbox-shell-react";
import "@add-ideas/toolbox-shell-react/styles.css";
import "./styles.css";
import { AppErrorBoundary } from "./components/AppErrorBoundary";
import { HelpDialog } from "./components/HelpDialog";
import { manifest } from "./toolbox/manifest";
const Workbench = lazy(async () => {
const module = await import("./components/Workbench");
return { default: module.Workbench };
});
export function App() {
const [helpOpen, setHelpOpen] = useState(false);
return (
<AppErrorBoundary>
<AppShell
app={manifest}
manifestUrl="./toolbox-app.json"
helpAction={{ onClick: () => setHelpOpen(true) }}
onContextError={(error) => {
console.warn(
"Toolbox context unavailable; continuing standalone.",
error,
);
}}
>
<Suspense
fallback={
<p className="workbench-loading" role="status">
Preparing the local SVG workbench
</p>
}
>
<Workbench />
</Suspense>
</AppShell>
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
</AppErrorBoundary>
);
}
+130
View File
@@ -0,0 +1,130 @@
import type {
SemanticSvgDocument,
SourcePatch,
SourceRange,
} from "../document/document.types";
import { buildReferenceIndex } from "../structure/reference-index";
export interface AccessibilityFinding {
severity: "info" | "warning" | "error";
rule: string;
nodeKey: string;
evidence: string;
limitation: string;
suggestedFix: string;
automaticFix: "add-title" | "add-description" | "mark-decorative" | null;
sourceRange?: SourceRange;
}
export function auditAccessibility(
semantic: SemanticSvgDocument,
): AccessibilityFinding[] {
const root = semantic.nodes.get(semantic.rootKey)!;
const children = root.childKeys.map((key) => semantic.nodes.get(key)!);
const title = children.find((node) => node.localName === "title");
const description = children.find((node) => node.localName === "desc");
const role = root.attributes.role;
const labelledBy = root.attributes["aria-labelledby"];
const label = root.attributes["aria-label"];
const hidden = root.attributes["aria-hidden"] === "true";
const findings: AccessibilityFinding[] = [];
if (!hidden && !title && !label && !labelledBy) {
findings.push({
severity: "warning",
rule: "svg-accessible-name",
nodeKey: root.key,
evidence: "The root has no title, aria-label or aria-labelledby.",
limitation:
"The embedding page can provide an accessible name outside this file.",
suggestedFix:
"Add a concise root title or explicitly mark the image decorative.",
automaticFix: "add-title",
sourceRange: root.sourceRange.openTag,
});
}
if (!hidden && !description) {
findings.push({
severity: "info",
rule: "svg-description",
nodeKey: root.key,
evidence: "No root description element is present.",
limitation:
"Not every simple or decorative SVG needs a long description.",
suggestedFix:
"Add a description when the image communicates non-trivial content.",
automaticFix: "add-description",
sourceRange: root.sourceRange.openTag,
});
}
if (
!hidden &&
role &&
!["img", "graphics-document", "presentation", "none"].includes(role)
) {
findings.push({
severity: "info",
rule: "svg-role-review",
nodeKey: root.key,
evidence: `Root role is “${role}”.`,
limitation:
"Role validity depends on the embedding and interaction model.",
suggestedFix: "Review the role against the intended embedding context.",
automaticFix: null,
sourceRange: root.attributeRanges.role?.valueRange,
});
}
const references = buildReferenceIndex(semantic);
for (const edge of references.edges.filter(
(candidate) =>
candidate.attribute.startsWith("aria-") &&
candidate.status !== "resolved",
)) {
const node = semantic.nodes.get(edge.sourceKey)!;
findings.push({
severity: "error",
rule: "aria-reference",
nodeKey: edge.sourceKey,
evidence: `${edge.attribute} target “${edge.targetId}” is ${edge.status}.`,
limitation: "Only local SVG ID references are evaluated.",
suggestedFix: "Repair the referenced ID or remove the broken token.",
automaticFix: null,
sourceRange: node.attributeRanges[edge.attribute]?.valueRange,
});
}
for (const node of semantic.nodes.values()) {
if (node.localName === "path" && /text/i.test(node.id ?? "")) {
findings.push({
severity: "info",
rule: "text-as-path-review",
nodeKey: node.key,
evidence:
"A path ID suggests that visible text may have been outlined.",
limitation: "The audit cannot infer author intent from path geometry.",
suggestedFix:
"Prefer a text element when editable, selectable text is required.",
automaticFix: null,
sourceRange: node.sourceRange.openTag,
});
}
}
return findings;
}
export function accessibilityFixPatch(
semantic: SemanticSvgDocument,
fix: "add-title" | "add-description",
text: string,
): SourcePatch {
const root = semantic.nodes.get(semantic.rootKey)!;
const escaped = text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
const tag = fix === "add-title" ? "title" : "desc";
return {
from: root.sourceRange.openTag.to,
to: root.sourceRange.openTag.to,
insert: `${semantic.preferences.newline}${semantic.preferences.indentation}<${tag}>${escaped}</${tag}>`,
label: fix === "add-title" ? "Add accessible title" : "Add description",
};
}
+23
View File
@@ -0,0 +1,23 @@
export interface AnimationKeyframe {
offset: number;
value: string;
easing?: string;
}
export interface AnimationDefinition {
id: string;
name: string;
targetNodeKey: string;
property: string;
kind: "attribute" | "style" | "transform";
enabled: boolean;
keyframes: AnimationKeyframe[];
timing: {
durationMs: number;
delayMs: number;
iterations: number | "infinite";
direction: "normal" | "reverse" | "alternate" | "alternate-reverse";
fillMode: "none" | "forwards" | "backwards" | "both";
easing: string;
};
}
+40
View File
@@ -0,0 +1,40 @@
import type { AnimationDefinition } from "./animation.types";
import { buildAnimationCss } from "./validation";
function cssString(value: string): string {
return Array.from(value, (character) => {
if (character === "\\") return "\\\\";
if (character === '"') return '\\"';
const code = character.codePointAt(0)!;
if (
code <= 0x1f ||
code === 0x7f ||
character === "<" ||
character === ">" ||
character === "&"
) {
return `\\${code.toString(16)} `;
}
return character;
}).join("");
}
export function animationStyle(
definitions: readonly AnimationDefinition[],
): string {
return buildAnimationCss(definitions, {
keyframeNamePrefix: "svg-tools-animation",
selectorFor: (definition) =>
`[data-svg-tools-node="${cssString(definition.targetNodeKey)}"]`,
});
}
export function withAnimationPreview(
sanitizedProjection: string,
definitions: readonly AnimationDefinition[],
): string {
const css = animationStyle(definitions);
if (!css) return sanitizedProjection;
const style = `<style data-svg-tools-preview="animation">${css.replaceAll("&", "&amp;").replaceAll("<", "&lt;")}</style>`;
return sanitizedProjection.replace(/<svg\b([^>]*)>/iu, `<svg$1>${style}`);
}
+342
View File
@@ -0,0 +1,342 @@
import { parse as parseCss, walk as walkCss } from "css-tree";
import { defaultSvgLimits } from "../app/limits";
import type { AnimationDefinition, AnimationKeyframe } from "./animation.types";
const MAXIMUM_KEYFRAMES = 10_000;
const MAXIMUM_LABEL_LENGTH = 4_096;
const MAXIMUM_VALUE_LENGTH = 65_536;
const MAXIMUM_EASING_LENGTH = 256;
const MAXIMUM_DURATION_MS = 604_800_000;
const MAXIMUM_ITERATIONS = 1_000_000;
const SAFE_PROPERTIES = new Set([
"color",
"fill",
"filter",
"opacity",
"stroke",
"stroke-width",
"transform",
"transform-origin",
"visibility",
]);
const DIRECTIONS = new Set([
"normal",
"reverse",
"alternate",
"alternate-reverse",
]);
const FILL_MODES = new Set(["none", "forwards", "backwards", "both"]);
const KINDS = new Set(["attribute", "style", "transform"]);
const EASING_KEYWORDS = new Set([
"linear",
"ease",
"ease-in",
"ease-out",
"ease-in-out",
"step-start",
"step-end",
]);
const RESOURCE_FUNCTIONS = new Set([
"cross-fade",
"element",
"image",
"image-set",
"paint",
"src",
"-webkit-image-set",
]);
const CSS_NUMBER = "[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[+-]?\\d+)?";
const CUBIC_BEZIER = new RegExp(
`^cubic-bezier\\(\\s*(${CSS_NUMBER})\\s*,\\s*(${CSS_NUMBER})\\s*,\\s*(${CSS_NUMBER})\\s*,\\s*(${CSS_NUMBER})\\s*\\)$`,
"iu",
);
const STEPS =
/^steps\(\s*(\d+)\s*(?:,\s*(jump-start|jump-end|jump-none|jump-both|start|end)\s*)?\)$/iu;
export class AnimationValidationError extends Error {
readonly path: string;
readonly reason: string;
constructor(reason: string, path: string) {
super(`${reason} (${path})`);
this.name = "AnimationValidationError";
this.path = path;
this.reason = reason;
}
}
function fail(reason: string, path: string): never {
throw new AnimationValidationError(reason, path);
}
function record(value: unknown, path: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
fail("Expected an object", path);
}
return value as Record<string, unknown>;
}
function boundedText(
value: unknown,
path: string,
maximumLength: number,
allowEmpty = false,
): string {
if (
typeof value !== "string" ||
(!allowEmpty && value.length === 0) ||
value.length > maximumLength
) {
fail("Expected a bounded string", path);
}
return value;
}
function finiteNumber(
value: unknown,
path: string,
minimum: number,
maximum: number,
): number {
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
value < minimum ||
value > maximum
) {
fail(`Expected a finite number from ${minimum} to ${maximum}`, path);
}
return value;
}
function safeEasing(value: unknown, path: string): string {
const easing = boundedText(value, path, MAXIMUM_EASING_LENGTH).trim();
if (EASING_KEYWORDS.has(easing.toLowerCase())) return easing;
const bezier = CUBIC_BEZIER.exec(easing);
if (bezier) {
const values = bezier.slice(1).map(Number);
if (
values.every(Number.isFinite) &&
values[0]! >= 0 &&
values[0]! <= 1 &&
values[2]! >= 0 &&
values[2]! <= 1
) {
return easing;
}
}
const steps = STEPS.exec(easing);
if (steps) {
const count = Number(steps[1]);
if (
Number.isSafeInteger(count) &&
count >= 1 &&
count <= MAXIMUM_ITERATIONS
) {
return easing;
}
}
fail("Unsupported or unsafe animation easing", path);
}
function safeCssValue(value: unknown, path: string): string {
const css = boundedText(value, path, MAXIMUM_VALUE_LENGTH);
const hasUnsafeControlCharacter = Array.from(css).some((character) => {
const code = character.charCodeAt(0);
return (
code <= 0x08 ||
code === 0x0b ||
code === 0x0c ||
(code >= 0x0e && code <= 0x1f) ||
code === 0x7f
);
});
if (
/[\\<>&;{}@]/u.test(css) ||
hasUnsafeControlCharacter ||
/\/\*|\*\//u.test(css)
) {
fail("Animation value contains unsafe CSS syntax", path);
}
let parseFailed = false;
let unsafeResource = false;
try {
const ast = parseCss(css, {
context: "value",
positions: false,
onParseError: () => {
parseFailed = true;
},
});
walkCss(ast, (node) => {
if (node.type === "Url") unsafeResource = true;
if (
node.type === "Function" &&
(node.name.toLowerCase() === "expression" ||
RESOURCE_FUNCTIONS.has(node.name.toLowerCase()))
) {
unsafeResource = true;
}
if (node.type === "Raw") parseFailed = true;
});
} catch {
parseFailed = true;
}
if (parseFailed) fail("Animation value is not valid CSS", path);
if (unsafeResource) {
fail("Animation values cannot resolve URLs or external resources", path);
}
return css;
}
function keyframes(value: unknown, path: string): AnimationKeyframe[] {
if (
!Array.isArray(value) ||
value.length < 1 ||
value.length > MAXIMUM_KEYFRAMES
) {
fail("Expected one or more bounded keyframes", path);
}
let previousOffset = -1;
return value.map((entry, index) => {
const framePath = `${path}[${index}]`;
const frame = record(entry, framePath);
const offset = finiteNumber(frame.offset, `${framePath}.offset`, 0, 1);
if (offset < previousOffset) {
fail("Keyframe offsets must be ordered", path);
}
previousOffset = offset;
return {
offset,
value: safeCssValue(frame.value, `${framePath}.value`),
...(frame.easing === undefined
? {}
: { easing: safeEasing(frame.easing, `${framePath}.easing`) }),
};
});
}
function definition(value: unknown, path: string): AnimationDefinition {
const item = record(value, path);
const property = boundedText(item.property, `${path}.property`, 64);
if (!SAFE_PROPERTIES.has(property)) {
fail(
`Animation property “${property}” is not application-safe`,
`${path}.property`,
);
}
const kind = boundedText(item.kind, `${path}.kind`, 32);
if (!KINDS.has(kind)) fail("Unsupported animation kind", `${path}.kind`);
if (typeof item.enabled !== "boolean") {
fail("Expected a boolean", `${path}.enabled`);
}
const timing = record(item.timing, `${path}.timing`);
const direction = boundedText(
timing.direction,
`${path}.timing.direction`,
32,
);
if (!DIRECTIONS.has(direction)) {
fail("Unsupported animation direction", `${path}.timing.direction`);
}
const fillMode = boundedText(timing.fillMode, `${path}.timing.fillMode`, 32);
if (!FILL_MODES.has(fillMode)) {
fail("Unsupported animation fill mode", `${path}.timing.fillMode`);
}
const iterations =
timing.iterations === "infinite"
? "infinite"
: finiteNumber(
timing.iterations,
`${path}.timing.iterations`,
0,
MAXIMUM_ITERATIONS,
);
return {
id: boundedText(item.id, `${path}.id`, MAXIMUM_LABEL_LENGTH),
name: boundedText(item.name, `${path}.name`, MAXIMUM_LABEL_LENGTH),
targetNodeKey: boundedText(
item.targetNodeKey,
`${path}.targetNodeKey`,
MAXIMUM_LABEL_LENGTH,
),
property,
kind: kind as AnimationDefinition["kind"],
enabled: item.enabled,
keyframes: keyframes(item.keyframes, `${path}.keyframes`),
timing: {
durationMs: finiteNumber(
timing.durationMs,
`${path}.timing.durationMs`,
1,
MAXIMUM_DURATION_MS,
),
delayMs: finiteNumber(
timing.delayMs,
`${path}.timing.delayMs`,
-MAXIMUM_DURATION_MS,
MAXIMUM_DURATION_MS,
),
iterations,
direction: direction as AnimationDefinition["timing"]["direction"],
fillMode: fillMode as AnimationDefinition["timing"]["fillMode"],
easing: safeEasing(timing.easing, `${path}.timing.easing`),
},
};
}
export function validateAnimationDefinitions(
value: unknown,
path = "$.animations",
): AnimationDefinition[] {
if (
!Array.isArray(value) ||
value.length > defaultSvgLimits.maximumAnimations
) {
fail("Expected a bounded animation array", path);
}
return value.map((entry, index) => definition(entry, `${path}[${index}]`));
}
export interface AnimationCssOptions {
keyframeNamePrefix: string;
selectorFor: (definition: AnimationDefinition, index: number) => string;
}
export function buildAnimationCss(
definitions: readonly AnimationDefinition[],
options: AnimationCssOptions,
): string {
if (!/^[A-Za-z][A-Za-z0-9_-]*$/u.test(options.keyframeNamePrefix)) {
throw new Error("Animation keyframe prefix is not a safe CSS identifier");
}
return validateAnimationDefinitions(definitions)
.filter((item) => item.enabled)
.map((item, index) => {
const name = `${options.keyframeNamePrefix}-${index}`;
const frames = item.keyframes
.map((frame) => {
const percent = Math.round(frame.offset * 100_000) / 1_000;
const frameEasing = frame.easing
? ` animation-timing-function: ${frame.easing};`
: "";
return `${percent}% { ${item.property}: ${frame.value};${frameEasing} }`;
})
.join(" ");
const iterations =
item.timing.iterations === "infinite"
? "infinite"
: String(item.timing.iterations);
const selector = options.selectorFor(item, index);
return `@keyframes ${name} { ${frames} }\n${selector} { animation: ${name} ${item.timing.durationMs}ms ${item.timing.easing} ${item.timing.delayMs}ms ${iterations} ${item.timing.direction} ${item.timing.fillMode}; }`;
})
.join("\n");
}
+65
View File
@@ -0,0 +1,65 @@
export interface SvgResourceLimits {
sourceSoftBytes: number;
sourceHardBytes: number;
maximumElements: number;
maximumDepth: number;
maximumAttributes: number;
maximumAttributeLength: number;
maximumTextLength: number;
maximumPathCommandsPerPath: number;
maximumPathCommandsTotal: number;
maximumCssRules: number;
maximumReferences: number;
maximumAnimations: number;
maximumFilterPrimitives: number;
maximumEmbeddedResourceBytes: number;
maximumHistoryEntries: number;
maximumHistoryBytes: number;
maximumOptimizationMs: number;
maximumRasterPixels: number;
}
export const defaultSvgLimits: Readonly<SvgResourceLimits> = {
sourceSoftBytes: 2 * 1024 * 1024,
sourceHardBytes: 20 * 1024 * 1024,
maximumElements: 100_000,
maximumDepth: 1_000,
maximumAttributes: 1_000_000,
maximumAttributeLength: 1_000_000,
maximumTextLength: 20 * 1024 * 1024,
maximumPathCommandsPerPath: 200_000,
maximumPathCommandsTotal: 1_000_000,
maximumCssRules: 100_000,
maximumReferences: 200_000,
maximumAnimations: 50_000,
maximumFilterPrimitives: 50_000,
maximumEmbeddedResourceBytes: 100 * 1024 * 1024,
maximumHistoryEntries: 500,
maximumHistoryBytes: 200 * 1024 * 1024,
maximumOptimizationMs: 30_000,
maximumRasterPixels: 100_000_000,
};
export const utf8ByteLength = (source: string): number => {
let bytes = 0;
for (let index = 0; index < source.length; index += 1) {
const code = source.charCodeAt(index);
if (code <= 0x7f) {
bytes += 1;
} else if (code <= 0x7ff) {
bytes += 2;
} else if (
code >= 0xd800 &&
code <= 0xdbff &&
source.charCodeAt(index + 1) >= 0xdc00 &&
source.charCodeAt(index + 1) <= 0xdfff
) {
bytes += 4;
index += 1;
} else {
// UTF-8 encoders replace any unpaired surrogate with U+FFFD.
bytes += 3;
}
}
return bytes;
};
+18
View File
@@ -0,0 +1,18 @@
export const STARTER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 420" width="640" height="420" role="img" aria-labelledby="title desc">
<title id="title">SVG Tools starter document</title>
<desc id="desc">A small source-faithful vector editing example.</desc>
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#725cff"/>
<stop offset="1" stop-color="#20b8a6"/>
</linearGradient>
</defs>
<rect id="background" x="20" y="20" width="600" height="380" rx="26" fill="#f6f8fc" stroke="#ccd4e2"/>
<circle id="sun" cx="500" cy="105" r="48" fill="#ffcb57"/>
<path id="curve" d="M90 300 L150 255 C210 175 285 345 350 250 S470 180 545 285 Q575 315 590 270 T615 250 A38 24 25 0 1 570 335" fill="none" stroke="url(#sky)" stroke-width="14" stroke-linecap="round"/>
<text id="label" x="72" y="105" font-family="system-ui, sans-serif" font-size="38" font-weight="700" fill="#233047">SVG Tools</text>
</svg>`;
export const EMPTY_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 420" width="640" height="420">
<title>Untitled SVG</title>
</svg>`;
+155
View File
@@ -0,0 +1,155 @@
import { defaultSvgLimits, utf8ByteLength } from "../app/limits";
import type {
DocumentTransaction,
SelectionState,
SourcePatch,
SvgDiagnostic,
} from "../document/document.types";
export interface HistorySnapshot {
past: readonly DocumentTransaction[];
future: readonly DocumentTransaction[];
bytes: number;
}
const EMPTY_SELECTION: SelectionState = {
nodeKeys: [],
primaryNodeKey: null,
};
export function createTransaction(input: {
label: string;
baseRevision: number;
sourceBefore: string;
sourceAfter: string;
patches?: SourcePatch[];
affectedNodeKeys?: string[];
selectionBefore?: SelectionState;
selectionAfter?: SelectionState;
diagnostics?: SvgDiagnostic[];
mergeKey?: string;
}): DocumentTransaction {
return {
id: globalThis.crypto?.randomUUID?.() ?? `transaction-${Date.now()}`,
label: input.label,
baseRevision: input.baseRevision,
sourceBefore: input.sourceBefore,
sourceAfter: input.sourceAfter,
sourcePatches: input.patches ?? [],
affectedNodeKeys: input.affectedNodeKeys ?? [],
selectionBefore: input.selectionBefore ?? EMPTY_SELECTION,
selectionAfter: input.selectionAfter ?? EMPTY_SELECTION,
diagnostics: input.diagnostics ?? [],
...(input.mergeKey ? { mergeKey: input.mergeKey } : {}),
timestamp: Date.now(),
};
}
function transactionBytes(transaction: DocumentTransaction): number {
return (
utf8ByteLength(transaction.sourceBefore) +
utf8ByteLength(transaction.sourceAfter)
);
}
export class CommandHistory {
readonly #maximumEntries: number;
readonly #maximumBytes: number;
#past: DocumentTransaction[] = [];
#future: DocumentTransaction[] = [];
#bytes = 0;
constructor(
maximumEntries = defaultSvgLimits.maximumHistoryEntries,
maximumBytes = defaultSvgLimits.maximumHistoryBytes,
) {
this.#maximumEntries = maximumEntries;
this.#maximumBytes = maximumBytes;
}
get snapshot(): HistorySnapshot {
return {
past: [...this.#past],
future: [...this.#future],
bytes: this.#bytes,
};
}
get canUndo(): boolean {
return this.#past.length > 0;
}
get canRedo(): boolean {
return this.#future.length > 0;
}
commit(transaction: DocumentTransaction): void {
const previous = this.#past.at(-1);
const merge =
previous !== undefined &&
Boolean(transaction.mergeKey) &&
transaction.mergeKey === previous?.mergeKey &&
transaction.timestamp - previous.timestamp < 1_000 &&
previous.sourceAfter === transaction.sourceBefore;
if (merge && previous) {
this.#bytes -= transactionBytes(previous);
this.#past[this.#past.length - 1] = {
...transaction,
id: previous.id,
sourceBefore: previous.sourceBefore,
selectionBefore: previous.selectionBefore,
sourcePatches: [
...previous.sourcePatches,
...transaction.sourcePatches,
],
};
} else {
this.#past.push(transaction);
}
this.#bytes += transactionBytes(this.#past.at(-1)!);
this.#future = [];
this.#trim();
}
undo(currentSource: string): DocumentTransaction | null {
const transaction = this.#past.at(-1);
if (!transaction) return null;
if (transaction.sourceAfter !== currentSource) {
throw new Error("Undo rejected because the source revision is stale");
}
this.#past.pop();
this.#future.push(transaction);
this.#bytes -= transactionBytes(transaction);
return transaction;
}
redo(currentSource: string): DocumentTransaction | null {
const transaction = this.#future.at(-1);
if (!transaction) return null;
if (transaction.sourceBefore !== currentSource) {
throw new Error("Redo rejected because the source revision is stale");
}
this.#future.pop();
this.#past.push(transaction);
this.#bytes += transactionBytes(transaction);
this.#trim();
return transaction;
}
clear(): void {
this.#past = [];
this.#future = [];
this.#bytes = 0;
}
#trim(): void {
while (
this.#past.length > this.#maximumEntries ||
this.#bytes > this.#maximumBytes
) {
const removed = this.#past.shift();
if (!removed) break;
this.#bytes -= transactionBytes(removed);
}
}
}
+42
View File
@@ -0,0 +1,42 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
export class AppErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error(
"SVG Tools encountered an unrecoverable interface error",
error,
info,
);
}
render(): ReactNode {
if (!this.state.error) return this.props.children;
return (
<main className="fatal-error" role="alert">
<h1>SVG Tools could not continue</h1>
<p>{this.state.error.message}</p>
<button
type="button"
className="primary-button"
onClick={() => globalThis.location.reload()}
>
Reload application
</button>
</main>
);
}
}
+579
View File
@@ -0,0 +1,579 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import type { AnimationDefinition } from "../animation/animation.types";
import { withAnimationPreview } from "../animation/preview";
import type { SemanticSvgDocument } from "../document/document.types";
import {
applyToPoint,
invert,
matrixToTransform,
multiply,
type Matrix,
type Point,
} from "../domain/affine";
import { resolveTransformChain } from "../domain/transform-chain";
import {
movePathHandle,
parsePathData,
pathHandles,
serializePathData,
type PathHandle,
type PathModel,
} from "../domain/path";
export interface CanvasPaneProps {
projection: string;
semantic: SemanticSvgDocument;
selectedKey: string | null;
disabled: boolean;
stale: boolean;
pathEditing: boolean;
showGrid: boolean;
transformPreview: Matrix | null;
animations: readonly AnimationDefinition[];
animationPreview: boolean;
view: CanvasView;
onSelect: (nodeKey: string) => void;
onCommitPath: (data: string, mergeKey?: string) => void;
onPathError: (message: string) => void;
onShowGridChange: (show: boolean) => void;
onViewChange: (view: CanvasView) => void;
}
export interface CanvasView {
zoom: number;
pan: { x: number; y: number };
}
interface ViewBox {
x: number;
y: number;
width: number;
height: number;
}
interface SelectionBox {
x: number;
y: number;
width: number;
height: number;
}
interface CanvasFrameMessage {
channel: string;
type: "ready" | "select" | "selection-box";
key?: string;
box?: SelectionBox | null;
}
function viewBoxFor(semantic: SemanticSvgDocument): ViewBox {
const root = semantic.nodes.get(semantic.rootKey)!;
const values = (root.attributes.viewBox ?? "")
.trim()
.split(/[\s,]+/u)
.map(Number);
if (
values.length === 4 &&
values.every(Number.isFinite) &&
values[2]! > 0 &&
values[3]! > 0
) {
return {
x: values[0]!,
y: values[1]!,
width: values[2]!,
height: values[3]!,
};
}
const width = Number.parseFloat(root.attributes.width ?? "640");
const height = Number.parseFloat(root.attributes.height ?? "480");
return {
x: 0,
y: 0,
width: Number.isFinite(width) && width > 0 ? width : 640,
height: Number.isFinite(height) && height > 0 ? height : 480,
};
}
function frameHtml(
source: string,
channel: string,
controllerUrl: string,
): string {
const controllerOrigin = new URL(controllerUrl).origin;
const escapedControllerUrl = controllerUrl
.replaceAll("&", "&amp;")
.replaceAll('"', "&quot;");
return `<!doctype html><html data-svg-tools-channel="${channel}"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src ${controllerOrigin}; connect-src 'none'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'; img-src data: blob:; style-src 'unsafe-inline'"><style>html,body{width:100%;height:100%;margin:0;overflow:hidden}body{display:grid;place-items:center;background:transparent}svg{display:block;width:100%;height:100%;max-width:100%;max-height:100%;overflow:visible}</style><script src="${escapedControllerUrl}" crossorigin="anonymous"></script></head><body>${source}</body></html>`;
}
export function CanvasPane({
projection,
semantic,
selectedKey,
disabled,
stale,
pathEditing,
showGrid,
transformPreview,
animations,
animationPreview,
view,
onSelect,
onCommitPath,
onPathError,
onShowGridChange,
onViewChange,
}: CanvasPaneProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [frameChannel] = useState(() => globalThis.crypto.randomUUID());
const [controllerUrl] = useState(
() =>
new URL("./canvas-frame-controller.js", globalThis.location.href).href,
);
const overlayRef = useRef<SVGSVGElement>(null);
const draftRef = useRef<PathModel | null>(null);
const dragRef = useRef<{
handle: PathHandle;
base: PathModel;
pointerId: number;
} | null>(null);
const [draft, setDraft] = useState<PathModel | null>(null);
const [selectionBox, setSelectionBox] = useState<SelectionBox | null>(null);
const [frameRevision, setFrameRevision] = useState(0);
const selectedNode = selectedKey
? semantic.nodes.get(selectedKey)
: undefined;
const transformChain = useMemo(
() => (selectedKey ? resolveTransformChain(semantic, selectedKey) : null),
[selectedKey, semantic],
);
const displayedMatrix = useMemo(
() =>
transformChain && transformPreview
? multiply(transformChain.matrix, transformPreview)
: (transformChain?.matrix ?? null),
[transformChain, transformPreview],
);
const displayedInverse = useMemo(
() => (displayedMatrix ? invert(displayedMatrix) : null),
[displayedMatrix],
);
const sourcePath =
selectedNode?.localName === "path"
? (selectedNode.attributes.d ?? "")
: null;
const parsedPath = useMemo(() => {
if (sourcePath === null) return null;
try {
return parsePathData(sourcePath);
} catch {
return null;
}
}, [sourcePath]);
const viewBox = useMemo(() => viewBoxFor(semantic), [semantic]);
const root = semantic.nodes.get(semantic.rootKey)!;
const preserveAspectRatio =
root.attributes.preserveAspectRatio ?? "xMidYMid meet";
const renderedProjection = useMemo(
() =>
animationPreview
? withAnimationPreview(projection, animations)
: projection,
[animationPreview, animations, projection],
);
const srcDoc = useMemo(
() => frameHtml(renderedProjection, frameChannel, controllerUrl),
[controllerUrl, frameChannel, renderedProjection],
);
const postFrame = useCallback(
(message: Record<string, unknown>) => {
iframeRef.current?.contentWindow?.postMessage(
{ ...message, channel: frameChannel },
"*",
);
},
[frameChannel],
);
useEffect(() => {
const receive = (event: MessageEvent<unknown>) => {
if (event.source !== iframeRef.current?.contentWindow) return;
const message = event.data as Partial<CanvasFrameMessage> | null;
if (!message || message.channel !== frameChannel) return;
if (message.type === "ready") {
setFrameRevision((value) => value + 1);
return;
}
if (message.type === "select" && typeof message.key === "string") {
onSelect(message.key);
return;
}
if (message.type === "selection-box" && message.key === selectedKey) {
const box = message.box;
setSelectionBox(
box && [box.x, box.y, box.width, box.height].every(Number.isFinite)
? box
: null,
);
}
};
globalThis.addEventListener("message", receive);
return () => globalThis.removeEventListener("message", receive);
}, [frameChannel, onSelect, selectedKey]);
useEffect(() => {
const next = pathEditing && parsedPath ? structuredClone(parsedPath) : null;
draftRef.current = next;
dragRef.current = null;
let active = true;
queueMicrotask(() => {
if (active) setDraft(next);
});
return () => {
active = false;
};
}, [parsedPath, pathEditing, selectedKey]);
useEffect(() => {
if (!selectedKey) {
queueMicrotask(() => setSelectionBox(null));
return;
}
postFrame({ type: "selection", key: selectedKey });
}, [draft, frameRevision, postFrame, selectedKey, transformPreview]);
useEffect(() => {
if (
pathEditing &&
transformChain &&
(!displayedInverse || transformChain.diagnostics.length)
) {
onPathError(
transformChain.diagnostics[0] ??
"Path handles cannot be edited through a non-invertible transform chain",
);
}
}, [displayedInverse, onPathError, pathEditing, transformChain]);
const selectionPoints = useMemo(() => {
if (!selectionBox || !displayedMatrix) return null;
return [
{ x: selectionBox.x, y: selectionBox.y },
{ x: selectionBox.x + selectionBox.width, y: selectionBox.y },
{
x: selectionBox.x + selectionBox.width,
y: selectionBox.y + selectionBox.height,
},
{ x: selectionBox.x, y: selectionBox.y + selectionBox.height },
].map((point) => applyToPoint(displayedMatrix, point));
}, [displayedMatrix, selectionBox]);
useEffect(() => {
if (!selectedKey) return;
const original = selectedNode?.attributes.transform ?? "";
const value = transformPreview
? `${original}${original ? " " : ""}${matrixToTransform(transformPreview)}`
: original || null;
postFrame({ type: "transform", key: selectedKey, value });
}, [frameRevision, postFrame, selectedKey, selectedNode, transformPreview]);
const updateFramePath = (model: PathModel) => {
if (!selectedKey) return;
postFrame({
type: "path",
key: selectedKey,
data: serializePathData(model),
});
};
const toSvgPoint = (event: {
clientX: number;
clientY: number;
}): Point | null => {
const overlay = overlayRef.current;
const matrix = overlay?.getScreenCTM();
if (!overlay || !matrix) return null;
const point = overlay.createSVGPoint();
point.x = event.clientX;
point.y = event.clientY;
const transformed = point.matrixTransform(matrix.inverse());
const rootPoint = { x: transformed.x, y: transformed.y };
return displayedInverse ? applyToPoint(displayedInverse, rootPoint) : null;
};
const beginDrag = (
event: ReactPointerEvent<SVGCircleElement>,
handle: PathHandle,
) => {
if (disabled || !draftRef.current || !displayedInverse) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
handle,
base: structuredClone(draftRef.current),
pointerId: event.pointerId,
};
};
const drag = (event: ReactPointerEvent<SVGCircleElement>) => {
const state = dragRef.current;
if (!state || state.pointerId !== event.pointerId) return;
const point = toSvgPoint(event);
if (!point) return;
try {
const next = movePathHandle(state.base, state.handle, point);
draftRef.current = next;
setDraft(next);
updateFramePath(next);
} catch (error) {
onPathError(
error instanceof Error
? error.message
: "The path handle could not be moved",
);
}
};
const finishDrag = (event: ReactPointerEvent<SVGCircleElement>) => {
if (dragRef.current?.pointerId !== event.pointerId) return;
dragRef.current = null;
if (draftRef.current) onCommitPath(serializePathData(draftRef.current));
};
const nudge = (
event: KeyboardEvent<SVGCircleElement>,
handle: PathHandle,
) => {
if (
!draftRef.current ||
!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)
)
return;
event.preventDefault();
const step = event.altKey ? 0.1 : event.shiftKey ? 10 : 1;
const delta = {
x:
event.key === "ArrowLeft"
? -step
: event.key === "ArrowRight"
? step
: 0,
y: event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0,
};
try {
const current = pathHandles(draftRef.current).find(
(candidate) => candidate.id === handle.id,
);
if (!current) return;
const next = movePathHandle(draftRef.current, current, {
x: current.point.x + delta.x,
y: current.point.y + delta.y,
});
draftRef.current = next;
setDraft(next);
updateFramePath(next);
onCommitPath(serializePathData(next), "path-keyboard");
} catch (error) {
onPathError(
error instanceof Error
? error.message
: "The path handle could not be moved",
);
}
};
const handles = draft ? pathHandles(draft) : [];
const controlLines = draft
? draft.segments.flatMap((segment, index) => {
if (segment.kind === "C") {
return [
{
id: `${index}:in`,
from: segment.from,
to: segment.control1,
derived: segment.derivedControl1,
},
{ id: `${index}:out`, from: segment.to, to: segment.control2 },
];
}
if (segment.kind === "Q") {
return [
{
id: `${index}:q-in`,
from: segment.from,
to: segment.control,
derived: segment.derivedControl,
},
{ id: `${index}:q-out`, from: segment.to, to: segment.control },
];
}
return [];
})
: [];
const projectPoint = (point: Point): Point =>
displayedMatrix ? applyToPoint(displayedMatrix, point) : point;
const setZoom = (zoom: number) => onViewChange({ ...view, zoom });
const panBy = (x: number, y: number) =>
onViewChange({ ...view, pan: { x: view.pan.x + x, y: view.pan.y + y } });
return (
<section className="panel canvas-panel" aria-labelledby="canvas-heading">
<div className="panel-heading compact canvas-heading">
<div>
<p className="eyebrow">Sanitized projection</p>
<h2 id="canvas-heading">Canvas</h2>
</div>
<div className="canvas-actions">
<button
type="button"
className="icon-button"
aria-label="Zoom out"
onClick={() => setZoom(Math.max(0.25, view.zoom / 1.2))}
>
</button>
<button
type="button"
className="zoom-readout"
onClick={() => onViewChange({ zoom: 1, pan: { x: 0, y: 0 } })}
aria-label="Reset canvas view"
>
{Math.round(view.zoom * 100)}%
</button>
<button
type="button"
className="icon-button"
aria-label="Zoom in"
onClick={() => setZoom(Math.min(8, view.zoom * 1.2))}
>
+
</button>
<button
type="button"
className="icon-button"
aria-label="Pan left"
onClick={() => panBy(-24, 0)}
>
</button>
<button
type="button"
className="icon-button"
aria-label="Pan up"
onClick={() => panBy(0, -24)}
>
</button>
<button
type="button"
className="icon-button"
aria-label="Pan down"
onClick={() => panBy(0, 24)}
>
</button>
<button
type="button"
className="icon-button"
aria-label="Pan right"
onClick={() => panBy(24, 0)}
>
</button>
<label className="compact-check">
<input
type="checkbox"
checked={showGrid}
onChange={(event) =>
onShowGridChange(event.currentTarget.checked)
}
/>{" "}
Grid
</label>
</div>
</div>
{stale ? (
<div className="stale-banner" role="status">
Showing the last valid canvas revision.
</div>
) : null}
<div className={`canvas-viewport${showGrid ? " has-grid" : ""}`}>
<div
className="canvas-stage"
style={{
transform: `translate(${view.pan.x}px, ${view.pan.y}px) scale(${view.zoom})`,
}}
>
<iframe
ref={iframeRef}
className="svg-preview-frame"
title="Sanitized SVG preview"
sandbox="allow-scripts"
srcDoc={srcDoc}
onLoad={() => postFrame({ type: "ping" })}
/>
<svg
ref={overlayRef}
className="canvas-overlay"
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}
preserveAspectRatio={preserveAspectRatio}
aria-label={
pathEditing ? "Path editing handles" : "Canvas selection overlay"
}
>
{selectionPoints ? (
<polygon
className="selection-outline"
points={selectionPoints
.map((point) => `${point.x},${point.y}`)
.join(" ")}
vectorEffect="non-scaling-stroke"
/>
) : null}
{pathEditing && displayedInverse
? controlLines.map((line) => (
<line
className={`control-line${line.derived ? " is-derived" : ""}`}
key={line.id}
x1={projectPoint(line.from).x}
y1={projectPoint(line.from).y}
x2={projectPoint(line.to).x}
y2={projectPoint(line.to).y}
vectorEffect="non-scaling-stroke"
/>
))
: null}
{pathEditing && displayedInverse
? handles.map((handle) => (
<circle
key={handle.id}
className={`path-handle is-${handle.role}${handle.derived ? " is-derived" : ""}`}
cx={projectPoint(handle.point).x}
cy={projectPoint(handle.point).y}
r={handle.role === "anchor" ? 5 : 4}
vectorEffect="non-scaling-stroke"
role="button"
tabIndex={0}
aria-label={`${handle.derived ? "Derived " : ""}${handle.role.replaceAll("-", " ")} for segment ${handle.segmentIndex + 1}`}
onPointerDown={(event) => beginDrag(event, handle)}
onPointerMove={drag}
onPointerUp={finishDrag}
onPointerCancel={finishDrag}
onKeyDown={(event) => nudge(event, handle)}
/>
))
: null}
</svg>
</div>
</div>
</section>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { useEffect, useRef } from "react";
export interface HelpDialogProps {
open: boolean;
onClose: () => void;
}
export function HelpDialog({ open, onClose }: HelpDialogProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
}, [open]);
return (
<dialog
ref={dialogRef}
className="tool-dialog help-dialog"
onClose={onClose}
>
<div className="dialog-heading">
<div>
<p className="eyebrow">Local-first vector workbench</p>
<h2>SVG Tools help</h2>
</div>
<button
type="button"
className="icon-button"
aria-label="Close help"
onClick={onClose}
>
×
</button>
</div>
<div className="dialog-body prose">
<p>
The source text is the canonical document. Tree, canvas and property
edits create precise source patches that can be undone.
</p>
<h3>Safety model</h3>
<p>
The canvas uses a sanitized, isolated editing projection. Scripts,
event handlers, navigation and external resources remain visible in
source diagnostics but do not run. Sanitizing source is always an
explicit preview-and-apply operation.
</p>
<h3>Invalid source</h3>
<p>
While XML is incomplete, source editing and undo stay available. The
canvas and tree deliberately retain the last valid revision; visual
edits are disabled until the source parses again.
</p>
<h3>Keyboard</h3>
<ul>
<li>
<kbd>Ctrl</kbd>/<kbd></kbd> + <kbd>O</kbd>: open a document
</li>
<li>
<kbd>Ctrl</kbd>/<kbd></kbd> + <kbd>S</kbd>: download SVG
</li>
<li>
<kbd>Ctrl</kbd>/<kbd></kbd> + <kbd>Z</kbd>: undo a visual/source
transaction
</li>
<li>
Arrow keys move a focused path handle; Shift moves ten units and Alt
moves one tenth.
</li>
</ul>
<p>
No files leave this browser. SVGZ decompression, optimization and
rasterization happen locally.
</p>
</div>
<div className="dialog-actions">
<a
className="secondary-button"
href="https://git.add-ideas.de/lotobo/svg-tools"
target="_blank"
rel="noreferrer"
>
Source and issues
</a>
<button type="button" className="primary-button" onClick={onClose}>
Done
</button>
</div>
</dialog>
);
}
+965
View File
@@ -0,0 +1,965 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
import type { AccessibilityFinding } from "../accessibility/audit";
import type { AnimationDefinition } from "../animation/animation.types";
import type {
SemanticSvgDocument,
SemanticSvgNode,
SvgDiagnostic,
} from "../document/document.types";
import {
diagnoseTransform,
multiply,
rotation,
scaling,
skewX,
skewY,
translation,
type Matrix,
} from "../domain/affine";
import {
describePathCommand,
parsePathData,
reversePath,
serializePathData,
splitSegment,
} from "../domain/path";
import { resolveTransformChain } from "../domain/transform-chain";
export type InspectorTab =
| "document"
| "element"
| "path"
| "transform"
| "animation"
| "accessibility";
export interface InspectorProps {
semantic: SemanticSvgDocument;
selectedKey: string | null;
disabled: boolean;
activeTab: InspectorTab;
pathEditing: boolean;
diagnostics: readonly SvgDiagnostic[];
accessibility: readonly AccessibilityFinding[];
animations: readonly AnimationDefinition[];
animationPreview: boolean;
onTabChange: (tab: InspectorTab) => void;
onPathEditingChange: (editing: boolean) => void;
onSetAttribute: (
name: string,
value: string | null,
mergeKey?: string,
) => void;
onCommitPath: (data: string, label: string) => void;
onTransformPreview: (matrix: Matrix | null) => void;
onApplyTransform: (matrix: Matrix, mode: "attribute" | "bake") => void;
onAccessibilityFix: (
fix: "add-title" | "add-description",
text: string,
) => void;
onAnimationsChange: (animations: AnimationDefinition[]) => void;
onAnimationPreviewChange: (preview: boolean) => void;
onApplyAnimations: () => void;
}
function Field({
label,
value,
type = "text",
onCommit,
}: {
label: string;
value: string;
type?: "text" | "number" | "color";
onCommit: (value: string) => void;
}) {
return (
<FieldEditor
key={`${label}:${value}`}
label={label}
value={value}
type={type}
onCommit={onCommit}
/>
);
}
function FieldEditor({
label,
value,
type,
onCommit,
}: {
label: string;
value: string;
type: "text" | "number" | "color";
onCommit: (value: string) => void;
}) {
const [draft, setDraft] = useState(value);
return (
<label className="field">
<span>{label}</span>
<input
type={type}
value={draft}
onChange={(event) => setDraft(event.currentTarget.value)}
onBlur={() => draft !== value && onCommit(draft)}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
if (event.key === "Escape") {
setDraft(value);
event.currentTarget.blur();
}
}}
/>
</label>
);
}
function DocumentPanel({
semantic,
onSetAttribute,
}: Pick<InspectorProps, "semantic" | "onSetAttribute">) {
const root = semantic.nodes.get(semantic.rootKey)!;
return (
<div className="inspector-section">
<h3>Document geometry</h3>
<Field
label="ViewBox"
value={root.attributes.viewBox ?? ""}
onCommit={(value) => onSetAttribute("viewBox", value || null)}
/>
<div className="field-grid">
<Field
label="Width"
value={root.attributes.width ?? ""}
onCommit={(value) => onSetAttribute("width", value || null)}
/>
<Field
label="Height"
value={root.attributes.height ?? ""}
onCommit={(value) => onSetAttribute("height", value || null)}
/>
</div>
<Field
label="preserveAspectRatio"
value={root.attributes.preserveAspectRatio ?? "xMidYMid meet"}
onCommit={(value) =>
onSetAttribute("preserveAspectRatio", value || null)
}
/>
<h3>Metrics</h3>
<dl className="metric-grid">
<div>
<dt>Elements</dt>
<dd>{semantic.metrics.elementCount.toLocaleString()}</dd>
</div>
<div>
<dt>Attributes</dt>
<dd>{semantic.metrics.attributeCount.toLocaleString()}</dd>
</div>
<div>
<dt>Path commands</dt>
<dd>{semantic.metrics.pathCommandCount.toLocaleString()}</dd>
</div>
<div>
<dt>References</dt>
<dd>{semantic.metrics.referenceCount.toLocaleString()}</dd>
</div>
<div>
<dt>Animations</dt>
<dd>{semantic.metrics.animationCount.toLocaleString()}</dd>
</div>
<div>
<dt>Source bytes</dt>
<dd>{semantic.metrics.sourceBytes.toLocaleString()}</dd>
</div>
</dl>
<p className="hint">
Changing document geometry writes only the relevant root attribute. The
source is not reformatted.
</p>
</div>
);
}
function ElementPanel({
node,
onSetAttribute,
}: {
node: SemanticSvgNode | undefined;
onSetAttribute: InspectorProps["onSetAttribute"];
}) {
const [newName, setNewName] = useState("");
const [newValue, setNewValue] = useState("");
if (!node)
return <p className="empty-state">Select an element to inspect it.</p>;
const submit = (event: FormEvent) => {
event.preventDefault();
if (!newName.trim()) return;
onSetAttribute(newName.trim(), newValue);
setNewName("");
setNewValue("");
};
return (
<div className="inspector-section">
<div className="element-summary">
<span className="element-glyph">&lt;{node.name}&gt;</span>
<span>{node.id ? `#${node.id}` : "No ID"}</span>
</div>
<h3>Common styling</h3>
<div className="field-grid">
<Field
label="Fill"
value={node.attributes.fill ?? ""}
onCommit={(value) =>
onSetAttribute("fill", value || null, "style-fill")
}
/>
<Field
label="Stroke"
value={node.attributes.stroke ?? ""}
onCommit={(value) =>
onSetAttribute("stroke", value || null, "style-stroke")
}
/>
<Field
label="Stroke width"
value={node.attributes["stroke-width"] ?? ""}
onCommit={(value) =>
onSetAttribute("stroke-width", value || null, "style-stroke-width")
}
/>
<Field
label="Opacity"
value={node.attributes.opacity ?? ""}
onCommit={(value) =>
onSetAttribute("opacity", value || null, "style-opacity")
}
/>
</div>
<h3>Attributes</h3>
<div className="attribute-list">
{Object.entries(node.attributes).map(([name, value]) => (
<div className="attribute-row" key={name}>
<Field
label={name}
value={value}
onCommit={(next) =>
onSetAttribute(name, next, `attribute:${name}`)
}
/>
<button
type="button"
className="icon-button"
aria-label={`Remove ${name}`}
onClick={() => onSetAttribute(name, null)}
>
×
</button>
</div>
))}
</div>
<form className="add-attribute" onSubmit={submit}>
<label className="field">
<span>New name</span>
<input
value={newName}
onChange={(event) => setNewName(event.currentTarget.value)}
/>
</label>
<label className="field">
<span>Value</span>
<input
value={newValue}
onChange={(event) => setNewValue(event.currentTarget.value)}
/>
</label>
<button className="secondary-button" type="submit">
Add attribute
</button>
</form>
</div>
);
}
function PathPanel({
node,
pathEditing,
onPathEditingChange,
onCommitPath,
}: {
node: SemanticSvgNode | undefined;
pathEditing: boolean;
onPathEditingChange: (editing: boolean) => void;
onCommitPath: (data: string, label: string) => void;
}) {
const [segmentIndex, setSegmentIndex] = useState(1);
if (node?.localName !== "path") {
return (
<p className="empty-state">
Select a path element to edit commands and handles.
</p>
);
}
let model;
try {
model = parsePathData(node.attributes.d ?? "");
} catch (error) {
return (
<div className="inline-error" role="alert">
{error instanceof Error ? error.message : "Invalid path data"}
</div>
);
}
const drawable = model.segments.filter(
(segment) => segment.kind !== "M" && segment.kind !== "Z",
).length;
return (
<div className="inspector-section">
<label className="toggle-row">
<span>
<strong>Edit handles</strong>
<small>Drag on canvas or use arrow keys</small>
</span>
<input
type="checkbox"
checked={pathEditing}
onChange={(event) => onPathEditingChange(event.currentTarget.checked)}
/>
</label>
<dl className="metric-grid">
<div>
<dt>Segments</dt>
<dd>{model.segments.length}</dd>
</div>
<div>
<dt>Drawable</dt>
<dd>{drawable}</dd>
</div>
<div>
<dt>Subpaths</dt>
<dd>
{model.segments.filter((segment) => segment.kind === "M").length}
</dd>
</div>
</dl>
<div className="button-row">
<button
type="button"
className="secondary-button"
onClick={() =>
onCommitPath(serializePathData(reversePath(model)), "Reverse path")
}
>
Reverse path
</button>
</div>
<div className="split-row">
<label className="field">
<span>Segment number</span>
<input
type="number"
min="1"
max={model.segments.length}
value={segmentIndex}
onChange={(event) =>
setSegmentIndex(event.currentTarget.valueAsNumber || 1)
}
/>
</label>
<button
type="button"
className="secondary-button"
onClick={() => {
try {
onCommitPath(
serializePathData(splitSegment(model, segmentIndex - 1)),
"Split path segment",
);
} catch (error) {
globalThis.alert(
error instanceof Error
? error.message
: "Segment cannot be split",
);
}
}}
>
Split at 50%
</button>
</div>
<h3>Path commands</h3>
<div className="path-command-table-wrap">
<table className="path-command-table">
<caption className="sr-only">
Path source commands and resolved geometry
</caption>
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Source</th>
<th scope="col">Endpoint</th>
<th scope="col">Controls / arc</th>
</tr>
</thead>
<tbody>
{model.segments.map((segment, index) => {
const row = describePathCommand(segment, node.attributes.d ?? "");
return (
<tr key={`${index}-${segment.kind}`}>
<th scope="row">{index + 1}</th>
<td>
<span className="path-command-kind">
<code>{row.sourceCommand}</code>
{row.sourceCommand.toUpperCase() !==
row.normalizedCommand ? (
<span
aria-label={`normalizes to ${row.normalizedCommand}`}
>
{row.normalizedCommand}
</span>
) : null}
</span>
<small>{row.form}</small>
<code
className="path-source-fragment"
title={row.sourceFragment}
>
{row.sourceFragment}
</code>
</td>
<td>
<code>{row.endpoint}</code>
</td>
<td>
{row.details.map((detail) => (
<span className="path-command-detail" key={detail.label}>
<strong>{detail.label}</strong>{" "}
<code>{detail.value}</code>
{detail.derived ? (
<span
className="derived-control-cue"
title="Reflected from the previous control point"
>
derived
</span>
) : null}
</span>
))}
{!row.details.length ? (
<span className="path-command-empty"></span>
) : null}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<details>
<summary>Normalized path data</summary>
<code className="code-block">{serializePathData(model)}</code>
</details>
<p className="hint">
Editing normalizes shorthand and relative commands into explicit
absolute geometry. The preview shows the exact replacement before each
transaction is committed.
</p>
</div>
);
}
function TransformPanel({
semantic,
node,
onPreview,
onApply,
}: {
semantic: SemanticSvgDocument;
node: SemanticSvgNode | undefined;
onPreview: (matrix: Matrix | null) => void;
onApply: (matrix: Matrix, mode: "attribute" | "bake") => void;
}) {
const [tx, setTx] = useState(0);
const [ty, setTy] = useState(0);
const [originX, setOriginX] = useState(0);
const [originY, setOriginY] = useState(0);
const [angle, setAngle] = useState(0);
const [sx, setSx] = useState(1);
const [sy, setSy] = useState(1);
const [skewXAngle, setSkewXAngle] = useState(0);
const [skewYAngle, setSkewYAngle] = useState(0);
const [mode, setMode] = useState<"attribute" | "bake">("attribute");
const matrix = useMemo(() => {
const operation = multiply(
multiply(multiply(rotation(angle), scaling(sx, sy)), skewX(skewXAngle)),
skewY(skewYAngle),
);
const aroundOrigin = multiply(
multiply(translation(originX, originY), operation),
translation(-originX, -originY),
);
return multiply(translation(tx, ty), aroundOrigin);
}, [angle, originX, originY, skewXAngle, skewYAngle, sx, sy, tx, ty]);
useEffect(() => {
onPreview(node ? matrix : null);
return () => onPreview(null);
}, [matrix, node, onPreview]);
const report = diagnoseTransform(
`matrix(${matrix.a} ${matrix.b} ${matrix.c} ${matrix.d} ${matrix.e} ${matrix.f})`,
);
const chain = useMemo(
() => (node ? resolveTransformChain(semantic, node.key) : null),
[node, semantic],
);
const bakeSupported = Boolean(
node &&
[
"path",
"line",
"polyline",
"polygon",
"rect",
"circle",
"ellipse",
].includes(node.localName),
);
if (!node)
return <p className="empty-state">Select an element to transform it.</p>;
const number = (
label: string,
value: number,
setter: (value: number) => void,
step = 1,
) => (
<label className="field">
<span>{label}</span>
<input
type="number"
step={step}
value={value}
onChange={(event) => setter(event.currentTarget.valueAsNumber || 0)}
/>
</label>
);
return (
<div className="inspector-section">
<div className="field-grid">
{number("Translate X", tx, setTx)}
{number("Translate Y", ty, setTy)}
{number("Origin X", originX, setOriginX)}
{number("Origin Y", originY, setOriginY)}
{number("Rotate °", angle, setAngle)}
{number("Skew X °", skewXAngle, setSkewXAngle)}
{number("Skew Y °", skewYAngle, setSkewYAngle)}
{number("Scale X", sx, setSx, 0.1)}
{number("Scale Y", sy, setSy, 0.1)}
</div>
<div className="button-row">
<button
type="button"
className="secondary-button"
onClick={() => setSx((value) => -value)}
>
Flip horizontal
</button>
<button
type="button"
className="secondary-button"
onClick={() => setSy((value) => -value)}
>
Flip vertical
</button>
</div>
<label className="field">
<span>Apply as</span>
<select
value={mode}
onChange={(event) =>
setMode(event.currentTarget.value as typeof mode)
}
>
<option value="attribute">Transform attribute</option>
<option value="bake">Bake into geometry</option>
</select>
</label>
{mode === "bake" && !bakeSupported ? (
<p className="inline-warning">
Baking this element type is not deterministic. Keep its transform
attribute or edit source.
</p>
) : null}
{mode === "bake" && bakeSupported ? (
<p className="inline-warning">
The current local transform and this draft are baked together. Safe
primitives stay native; general rectangles and ellipses convert to
paths. Stroke consequences and the source diff are shown before Apply.
</p>
) : null}
<h3>Ancestor transform chain</h3>
{chain?.entries.map((entry) => (
<div className="transform-chain-row" key={entry.nodeKey}>
<strong>
&lt;{entry.elementName}&gt;
{entry.elementId ? `#${entry.elementId}` : ""}
</strong>
<code>{entry.source}</code>
<small>
matrix(
{[
entry.combined.a,
entry.combined.b,
entry.combined.c,
entry.combined.d,
entry.combined.e,
entry.combined.f,
]
.map((value) => value.toFixed(4))
.join(" ")}
)
</small>
</div>
))}
{!chain?.entries.length ? (
<p className="hint">
No source transform attributes in this selection chain.
</p>
) : null}
{chain?.diagnostics.map((message) => (
<p className="diagnostic is-warning" key={message}>
{message}
</p>
))}
{report.diagnostics.map((diagnostic) => (
<p
className={`diagnostic is-${diagnostic.severity}`}
key={diagnostic.code}
>
{diagnostic.message}
</p>
))}
<code className="code-block">
matrix(
{[matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f]
.map((value) => value.toFixed(4))
.join(" ")}
)
</code>
<div className="button-row end">
<button
type="button"
className="secondary-button"
onClick={() => {
setTx(0);
setTy(0);
setOriginX(0);
setOriginY(0);
setAngle(0);
setSx(1);
setSy(1);
setSkewXAngle(0);
setSkewYAngle(0);
}}
>
Reset
</button>
<button
type="button"
className="primary-button"
disabled={mode === "bake" && !bakeSupported}
onClick={() => onApply(matrix, mode)}
>
Preview transform
</button>
</div>
</div>
);
}
function AnimationPanel({
node,
animations,
preview,
onChange,
onPreviewChange,
onApply,
}: {
node: SemanticSvgNode | undefined;
animations: readonly AnimationDefinition[];
preview: boolean;
onChange: (animations: AnimationDefinition[]) => void;
onPreviewChange: (preview: boolean) => void;
onApply: () => void;
}) {
const add = () => {
if (!node) return;
onChange([
...animations,
{
id: globalThis.crypto.randomUUID(),
name: "Opacity pulse",
targetNodeKey: node.key,
property: "opacity",
kind: "style",
enabled: true,
keyframes: [
{ offset: 0, value: "0.25" },
{ offset: 1, value: "1" },
],
timing: {
durationMs: 1_000,
delayMs: 0,
iterations: "infinite",
direction: "alternate",
fillMode: "both",
easing: "ease-in-out",
},
},
]);
};
return (
<div className="inspector-section">
<label className="toggle-row">
<span>
<strong>Preview animations</strong>
<small>App-owned CSS in the isolated canvas only</small>
</span>
<input
type="checkbox"
checked={preview}
onChange={(event) => onPreviewChange(event.currentTarget.checked)}
/>
</label>
<button
type="button"
className="secondary-button"
disabled={!node}
onClick={add}
>
Add opacity animation
</button>
<div className="animation-list">
{animations.map((animation, index) => (
<article className="animation-card" key={animation.id}>
<div>
<strong>{animation.name}</strong>
<small>
{animation.property} · {animation.timing.durationMs} ms
</small>
</div>
<label>
<input
type="checkbox"
checked={animation.enabled}
onChange={(event) =>
onChange(
animations.map((candidate, candidateIndex) =>
candidateIndex === index
? { ...candidate, enabled: event.currentTarget.checked }
: candidate,
),
)
}
/>{" "}
enabled
</label>
<button
type="button"
className="icon-button"
aria-label={`Remove ${animation.name}`}
onClick={() =>
onChange(
animations.filter(
(_, candidateIndex) => candidateIndex !== index,
),
)
}
>
×
</button>
</article>
))}
{!animations.length ? (
<p className="empty-state">No app-side animations yet.</p>
) : null}
</div>
<button
type="button"
className="primary-button"
disabled={!animations.length}
onClick={onApply}
>
Apply animations to source
</button>
<p className="hint">
Preview definitions are stored in SVG Tools projects. Applying generates
explicit CSS in the SVG and requires stable target IDs.
</p>
</div>
);
}
function AccessibilityPanel({
findings,
diagnostics,
onFix,
}: {
findings: readonly AccessibilityFinding[];
diagnostics: readonly SvgDiagnostic[];
onFix: InspectorProps["onAccessibilityFix"];
}) {
return (
<div className="inspector-section">
<p className="hint">
This audit reports evidence and limitations; it cannot determine author
intent or replace testing with assistive technology.
</p>
<div className="finding-list">
{findings.map((finding, index) => (
<article
className={`finding is-${finding.severity}`}
key={`${finding.rule}-${index}`}
>
<div className="finding-title">
<strong>{finding.rule}</strong>
<span>{finding.severity}</span>
</div>
<p>{finding.evidence}</p>
<small>{finding.limitation}</small>
<p>
<strong>Suggestion:</strong> {finding.suggestedFix}
</p>
{finding.automaticFix === "add-title" ? (
<button
type="button"
className="secondary-button"
onClick={() => onFix("add-title", "Describe this image")}
>
Add title
</button>
) : null}
{finding.automaticFix === "add-description" ? (
<button
type="button"
className="secondary-button"
onClick={() =>
onFix("add-description", "Longer description of this image")
}
>
Add description
</button>
) : null}
</article>
))}
{!findings.length ? (
<p className="success-state">
No findings from the current local audit.
</p>
) : null}
</div>
{diagnostics
.filter((item) => item.code.startsWith("reference-"))
.map((item, index) => (
<p
className={`diagnostic is-${item.severity}`}
key={`${item.code}-${index}`}
>
{item.message}
</p>
))}
</div>
);
}
export function Inspector(props: InspectorProps) {
const node = props.selectedKey
? props.semantic.nodes.get(props.selectedKey)
: undefined;
const tabs: Array<{ id: InspectorTab; label: string }> = [
{ id: "document", label: "Document" },
{ id: "element", label: "Element" },
{ id: "path", label: "Path" },
{ id: "transform", label: "Transform" },
{ id: "animation", label: "Animate" },
{ id: "accessibility", label: "A11y" },
];
return (
<section
className="panel inspector-panel"
aria-labelledby="inspector-heading"
aria-disabled={props.disabled}
>
<div className="panel-heading compact">
<div>
<p className="eyebrow">Properties</p>
<h2 id="inspector-heading">Inspector</h2>
</div>
</div>
<div
className="inspector-tabs"
role="tablist"
aria-label="Inspector sections"
>
{tabs.map((tab) => (
<button
type="button"
role="tab"
aria-selected={props.activeTab === tab.id}
className={props.activeTab === tab.id ? "is-active" : ""}
key={tab.id}
onClick={() => props.onTabChange(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className="inspector-body" role="tabpanel">
<fieldset disabled={props.disabled} className="inspector-fieldset">
{props.activeTab === "document" ? (
<DocumentPanel
semantic={props.semantic}
onSetAttribute={props.onSetAttribute}
/>
) : null}
{props.activeTab === "element" ? (
<ElementPanel node={node} onSetAttribute={props.onSetAttribute} />
) : null}
{props.activeTab === "path" ? (
<PathPanel
node={node}
pathEditing={props.pathEditing}
onPathEditingChange={props.onPathEditingChange}
onCommitPath={props.onCommitPath}
/>
) : null}
{props.activeTab === "transform" ? (
<TransformPanel
semantic={props.semantic}
node={node}
onPreview={props.onTransformPreview}
onApply={props.onApplyTransform}
/>
) : null}
{props.activeTab === "animation" ? (
<AnimationPanel
node={node}
animations={props.animations}
preview={props.animationPreview}
onChange={props.onAnimationsChange}
onPreviewChange={props.onAnimationPreviewChange}
onApply={props.onApplyAnimations}
/>
) : null}
{props.activeTab === "accessibility" ? (
<AccessibilityPanel
findings={props.accessibility}
diagnostics={props.diagnostics}
onFix={props.onAccessibilityFix}
/>
) : null}
</fieldset>
</div>
</section>
);
}
+164
View File
@@ -0,0 +1,164 @@
import { useEffect, useRef } from "react";
import { defaultKeymap, indentWithTab } from "@codemirror/commands";
import { xml } from "@codemirror/lang-xml";
import {
bracketMatching,
defaultHighlightStyle,
foldGutter,
foldKeymap,
indentOnInput,
syntaxHighlighting,
} from "@codemirror/language";
import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
import { Annotation, EditorState } from "@codemirror/state";
import {
crosshairCursor,
drawSelection,
dropCursor,
EditorView,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
rectangularSelection,
} from "@codemirror/view";
import type { SourceRange } from "../document/document.types";
const externalUpdate = Annotation.define<boolean>();
export interface SourceEditorProps {
value: string;
onChange: (value: string) => void;
onSelectionChange?: (offset: number) => void;
onUndo?: () => void;
onRedo?: () => void;
revealRange?: SourceRange;
ariaLabel?: string;
}
export function SourceEditor({
value,
onChange,
onSelectionChange,
onUndo,
onRedo,
revealRange,
ariaLabel = "SVG source",
}: SourceEditorProps) {
const hostRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<EditorView | null>(null);
const onChangeRef = useRef(onChange);
const onSelectionChangeRef = useRef(onSelectionChange);
const onUndoRef = useRef(onUndo);
const onRedoRef = useRef(onRedo);
const initialValueRef = useRef(value);
useEffect(() => {
onChangeRef.current = onChange;
onSelectionChangeRef.current = onSelectionChange;
onUndoRef.current = onUndo;
onRedoRef.current = onRedo;
}, [onChange, onRedo, onSelectionChange, onUndo]);
useEffect(() => {
if (!hostRef.current) return;
const view = new EditorView({
state: EditorState.create({
doc: initialValueRef.current,
extensions: [
lineNumbers(),
highlightActiveLineGutter(),
foldGutter(),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
bracketMatching(),
rectangularSelection(),
crosshairCursor(),
highlightActiveLine(),
highlightSelectionMatches(),
xml(),
keymap.of([
{
key: "Mod-z",
run: () => {
onUndoRef.current?.();
return true;
},
},
{
key: "Shift-Mod-z",
run: () => {
onRedoRef.current?.();
return true;
},
},
{
key: "Mod-y",
run: () => {
onRedoRef.current?.();
return true;
},
},
indentWithTab,
...defaultKeymap,
...searchKeymap,
...foldKeymap,
]),
EditorView.lineWrapping,
EditorView.contentAttributes.of({ "aria-label": ariaLabel }),
EditorView.theme({
"&": { height: "100%", background: "transparent" },
".cm-scroller": { overflow: "auto", fontFamily: "var(--svg-mono)" },
".cm-gutters": {
background: "color-mix(in srgb, Canvas 94%, currentColor 6%)",
},
"&.cm-focused": { outline: "none" },
}),
EditorView.updateListener.of((update) => {
const external = update.transactions.some((transaction) =>
transaction.annotation(externalUpdate),
);
if (update.docChanged && !external) {
onChangeRef.current(update.state.doc.toString());
}
if (update.selectionSet && !external) {
onSelectionChangeRef.current?.(update.state.selection.main.head);
}
}),
],
}),
parent: hostRef.current,
});
editorRef.current = view;
return () => {
editorRef.current = null;
view.destroy();
};
}, [ariaLabel]);
useEffect(() => {
const view = editorRef.current;
if (!view || view.state.doc.toString() === value) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
annotations: externalUpdate.of(true),
});
}, [value]);
useEffect(() => {
const view = editorRef.current;
if (!view || !revealRange) return;
const from = Math.min(revealRange.from, view.state.doc.length);
const to = Math.min(Math.max(from, revealRange.to), view.state.doc.length);
view.dispatch({
selection: { anchor: from, head: to },
effects: EditorView.scrollIntoView(from, { y: "center" }),
annotations: externalUpdate.of(true),
});
}, [revealRange]);
return <div className="source-editor-host" ref={hostRef} />;
}
+264
View File
@@ -0,0 +1,264 @@
import { useMemo, useRef, useState, type KeyboardEvent } from "react";
import type {
SemanticSvgDocument,
SemanticSvgNode,
SvgDiagnostic,
} from "../document/document.types";
interface VisibleNode {
node: SemanticSvgNode;
level: number;
}
const MAXIMUM_VISIBLE_TREE_ROWS = 2_000;
export interface StructureTreeProps {
semantic: SemanticSvgDocument;
selectedKey: string | null;
expanded: ReadonlySet<string>;
diagnostics: readonly SvgDiagnostic[];
disabled: boolean;
onSelect: (nodeKey: string) => void;
onExpandedChange: (expanded: Set<string>) => void;
}
function searchable(node: SemanticSvgNode): string {
return `${node.name} ${node.id ?? ""} ${node.classes.join(" ")} ${Object.keys(node.attributes).join(" ")}`.toLowerCase();
}
function iconFor(node: SemanticSvgNode): string {
if (node.localName === "svg") return "◇";
if (node.localName === "path") return "⌁";
if (["circle", "ellipse"].includes(node.localName)) return "○";
if (["rect", "image"].includes(node.localName)) return "□";
if (["text", "tspan", "textPath"].includes(node.localName)) return "T";
if (["g", "defs", "symbol"].includes(node.localName)) return "▱";
return "·";
}
export function StructureTree({
semantic,
selectedKey,
expanded,
diagnostics,
disabled,
onSelect,
onExpandedChange,
}: StructureTreeProps) {
const [query, setQuery] = useState("");
const treeRef = useRef<HTMLDivElement>(null);
const normalized = query.trim().toLowerCase();
const included = useMemo(() => {
if (!normalized) return null;
const result = new Set<string>();
for (const node of semantic.nodes.values()) {
if (!searchable(node).includes(normalized)) continue;
let current: SemanticSvgNode | undefined = node;
while (current) {
result.add(current.key);
current = current.parentKey
? semantic.nodes.get(current.parentKey)
: undefined;
}
}
return result;
}, [normalized, semantic]);
const visibleResult = useMemo(() => {
const rows: VisibleNode[] = [];
let truncated = false;
const visit = (key: string, level: number) => {
if (rows.length >= MAXIMUM_VISIBLE_TREE_ROWS) {
truncated = true;
return;
}
const node = semantic.nodes.get(key);
if (!node || (included && !included.has(key))) return;
rows.push({ node, level });
if (normalized || expanded.has(key)) {
for (const child of node.childKeys) {
visit(child, level + 1);
if (truncated) break;
}
}
};
visit(semantic.rootKey, 1);
return { rows, truncated };
}, [expanded, included, normalized, semantic]);
const visible = visibleResult.rows;
const counts = useMemo(() => {
const map = new Map<string, number>();
for (const diagnostic of diagnostics) {
if (diagnostic.nodeKey)
map.set(diagnostic.nodeKey, (map.get(diagnostic.nodeKey) ?? 0) + 1);
}
return map;
}, [diagnostics]);
const toggle = (node: SemanticSvgNode) => {
const next = new Set(expanded);
if (next.has(node.key)) next.delete(node.key);
else next.add(node.key);
onExpandedChange(next);
};
const focusAt = (index: number) => {
const bounded = Math.max(0, Math.min(visible.length - 1, index));
const key = visible[bounded]?.node.key;
if (!key) return;
onSelect(key);
requestAnimationFrame(() => {
treeRef.current
?.querySelector<HTMLElement>(`[data-node-key="${CSS.escape(key)}"]`)
?.focus();
});
};
const onKeyDown = (
event: KeyboardEvent,
index: number,
node: SemanticSvgNode,
) => {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
focusAt(index + 1);
break;
case "ArrowUp":
event.preventDefault();
focusAt(index - 1);
break;
case "Home":
event.preventDefault();
focusAt(0);
break;
case "End":
event.preventDefault();
focusAt(visible.length - 1);
break;
case "ArrowRight":
event.preventDefault();
if (node.childKeys.length && !expanded.has(node.key)) toggle(node);
else if (node.childKeys.length) focusAt(index + 1);
break;
case "ArrowLeft":
event.preventDefault();
if (expanded.has(node.key)) toggle(node);
else if (node.parentKey) onSelect(node.parentKey);
break;
case "Enter":
case " ":
event.preventDefault();
onSelect(node.key);
break;
}
};
return (
<section className="panel tree-panel" aria-labelledby="structure-heading">
<div className="panel-heading compact">
<div>
<p className="eyebrow">Document</p>
<h2 id="structure-heading">Structure</h2>
</div>
<span className="count-badge">{semantic.metrics.elementCount}</span>
</div>
<label className="tree-search">
<span className="sr-only">Filter elements</span>
<input
type="search"
value={query}
placeholder="Filter elements…"
onChange={(event) => setQuery(event.currentTarget.value)}
/>
</label>
<div
className="semantic-tree"
role="tree"
aria-label="SVG element structure"
aria-disabled={disabled}
ref={treeRef}
>
{visible.map(({ node, level }, index) => {
const children = node.childKeys.length > 0;
const selected = node.key === selectedKey;
return (
<div
className="tree-row"
key={node.key}
style={{ "--tree-depth": level } as React.CSSProperties}
>
<button
type="button"
className="tree-expander"
aria-label={
children
? `${expanded.has(node.key) ? "Collapse" : "Expand"} ${node.name}`
: "No children"
}
disabled={!children}
tabIndex={-1}
onClick={() => toggle(node)}
>
{children
? expanded.has(node.key) || normalized
? "▾"
: "▸"
: ""}
</button>
<button
type="button"
role="treeitem"
aria-level={level}
aria-expanded={
children
? expanded.has(node.key) || Boolean(normalized)
: undefined
}
aria-selected={selected}
tabIndex={selected || (!selectedKey && index === 0) ? 0 : -1}
className={`tree-item${selected ? " is-selected" : ""}`}
data-node-key={node.key}
disabled={disabled}
onClick={() => onSelect(node.key)}
onKeyDown={(event) => onKeyDown(event, index, node)}
>
<span className="tree-icon" aria-hidden="true">
{iconFor(node)}
</span>
<span className="tree-label">
<strong>{node.name}</strong>
{node.id ? <span>#{node.id}</span> : null}
{node.classes.slice(0, 2).map((name) => (
<span key={name}>.{name}</span>
))}
</span>
{!node.rendered ? (
<span className="tree-kind" title="Definition or metadata">
def
</span>
) : null}
{counts.has(node.key) ? (
<span
className="diagnostic-count"
aria-label={`${counts.get(node.key)} diagnostics`}
>
{counts.get(node.key)}
</span>
) : null}
</button>
</div>
);
})}
{!visible.length ? (
<p className="empty-state">No elements match this filter.</p>
) : null}
{visibleResult.truncated ? (
<p className="tree-limit-note" role="status">
Showing the first {MAXIMUM_VISIBLE_TREE_ROWS.toLocaleString()} rows.
Narrow the filter or collapse branches to inspect the rest.
</p>
) : null}
</div>
</section>
);
}
File diff suppressed because it is too large Load Diff
+741
View File
@@ -0,0 +1,741 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { EMPTY_SVG, STARTER_SVG } from "../app/sample";
import type { SemanticSvgDocument } from "../document/document.types";
import { downloadBlob } from "../export/file-name";
import {
createSelectedSvgSource,
createSymbolSpriteSource,
} from "../export/derived-svg-export";
import {
rasterizeProjection,
type RasterFormat,
} from "../export/raster-export";
import { createSvgExport } from "../export/svg-export";
import { lineDiff } from "../format/diff";
import { formatSvgSource } from "../format/formatter";
import { OptimizerClient } from "../optimization/optimizer-client";
import {
configForProfile,
optionalOptimizationPlugins,
optimizationProfiles,
type OptionalOptimizationPlugin,
type OptimizationProfile,
} from "../optimization/profiles";
import { createSanitizedCandidate } from "../security/sanitize-svg";
import { SVGO_VERSION } from "../version";
function useModal(open: boolean) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
}, [open]);
return ref;
}
export function ChangePreviewDialog({
open,
title,
description,
before,
after,
applyLabel = "Apply change",
onApply,
onClose,
}: {
open: boolean;
title: string;
description: string;
before: string;
after: string;
applyLabel?: string;
onApply: () => void;
onClose: () => void;
}) {
const ref = useModal(open);
const lines = useMemo(() => lineDiff(before, after), [after, before]);
const changed = lines.filter((line) => line.kind !== "same").length;
return (
<dialog className="tool-dialog preview-dialog" ref={ref} onClose={onClose}>
<div className="dialog-heading">
<div>
<p className="eyebrow">Preview required</p>
<h2>{title}</h2>
</div>
<button
type="button"
className="icon-button"
aria-label={`Close ${title}`}
onClick={onClose}
>
×
</button>
</div>
<div className="dialog-body">
<p>{description}</p>
<p className="change-summary">
{changed.toLocaleString()} changed lines ·{" "}
{before.length.toLocaleString()} {after.length.toLocaleString()}{" "}
characters
</p>
<div className="source-diff" aria-label="Source change preview">
{lines.map((line, index) => (
<div
className={`diff-line is-${line.kind}`}
key={`${index}-${line.kind}`}
>
<span>
{line.kind === "add" ? "+" : line.kind === "remove" ? "" : " "}
</span>
<code>{line.text || " "}</code>
</div>
))}
</div>
</div>
<div className="dialog-actions">
<button type="button" className="secondary-button" onClick={onClose}>
Cancel
</button>
<button
type="button"
className="primary-button"
disabled={before === after}
onClick={onApply}
>
{applyLabel}
</button>
</div>
</dialog>
);
}
export function OptimizeDialog({
open,
source,
revision,
fileName,
onApply,
onStatus,
onClose,
}: {
open: boolean;
source: string;
revision: number;
fileName: string;
onApply: (source: string, expectedRevision: number) => void;
onStatus: (message: string) => void;
onClose: () => void;
}) {
const ref = useModal(open);
const clientRef = useRef(new OptimizerClient());
const [profile, setProfile] = useState<OptimizationProfile>("conservative");
const [optionalPlugins, setOptionalPlugins] = useState<
OptionalOptimizationPlugin[]
>([]);
const pluginNames = useMemo(
() =>
(configForProfile(profile, optionalPlugins).plugins ?? []).map(
(plugin) => (typeof plugin === "string" ? plugin : plugin.name),
),
[optionalPlugins, profile],
);
const [result, setResult] = useState<{
source: string;
inputBytes: number;
outputBytes: number;
elapsedMs: number;
profile: OptimizationProfile;
optionalPlugins: OptionalOptimizationPlugin[];
revision: number;
} | null>(null);
const [error, setError] = useState("");
const [running, setRunning] = useState(false);
useEffect(() => {
if (open) return;
clientRef.current.cancel();
let active = true;
queueMicrotask(() => {
if (!active) return;
setRunning(false);
setResult(null);
setError("");
});
return () => {
active = false;
};
}, [open]);
const run = async () => {
setError("");
setRunning(true);
setResult(null);
try {
const optimized = await clientRef.current.optimize(
source,
profile,
optionalPlugins,
);
setResult({ ...optimized, revision });
} catch (failure) {
setError(
failure instanceof Error ? failure.message : "Optimization failed",
);
} finally {
setRunning(false);
}
};
const downloadOptimized = () => {
if (!result) return;
try {
const requestedName = fileName.replace(/\.svgz?$/iu, "-optimized.svg");
const artifact = createSvgExport(result.source, "svg", requestedName);
downloadBlob(artifact.blob, artifact.fileName);
onStatus(
`${artifact.fileName} downloaded without changing the current document.`,
);
} catch (failure) {
onStatus(
failure instanceof Error ? failure.message : "Optimized export failed",
);
}
};
const lines = useMemo(
() => (result ? lineDiff(source, result.source) : []),
[result, source],
);
return (
<dialog className="tool-dialog optimize-dialog" ref={ref} onClose={onClose}>
<div className="dialog-heading">
<div>
<p className="eyebrow">Worker-backed preview</p>
<h2>Optimize SVG</h2>
</div>
<button
type="button"
className="icon-button"
aria-label="Close optimizer"
onClick={onClose}
>
×
</button>
</div>
<div className="dialog-body">
<p className="change-summary">
SVGO {SVGO_VERSION} runs locally in a dedicated worker.
</p>
<div className="plugin-list" aria-label="Active SVGO plugins">
{pluginNames.map((name) => (
<code key={name}>{name}</code>
))}
</div>
<div className="profile-grid">
{optimizationProfiles.map((candidate) => (
<label
className={`profile-card${profile === candidate.id ? " is-selected" : ""}`}
key={candidate.id}
>
<input
type="radio"
name="profile"
value={candidate.id}
checked={profile === candidate.id}
onChange={() => {
setProfile(candidate.id);
setResult(null);
}}
/>
<strong>{candidate.label}</strong>
<span>{candidate.description}</span>
<small>{candidate.risk}</small>
</label>
))}
</div>
<fieldset className="optional-plugin-list">
<legend>Optional plugins</legend>
{optionalOptimizationPlugins.map((plugin) => {
const checked = optionalPlugins.includes(plugin.id);
return (
<label
className={`plugin-option${checked ? " is-selected" : ""}`}
key={plugin.id}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
setOptionalPlugins((current) =>
checked
? current.filter((id) => id !== plugin.id)
: [...current, plugin.id],
);
setResult(null);
}}
/>
<span>
<strong>{plugin.label}</strong>
<small>{plugin.risk}</small>
</span>
</label>
);
})}
</fieldset>
<p className="inline-warning">
Optimization is not sanitization. Compare the source and rendering
before applying structural changes.
</p>
<button
type="button"
className="secondary-button"
disabled={running}
onClick={() => void run()}
>
{running ? "Optimizing locally…" : "Generate preview"}
</button>
{error ? (
<p className="inline-error" role="alert">
{error}
</p>
) : null}
{result ? (
<>
<p className="change-summary">
{result.inputBytes.toLocaleString()} {" "}
{result.outputBytes.toLocaleString()} bytes (
{Math.round(
(1 - result.outputBytes / Math.max(1, result.inputBytes)) * 100,
)}
% smaller) · {Math.round(result.elapsedMs)} ms
</p>
<button
type="button"
className="secondary-button"
onClick={downloadOptimized}
>
Download optimized SVG
</button>
<div
className="source-diff"
aria-label="Optimization source preview"
>
{lines.map((line, index) => (
<div
className={`diff-line is-${line.kind}`}
key={`${index}-${line.kind}`}
>
<span>
{line.kind === "add"
? "+"
: line.kind === "remove"
? ""
: " "}
</span>
<code>{line.text || " "}</code>
</div>
))}
</div>
</>
) : null}
</div>
<div className="dialog-actions">
<button type="button" className="secondary-button" onClick={onClose}>
Cancel
</button>
<button
type="button"
className="primary-button"
disabled={
!result ||
result.revision !== revision ||
result.profile !== profile ||
result.optionalPlugins.join("|") !== optionalPlugins.join("|")
}
onClick={() => result && onApply(result.source, result.revision)}
>
Apply optimized source
</button>
</div>
</dialog>
);
}
export function ExportDialog({
open,
source,
projection,
fileName,
valid,
semantic,
selectedKeys,
onSaveProject,
onStatus,
onClose,
}: {
open: boolean;
source: string;
projection: string;
fileName: string;
valid: boolean;
semantic: SemanticSvgDocument;
selectedKeys: readonly string[];
onSaveProject: () => void;
onStatus: (message: string) => void;
onClose: () => void;
}) {
const ref = useModal(open);
const [format, setFormat] = useState<RasterFormat>("png");
const [width, setWidth] = useState("");
const [height, setHeight] = useState("");
const [scale, setScale] = useState(1);
const [background, setBackground] = useState("");
const [busy, setBusy] = useState(false);
const svg = (kind: "svg" | "svgz") => {
try {
const artifact = createSvgExport(source, kind, fileName);
downloadBlob(artifact.blob, artifact.fileName);
onStatus(`${artifact.fileName} downloaded locally.`);
} catch (error) {
onStatus(error instanceof Error ? error.message : "Export failed");
}
};
const derived = (
kind: "formatted" | "sanitized" | "selection" | "sprite",
) => {
try {
let output: { source: string; warnings: string[] };
if (kind === "formatted") {
output = {
source: formatSvgSource(source, semantic.preferences.indentation),
warnings: [
"Formatting rewrites whitespace and attribute order in this downloaded copy only.",
],
};
} else if (kind === "sanitized") {
const candidate = createSanitizedCandidate(semantic);
output = {
source: candidate.source,
warnings: [
`Editing-projection policy v1 applied ${candidate.findings.length} security finding(s).`,
],
};
} else if (kind === "selection") {
output = createSelectedSvgSource(semantic, selectedKeys);
} else {
output = createSymbolSpriteSource(semantic, selectedKeys);
}
const suffix = kind === "selection" ? "selected" : kind;
const requestedName = fileName.replace(/\.svgz?$/iu, `-${suffix}.svg`);
const artifact = createSvgExport(output.source, "svg", requestedName);
downloadBlob(artifact.blob, artifact.fileName);
onStatus(
`${artifact.fileName} downloaded locally. ${output.warnings.join(" ")}`,
);
} catch (error) {
onStatus(
error instanceof Error ? error.message : "Derived SVG export failed",
);
}
};
const raster = async () => {
setBusy(true);
try {
const artifact = await rasterizeProjection(projection, {
format,
fileName,
...(width ? { width: Number(width) } : {}),
...(height ? { height: Number(height) } : {}),
scale,
...(background ? { background } : {}),
});
downloadBlob(artifact.blob, artifact.fileName);
onStatus(
`${artifact.fileName} exported at ${artifact.size.width} × ${artifact.size.height}.`,
);
} catch (error) {
onStatus(error instanceof Error ? error.message : "Raster export failed");
} finally {
setBusy(false);
}
};
return (
<dialog className="tool-dialog export-dialog" ref={ref} onClose={onClose}>
<div className="dialog-heading">
<div>
<p className="eyebrow">Local download</p>
<h2>Export</h2>
</div>
<button
type="button"
className="icon-button"
aria-label="Close export"
onClick={onClose}
>
×
</button>
</div>
<div className="dialog-body export-sections">
<section>
<h3>Source-faithful vector</h3>
<p>
SVG retains the exact canonical UTF-8 source. SVGZ adds
deterministic gzip compression.
</p>
<div className="button-row">
<button
type="button"
className="secondary-button"
disabled={!valid}
onClick={() => svg("svg")}
>
Download SVG
</button>
<button
type="button"
className="secondary-button"
disabled={!valid}
onClick={() => svg("svgz")}
>
Download SVGZ
</button>
</div>
</section>
<section>
<h3>Derived SVG copies</h3>
<p>
These downloads are generated explicitly without replacing canonical
source. Selection and sprite exports use the sanitized editing
projection.
</p>
<div className="button-row">
<button
type="button"
className="secondary-button"
disabled={!valid}
onClick={() => derived("formatted")}
>
Formatted SVG
</button>
<button
type="button"
className="secondary-button"
disabled={!valid}
onClick={() => derived("sanitized")}
>
Sanitized SVG
</button>
<button
type="button"
className="secondary-button"
disabled={!valid || selectedKeys.length === 0}
onClick={() => derived("selection")}
>
Selected object
</button>
<button
type="button"
className="secondary-button"
disabled={!valid}
onClick={() => derived("sprite")}
>
Symbol sprite
</button>
</div>
</section>
<section>
<h3>SVG Tools project</h3>
<p>
Preserves canonical source, selection, viewport, panels and app-side
animation definitions.
</p>
<button
type="button"
className="secondary-button"
onClick={onSaveProject}
>
Download project
</button>
</section>
<section>
<h3>Rasterized projection</h3>
<p>
Raster export uses the sanitized editing projection and preserves
aspect ratio when only one dimension is supplied.
</p>
<div className="field-grid">
<label className="field">
<span>Format</span>
<select
value={format}
onChange={(event) =>
setFormat(event.currentTarget.value as RasterFormat)
}
>
<option value="png">PNG</option>
<option value="jpeg">JPEG</option>
<option value="webp">WebP</option>
</select>
</label>
<label className="field">
<span>Scale</span>
<input
type="number"
min="0.1"
max="16"
step="0.1"
value={scale}
onChange={(event) =>
setScale(event.currentTarget.valueAsNumber || 1)
}
/>
</label>
<label className="field">
<span>Width (optional)</span>
<input
type="number"
min="1"
value={width}
onChange={(event) => setWidth(event.currentTarget.value)}
/>
</label>
<label className="field">
<span>Height (optional)</span>
<input
type="number"
min="1"
value={height}
onChange={(event) => setHeight(event.currentTarget.value)}
/>
</label>
<label className="field">
<span>Background (optional)</span>
<input
value={background}
placeholder={format === "jpeg" ? "#ffffff" : "transparent"}
onChange={(event) => setBackground(event.currentTarget.value)}
/>
</label>
</div>
<button
type="button"
className="primary-button"
disabled={busy || !valid}
onClick={() => void raster()}
>
{busy ? "Rasterizing…" : `Export ${format.toUpperCase()}`}
</button>
</section>
</div>
<div className="dialog-actions">
<button type="button" className="primary-button" onClick={onClose}>
Done
</button>
</div>
</dialog>
);
}
export function NewDocumentDialog({
open,
onCreate,
onClose,
}: {
open: boolean;
onCreate: (source: string, fileName: string) => void;
onClose: () => void;
}) {
const ref = useModal(open);
const [template, setTemplate] = useState<"starter" | "empty">("starter");
const [width, setWidth] = useState(640);
const [height, setHeight] = useState(420);
const create = () => {
const source =
template === "starter"
? STARTER_SVG
: EMPTY_SVG.replaceAll("640", String(width)).replaceAll(
"420",
String(height),
);
onCreate(source, "untitled.svg");
};
return (
<dialog className="tool-dialog new-dialog" ref={ref} onClose={onClose}>
<div className="dialog-heading">
<div>
<p className="eyebrow">Fresh document</p>
<h2>New SVG</h2>
</div>
<button
type="button"
className="icon-button"
aria-label="Close new document"
onClick={onClose}
>
×
</button>
</div>
<div className="dialog-body">
<div className="profile-grid">
<label
className={`profile-card${template === "starter" ? " is-selected" : ""}`}
>
<input
type="radio"
checked={template === "starter"}
onChange={() => setTemplate("starter")}
/>
<strong>Starter artwork</strong>
<span>
Gradients, text and an editable path with every major curve
family.
</span>
</label>
<label
className={`profile-card${template === "empty" ? " is-selected" : ""}`}
>
<input
type="radio"
checked={template === "empty"}
onChange={() => setTemplate("empty")}
/>
<strong>Empty document</strong>
<span>A minimal SVG root and accessible title.</span>
</label>
</div>
{template === "empty" ? (
<div className="field-grid">
<label className="field">
<span>Width</span>
<input
type="number"
min="1"
value={width}
onChange={(event) =>
setWidth(event.currentTarget.valueAsNumber || 640)
}
/>
</label>
<label className="field">
<span>Height</span>
<input
type="number"
min="1"
value={height}
onChange={(event) =>
setHeight(event.currentTarget.valueAsNumber || 420)
}
/>
</label>
</div>
) : null}
</div>
<div className="dialog-actions">
<button type="button" className="secondary-button" onClick={onClose}>
Cancel
</button>
<button type="button" className="primary-button" onClick={create}>
Create document
</button>
</div>
</dialog>
);
}
+144
View File
@@ -0,0 +1,144 @@
import type {
SemanticSvgDocument,
SemanticSvgNode,
SvgDiagnostic,
} from "../document/document.types";
const NUMBER_ATTRIBUTES = new Set([
"x",
"y",
"x1",
"y1",
"x2",
"y2",
"cx",
"cy",
"r",
"rx",
"ry",
"width",
"height",
"stroke-width",
]);
function nodeFinding(
node: SemanticSvgNode,
code: string,
message: string,
attribute?: string,
severity: SvgDiagnostic["severity"] = "warning",
): SvgDiagnostic {
return {
severity,
code,
message,
nodeKey: node.key,
range:
(attribute ? node.attributeRanges[attribute]?.valueRange : undefined) ??
node.sourceRange.openTag,
};
}
export function geometryDiagnostics(
semantic: SemanticSvgDocument,
): SvgDiagnostic[] {
const diagnostics: SvgDiagnostic[] = [];
for (const node of semantic.nodes.values()) {
for (const [name, value] of Object.entries(node.attributes)) {
if (NUMBER_ATTRIBUTES.has(name)) {
const numeric = Number.parseFloat(value);
if (!Number.isFinite(numeric)) {
diagnostics.push(
nodeFinding(
node,
"non-finite-geometry",
`${name} is not finite.`,
name,
"error",
),
);
}
}
}
if (["rect", "image", "svg"].includes(node.localName)) {
for (const name of ["width", "height"] as const) {
const value = node.attributes[name];
if (value !== undefined && Number.parseFloat(value) < 0) {
diagnostics.push(
nodeFinding(
node,
"negative-dimension",
`${name} cannot be negative.`,
name,
"error",
),
);
} else if (value !== undefined && Number.parseFloat(value) === 0) {
diagnostics.push(
nodeFinding(
node,
"zero-dimension",
`${name} is zero, so the element has no area.`,
name,
),
);
}
}
}
if (
node.localName === "circle" &&
Number.parseFloat(node.attributes.r ?? "0") <= 0
) {
diagnostics.push(
nodeFinding(
node,
"degenerate-circle",
"Circle radius is zero or negative.",
"r",
),
);
}
if (node.localName === "path") {
const data = node.attributes.d ?? "";
if (!data.trim()) {
diagnostics.push(
nodeFinding(node, "empty-path", "Path data is empty.", "d"),
);
} else if (!/[LlHhVvCcSsQqTtAaZz]/u.test(data)) {
diagnostics.push(
nodeFinding(
node,
"move-only-path",
"Path contains no drawable segment.",
"d",
),
);
}
if (/\b(?:NaN|Infinity|-Infinity)\b/u.test(data)) {
diagnostics.push(
nodeFinding(
node,
"non-finite-path",
"Path data contains a non-finite value.",
"d",
"error",
),
);
}
}
if (
node.attributes.transform &&
/matrix\([^)]*\b0(?:[\s,]+0){3}/u.test(node.attributes.transform)
) {
diagnostics.push(
nodeFinding(
node,
"possibly-non-invertible-transform",
"Transform may be non-invertible; handle dragging will be disabled if its determinant is zero.",
"transform",
),
);
}
}
return diagnostics;
}
+115
View File
@@ -0,0 +1,115 @@
export interface SourceRange {
from: number;
to: number;
}
export type DiagnosticSeverity = "info" | "warning" | "error";
export interface SvgDiagnostic {
severity: DiagnosticSeverity;
code: string;
message: string;
range?: SourceRange;
nodeKey?: string;
fixable?: boolean;
}
export interface AttributeSourceRange {
name: string;
nameRange: SourceRange;
valueRange: SourceRange;
fullRange: SourceRange;
quote: '"' | "'";
}
export interface ElementSourceRange {
openTag: SourceRange;
name: SourceRange;
content?: SourceRange;
full: SourceRange;
}
export interface SemanticSvgNode {
key: string;
name: string;
localName: string;
namespaceUri: string | null;
parentKey: string | null;
childKeys: string[];
depth: number;
id?: string;
classes: string[];
attributes: Readonly<Record<string, string>>;
attributeRanges: Readonly<Record<string, AttributeSourceRange>>;
sourceRange: ElementSourceRange;
rendered: boolean;
text: string;
}
export interface SvgSourcePreferences {
newline: "\n" | "\r\n";
indentation: string;
attributeQuote: '"' | "'";
}
export interface SvgDocumentMetrics {
sourceBytes: number;
elementCount: number;
attributeCount: number;
maximumDepth: number;
pathCommandCount: number;
referenceCount: number;
cssRuleCount: number;
animationCount: number;
filterPrimitiveCount: number;
textLength: number;
embeddedResourceBytes: number;
}
export interface SemanticSvgDocument {
revision: number;
source: string;
rootKey: string;
nodes: ReadonlyMap<string, SemanticSvgNode>;
order: readonly string[];
document: XMLDocument;
preferences: SvgSourcePreferences;
diagnostics: readonly SvgDiagnostic[];
metrics: SvgDocumentMetrics;
}
export interface SvgParseResult {
revision: number;
source: string;
valid: boolean;
semantic: SemanticSvgDocument | null;
diagnostics: readonly SvgDiagnostic[];
preferences: SvgSourcePreferences;
}
export interface SourcePatch {
from: number;
to: number;
insert: string;
label: string;
}
export interface SelectionState {
nodeKeys: string[];
primaryNodeKey: string | null;
}
export interface DocumentTransaction {
id: string;
label: string;
baseRevision: number;
sourceBefore: string;
sourceAfter: string;
sourcePatches: SourcePatch[];
affectedNodeKeys: string[];
selectionBefore: SelectionState;
selectionAfter: SelectionState;
diagnostics: SvgDiagnostic[];
mergeKey?: string;
timestamp: number;
}
+830
View File
@@ -0,0 +1,830 @@
import { xmlLanguage } from "@codemirror/lang-xml";
import {
defaultSvgLimits,
utf8ByteLength,
type SvgResourceLimits,
} from "../app/limits";
import type {
AttributeSourceRange,
ElementSourceRange,
SemanticSvgDocument,
SemanticSvgNode,
SvgDiagnostic,
SvgDocumentMetrics,
SvgParseResult,
SvgSourcePreferences,
} from "./document.types";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const NON_RENDERING_ELEMENTS = new Set([
"defs",
"title",
"desc",
"metadata",
"style",
"script",
"symbol",
"linearGradient",
"radialGradient",
"pattern",
"marker",
"clipPath",
"mask",
"filter",
"animate",
"animateMotion",
"animateTransform",
"set",
]);
const ANIMATION_ELEMENTS = new Set([
"animate",
"animateMotion",
"animateTransform",
"set",
]);
const FILTER_PRIMITIVE_PATTERN = /^fe[A-Z]/u;
const REFERENCE_ATTRIBUTE_PATTERN = /(?:^|\s)url\(\s*#[^)]+\)|^#[^\s]+$/u;
const PATH_COMMAND_PATTERN =
/([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/gu;
const PATH_NUMBER_PATTERN =
/[-+]?(?:(?:\d+\.\d*)|(?:\.\d+)|(?:\d+))(?:[eE][-+]?\d+)?/gu;
const PATH_ARITY: Readonly<Record<string, number>> = {
M: 2,
L: 2,
H: 1,
V: 1,
C: 6,
S: 4,
Q: 4,
T: 2,
};
interface ScannedElement {
name: string;
nameFrom: number;
nameTo: number;
openFrom: number;
openTo: number;
fullTo: number;
selfClosing: boolean;
attributes: Record<string, AttributeSourceRange>;
}
function preferencesFor(source: string): SvgSourcePreferences {
const indentation = /\r?\n([\t ]+)\S/u.exec(source)?.[1] ?? " ";
const singleQuotes = (source.match(/='[^']*'/gu) ?? []).length;
const doubleQuotes = (source.match(/="[^"]*"/gu) ?? []).length;
return {
newline: source.includes("\r\n") ? "\r\n" : "\n",
indentation,
attributeQuote: singleQuotes > doubleQuotes ? "'" : '"',
};
}
function findTagEnd(source: string, from: number): number {
let quote: string | null = null;
for (let index = from; index < source.length; index += 1) {
const character = source[index];
if (quote) {
if (character === quote) quote = null;
} else if (character === '"' || character === "'") {
quote = character;
} else if (character === ">") {
return index + 1;
}
}
return source.length;
}
function scanAttributes(
source: string,
start: number,
end: number,
): Record<string, AttributeSourceRange> {
const attributes: Record<string, AttributeSourceRange> = {};
let index = start;
while (index < end) {
const whitespaceStart = index;
while (/\s/u.test(source[index] ?? "")) index += 1;
if (index >= end || source[index] === "/" || source[index] === ">") break;
const nameFrom = index;
while (/[^\s=/>]/u.test(source[index] ?? "")) index += 1;
const nameTo = index;
const name = source.slice(nameFrom, nameTo);
while (/\s/u.test(source[index] ?? "")) index += 1;
if (source[index] !== "=") {
while (index < end && !/\s|>/u.test(source[index] ?? "")) index += 1;
continue;
}
index += 1;
while (/\s/u.test(source[index] ?? "")) index += 1;
const quote = source[index];
if (quote !== '"' && quote !== "'") {
while (index < end && !/\s|>/u.test(source[index] ?? "")) index += 1;
continue;
}
index += 1;
const valueFrom = index;
while (index < end && source[index] !== quote) index += 1;
const valueTo = index;
if (source[index] === quote) index += 1;
attributes[name] = {
name,
nameRange: { from: nameFrom, to: nameTo },
valueRange: { from: valueFrom, to: valueTo },
fullRange: { from: whitespaceStart, to: index },
quote,
};
}
return attributes;
}
function scanElements(
source: string,
diagnostics: SvgDiagnostic[],
): ScannedElement[] {
const elements: ScannedElement[] = [];
const stack: number[] = [];
let index = 0;
while (index < source.length) {
const opening = source.indexOf("<", index);
if (opening < 0) break;
if (source.startsWith("<!--", opening)) {
const end = source.indexOf("-->", opening + 4);
if (end < 0) {
diagnostics.push({
severity: "error",
code: "xml-unclosed-comment",
message: "XML comment is not closed.",
range: { from: opening, to: source.length },
});
}
index = end < 0 ? source.length : end + 3;
continue;
}
if (source.startsWith("<![CDATA[", opening)) {
const end = source.indexOf("]]>", opening + 9);
if (end < 0) {
diagnostics.push({
severity: "error",
code: "xml-unclosed-cdata",
message: "CDATA section is not closed.",
range: { from: opening, to: source.length },
});
}
index = end < 0 ? source.length : end + 3;
continue;
}
if (source.startsWith("<?", opening)) {
const end = source.indexOf("?>", opening + 2);
if (end < 0) {
diagnostics.push({
severity: "error",
code: "xml-unclosed-processing-instruction",
message: "Processing instruction is not closed.",
range: { from: opening, to: source.length },
});
}
index = end < 0 ? source.length : end + 2;
continue;
}
if (source.startsWith("<!", opening)) {
index = findTagEnd(source, opening + 2);
continue;
}
if (source.startsWith("</", opening)) {
const closeTo = findTagEnd(source, opening + 2);
if (source[closeTo - 1] !== ">") {
diagnostics.push({
severity: "error",
code: "xml-unclosed-closing-tag",
message: "Closing tag is not closed.",
range: { from: opening, to: source.length },
});
}
let cursor = opening + 2;
while (/\s/u.test(source[cursor] ?? "")) cursor += 1;
const nameFrom = cursor;
while (/[^\s>]/u.test(source[cursor] ?? "")) cursor += 1;
const closingName = source.slice(nameFrom, cursor);
const last = stack.pop();
if (last === undefined) {
diagnostics.push({
severity: "error",
code: "xml-unexpected-closing-tag",
message: `Unexpected closing element </${closingName}>.`,
range: { from: opening, to: closeTo },
});
} else {
const expected = elements[last]!;
expected.fullTo = closeTo;
if (expected.name !== closingName) {
diagnostics.push({
severity: "error",
code: "xml-mismatched-closing-tag",
message: `Expected </${expected.name}> but found </${closingName}>.`,
range: { from: opening, to: closeTo },
});
}
}
index = closeTo;
continue;
}
const openTo = findTagEnd(source, opening + 1);
if (source[openTo - 1] !== ">") {
diagnostics.push({
severity: "error",
code: "xml-unclosed-start-tag",
message: "Start tag is not closed.",
range: { from: opening, to: source.length },
});
}
let cursor = opening + 1;
while (/\s/u.test(source[cursor] ?? "")) cursor += 1;
const nameFrom = cursor;
while (/[^\s/>]/u.test(source[cursor] ?? "")) cursor += 1;
const nameTo = cursor;
const name = source.slice(nameFrom, nameTo);
const selfClosing = /\/\s*>$/u.test(source.slice(opening, openTo));
const element: ScannedElement = {
name,
nameFrom,
nameTo,
openFrom: opening,
openTo,
fullTo: openTo,
selfClosing,
attributes: scanAttributes(source, nameTo, openTo - 1),
};
elements.push(element);
if (!selfClosing) stack.push(elements.length - 1);
index = openTo;
}
for (const elementIndex of stack) {
const element = elements[elementIndex]!;
diagnostics.push({
severity: "error",
code: "xml-unclosed-element",
message: `Element <${element.name}> is not closed.`,
range: { from: element.openFrom, to: element.openTo },
});
}
return elements;
}
function syntaxDiagnostics(source: string): SvgDiagnostic[] {
const diagnostics: SvgDiagnostic[] = [];
const tree = xmlLanguage.parser.parse(source);
tree.iterate({
enter(node) {
if (node.type.isError) {
diagnostics.push({
severity: "error",
code: "xml-syntax",
message: "Malformed XML syntax.",
range: { from: node.from, to: Math.max(node.to, node.from + 1) },
});
}
},
});
return diagnostics;
}
function countArcGroups(rawNumbers: readonly string[]): number {
let index = 0;
let remainder: string | null = null;
const number = (): string | null => {
if (remainder !== null) {
const value = remainder;
remainder = null;
return value;
}
const value = rawNumbers[index];
if (value === undefined) return null;
index += 1;
return value;
};
const flag = (): boolean => {
const value = number();
if (!value || (value[0] !== "0" && value[0] !== "1")) return false;
if (value.length > 1) remainder = value.slice(1);
return true;
};
let groups = 0;
while (index < rawNumbers.length || remainder !== null) {
if (number() === null || number() === null || number() === null) break;
if (!flag() || !flag()) break;
if (number() === null || number() === null) break;
groups += 1;
}
return groups;
}
function countPathCommands(source: string): number {
let count = 0;
for (const match of source.matchAll(PATH_COMMAND_PATTERN)) {
const command = match[1]!.toUpperCase();
if (command === "Z") {
count += 1;
continue;
}
const numbers = Array.from(
match[2]!.matchAll(PATH_NUMBER_PATTERN),
(numberMatch) => numberMatch[0],
);
count +=
command === "A"
? countArcGroups(numbers)
: Math.floor(numbers.length / (PATH_ARITY[command] ?? Infinity));
}
return count;
}
function hashIdentity(value: string): string {
let hash = 2_166_136_261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16_777_619);
}
return (hash >>> 0).toString(36);
}
function uniqueKey(
element: Element,
token: ScannedElement,
structuralPath: string,
duplicateIds: ReadonlySet<string>,
used: Set<string>,
): string {
const id = element.getAttribute("id");
const preferred =
id && !duplicateIds.has(id)
? `id:${id}`
: `node:${hashIdentity(`${structuralPath}|${token.openFrom}|${element.localName}|${id ?? ""}`)}`;
let key = preferred;
let suffix = 2;
while (used.has(key)) {
key = `${preferred}:${suffix}`;
suffix += 1;
}
used.add(key);
return key;
}
function elementChildren(element: Element): Element[] {
return Array.from(element.children);
}
function collectElements(root: Element): Element[] {
const result: Element[] = [];
const pending = [root];
while (pending.length > 0) {
const element = pending.pop()!;
result.push(element);
const children = elementChildren(element);
for (let index = children.length - 1; index >= 0; index -= 1) {
pending.push(children[index]!);
}
}
return result;
}
function idCounts(elements: readonly Element[]): Map<string, number> {
const counts = new Map<string, number>();
for (const element of elements) {
const id = element.getAttribute("id");
if (id) counts.set(id, (counts.get(id) ?? 0) + 1);
}
return counts;
}
export function parseSvgSource(
source: string,
revision: number,
limits: Readonly<SvgResourceLimits> = defaultSvgLimits,
): SvgParseResult {
const sourceBytes = utf8ByteLength(source);
if (sourceBytes > limits.sourceHardBytes) {
return {
revision,
source,
valid: false,
semantic: null,
diagnostics: [
{
severity: "error",
code: "source-hard-limit",
message: `Source exceeds the ${limits.sourceHardBytes.toLocaleString()} byte hard limit.`,
range: { from: 0, to: source.length },
},
],
preferences: {
newline: "\n",
indentation: " ",
attributeQuote: '"',
},
};
}
const preferences = preferencesFor(source);
const diagnostics = syntaxDiagnostics(source);
if (sourceBytes > limits.sourceSoftBytes) {
diagnostics.push({
severity: "warning",
code: "source-soft-limit",
message: "Large document: live projection updates may be throttled.",
range: { from: 0, to: Math.min(source.length, 1) },
});
}
const doctype = /<!DOCTYPE\b[^>]*>/iu.exec(source);
if (doctype) {
diagnostics.push({
severity: "warning",
code: "doctype-preserved",
message:
"DOCTYPE is preserved in source but ignored by the editing projection.",
range: { from: doctype.index, to: doctype.index + doctype[0].length },
});
}
const entityDeclaration = /<!ENTITY\b/iu.exec(source);
if (entityDeclaration) {
diagnostics.push({
severity: "error",
code: "entity-declaration-blocked",
message:
"Entity declarations are preserved in source but are not parsed by the local editing model.",
range: {
from: entityDeclaration.index,
to: entityDeclaration.index + entityDeclaration[0].length,
},
});
}
const tokens = scanElements(source, diagnostics);
if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
return {
revision,
source,
valid: false,
semantic: null,
diagnostics,
preferences,
};
}
const parserSource = doctype
? `${source.slice(0, doctype.index)}${source.slice(doctype.index + doctype[0].length)}`
: source;
const document = new DOMParser().parseFromString(
parserSource,
"image/svg+xml",
);
const parserError = Array.from(
document.getElementsByTagName("parsererror"),
)[0];
if (parserError) {
diagnostics.push({
severity: "error",
code: "xml-parser-error",
message:
parserError.textContent?.trim() ||
"The XML parser rejected this source.",
range: { from: 0, to: Math.min(source.length, 1) },
});
}
const root = document.documentElement;
if (
!parserError &&
(root.localName !== "svg" || root.namespaceURI !== SVG_NAMESPACE)
) {
diagnostics.push({
severity: "error",
code: "non-svg-root",
message: "The document root must be an SVG element in the SVG namespace.",
range: { from: 0, to: Math.min(source.length, 1) },
});
}
if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
return {
revision,
source,
valid: false,
semantic: null,
diagnostics,
preferences,
};
}
const elements = collectElements(root);
if (tokens.length !== elements.length) {
diagnostics.push({
severity: "error",
code: "source-map-mismatch",
message: "Could not map every XML element to an exact source range.",
range: { from: 0, to: source.length },
});
return {
revision,
source,
valid: false,
semantic: null,
diagnostics,
preferences,
};
}
if (elements.length > limits.maximumElements) {
diagnostics.push({
severity: "error",
code: "element-limit",
message: "Element count exceeds the configured processing limit.",
range: { from: 0, to: source.length },
});
return {
revision,
source,
valid: false,
semantic: null,
diagnostics,
preferences,
};
}
const counts = idCounts(elements);
const duplicateIds = new Set(
[...counts].filter(([, count]) => count > 1).map(([id]) => id),
);
const keyByElement = new Map<Element, string>();
const elementIndexByElement = new Map(
elements.map((element, index) => [element, index] as const),
);
const depthByElement = new Map<Element, number>();
const usedKeys = new Set<string>();
let attributeCount = 0;
let maximumDepth = 0;
let pathCommandCount = 0;
let referenceCount = 0;
let cssRuleCount = 0;
let animationCount = 0;
let filterPrimitiveCount = 0;
let textLength = 0;
let embeddedResourceBytes = 0;
const pendingKeys: Array<{
element: Element;
path: string;
depth: number;
}> = [{ element: root, path: "0", depth: 0 }];
while (pendingKeys.length > 0) {
const { element, path, depth } = pendingKeys.pop()!;
const elementIndex = elementIndexByElement.get(element)!;
const token = tokens[elementIndex]!;
keyByElement.set(
element,
uniqueKey(element, token, path, duplicateIds, usedKeys),
);
depthByElement.set(element, depth);
maximumDepth = Math.max(maximumDepth, depth);
const children = elementChildren(element);
for (let index = children.length - 1; index >= 0; index -= 1) {
pendingKeys.push({
element: children[index]!,
path: `${path}.${index}`,
depth: depth + 1,
});
}
}
const nodes = new Map<string, SemanticSvgNode>();
const firstNodeById = new Map<string, SemanticSvgNode>();
const order: string[] = [];
elements.forEach((element, index) => {
const token = tokens[index]!;
const key = keyByElement.get(element)!;
const parent = element.parentElement;
const attributes = Object.fromEntries(
Array.from(element.attributes).map((attribute) => [
attribute.name,
attribute.value,
]),
);
attributeCount += element.attributes.length;
for (const attribute of Array.from(element.attributes)) {
if (attribute.value.length > limits.maximumAttributeLength) {
diagnostics.push({
severity: "error",
code: "attribute-length-limit",
message: `Attribute “${attribute.name}” exceeds the configured length limit.`,
nodeKey: key,
range: token.attributes[attribute.name]?.valueRange ?? {
from: token.openFrom,
to: token.openTo,
},
});
}
if (/^data:/iu.test(attribute.value)) {
const bytes = utf8ByteLength(attribute.value);
embeddedResourceBytes += bytes;
if (bytes > limits.maximumEmbeddedResourceBytes) {
diagnostics.push({
severity: "error",
code: "embedded-resource-limit",
message: `Embedded data in “${attribute.name}” exceeds the configured size limit.`,
nodeKey: key,
range: token.attributes[attribute.name]?.valueRange,
});
}
}
}
if (element.localName === "path") {
const commandCount = countPathCommands(element.getAttribute("d") ?? "");
pathCommandCount += commandCount;
if (commandCount > limits.maximumPathCommandsPerPath) {
diagnostics.push({
severity: "error",
code: "path-command-per-element-limit",
message:
"Path command count exceeds the per-element processing limit.",
nodeKey: key,
range: token.attributes.d?.valueRange ?? {
from: token.openFrom,
to: token.openTo,
},
});
}
}
if (element.localName === "style") {
cssRuleCount += (element.textContent?.match(/[{]/gu) ?? []).length;
}
referenceCount += Object.values(attributes).filter((value) =>
REFERENCE_ATTRIBUTE_PATTERN.test(value),
).length;
if (ANIMATION_ELEMENTS.has(element.localName)) animationCount += 1;
if (FILTER_PRIMITIVE_PATTERN.test(element.localName))
filterPrimitiveCount += 1;
const sourceRange: ElementSourceRange = {
openTag: { from: token.openFrom, to: token.openTo },
name: { from: token.nameFrom, to: token.nameTo },
full: { from: token.openFrom, to: token.fullTo },
};
if (!token.selfClosing && token.fullTo > token.openTo) {
const closingStart = source.lastIndexOf("</", token.fullTo);
sourceRange.content = {
from: token.openTo,
to: Math.max(token.openTo, closingStart),
};
}
const node: SemanticSvgNode = {
key,
name: element.tagName,
localName: element.localName,
namespaceUri: element.namespaceURI,
parentKey: parent ? (keyByElement.get(parent) ?? null) : null,
childKeys: elementChildren(element).map(
(child) => keyByElement.get(child)!,
),
depth: depthByElement.get(element)!,
...(element.id ? { id: element.id } : {}),
classes: Array.from(element.classList),
attributes,
attributeRanges: token.attributes,
sourceRange,
rendered: !NON_RENDERING_ELEMENTS.has(element.localName),
text:
elementChildren(element).length === 0
? (element.textContent ?? "")
: "",
};
textLength += node.text.length;
if (node.text.length > limits.maximumTextLength) {
diagnostics.push({
severity: "error",
code: "text-length-limit",
message: "Element text exceeds the configured length limit.",
nodeKey: key,
range: sourceRange.content ?? sourceRange.full,
});
}
nodes.set(key, node);
if (node.id && !firstNodeById.has(node.id)) {
firstNodeById.set(node.id, node);
}
order.push(key);
});
for (const id of duplicateIds) {
const node = firstNodeById.get(id);
diagnostics.push({
severity: "warning",
code: "duplicate-id",
message: `The ID “${id}” is used more than once.`,
nodeKey: node?.key,
range: node?.attributeRanges.id?.valueRange,
fixable: true,
});
}
if (attributeCount > limits.maximumAttributes) {
diagnostics.push({
severity: "error",
code: "attribute-limit",
message: "Attribute count exceeds the configured processing limit.",
range: { from: 0, to: source.length },
});
}
if (maximumDepth > limits.maximumDepth) {
diagnostics.push({
severity: "error",
code: "depth-limit",
message: "XML depth exceeds the configured processing limit.",
range: { from: 0, to: source.length },
});
}
if (pathCommandCount > limits.maximumPathCommandsTotal) {
diagnostics.push({
severity: "error",
code: "path-command-limit",
message:
"Total path command count exceeds the configured processing limit.",
range: { from: 0, to: source.length },
});
}
if (referenceCount > limits.maximumReferences) {
diagnostics.push({
severity: "error",
code: "reference-limit",
message: "Reference count exceeds the configured processing limit.",
range: { from: 0, to: source.length },
});
}
if (cssRuleCount > limits.maximumCssRules) {
diagnostics.push({
severity: "error",
code: "css-rule-limit",
message: "CSS rule count exceeds the configured processing limit.",
range: { from: 0, to: source.length },
});
}
if (animationCount > limits.maximumAnimations) {
diagnostics.push({
severity: "error",
code: "animation-limit",
message: "Animation count exceeds the configured processing limit.",
range: { from: 0, to: source.length },
});
}
if (filterPrimitiveCount > limits.maximumFilterPrimitives) {
diagnostics.push({
severity: "error",
code: "filter-primitive-limit",
message:
"Filter primitive count exceeds the configured processing limit.",
range: { from: 0, to: source.length },
});
}
if (textLength > limits.maximumTextLength) {
diagnostics.push({
severity: "error",
code: "total-text-limit",
message: "Total text length exceeds the configured processing limit.",
range: { from: 0, to: source.length },
});
}
if (embeddedResourceBytes > limits.maximumEmbeddedResourceBytes) {
diagnostics.push({
severity: "error",
code: "embedded-resource-total-limit",
message: "Total embedded data exceeds the configured size limit.",
range: { from: 0, to: source.length },
});
}
const metrics: SvgDocumentMetrics = {
sourceBytes,
elementCount: elements.length,
attributeCount,
maximumDepth,
pathCommandCount,
referenceCount,
cssRuleCount,
animationCount,
filterPrimitiveCount,
textLength,
embeddedResourceBytes,
};
const semantic: SemanticSvgDocument = {
revision,
source,
rootKey: keyByElement.get(root)!,
nodes,
order,
document,
preferences,
diagnostics,
metrics,
};
return {
revision,
source,
valid: !diagnostics.some((diagnostic) => diagnostic.severity === "error"),
semantic,
diagnostics,
preferences,
};
}
+127
View File
@@ -0,0 +1,127 @@
import type {
SemanticSvgNode,
SourcePatch,
SvgSourcePreferences,
} from "./document.types";
const XML_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_.:-]*$/u;
export function escapeXmlAttribute(value: string, quote: '"' | "'"): string {
const common = value.replaceAll("&", "&amp;").replaceAll("<", "&lt;");
return quote === '"'
? common.replaceAll('"', "&quot;")
: common.replaceAll("'", "&apos;");
}
export function applySourcePatches(
source: string,
patches: readonly SourcePatch[],
): string {
const ordered = [...patches].sort((left, right) => right.from - left.from);
let previousFrom = source.length + 1;
let result = source;
for (const patch of ordered) {
if (
patch.from < 0 ||
patch.to < patch.from ||
patch.to > source.length ||
patch.to > previousFrom
) {
throw new Error(`Invalid or overlapping source patch: ${patch.label}`);
}
result = `${result.slice(0, patch.from)}${patch.insert}${result.slice(patch.to)}`;
previousFrom = patch.from;
}
return result;
}
export function patchAttribute(
source: string,
node: SemanticSvgNode,
name: string,
value: string | null,
preferences: SvgSourcePreferences,
): SourcePatch | null {
if (!XML_NAME_PATTERN.test(name)) {
throw new Error(`Invalid XML attribute name: ${name}`);
}
const existing = node.attributeRanges[name];
if (existing && value === null) {
return {
from: existing.fullRange.from,
to: existing.fullRange.to,
insert: "",
label: `Remove ${name}`,
};
}
if (existing && value !== null) {
return {
from: existing.valueRange.from,
to: existing.valueRange.to,
insert: escapeXmlAttribute(value, existing.quote),
label: `Set ${name}`,
};
}
if (!existing && value === null) return null;
const open = source.slice(
node.sourceRange.openTag.from,
node.sourceRange.openTag.to,
);
const closingOffset = /\/\s*>$/u.test(open)
? open.lastIndexOf("/")
: open.lastIndexOf(">");
const insertion = node.sourceRange.openTag.from + closingOffset;
const quote = preferences.attributeQuote;
return {
from: insertion,
to: insertion,
insert: ` ${name}=${quote}${escapeXmlAttribute(value ?? "", quote)}${quote}`,
label: `Add ${name}`,
};
}
export function patchTextContent(
node: SemanticSvgNode,
value: string,
): SourcePatch {
const content = node.sourceRange.content;
if (!content)
throw new Error("Cannot edit text content of a self-closing element");
const escaped = value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
return {
from: content.from,
to: content.to,
insert: escaped,
label: "Edit text content",
};
}
export function replaceSubtree(
node: SemanticSvgNode,
serialized: string,
label = "Replace element",
): SourcePatch {
return {
from: node.sourceRange.full.from,
to: node.sourceRange.full.to,
insert: serialized,
label,
};
}
export function sourceWithAttribute(
source: string,
node: SemanticSvgNode,
name: string,
value: string | null,
preferences: SvgSourcePreferences,
): { source: string; patch: SourcePatch | null } {
const patch = patchAttribute(source, node, name, value, preferences);
return {
source: patch ? applySourcePatches(source, [patch]) : source,
patch,
};
}
+437
View File
@@ -0,0 +1,437 @@
export interface Point {
x: number;
y: number;
}
export interface Matrix {
a: number;
b: number;
c: number;
d: number;
e: number;
f: number;
}
export const IDENTITY: Readonly<Matrix> = Object.freeze({
a: 1,
b: 0,
c: 0,
d: 1,
e: 0,
f: 0,
});
const DEGREES = Math.PI / 180;
const NUMBER_PATTERN = /^[+-]?(?:(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?)/u;
export function multiply(left: Matrix, right: Matrix): Matrix {
return {
a: left.a * right.a + left.c * right.b,
b: left.b * right.a + left.d * right.b,
c: left.a * right.c + left.c * right.d,
d: left.b * right.c + left.d * right.d,
e: left.a * right.e + left.c * right.f + left.e,
f: left.b * right.e + left.d * right.f + left.f,
};
}
export function determinant(matrix: Matrix): number {
return matrix.a * matrix.d - matrix.b * matrix.c;
}
export function invert(matrix: Matrix, epsilon = 1e-12): Matrix | null {
const value = determinant(matrix);
const scale = Math.max(
Math.abs(matrix.a),
Math.abs(matrix.b),
Math.abs(matrix.c),
Math.abs(matrix.d),
);
if (
!Number.isFinite(value) ||
scale === 0 ||
Math.abs(value) <= epsilon * scale * scale
) {
return null;
}
return {
a: matrix.d / value,
b: -matrix.b / value,
c: -matrix.c / value,
d: matrix.a / value,
e: (matrix.c * matrix.f - matrix.d * matrix.e) / value,
f: (matrix.b * matrix.e - matrix.a * matrix.f) / value,
};
}
export function applyToPoint(matrix: Matrix, point: Point): Point {
return {
x: matrix.a * point.x + matrix.c * point.y + matrix.e,
y: matrix.b * point.x + matrix.d * point.y + matrix.f,
};
}
export function matrixNearlyEqual(
left: Matrix,
right: Matrix,
epsilon = 1e-10,
): boolean {
return (["a", "b", "c", "d", "e", "f"] as const).every((key) => {
const scale = Math.max(1, Math.abs(left[key]), Math.abs(right[key]));
return Math.abs(left[key] - right[key]) <= epsilon * scale;
});
}
export const translation = (tx: number, ty = 0): Matrix => ({
a: 1,
b: 0,
c: 0,
d: 1,
e: tx,
f: ty,
});
export const scaling = (sx: number, sy = sx): Matrix => ({
a: sx,
b: 0,
c: 0,
d: sy,
e: 0,
f: 0,
});
export function rotation(angleDegrees: number): Matrix {
const angle = angleDegrees * DEGREES;
const cosine = Math.cos(angle);
const sine = Math.sin(angle);
return { a: cosine, b: sine, c: -sine, d: cosine, e: 0, f: 0 };
}
export const skewX = (angleDegrees: number): Matrix => ({
a: 1,
b: 0,
c: Math.tan(angleDegrees * DEGREES),
d: 1,
e: 0,
f: 0,
});
export const skewY = (angleDegrees: number): Matrix => ({
a: 1,
b: Math.tan(angleDegrees * DEGREES),
c: 0,
d: 1,
e: 0,
f: 0,
});
export type TransformFunction =
| { kind: "matrix"; value: Matrix }
| { kind: "translate"; tx: number; ty?: number }
| { kind: "scale"; sx: number; sy?: number }
| { kind: "rotate"; angle: number; cx?: number; cy?: number }
| { kind: "skewX"; angle: number }
| { kind: "skewY"; angle: number };
export class TransformSyntaxError extends SyntaxError {
readonly offset: number;
constructor(message: string, offset: number) {
super(`${message} at offset ${offset}`);
this.name = "TransformSyntaxError";
this.offset = offset;
}
}
function parseNumberList(source: string, baseOffset: number): number[] {
const values: number[] = [];
let offset = 0;
const skipWhitespace = () => {
const before = offset;
while (/\s/u.test(source[offset] ?? "")) offset += 1;
return offset !== before;
};
skipWhitespace();
while (offset < source.length) {
const match = NUMBER_PATTERN.exec(source.slice(offset));
if (!match) {
throw new TransformSyntaxError("Expected a number", baseOffset + offset);
}
const value = Number(match[0]);
if (!Number.isFinite(value)) {
throw new TransformSyntaxError(
"Number is not finite",
baseOffset + offset,
);
}
values.push(value);
offset += match[0].length;
const spaced = skipWhitespace();
let comma = false;
if (source[offset] === ",") {
comma = true;
offset += 1;
skipWhitespace();
}
if (offset < source.length && !spaced && !comma) {
throw new TransformSyntaxError(
"Expected whitespace or a comma between numbers",
baseOffset + offset,
);
}
if (comma && offset === source.length) {
throw new TransformSyntaxError("Trailing comma", baseOffset + offset - 1);
}
}
return values;
}
function transformFromValues(
name: string,
values: readonly number[],
offset: number,
): TransformFunction {
const expect = (...arities: number[]) => {
if (!arities.includes(values.length)) {
throw new TransformSyntaxError(
`${name}() expects ${arities.join(" or ")} arguments, received ${values.length}`,
offset,
);
}
};
const at = (index: number): number => {
const value = values[index];
if (value === undefined)
throw new TransformSyntaxError("Missing argument", offset);
return value;
};
switch (name) {
case "matrix":
expect(6);
return {
kind: "matrix",
value: {
a: at(0),
b: at(1),
c: at(2),
d: at(3),
e: at(4),
f: at(5),
},
};
case "translate":
expect(1, 2);
return values.length === 1
? { kind: "translate", tx: at(0) }
: { kind: "translate", tx: at(0), ty: at(1) };
case "scale":
expect(1, 2);
return values.length === 1
? { kind: "scale", sx: at(0) }
: { kind: "scale", sx: at(0), sy: at(1) };
case "rotate":
expect(1, 3);
return values.length === 1
? { kind: "rotate", angle: at(0) }
: { kind: "rotate", angle: at(0), cx: at(1), cy: at(2) };
case "skewX":
expect(1);
return { kind: "skewX", angle: at(0) };
case "skewY":
expect(1);
return { kind: "skewY", angle: at(0) };
default:
throw new TransformSyntaxError(`Unsupported transform ${name}()`, offset);
}
}
export function parseTransformList(source: string): TransformFunction[] {
if (source.trim() === "" || source.trim() === "none") return [];
const result: TransformFunction[] = [];
let offset = 0;
while (offset < source.length) {
while (/[\s,]/u.test(source[offset] ?? "")) offset += 1;
if (offset === source.length) break;
const start = offset;
const nameMatch = /^[A-Za-z][A-Za-z0-9]*/u.exec(source.slice(offset));
if (!nameMatch)
throw new TransformSyntaxError("Expected a transform name", offset);
const name = nameMatch[0];
offset += name.length;
while (/\s/u.test(source[offset] ?? "")) offset += 1;
if (source[offset] !== "(") {
throw new TransformSyntaxError(`Expected “(” after ${name}`, offset);
}
offset += 1;
const argumentsFrom = offset;
while (offset < source.length && source[offset] !== ")") {
if (source[offset] === "(") {
throw new TransformSyntaxError(
"Nested parentheses are not allowed",
offset,
);
}
offset += 1;
}
if (offset >= source.length) {
throw new TransformSyntaxError(`Unclosed ${name}()`, start);
}
result.push(
transformFromValues(
name,
parseNumberList(source.slice(argumentsFrom, offset), argumentsFrom),
start,
),
);
offset += 1;
}
return result;
}
export function transformToMatrix(transform: TransformFunction): Matrix {
switch (transform.kind) {
case "matrix":
return transform.value;
case "translate":
return translation(transform.tx, transform.ty ?? 0);
case "scale":
return scaling(transform.sx, transform.sy ?? transform.sx);
case "rotate": {
const rotated = rotation(transform.angle);
if (transform.cx === undefined || transform.cy === undefined)
return rotated;
return multiply(
multiply(translation(transform.cx, transform.cy), rotated),
translation(-transform.cx, -transform.cy),
);
}
case "skewX":
return skewX(transform.angle);
case "skewY":
return skewY(transform.angle);
}
}
export function composeTransformList(
transforms: readonly TransformFunction[],
): Matrix {
return transforms.reduce<Matrix>(
(current, transform) => multiply(current, transformToMatrix(transform)),
{ ...IDENTITY },
);
}
export function formatNumber(value: number, precision = 8): string {
if (!Number.isFinite(value))
throw new RangeError("Cannot serialize a non-finite number");
const rounded = Number(value.toFixed(Math.max(0, Math.min(15, precision))));
return Object.is(rounded, -0) ? "0" : String(rounded);
}
export function matrixToTransform(matrix: Matrix): string {
return `matrix(${[matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f]
.map((value) => formatNumber(value))
.join(" ")})`;
}
export interface CanonicalDecomposition {
translateX: number;
translateY: number;
rotationDegrees: number;
scaleX: number;
scaleY: number;
skewXDegrees: number;
determinant: number;
reflected: boolean;
singular: boolean;
residual: number;
}
export function recomposeCanonical(value: CanonicalDecomposition): Matrix {
return multiply(
multiply(
multiply(
translation(value.translateX, value.translateY),
rotation(value.rotationDegrees),
),
scaling(value.scaleX, value.scaleY),
),
skewX(value.skewXDegrees),
);
}
export function decomposeCanonical(matrix: Matrix): CanonicalDecomposition {
const value = determinant(matrix);
const scaleX = Math.hypot(matrix.a, matrix.b);
let rotationDegrees: number;
let scaleY: number;
let shear: number;
if (scaleX === 0) {
scaleY = Math.hypot(matrix.c, matrix.d);
rotationDegrees =
scaleY === 0 ? 0 : Math.atan2(-matrix.c, matrix.d) / DEGREES;
shear = 0;
} else {
rotationDegrees = Math.atan2(matrix.b, matrix.a) / DEGREES;
scaleY = value / scaleX;
shear = (matrix.a * matrix.c + matrix.b * matrix.d) / (scaleX * scaleX);
}
const result: CanonicalDecomposition = {
translateX: matrix.e,
translateY: matrix.f,
rotationDegrees,
scaleX,
scaleY,
skewXDegrees: Math.atan(shear) / DEGREES,
determinant: value,
reflected: value < 0,
singular: Math.abs(value) < 1e-12,
residual: 0,
};
const recomposed = recomposeCanonical(result);
result.residual = Math.max(
...(["a", "b", "c", "d", "e", "f"] as const).map((key) =>
Math.abs(matrix[key] - recomposed[key]),
),
);
return result;
}
export interface TransformDiagnostic {
severity: "info" | "warning" | "error";
code: string;
message: string;
}
export function diagnoseTransform(source: string): {
transforms: TransformFunction[];
matrix: Matrix;
decomposition: CanonicalDecomposition;
diagnostics: TransformDiagnostic[];
} {
const transforms = parseTransformList(source);
const matrix = composeTransformList(transforms);
const decomposition = decomposeCanonical(matrix);
const diagnostics: TransformDiagnostic[] = [];
if (decomposition.singular) {
diagnostics.push({
severity: "error",
code: "singular-transform",
message: "This transform collapses geometry and cannot be inverted.",
});
} else if (Math.abs(decomposition.determinant) < 1e-8) {
diagnostics.push({
severity: "warning",
code: "near-singular-transform",
message: "This transform is close to collapsing geometry.",
});
}
if (decomposition.reflected) {
diagnostics.push({
severity: "info",
code: "reflected-transform",
message: "This transform reflects the selected geometry.",
});
}
return { transforms, matrix, decomposition, diagnostics };
}
+451
View File
@@ -0,0 +1,451 @@
import type {
SemanticSvgNode,
SourcePatch,
SvgSourcePreferences,
} from "../document/document.types";
import { patchAttribute } from "../document/source-patcher";
import { applyToPoint, determinant, type Matrix, type Point } from "./affine";
import {
parsePathData,
serializePathData,
transformPath,
type PathModel,
} from "./path";
export interface BakeTransformResult {
patches: SourcePatch[];
outputElement: string;
convertedToPath: boolean;
warnings: string[];
}
const GEOMETRY_ATTRIBUTES: Record<string, readonly string[]> = {
line: ["x1", "y1", "x2", "y2"],
polyline: ["points"],
polygon: ["points"],
rect: ["x", "y", "width", "height", "rx", "ry"],
circle: ["cx", "cy", "r"],
ellipse: ["cx", "cy", "rx", "ry"],
};
const SVG_NUMBER_PATTERN =
/^[+-]?(?:(?:\d+\.\d*)|(?:\.\d+)|(?:\d+))(?:[eE][+-]?\d+)?$/u;
function finiteAttribute(
node: SemanticSvgNode,
name: string,
fallback?: number,
): number {
const raw = node.attributes[name];
if (raw === undefined && fallback !== undefined) return fallback;
const normalized = raw?.trim() ?? "";
const value = Number(normalized);
if (!SVG_NUMBER_PATTERN.test(normalized) || !Number.isFinite(value)) {
throw new Error(
`<${node.name}> requires a finite ${name} attribute for deterministic baking`,
);
}
return value;
}
function nonnegativeAttribute(
node: SemanticSvgNode,
name: string,
fallback?: number,
): number {
const value = finiteAttribute(node, name, fallback);
if (value < 0) {
throw new Error(`<${node.name}> requires a non-negative ${name} attribute`);
}
return value;
}
function number(value: number): string {
if (!Number.isFinite(value)) throw new Error("Baked geometry is not finite");
const serialized = Number(value.toPrecision(15));
return String(Object.is(serialized, -0) ? 0 : serialized);
}
function setAttribute(
source: string,
node: SemanticSvgNode,
name: string,
value: string | null,
preferences: SvgSourcePreferences,
patches: SourcePatch[],
): void {
const patch = patchAttribute(source, node, name, value, preferences);
if (patch) patches.push(patch);
}
function parsePoints(value: string): Point[] {
const values = value
.trim()
.split(/[\s,]+/u)
.filter(Boolean)
.map((token) => (SVG_NUMBER_PATTERN.test(token) ? Number(token) : NaN));
if (
values.length < 2 ||
values.length % 2 !== 0 ||
!values.every(Number.isFinite)
) {
throw new Error(
"Polyline and polygon points must contain finite coordinate pairs",
);
}
const points: Point[] = [];
for (let index = 0; index < values.length; index += 2) {
points.push({ x: values[index]!, y: values[index + 1]! });
}
return points;
}
function ellipsePath(
cx: number,
cy: number,
rx: number,
ry: number,
): PathModel {
if (rx < 0 || ry < 0) throw new Error("Ellipse radii cannot be negative");
return {
segments: [
{ kind: "M", to: { x: cx + rx, y: cy } },
{
kind: "A",
from: { x: cx + rx, y: cy },
to: { x: cx - rx, y: cy },
rx,
ry,
rotation: 0,
largeArc: false,
sweep: true,
},
{
kind: "A",
from: { x: cx - rx, y: cy },
to: { x: cx + rx, y: cy },
rx,
ry,
rotation: 0,
largeArc: false,
sweep: true,
},
{
kind: "Z",
from: { x: cx + rx, y: cy },
to: { x: cx + rx, y: cy },
},
],
};
}
function rectPath(node: SemanticSvgNode): PathModel {
const x = finiteAttribute(node, "x", 0);
const y = finiteAttribute(node, "y", 0);
const width = finiteAttribute(node, "width");
const height = finiteAttribute(node, "height");
if (width < 0 || height < 0)
throw new Error("Rectangle dimensions cannot be negative");
const rawRx =
node.attributes.rx === undefined
? undefined
: nonnegativeAttribute(node, "rx");
const rawRy =
node.attributes.ry === undefined
? undefined
: nonnegativeAttribute(node, "ry");
const rx = Math.min(width / 2, Math.max(0, rawRx ?? rawRy ?? 0));
const ry = Math.min(height / 2, Math.max(0, rawRy ?? rawRx ?? 0));
if (rx === 0 || ry === 0) {
return {
segments: [
{ kind: "M", to: { x, y } },
{ kind: "L", from: { x, y }, to: { x: x + width, y } },
{
kind: "L",
from: { x: x + width, y },
to: { x: x + width, y: y + height },
},
{
kind: "L",
from: { x: x + width, y: y + height },
to: { x, y: y + height },
},
{ kind: "Z", from: { x, y: y + height }, to: { x, y } },
],
};
}
return {
segments: [
{ kind: "M", to: { x: x + rx, y } },
{ kind: "L", from: { x: x + rx, y }, to: { x: x + width - rx, y } },
{
kind: "A",
from: { x: x + width - rx, y },
to: { x: x + width, y: y + ry },
rx,
ry,
rotation: 0,
largeArc: false,
sweep: true,
},
{
kind: "L",
from: { x: x + width, y: y + ry },
to: { x: x + width, y: y + height - ry },
},
{
kind: "A",
from: { x: x + width, y: y + height - ry },
to: { x: x + width - rx, y: y + height },
rx,
ry,
rotation: 0,
largeArc: false,
sweep: true,
},
{
kind: "L",
from: { x: x + width - rx, y: y + height },
to: { x: x + rx, y: y + height },
},
{
kind: "A",
from: { x: x + rx, y: y + height },
to: { x, y: y + height - ry },
rx,
ry,
rotation: 0,
largeArc: false,
sweep: true,
},
{ kind: "L", from: { x, y: y + height - ry }, to: { x, y: y + ry } },
{
kind: "A",
from: { x, y: y + ry },
to: { x: x + rx, y },
rx,
ry,
rotation: 0,
largeArc: false,
sweep: true,
},
{ kind: "Z", from: { x: x + rx, y }, to: { x: x + rx, y } },
],
};
}
function renameElementPatches(
source: string,
node: SemanticSvgNode,
localName: string,
): SourcePatch[] {
const prefix = node.name.includes(":")
? `${node.name.slice(0, node.name.indexOf(":"))}:`
: "";
const replacementName = `${prefix}${localName}`;
const openSource = source.slice(
node.sourceRange.openTag.from,
node.sourceRange.openTag.to,
);
const nameOffset = openSource.indexOf(node.name);
if (nameOffset < 0)
throw new Error("Element name source range is unavailable");
const patches: SourcePatch[] = [
{
from: node.sourceRange.openTag.from + nameOffset,
to: node.sourceRange.openTag.from + nameOffset + node.name.length,
insert: replacementName,
label: `Convert ${node.localName} to ${localName}`,
},
];
if (node.sourceRange.content) {
const full = source.slice(
node.sourceRange.full.from,
node.sourceRange.full.to,
);
const closing = full.lastIndexOf(`</${node.name}`);
if (closing < 0)
throw new Error("Closing element name source range is unavailable");
const from = node.sourceRange.full.from + closing + 2;
patches.push({
from,
to: from + node.name.length,
insert: replacementName,
label: `Rename closing ${node.localName}`,
});
}
return patches;
}
function strokeWarnings(node: SemanticSvgNode, matrix: Matrix): string[] {
if (!node.attributes.stroke || node.attributes.stroke === "none") return [];
const sx = Math.hypot(matrix.a, matrix.b);
if (node.attributes["vector-effect"] === "non-scaling-stroke") {
return [
"The element uses non-scaling-stroke. Geometry is baked while stroke properties remain unchanged.",
];
}
if (isConformal(matrix)) {
if (Math.abs(sx - 1) <= 1e-10) return [];
return [
"The transform scales the rendered stroke. This preview keeps stroke properties unchanged; review stroke width, dashes and markers before applying.",
];
}
return [
"Non-uniform scale or skew cannot be represented by one exact stroke-width. Geometry is exact, while stroke properties remain unchanged.",
];
}
function nearlyZero(value: number, scale = 1): boolean {
return Math.abs(value) <= 1e-12 * Math.max(1, scale);
}
function isAxisAligned(matrix: Matrix): boolean {
return nearlyZero(matrix.b) && nearlyZero(matrix.c);
}
function isConformal(matrix: Matrix): boolean {
const firstLength = Math.hypot(matrix.a, matrix.b);
const secondLength = Math.hypot(matrix.c, matrix.d);
const dot = matrix.a * matrix.c + matrix.b * matrix.d;
return (
nearlyZero(firstLength - secondLength, firstLength) &&
nearlyZero(dot, firstLength * secondLength)
);
}
export function bakeElementTransform(
source: string,
node: SemanticSvgNode,
matrix: Matrix,
preferences: SvgSourcePreferences,
): BakeTransformResult {
if (Math.abs(determinant(matrix)) <= 1e-12) {
throw new Error(
"A singular transform cannot be baked into editable geometry",
);
}
const patches: SourcePatch[] = [];
const warnings = strokeWarnings(node, matrix);
let convertedToPath = false;
let outputElement = node.localName;
const set = (name: string, value: string | null) =>
setAttribute(source, node, name, value, preferences, patches);
if (node.localName === "path") {
const transformed = transformPath(
parsePathData(node.attributes.d ?? ""),
matrix,
);
set("d", serializePathData(transformed, 15));
} else if (node.localName === "line") {
const first = applyToPoint(matrix, {
x: finiteAttribute(node, "x1", 0),
y: finiteAttribute(node, "y1", 0),
});
const second = applyToPoint(matrix, {
x: finiteAttribute(node, "x2", 0),
y: finiteAttribute(node, "y2", 0),
});
set("x1", number(first.x));
set("y1", number(first.y));
set("x2", number(second.x));
set("y2", number(second.y));
} else if (node.localName === "polyline" || node.localName === "polygon") {
const points = parsePoints(node.attributes.points ?? "").map((point) =>
applyToPoint(matrix, point),
);
set(
"points",
points.map((point) => `${number(point.x)},${number(point.y)}`).join(" "),
);
} else if (node.localName === "rect" && isAxisAligned(matrix)) {
const x = finiteAttribute(node, "x", 0);
const y = finiteAttribute(node, "y", 0);
const width = nonnegativeAttribute(node, "width");
const height = nonnegativeAttribute(node, "height");
const first = applyToPoint(matrix, { x, y });
const second = applyToPoint(matrix, { x: x + width, y: y + height });
set("x", number(Math.min(first.x, second.x)));
set("y", number(Math.min(first.y, second.y)));
set("width", number(Math.abs(second.x - first.x)));
set("height", number(Math.abs(second.y - first.y)));
if (node.attributes.rx !== undefined || node.attributes.ry !== undefined) {
const rx =
node.attributes.rx === undefined
? nonnegativeAttribute(node, "ry")
: nonnegativeAttribute(node, "rx");
const ry =
node.attributes.ry === undefined
? nonnegativeAttribute(node, "rx")
: nonnegativeAttribute(node, "ry");
set("rx", number(Math.abs(rx * matrix.a)));
set("ry", number(Math.abs(ry * matrix.d)));
}
} else if (node.localName === "circle" && isConformal(matrix)) {
const center = applyToPoint(matrix, {
x: finiteAttribute(node, "cx", 0),
y: finiteAttribute(node, "cy", 0),
});
const scale = Math.hypot(matrix.a, matrix.b);
set("cx", number(center.x));
set("cy", number(center.y));
set("r", number(nonnegativeAttribute(node, "r") * scale));
} else if (node.localName === "circle" && isAxisAligned(matrix)) {
const center = applyToPoint(matrix, {
x: finiteAttribute(node, "cx", 0),
y: finiteAttribute(node, "cy", 0),
});
const radius = nonnegativeAttribute(node, "r");
patches.push(...renameElementPatches(source, node, "ellipse"));
outputElement = "ellipse";
set("cx", number(center.x));
set("cy", number(center.y));
set("r", null);
set("rx", number(Math.abs(radius * matrix.a)));
set("ry", number(Math.abs(radius * matrix.d)));
warnings.push("The non-uniformly scaled circle becomes an ellipse.");
} else if (node.localName === "ellipse" && isAxisAligned(matrix)) {
const center = applyToPoint(matrix, {
x: finiteAttribute(node, "cx", 0),
y: finiteAttribute(node, "cy", 0),
});
set("cx", number(center.x));
set("cy", number(center.y));
set("rx", number(Math.abs(nonnegativeAttribute(node, "rx") * matrix.a)));
set("ry", number(Math.abs(nonnegativeAttribute(node, "ry") * matrix.d)));
} else {
let path: PathModel;
if (node.localName === "rect") path = rectPath(node);
else if (node.localName === "circle") {
path = ellipsePath(
finiteAttribute(node, "cx", 0),
finiteAttribute(node, "cy", 0),
nonnegativeAttribute(node, "r"),
nonnegativeAttribute(node, "r"),
);
} else if (node.localName === "ellipse") {
path = ellipsePath(
finiteAttribute(node, "cx", 0),
finiteAttribute(node, "cy", 0),
nonnegativeAttribute(node, "rx"),
nonnegativeAttribute(node, "ry"),
);
} else {
throw new Error(
`Transform baking for <${node.name}> is not deterministic in this release`,
);
}
convertedToPath = true;
outputElement = "path";
warnings.push(
`<${node.localName}> is converted to a path because the complete affine result is represented explicitly.`,
);
patches.push(...renameElementPatches(source, node, "path"));
for (const name of GEOMETRY_ATTRIBUTES[node.localName] ?? [])
set(name, null);
set("d", serializePathData(transformPath(path, matrix), 15));
}
set("transform", null);
return { patches, outputElement, convertedToPath, warnings };
}
+1059
View File
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
import type { SemanticSvgDocument } from "../document/document.types";
import {
IDENTITY,
composeTransformList,
invert,
matrixNearlyEqual,
multiply,
parseTransformList,
type Matrix,
} from "./affine";
export interface TransformChainEntry {
nodeKey: string;
elementName: string;
elementId?: string;
source: string;
local: Matrix;
combined: Matrix;
}
export interface TransformChainResult {
entries: TransformChainEntry[];
matrix: Matrix;
inverse: Matrix | null;
diagnostics: string[];
}
export function resolveTransformChain(
semantic: SemanticSvgDocument,
nodeKey: string,
): TransformChainResult {
const lineage = [];
let current = semantic.nodes.get(nodeKey);
while (current) {
lineage.push(current);
current = current.parentKey
? semantic.nodes.get(current.parentKey)
: undefined;
}
lineage.reverse();
const entries: TransformChainEntry[] = [];
const diagnostics: string[] = [];
let combined: Matrix = { ...IDENTITY };
for (const node of lineage) {
const source = node.attributes.transform?.trim() ?? "";
let local: Matrix = { ...IDENTITY };
if (source) {
try {
local = composeTransformList(parseTransformList(source));
} catch (error) {
diagnostics.push(
`<${node.name}>${node.id ? `#${node.id}` : ""}: ${error instanceof Error ? error.message : "Invalid transform"}`,
);
}
}
if (/(?:^|;)\s*transform\s*:/iu.test(node.attributes.style ?? "")) {
diagnostics.push(
`<${node.name}>${node.id ? `#${node.id}` : ""}: inline CSS transforms are rendered by the browser but are not included in deterministic path-handle coordinates.`,
);
}
combined = multiply(combined, local);
if (source || !matrixNearlyEqual(local, IDENTITY)) {
entries.push({
nodeKey: node.key,
elementName: node.name,
...(node.id ? { elementId: node.id } : {}),
source,
local,
combined: { ...combined },
});
}
}
return {
entries,
matrix: combined,
inverse: diagnostics.length ? null : invert(combined),
diagnostics,
};
}
+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") };
}
+66
View File
@@ -0,0 +1,66 @@
export interface DiffLine {
kind: "same" | "add" | "remove";
text: string;
oldLine?: number;
newLine?: number;
}
export function lineDiff(
before: string,
after: string,
limit = 2_000,
): DiffLine[] {
const left = before.split(/\r?\n/u);
const right = after.split(/\r?\n/u);
if (left.length * right.length > limit * limit) {
return [
{
kind: "remove",
text: `${left.length} lines (${before.length} characters)`,
oldLine: 1,
},
{
kind: "add",
text: `${right.length} lines (${after.length} characters)`,
newLine: 1,
},
];
}
const lengths = Array.from(
{ length: left.length + 1 },
() => new Uint32Array(right.length + 1),
);
for (let i = left.length - 1; i >= 0; i -= 1) {
for (let j = right.length - 1; j >= 0; j -= 1) {
lengths[i]![j] =
left[i] === right[j]
? lengths[i + 1]![j + 1]! + 1
: Math.max(lengths[i + 1]![j]!, lengths[i]![j + 1]!);
}
}
const output: DiffLine[] = [];
let i = 0;
let j = 0;
while (i < left.length || j < right.length) {
if (i < left.length && j < right.length && left[i] === right[j]) {
output.push({
kind: "same",
text: left[i]!,
oldLine: i + 1,
newLine: j + 1,
});
i += 1;
j += 1;
} else if (
j < right.length &&
(i === left.length || lengths[i]![j + 1]! >= lengths[i + 1]![j]!)
) {
output.push({ kind: "add", text: right[j]!, newLine: j + 1 });
j += 1;
} else {
output.push({ kind: "remove", text: left[i]!, oldLine: i + 1 });
i += 1;
}
}
return output;
}
+78
View File
@@ -0,0 +1,78 @@
const XML_NAMESPACE = "http://www.w3.org/2000/xmlns/";
function escapeText(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function escapeAttribute(value: string): string {
return escapeText(value).replaceAll('"', "&quot;");
}
function serializeNode(
node: Node,
depth: number,
indentation: string,
newline: string,
): string {
const prefix = indentation.repeat(depth);
if (node.nodeType === Node.COMMENT_NODE)
return `${prefix}<!--${node.nodeValue ?? ""}-->`;
if (node.nodeType === Node.CDATA_SECTION_NODE)
return `${prefix}<![CDATA[${node.nodeValue ?? ""}]]>`;
if (node.nodeType === Node.PROCESSING_INSTRUCTION_NODE) {
const instruction = node as ProcessingInstruction;
return `${prefix}<?${instruction.target} ${instruction.data}?>`;
}
if (node.nodeType === Node.TEXT_NODE) {
const value = node.nodeValue ?? "";
return value.trim() ? `${prefix}${escapeText(value.trim())}` : "";
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const element = node as Element;
const attributes = Array.from(element.attributes)
.sort((left, right) => {
const leftNamespace = left.namespaceURI === XML_NAMESPACE ? 0 : 1;
const rightNamespace = right.namespaceURI === XML_NAMESPACE ? 0 : 1;
return (
leftNamespace - rightNamespace || left.name.localeCompare(right.name)
);
})
.map(
(attribute) => ` ${attribute.name}="${escapeAttribute(attribute.value)}"`,
)
.join("");
const children = Array.from(element.childNodes)
.map((child) => serializeNode(child, depth + 1, indentation, newline))
.filter(Boolean);
if (children.length === 0)
return `${prefix}<${element.tagName}${attributes}/>`;
const inlineText =
element.childNodes.length === 1 &&
element.firstChild?.nodeType === Node.TEXT_NODE;
if (inlineText) {
return `${prefix}<${element.tagName}${attributes}>${escapeText(element.textContent ?? "")}</${element.tagName}>`;
}
return `${prefix}<${element.tagName}${attributes}>${newline}${children.join(newline)}${newline}${prefix}</${element.tagName}>`;
}
export function formatSvgSource(source: string, indentation = " "): string {
const document = new DOMParser().parseFromString(source, "image/svg+xml");
if (
document.documentElement.localName === "parsererror" ||
document.querySelector("parsererror")
) {
throw new Error("Only well-formed SVG can be formatted");
}
const newline = source.includes("\r\n") ? "\r\n" : "\n";
const declaration = /^\s*<\?xml[^?]*\?>/iu.exec(source)?.[0].trim();
const doctype = /<!DOCTYPE\b[^>]*>/iu.exec(source)?.[0];
const parts = [
declaration,
doctype,
serializeNode(document.documentElement, 0, indentation, newline),
].filter(Boolean);
return `${parts.join(newline)}${newline}`;
}
+9
View File
@@ -0,0 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+31
View File
@@ -0,0 +1,31 @@
import type {
OptimizationProfile,
OptionalOptimizationPlugin,
} from "./profiles";
export interface OptimizationRequest {
type: "optimize";
jobId: number;
source: string;
profile: OptimizationProfile;
optionalPlugins: OptionalOptimizationPlugin[];
}
export interface OptimizationResult {
type: "result";
jobId: number;
profile: OptimizationProfile;
optionalPlugins: OptionalOptimizationPlugin[];
source: string;
inputBytes: number;
outputBytes: number;
elapsedMs: number;
}
export interface OptimizationFailure {
type: "error";
jobId: number;
message: string;
}
export type OptimizationResponse = OptimizationResult | OptimizationFailure;
+113
View File
@@ -0,0 +1,113 @@
import { defaultSvgLimits } from "../app/limits";
import type {
OptimizationResponse,
OptimizationResult,
} from "./optimization.types";
import type {
OptimizationProfile,
OptionalOptimizationPlugin,
} from "./profiles";
export class OptimizationCancelledError extends Error {
constructor(message = "Optimization was cancelled") {
super(message);
this.name = "OptimizationCancelledError";
}
}
export class OptimizerClient {
#jobId = 0;
#pending: { jobId: number; cancel: () => void } | null = null;
cancel(): void {
this.#jobId += 1;
this.#pending?.cancel();
}
async optimize(
source: string,
profile: OptimizationProfile,
optionalPlugins: readonly OptionalOptimizationPlugin[] = [],
signal?: AbortSignal,
): Promise<OptimizationResult> {
this.cancel();
const jobId = this.#jobId;
if (signal?.aborted) throw new OptimizationCancelledError();
const worker = new Worker(
new URL("./optimizer.worker.ts", import.meta.url),
{
type: "module",
name: "svg-tools-optimizer",
},
);
return new Promise<OptimizationResult>((resolve, reject) => {
let settled = false;
const cleanup = () => {
globalThis.clearTimeout(timeout);
signal?.removeEventListener("abort", abort);
worker.removeEventListener("error", onError);
worker.removeEventListener("message", onMessage);
worker.terminate();
if (this.#pending?.jobId === jobId) this.#pending = null;
};
const resolveOnce = (result: OptimizationResult) => {
if (settled) return;
settled = true;
cleanup();
resolve(result);
};
const rejectOnce = (error: Error) => {
if (settled) return;
settled = true;
cleanup();
reject(error);
};
const abort = () => {
rejectOnce(new OptimizationCancelledError());
};
const onError = (event: ErrorEvent) => {
rejectOnce(
new Error(event.message || "The optimization worker failed"),
);
};
const onMessage = (event: MessageEvent<OptimizationResponse>) => {
if (event.data.jobId !== jobId) return;
if (event.data.type === "error") {
rejectOnce(new Error(event.data.message));
} else {
resolveOnce(event.data);
}
};
this.#pending = { jobId, cancel: abort };
const timeout = globalThis.setTimeout(() => {
rejectOnce(
new Error(
`Optimization exceeded the ${defaultSvgLimits.maximumOptimizationMs / 1_000}-second safety limit`,
),
);
}, defaultSvgLimits.maximumOptimizationMs);
signal?.addEventListener("abort", abort, { once: true });
worker.addEventListener("error", onError, { once: true });
worker.addEventListener("message", onMessage);
if (signal?.aborted) {
abort();
return;
}
try {
worker.postMessage({
type: "optimize",
jobId,
source,
profile,
optionalPlugins: [...optionalPlugins],
});
} catch (error) {
rejectOnce(
error instanceof Error
? error
: new Error("The optimization worker could not be started"),
);
}
});
}
}
+48
View File
@@ -0,0 +1,48 @@
/// <reference lib="webworker" />
import { optimize } from "svgo/browser";
import { defaultSvgLimits, utf8ByteLength } from "../app/limits";
import type {
OptimizationRequest,
OptimizationResponse,
} from "./optimization.types";
import { configForProfile } from "./profiles";
const scope: DedicatedWorkerGlobalScope = self as DedicatedWorkerGlobalScope;
scope.addEventListener(
"message",
(event: MessageEvent<OptimizationRequest>) => {
const request = event.data;
if (request.type !== "optimize") return;
const started = performance.now();
let response: OptimizationResponse;
try {
const inputBytes = utf8ByteLength(request.source);
if (inputBytes > defaultSvgLimits.sourceHardBytes) {
throw new Error("Source exceeds the optimization hard limit");
}
const result = optimize(
request.source,
configForProfile(request.profile, request.optionalPlugins),
);
response = {
type: "result",
jobId: request.jobId,
profile: request.profile,
optionalPlugins: request.optionalPlugins,
source: result.data,
inputBytes,
outputBytes: utf8ByteLength(result.data),
elapsedMs: performance.now() - started,
};
} catch (error) {
response = {
type: "error",
jobId: request.jobId,
message: error instanceof Error ? error.message : "Optimization failed",
};
}
scope.postMessage(response);
},
);
+153
View File
@@ -0,0 +1,153 @@
import type { Config } from "svgo/browser";
export type OptimizationProfile = "conservative" | "standard" | "aggressive";
export type OptionalOptimizationPlugin =
| "convertStyleToAttrs"
| "removeDimensions"
| "removeOffCanvasPaths"
| "reusePaths";
export interface OptimizationProfileDescription {
id: OptimizationProfile;
label: string;
description: string;
risk: string;
}
export const optimizationProfiles: readonly OptimizationProfileDescription[] = [
{
id: "conservative",
label: "Conservative",
description:
"Cleans metadata and syntax while retaining IDs, viewBox, shapes and path spelling where possible.",
risk: "Low, but every optimizer can expose renderer differences; review the preview.",
},
{
id: "standard",
label: "Standard",
description:
"Applies SVGO defaults while retaining IDs and the viewBox, then sorts attributes.",
risk: "May rewrite path data, styles and groups without changing intended appearance.",
},
{
id: "aggressive",
label: "Aggressive",
description:
"Uses multiple passes, converts basic shapes to paths and collapses eligible groups.",
risk: "Largest source changes; animation, scripts or editor workflows may depend on original structure.",
},
];
export const optionalOptimizationPlugins: readonly {
id: OptionalOptimizationPlugin;
label: string;
risk: string;
}[] = [
{
id: "convertStyleToAttrs",
label: "Convert style to attributes",
risk: "Rewrites inline CSS declarations.",
},
{
id: "removeDimensions",
label: "Remove width and height",
risk: "Makes sizing depend on viewBox and embedding CSS.",
},
{
id: "removeOffCanvasPaths",
label: "Remove off-canvas paths",
risk: "Can remove content intended for later animation or viewport changes.",
},
{
id: "reusePaths",
label: "Reuse duplicate paths",
risk: "Introduces definitions and use references.",
},
];
const optionalPluginIds = new Set(
optionalOptimizationPlugins.map((plugin) => plugin.id),
);
function withOptionalPlugins(
config: Config,
selected: readonly OptionalOptimizationPlugin[],
): Config {
const unique = [...new Set(selected)];
for (const plugin of unique) {
if (!optionalPluginIds.has(plugin)) {
throw new Error(`Unsupported optional SVGO plugin: ${plugin}`);
}
}
return {
...config,
plugins: [...(config.plugins ?? []), ...unique],
};
}
export function configForProfile(
profile: OptimizationProfile,
optionalPlugins: readonly OptionalOptimizationPlugin[] = [],
): Config {
if (profile === "conservative") {
return withOptionalPlugins(
{
multipass: false,
floatPrecision: 6,
plugins: [
{
name: "preset-default",
params: {
overrides: {
cleanupIds: false,
removeViewBox: false,
convertPathData: false,
convertShapeToPath: false,
collapseGroups: false,
removeUnknownsAndDefaults: false,
},
},
},
],
js2svg: { pretty: true, indent: 2 },
},
optionalPlugins,
);
}
if (profile === "standard") {
return withOptionalPlugins(
{
multipass: false,
floatPrecision: 4,
plugins: [
{
name: "preset-default",
params: {
overrides: { cleanupIds: false, removeViewBox: false },
},
},
"sortAttrs",
],
},
optionalPlugins,
);
}
return withOptionalPlugins(
{
multipass: true,
floatPrecision: 3,
plugins: [
{
name: "preset-default",
params: {
overrides: { cleanupIds: false, removeViewBox: false },
},
},
"convertShapeToPath",
"collapseGroups",
"sortAttrs",
],
},
optionalPlugins,
);
}
+295
View File
@@ -0,0 +1,295 @@
import { defaultSvgLimits, utf8ByteLength } from "../app/limits";
import type { AnimationDefinition } from "../animation/animation.types";
import {
AnimationValidationError,
validateAnimationDefinitions,
} from "../animation/validation";
export const PROJECT_FORMAT = "de.add-ideas.svg-tools/project";
export const PROJECT_SCHEMA_VERSION = 1;
export const PROJECT_MIME = "application/vnd.add-ideas.svg-tools+json";
export interface ProjectUiState {
selectedNodeKey: string | null;
expandedNodeKeys: string[];
activePanel: string;
zoom: number;
pan: { x: number; y: number };
showGrid: boolean;
sourceSelection?: { anchor: number; head: number };
}
export interface SvgToolsProject {
format: typeof PROJECT_FORMAT;
schemaVersion: typeof PROJECT_SCHEMA_VERSION;
appVersion: string;
document: { source: string };
ui: ProjectUiState;
animations: AnimationDefinition[];
metadata: {
title: string;
originalFileName?: string;
createdAt: string;
updatedAt: string;
};
}
export class ProjectFormatError extends Error {
readonly code: string;
readonly path?: string;
constructor(code: string, message: string, path?: string) {
super(path ? `${message} (${path})` : message);
this.name = "ProjectFormatError";
this.code = code;
this.path = path;
}
}
function record(value: unknown, path: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ProjectFormatError("INVALID_PROJECT", "Expected an object", path);
}
return value as Record<string, unknown>;
}
function text(value: unknown, path: string, allowEmpty = false): string {
if (
typeof value !== "string" ||
(!allowEmpty && !value) ||
value.length > 1_000_000
) {
throw new ProjectFormatError(
"INVALID_PROJECT",
"Expected a bounded string",
path,
);
}
return value;
}
function finite(
value: unknown,
path: string,
minimum: number,
maximum: number,
): number {
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
value < minimum ||
value > maximum
) {
throw new ProjectFormatError(
"INVALID_PROJECT",
`Expected a number from ${minimum} to ${maximum}`,
path,
);
}
return value;
}
function stringArray(value: unknown, path: string, limit: number): string[] {
if (!Array.isArray(value) || value.length > limit) {
throw new ProjectFormatError(
"INVALID_PROJECT",
"Expected a bounded array",
path,
);
}
const result = value.map((entry, index) => text(entry, `${path}[${index}]`));
if (new Set(result).size !== result.length) {
throw new ProjectFormatError(
"INVALID_PROJECT",
"Duplicate values are not allowed",
path,
);
}
return result;
}
export function validateProject(value: unknown): SvgToolsProject {
const root = record(value, "$");
if (root.format !== PROJECT_FORMAT) {
throw new ProjectFormatError(
"UNSUPPORTED_FORMAT",
"This is not an SVG Tools project",
"$.format",
);
}
if (root.schemaVersion !== PROJECT_SCHEMA_VERSION) {
throw new ProjectFormatError(
"UNSUPPORTED_SCHEMA_VERSION",
`Project schema ${String(root.schemaVersion)} is not supported`,
"$.schemaVersion",
);
}
const document = record(root.document, "$.document");
const source = text(document.source, "$.document.source", true);
if (utf8ByteLength(source) > defaultSvgLimits.sourceHardBytes) {
throw new ProjectFormatError(
"SOURCE_TOO_LARGE",
"Project SVG source exceeds the hard limit",
"$.document.source",
);
}
const ui = record(root.ui, "$.ui");
const pan = record(ui.pan, "$.ui.pan");
const selectedNodeKey =
ui.selectedNodeKey === null
? null
: text(ui.selectedNodeKey, "$.ui.selectedNodeKey");
if (typeof ui.showGrid !== "boolean") {
throw new ProjectFormatError(
"INVALID_PROJECT",
"Expected a boolean",
"$.ui.showGrid",
);
}
let sourceSelection: ProjectUiState["sourceSelection"];
if (ui.sourceSelection !== undefined) {
const selection = record(ui.sourceSelection, "$.ui.sourceSelection");
sourceSelection = {
anchor: finite(
selection.anchor,
"$.ui.sourceSelection.anchor",
0,
source.length,
),
head: finite(
selection.head,
"$.ui.sourceSelection.head",
0,
source.length,
),
};
}
let animations: AnimationDefinition[];
try {
animations = validateAnimationDefinitions(root.animations);
} catch (error) {
if (error instanceof AnimationValidationError) {
throw new ProjectFormatError("INVALID_PROJECT", error.reason, error.path);
}
throw error;
}
const metadata = record(root.metadata, "$.metadata");
const originalFileName =
metadata.originalFileName === undefined
? undefined
: text(metadata.originalFileName, "$.metadata.originalFileName");
return {
format: PROJECT_FORMAT,
schemaVersion: PROJECT_SCHEMA_VERSION,
appVersion: text(root.appVersion, "$.appVersion"),
document: { source },
ui: {
selectedNodeKey,
expandedNodeKeys: stringArray(
ui.expandedNodeKeys,
"$.ui.expandedNodeKeys",
100_000,
),
activePanel: text(ui.activePanel, "$.ui.activePanel"),
zoom: finite(ui.zoom, "$.ui.zoom", 0.01, 128),
pan: {
x: finite(pan.x, "$.ui.pan.x", -1e9, 1e9),
y: finite(pan.y, "$.ui.pan.y", -1e9, 1e9),
},
showGrid: ui.showGrid,
...(sourceSelection ? { sourceSelection } : {}),
},
animations,
metadata: {
title: text(metadata.title, "$.metadata.title"),
createdAt: text(metadata.createdAt, "$.metadata.createdAt"),
updatedAt: text(metadata.updatedAt, "$.metadata.updatedAt"),
...(originalFileName ? { originalFileName } : {}),
},
};
}
function stableJson(value: unknown): string {
if (
value === null ||
typeof value === "boolean" ||
typeof value === "number"
) {
return JSON.stringify(value);
}
if (typeof value === "string") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (value && typeof value === "object") {
const data = value as Record<string, unknown>;
return `{${Object.keys(data)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableJson(data[key])}`)
.join(",")}}`;
}
throw new ProjectFormatError(
"INVALID_PROJECT",
"Project contains a non-JSON value",
);
}
export function createProject(
input: Omit<SvgToolsProject, "format" | "schemaVersion">,
): SvgToolsProject {
return validateProject({
format: PROJECT_FORMAT,
schemaVersion: PROJECT_SCHEMA_VERSION,
...input,
});
}
export function serializeProject(project: SvgToolsProject): string {
const source = `${stableJson(validateProject(project))}\n`;
if (utf8ByteLength(source) > defaultSvgLimits.sourceHardBytes * 4) {
throw new ProjectFormatError(
"PROJECT_TOO_LARGE",
"Project exceeds the processing limit",
);
}
return source;
}
export function parseProject(source: string): SvgToolsProject {
if (utf8ByteLength(source) > defaultSvgLimits.sourceHardBytes * 4) {
throw new ProjectFormatError(
"PROJECT_TOO_LARGE",
"Project exceeds the processing limit",
);
}
try {
return validateProject(
JSON.parse(source.replace(/^\uFEFF/u, "")) as unknown,
);
} catch (error) {
if (error instanceof ProjectFormatError) throw error;
throw new ProjectFormatError(
"INVALID_JSON",
"The selected project is not valid JSON",
);
}
}
export async function readProject(file: Blob): Promise<SvgToolsProject> {
if (file.size > defaultSvgLimits.sourceHardBytes * 4) {
throw new ProjectFormatError(
"PROJECT_TOO_LARGE",
"Project exceeds the processing limit",
);
}
const bytes = new Uint8Array(await file.arrayBuffer());
try {
return parseProject(
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
);
} catch (error) {
if (error instanceof ProjectFormatError) throw error;
throw new ProjectFormatError(
"INVALID_UTF8",
"The selected project is not valid UTF-8",
);
}
}
+432
View File
@@ -0,0 +1,432 @@
import DOMPurify from "dompurify";
import { parse as parseCss, walk as walkCss } from "css-tree";
import { defaultSvgLimits } from "../app/limits";
import type {
SemanticSvgDocument,
SemanticSvgNode,
} from "../document/document.types";
import type { SvgProjection, SvgSecurityFinding } from "./security.types";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const BLOCKED_ELEMENTS = new Set([
"script",
"foreignObject",
"animate",
"animateMotion",
"animateTransform",
"set",
]);
const UNSAFE_CSS_PROPERTIES = new Set(["behavior", "-moz-binding"]);
const UNSAFE_CSS_FUNCTIONS = new Set([
"cross-fade",
"image",
"image-set",
"-webkit-image-set",
"paint",
"src",
]);
const URL_ATTRIBUTES = new Set([
"href",
"xlink:href",
"src",
"fill",
"stroke",
"filter",
"clip-path",
"mask",
"marker-start",
"marker-mid",
"marker-end",
"cursor",
]);
const SAFE_IMAGE_DATA = /^data:image\/(?:png|jpeg|gif|webp|avif);base64,/iu;
const LOCAL_FRAGMENT = /^#[A-Za-z_][A-Za-z0-9_.:-]*$/u;
const URL_FUNCTION = /url\(\s*(["']?)(.*?)\1\s*\)/giu;
function finding(
node: SemanticSvgNode,
code: string,
description: string,
action: SvgSecurityFinding["editingProjectionAction"],
severity: SvgSecurityFinding["severity"] = "error",
attribute?: string,
): SvgSecurityFinding {
return {
severity,
code,
nodeKey: node.key,
sourceRange:
(attribute ? node.attributeRanges[attribute]?.fullRange : undefined) ??
node.sourceRange.openTag,
description,
sourcePreserved: true,
editingProjectionAction: action,
};
}
function isUniqueLocalFragment(
value: string,
idCounts: ReadonlyMap<string, number>,
): boolean {
const target = value.trim();
return LOCAL_FRAGMENT.test(target) && idCounts.get(target.slice(1)) === 1;
}
function isSafeLocalReference(
value: string,
idCounts: ReadonlyMap<string, number>,
): boolean {
if (isUniqueLocalFragment(value, idCounts)) return true;
let safe = true;
let encountered = false;
value.replace(URL_FUNCTION, (_match, _quote: string, target: string) => {
encountered = true;
if (!isUniqueLocalFragment(target, idCounts)) safe = false;
return "";
});
return encountered && safe;
}
interface CssPolicyResult {
code: string;
description: string;
}
function inspectCssPolicy(
css: string,
context: "stylesheet" | "declarationList",
idCounts: ReadonlyMap<string, number>,
): CssPolicyResult | null {
let parseFailed = false;
let issue: CssPolicyResult | null = null;
try {
const ast = parseCss(css, {
context,
positions: false,
onParseError: () => {
parseFailed = true;
},
});
walkCss(ast, (node) => {
if (issue) return;
if (node.type === "Atrule" && node.name.toLowerCase() === "import") {
issue = {
code: "css-import",
description: "CSS @import is removed from the projection.",
};
} else if (
node.type === "Declaration" &&
UNSAFE_CSS_PROPERTIES.has(node.property.toLowerCase())
) {
issue = {
code: "css-behavior",
description: `CSS property “${node.property}” is not allowed in the projection.`,
};
} else if (
node.type === "Url" &&
!isUniqueLocalFragment(node.value, idCounts)
) {
issue = {
code: "css-external-url",
description:
"CSS URLs must resolve to one unique local fragment in the projection.",
};
} else if (
node.type === "Function" &&
node.name.toLowerCase() === "expression"
) {
issue = {
code: "css-expression",
description:
"CSS expression-like functions are not allowed in the projection.",
};
} else if (
node.type === "Function" &&
UNSAFE_CSS_FUNCTIONS.has(node.name.toLowerCase())
) {
issue = {
code: "css-resource-function",
description: `CSS function “${node.name}()” can resolve external resources and is not allowed in the projection.`,
};
}
});
} catch {
parseFailed = true;
}
return (
issue ??
(parseFailed
? {
code: "css-parser-error",
description:
"CSS could not be parsed safely and is removed from the projection.",
}
: null)
);
}
function inspectNode(
node: SemanticSvgNode,
idCounts: ReadonlyMap<string, number>,
): SvgSecurityFinding[] {
const findings: SvgSecurityFinding[] = [];
if (node.namespaceUri !== null && node.namespaceUri !== SVG_NAMESPACE) {
findings.push(
finding(
node,
"unknown-namespace",
`Element namespace “${node.namespaceUri}” is not rendered.`,
"removed",
),
);
}
if (BLOCKED_ELEMENTS.has(node.localName)) {
findings.push(
finding(
node,
`blocked-${node.localName.toLowerCase()}`,
`<${node.localName}> is preserved in source but removed from all rendered projections.`,
"removed",
),
);
}
for (const [name, value] of Object.entries(node.attributes)) {
const lowerName = name.toLowerCase();
if (lowerName.startsWith("on")) {
findings.push(
finding(
node,
"event-handler",
`Executable event attribute “${name}” is removed from the projection.`,
"removed",
"error",
name,
),
);
continue;
}
if (URL_ATTRIBUTES.has(lowerName)) {
const trimmed = value.trim();
const imageDataAllowed =
node.localName === "image" &&
(lowerName === "href" || lowerName === "xlink:href") &&
SAFE_IMAGE_DATA.test(trimmed) &&
trimmed.length <= defaultSvgLimits.maximumEmbeddedResourceBytes * 1.4;
const localAllowed = isSafeLocalReference(trimmed, idCounts);
const plainPaint =
["fill", "stroke"].includes(lowerName) && !/url\s*\(/iu.test(trimmed);
if (
!imageDataAllowed &&
!localAllowed &&
!plainPaint &&
trimmed !== "none"
) {
findings.push(
finding(
node,
"unsafe-url",
`External or executable URL in “${name}” is neutralized in the projection.`,
"neutralized",
"error",
name,
),
);
}
}
const cssIssue =
lowerName === "style"
? inspectCssPolicy(value, "declarationList", idCounts)
: null;
if (cssIssue) {
findings.push(
finding(
node,
"unsafe-inline-css",
cssIssue.description,
"removed",
"error",
name,
),
);
}
}
if (node.localName === "style") {
const cssIssue = inspectCssPolicy(node.text, "stylesheet", idCounts);
if (cssIssue) {
findings.push(
finding(node, "unsafe-stylesheet", cssIssue.description, "removed"),
);
}
}
return findings;
}
export function inspectSvgSecurity(
semantic: SemanticSvgDocument,
): SvgSecurityFinding[] {
const idCounts = new Map<string, number>();
for (const node of semantic.nodes.values()) {
if (node.id) idCounts.set(node.id, (idCounts.get(node.id) ?? 0) + 1);
}
const findings = semantic.order.flatMap((key) =>
inspectNode(semantic.nodes.get(key)!, idCounts),
);
if (/<!DOCTYPE\b/iu.test(semantic.source)) {
findings.push({
severity: "warning",
code: "doctype-ignored",
description:
"DOCTYPE remains in source but is excluded from rendering and no external entity is resolved.",
sourcePreserved: true,
editingProjectionAction: "removed",
});
}
return findings;
}
function matchingSemanticNode(
element: Element,
order: readonly string[],
semantic: SemanticSvgDocument,
index: number,
): SemanticSvgNode | null {
const exact = semantic.nodes.get(order[index] ?? "");
if (exact?.localName === element.localName) return exact;
const id = element.getAttribute("id");
if (id) {
return (
[...semantic.nodes.values()].find((candidate) => candidate.id === id) ??
null
);
}
return exact ?? null;
}
function postSanitize(
document: XMLDocument,
findings: readonly SvgSecurityFinding[],
): void {
const findingsByNode = new Map<string, SvgSecurityFinding[]>();
for (const item of findings) {
if (!item.nodeKey) continue;
const existing = findingsByNode.get(item.nodeKey) ?? [];
existing.push(item);
findingsByNode.set(item.nodeKey, existing);
}
const idCounts = new Map<string, number>();
for (const element of Array.from(document.querySelectorAll("[id]"))) {
const id = element.getAttribute("id");
if (id) idCounts.set(id, (idCounts.get(id) ?? 0) + 1);
}
for (const element of Array.from(document.querySelectorAll("*"))) {
const nodeKey = element.getAttribute("data-svg-tools-node");
if (nodeKey && findingsByNode.has(nodeKey)) {
const nodeFindings = findingsByNode.get(nodeKey)!;
if (
nodeFindings.some(
(item) =>
item.code.startsWith("blocked-") ||
item.code === "unknown-namespace",
)
) {
element.remove();
continue;
}
for (const attribute of Array.from(element.attributes)) {
const lower = attribute.name.toLowerCase();
if (lower.startsWith("on")) element.removeAttribute(attribute.name);
if (
URL_ATTRIBUTES.has(lower) &&
!isSafeLocalReference(attribute.value, idCounts) &&
!(
element.localName === "image" &&
SAFE_IMAGE_DATA.test(attribute.value) &&
attribute.value.length <=
defaultSvgLimits.maximumEmbeddedResourceBytes * 1.4
) &&
!(
["fill", "stroke"].includes(lower) &&
!/url\s*\(/iu.test(attribute.value)
)
) {
element.removeAttribute(attribute.name);
}
}
if (
/unsafe-inline-css|unsafe-stylesheet/u.test(
nodeFindings.map((item) => item.code).join(" "),
)
) {
element.removeAttribute("style");
if (element.localName === "style") element.remove();
}
}
if (element.localName === "a") {
element.removeAttribute("href");
element.removeAttribute("xlink:href");
element.removeAttribute("target");
}
}
}
export function createEditingProjection(
semantic: SemanticSvgDocument,
): SvgProjection {
const sourceDocument = semantic.document.cloneNode(true) as XMLDocument;
const elements = [
sourceDocument.documentElement,
...Array.from(sourceDocument.documentElement.querySelectorAll("*")),
];
elements.forEach((element, index) => {
const node = matchingSemanticNode(element, semantic.order, semantic, index);
if (node) element.setAttribute("data-svg-tools-node", node.key);
});
const findings = inspectSvgSecurity(semantic);
const originalElementCount = elements.length;
const purified = DOMPurify.sanitize(
new XMLSerializer().serializeToString(sourceDocument.documentElement),
{
USE_PROFILES: { svg: true, svgFilters: true },
FORBID_TAGS: [...BLOCKED_ELEMENTS],
ALLOW_DATA_ATTR: true,
RETURN_DOM: false,
},
);
const projectionDocument = new DOMParser().parseFromString(
purified,
"image/svg+xml",
);
if (projectionDocument.documentElement.localName !== "svg") {
throw new Error("Sanitizer did not produce an SVG root");
}
postSanitize(projectionDocument, findings);
const projectionElementCount =
1 + projectionDocument.documentElement.querySelectorAll("*").length;
return {
source: new XMLSerializer().serializeToString(
projectionDocument.documentElement,
),
findings,
removedCount: Math.max(0, originalElementCount - projectionElementCount),
policy: "editing-projection-v1",
};
}
export function createSanitizedCandidate(
semantic: SemanticSvgDocument,
): SvgProjection {
const projection = createEditingProjection(semantic);
const document = new DOMParser().parseFromString(
projection.source,
"image/svg+xml",
);
for (const element of Array.from(
document.querySelectorAll("[data-svg-tools-node]"),
)) {
element.removeAttribute("data-svg-tools-node");
}
return {
...projection,
source: new XMLSerializer().serializeToString(document.documentElement),
};
}
+18
View File
@@ -0,0 +1,18 @@
import type { SourceRange } from "../document/document.types";
export interface SvgSecurityFinding {
severity: "info" | "warning" | "error";
code: string;
nodeKey?: string;
sourceRange?: SourceRange;
description: string;
sourcePreserved: boolean;
editingProjectionAction: "allowed" | "neutralized" | "removed" | "replaced";
}
export interface SvgProjection {
source: string;
findings: SvgSecurityFinding[];
removedCount: number;
policy: "editing-projection-v1";
}
+251
View File
@@ -0,0 +1,251 @@
import type {
SemanticSvgDocument,
SourcePatch,
SvgDiagnostic,
} from "../document/document.types";
import { escapeXmlAttribute } from "../document/source-patcher";
const DIRECT_REFERENCE_ATTRIBUTES = new Set([
"href",
"xlink:href",
"aria-labelledby",
"aria-describedby",
]);
const URL_REFERENCE_ATTRIBUTES = new Set([
"fill",
"stroke",
"filter",
"clip-path",
"mask",
"marker-start",
"marker-mid",
"marker-end",
"cursor",
]);
const URL_REFERENCE_PATTERN = /url\(\s*(["']?)#([^)'"\s]+)\1\s*\)/giu;
export interface SvgReferenceEdge {
sourceKey: string;
attribute: string;
targetId: string;
targetKey: string | null;
status: "resolved" | "missing" | "ambiguous" | "cyclic";
}
export interface SvgReferenceIndex {
ids: ReadonlyMap<string, readonly string[]>;
edges: readonly SvgReferenceEdge[];
incomingById: ReadonlyMap<string, readonly SvgReferenceEdge[]>;
diagnostics: readonly SvgDiagnostic[];
}
function targetsForAttribute(name: string, value: string): string[] {
if (DIRECT_REFERENCE_ATTRIBUTES.has(name)) {
if (name.startsWith("aria-")) {
return value.trim().split(/\s+/u).filter(Boolean);
}
return value.startsWith("#") ? [value.slice(1)] : [];
}
if (!URL_REFERENCE_ATTRIBUTES.has(name) && name !== "style") return [];
return Array.from(
value.matchAll(URL_REFERENCE_PATTERN),
(match) => match[2]!,
).filter(Boolean);
}
function markCycles(
edges: SvgReferenceEdge[],
idByKey: ReadonlyMap<string, string>,
) {
const outgoing = new Map<string, SvgReferenceEdge[]>();
for (const edge of edges) {
const list = outgoing.get(edge.sourceKey) ?? [];
list.push(edge);
outgoing.set(edge.sourceKey, list);
}
const state = new Map<string, "visiting" | "visited">();
for (const start of idByKey.keys()) {
if (state.has(start)) continue;
const activeIndex = new Map<string, number>();
const stack: Array<{
key: string;
edges: SvgReferenceEdge[];
next: number;
via?: SvgReferenceEdge;
}> = [
{
key: start,
edges: outgoing.get(start) ?? [],
next: 0,
},
];
state.set(start, "visiting");
activeIndex.set(start, 0);
while (stack.length > 0) {
const frame = stack.at(-1)!;
if (frame.next >= frame.edges.length) {
state.set(frame.key, "visited");
activeIndex.delete(frame.key);
stack.pop();
continue;
}
const edge = frame.edges[frame.next]!;
frame.next += 1;
if (!edge.targetKey) continue;
const targetState = state.get(edge.targetKey);
if (targetState === "visiting") {
edge.status = "cyclic";
const cycleStart = activeIndex.get(edge.targetKey)!;
for (let index = cycleStart + 1; index < stack.length; index += 1) {
if (stack[index]!.via) stack[index]!.via!.status = "cyclic";
}
} else if (!targetState) {
state.set(edge.targetKey, "visiting");
activeIndex.set(edge.targetKey, stack.length);
stack.push({
key: edge.targetKey,
edges: outgoing.get(edge.targetKey) ?? [],
next: 0,
via: edge,
});
}
}
}
}
export function buildReferenceIndex(
semantic: SemanticSvgDocument,
): SvgReferenceIndex {
const ids = new Map<string, string[]>();
const idByKey = new Map<string, string>();
for (const node of semantic.nodes.values()) {
if (!node.id) continue;
const list = ids.get(node.id) ?? [];
list.push(node.key);
ids.set(node.id, list);
idByKey.set(node.key, node.id);
}
const edges: SvgReferenceEdge[] = [];
for (const node of semantic.nodes.values()) {
for (const [attribute, value] of Object.entries(node.attributes)) {
for (const targetId of targetsForAttribute(attribute, value)) {
const targets = ids.get(targetId) ?? [];
edges.push({
sourceKey: node.key,
attribute,
targetId,
targetKey: targets.length === 1 ? targets[0]! : null,
status:
targets.length === 0
? "missing"
: targets.length > 1
? "ambiguous"
: "resolved",
});
}
}
}
markCycles(edges, idByKey);
const incomingById = new Map<string, SvgReferenceEdge[]>();
for (const edge of edges) {
const list = incomingById.get(edge.targetId) ?? [];
list.push(edge);
incomingById.set(edge.targetId, list);
}
const diagnostics: SvgDiagnostic[] = edges
.filter((edge) => edge.status !== "resolved")
.map((edge) => {
const node = semantic.nodes.get(edge.sourceKey)!;
return {
severity: edge.status === "cyclic" ? "warning" : "error",
code: `reference-${edge.status}`,
message: `${edge.attribute} references “${edge.targetId}” (${edge.status}).`,
nodeKey: edge.sourceKey,
range: node.attributeRanges[edge.attribute]?.valueRange,
};
});
return { ids, edges, incomingById, diagnostics };
}
export interface IdRenamePreview {
oldId: string;
newId: string;
patches: SourcePatch[];
affectedNodeKeys: string[];
warnings: string[];
}
export function previewIdRename(
semantic: SemanticSvgDocument,
nodeKey: string,
newId: string,
): IdRenamePreview {
if (!/^[A-Za-z_][A-Za-z0-9_.:-]*$/u.test(newId)) {
throw new Error("ID must be a portable XML name");
}
const node = semantic.nodes.get(nodeKey);
if (!node?.id) throw new Error("Selected element has no ID");
const index = buildReferenceIndex(semantic);
if (index.ids.has(newId) && newId !== node.id) {
throw new Error(`ID “${newId}” already exists`);
}
const patches: SourcePatch[] = [];
const idRange = node.attributeRanges.id?.valueRange;
if (!idRange) throw new Error("ID source range is unavailable");
patches.push({
from: idRange.from,
to: idRange.to,
insert: escapeXmlAttribute(newId, node.attributeRanges.id!.quote),
label: "Rename ID",
});
const affected = new Set([nodeKey]);
const patchedAttributes = new Set<string>();
for (const edge of index.incomingById.get(node.id) ?? []) {
const patchIdentity = `${edge.sourceKey}\u0000${edge.attribute}`;
if (patchedAttributes.has(patchIdentity)) continue;
patchedAttributes.add(patchIdentity);
const sourceNode = semantic.nodes.get(edge.sourceKey)!;
const range = sourceNode.attributeRanges[edge.attribute];
if (!range) continue;
const value = sourceNode.attributes[edge.attribute]!;
let replaced: string;
if (edge.attribute.startsWith("aria-")) {
replaced = value
.split(/(\s+)/u)
.map((part) => (part === node.id ? newId : part))
.join("");
} else if (DIRECT_REFERENCE_ATTRIBUTES.has(edge.attribute)) {
replaced = value === `#${node.id}` ? `#${newId}` : value;
} else {
replaced = value.replace(
URL_REFERENCE_PATTERN,
(match, _quote: string, targetId: string) =>
targetId === node.id
? match.replace(`#${node.id}`, `#${newId}`)
: match,
);
}
patches.push({
from: range.valueRange.from,
to: range.valueRange.to,
insert: escapeXmlAttribute(replaced, range.quote),
label: `Update ${edge.attribute} reference`,
});
affected.add(edge.sourceKey);
}
const warnings = [...semantic.nodes.values()].some(
(candidate) =>
candidate.localName === "style" && candidate.text.includes(`#${node.id}`),
)
? [
"Stylesheet selectors reference this ID; automatic CSS rewriting is intentionally not applied yet.",
]
: [];
return {
oldId: node.id,
newId,
patches,
affectedNodeKeys: [...affected],
warnings,
};
}
+1476
View File
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
class TestResizeObserver implements ResizeObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
Object.defineProperty(globalThis, "ResizeObserver", {
configurable: true,
value: TestResizeObserver,
});
if (!globalThis.CSS) {
Object.defineProperty(globalThis, "CSS", {
configurable: true,
value: {},
});
}
if (!globalThis.CSS.escape) {
globalThis.CSS.escape = (value: string) =>
value.replace(/[^A-Za-z0-9_-]/gu, (character) => `\\${character}`);
}
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: vi.fn(() => "blob:svg-tools-test"),
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: vi.fn(),
});
if (
!(
HTMLDialogElement.prototype as HTMLDialogElement & {
showModal?: () => void;
}
).showModal
) {
HTMLDialogElement.prototype.showModal = function showModal() {
this.setAttribute("open", "");
};
}
const nativeDialogClose = HTMLDialogElement.prototype.close;
HTMLDialogElement.prototype.close = function close(returnValue?: string) {
if (nativeDialogClose) {
try {
nativeDialogClose.call(this, returnValue);
return;
} catch {
// jsdom versions without a complete dialog implementation use the fallback.
}
}
this.removeAttribute("open");
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
+49
View File
@@ -0,0 +1,49 @@
{
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.svg-tools",
"name": "SVG Tools",
"version": "0.1.0",
"description": "Inspect, edit, optimize and transform SVG documents locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["graphics", "design", "developer"],
"tags": [
"svg",
"vector",
"path",
"xml",
"transform",
"optimize",
"accessibility"
],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": false,
"workers": true,
"indexedDb": false,
"crossOriginIsolated": false,
"topLevelContext": false
},
"privacy": {
"processing": "local",
"fileUploads": false,
"telemetry": false
},
"source": {
"repository": "https://git.add-ideas.de/lotobo/svg-tools",
"license": "GPL-3.0-or-later"
},
"actions": [
{
"id": "source",
"label": "Source",
"url": "https://git.add-ideas.de/lotobo/svg-tools"
}
],
"assets": ["./canvas-frame-controller.js"]
}
+4
View File
@@ -0,0 +1,4 @@
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
import manifestSource from "./manifest.source.json";
export const manifest = defineToolboxApp(parseToolboxApp(manifestSource));
+5
View File
@@ -0,0 +1,5 @@
export const APPLICATION_VERSION = "0.1.0";
export const DOMPURIFY_VERSION = "3.4.12";
export const SVGO_VERSION = "4.0.2";
export const SVG_PATH_COMMANDER_VERSION = "2.2.1";
export const CSS_TREE_VERSION = "3.2.1";