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