Files
svg-tools/src/domain/bake-transform.ts
T

452 lines
14 KiB
TypeScript

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 };
}