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 = { 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(` 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 }; }