452 lines
14 KiB
TypeScript
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 };
|
|
}
|