feat: introduce local-first SVG workbench

This commit is contained in:
2026-08-02 16:31:49 +02:00
commit 39d9802daa
97 changed files with 20702 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
import fc from "fast-check";
import { describe, expect, it } from "vitest";
import {
IDENTITY,
applyToPoint,
composeTransformList,
decomposeCanonical,
diagnoseTransform,
invert,
matrixNearlyEqual,
multiply,
parseTransformList,
recomposeCanonical,
rotation,
scaling,
translation,
} from "../../src/domain/affine";
describe("SVG affine transforms", () => {
it("parses and composes source order according to SVG matrix semantics", () => {
const matrix = composeTransformList(
parseTransformList("translate(10 20) scale(2)"),
);
expect(matrix).toEqual({ a: 2, b: 0, c: 0, d: 2, e: 10, f: 20 });
expect(applyToPoint(matrix, { x: 1, y: 1 })).toEqual({ x: 12, y: 22 });
});
it("inverts and canonically decomposes a reflected shear", () => {
const matrix = composeTransformList(
parseTransformList("translate(8 -2) rotate(23) scale(3 -2) skewX(14)"),
);
expect(matrixNearlyEqual(multiply(matrix, invert(matrix)!), IDENTITY)).toBe(
true,
);
const decomposition = decomposeCanonical(matrix);
expect(decomposition.reflected).toBe(true);
expect(matrixNearlyEqual(recomposeCanonical(decomposition), matrix)).toBe(
true,
);
expect(decomposition.residual).toBeLessThan(1e-10);
});
it("rejects malformed arities and diagnoses singular matrices", () => {
expect(() => parseTransformList("rotate(10 20)")).toThrow(
/expects 1 or 3/u,
);
expect(diagnoseTransform("scale(1 0)").diagnostics).toContainEqual(
expect.objectContaining({
code: "singular-transform",
severity: "error",
}),
);
});
it("round-trips bounded points through generated nonsingular transform chains", () => {
const coordinate = fc
.integer({ min: -10_000, max: 10_000 })
.map((value) => value / 10);
const nonzeroScale = fc
.integer({ min: -100, max: 100 })
.filter((value) => value !== 0)
.map((value) => value / 10);
fc.assert(
fc.property(
coordinate,
coordinate,
fc.integer({ min: -720, max: 720 }),
nonzeroScale,
nonzeroScale,
coordinate,
coordinate,
(tx, ty, angle, sx, sy, x, y) => {
const matrix = multiply(
translation(tx, ty),
multiply(rotation(angle), scaling(sx, sy)),
);
const inverse = invert(matrix);
if (!inverse) return false;
const recovered = applyToPoint(
inverse,
applyToPoint(matrix, { x, y }),
);
return (
Math.abs(recovered.x - x) < 1e-8 && Math.abs(recovered.y - y) < 1e-8
);
},
),
{ numRuns: 250 },
);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import { applySourcePatches } from "../../src/document/source-patcher";
import { parseSvgSource } from "../../src/document/source-parser";
import {
composeTransformList,
parseTransformList,
} from "../../src/domain/affine";
import { bakeElementTransform } from "../../src/domain/bake-transform";
function bake(source: string, id: string) {
const parsed = parseSvgSource(source, 1);
expect(parsed.semantic).not.toBeNull();
const semantic = parsed.semantic!;
const node = [...semantic.nodes.values()].find(
(candidate) => candidate.id === id,
)!;
const matrix = composeTransformList(
parseTransformList(node.attributes.transform ?? ""),
);
const result = bakeElementTransform(
source,
node,
matrix,
semantic.preferences,
);
return { result, source: applySourcePatches(source, result.patches) };
}
describe("element transform baking", () => {
it("retains lines, polygons, and safe rectangles as native elements", () => {
const line = bake(
'<svg xmlns="http://www.w3.org/2000/svg"><line id="shape" x1="1" y1="2" x2="3" y2="4" transform="translate(10 20)"/></svg>',
"shape",
);
expect(line.source).toContain(
'<line id="shape" x1="11" y1="22" x2="13" y2="24"',
);
expect(line.source).not.toContain("transform=");
expect(line.result.warnings).toEqual([]);
const polygon = bake(
'<svg xmlns="http://www.w3.org/2000/svg"><polygon id="shape" points="0,0 2,0 2,2" transform="scale(2 3)"/></svg>',
"shape",
);
expect(polygon.source).toContain('points="0,0 4,0 4,6"');
const rectangle = bake(
'<svg xmlns="http://www.w3.org/2000/svg"><rect id="shape" x="2" y="3" width="4" height="5" rx="1" transform="translate(1 2) scale(2 3)"/></svg>',
"shape",
);
expect(rectangle.result.convertedToPath).toBe(false);
expect(rectangle.source).toContain(
'<rect id="shape" x="5" y="11" width="8" height="15" rx="2"',
);
expect(rectangle.source).toContain('ry="3"');
});
it("converts general rectangles to paths and preserves ordinary attributes", () => {
const baked = bake(
'<svg xmlns="http://www.w3.org/2000/svg"><rect id="shape" class="important" x="0" y="0" width="10" height="5" rx="2" transform="rotate(30)"/></svg>',
"shape",
);
expect(baked.result).toMatchObject({
outputElement: "path",
convertedToPath: true,
});
expect(baked.source).toContain('<path id="shape" class="important"');
expect(baked.source).toContain(' d="M ');
expect(baked.source).not.toMatch(/\s(?:x|y|width|height|rx|transform)=/u);
});
it("retains uniform circles and converts axis-scaled circles to ellipses", () => {
const circle = bake(
'<svg xmlns="http://www.w3.org/2000/svg"><circle id="shape" cx="4" cy="5" r="3" transform="rotate(40) scale(2)"/></svg>',
"shape",
);
expect(circle.result.outputElement).toBe("circle");
expect(circle.source).toContain('r="6"');
const ellipse = bake(
'<svg xmlns="http://www.w3.org/2000/svg"><circle id="shape" cx="4" cy="5" r="3" transform="scale(2 4)"/></svg>',
"shape",
);
expect(ellipse.result.outputElement).toBe("ellipse");
expect(ellipse.source).toContain('<ellipse id="shape"');
expect(ellipse.source).toContain('rx="6"');
expect(ellipse.source).toContain('ry="12"');
expect(ellipse.source).not.toContain(' r="');
});
it("bakes paths with high precision and reports stroke consequences", () => {
const baked = bake(
'<svg xmlns="http://www.w3.org/2000/svg"><path id="shape" d="M0 0A10 4 20 0 1 20 0" stroke="red" transform="matrix(-2 .5 .25 3 4 8)"/></svg>',
"shape",
);
expect(baked.source).toContain('<path id="shape" d="M 4 8 A ');
expect(baked.source).not.toContain("transform=");
expect(baked.result.warnings).toContainEqual(
expect.stringContaining("stroke-width"),
);
});
it("refuses unsupported text baking instead of partially applying", () => {
const source =
'<svg xmlns="http://www.w3.org/2000/svg"><text id="shape" transform="rotate(2)">Text</text></svg>';
expect(() => bake(source, "shape")).toThrow(/not deterministic/u);
});
it("refuses non-SVG numeric spellings instead of coercing geometry", () => {
const source =
'<svg xmlns="http://www.w3.org/2000/svg"><rect id="shape" width="0x10" height="4" transform="translate(1)"/></svg>';
expect(() => bake(source, "shape")).toThrow(/finite width/u);
});
});
+168
View File
@@ -0,0 +1,168 @@
import { describe, expect, it } from "vitest";
import {
arcEndpointToCenter,
describePathCommand,
movePathHandle,
parsePathData,
pathHandles,
pointOnArc,
reversePath,
serializePathData,
splitSegment,
transformPath,
} from "../../src/domain/path";
describe("application-owned SVG path model", () => {
it("normalizes every command family, relatives, repeats and shorthand controls", () => {
const model = parsePathData(
"m 10 10 5 5 h 10 v 10 c 1 2 3 4 5 6 s 7 8 9 10 q 2 3 4 5 t 6 7 a 8 9 30 0 1 10 11 z",
);
expect(model.segments.map((segment) => segment.kind)).toEqual([
"M",
"L",
"L",
"L",
"C",
"C",
"Q",
"Q",
"A",
"Z",
]);
const firstCubic = model.segments[4];
const smoothCubic = model.segments[5];
expect(firstCubic?.kind).toBe("C");
expect(smoothCubic?.kind).toBe("C");
if (firstCubic?.kind === "C" && smoothCubic?.kind === "C") {
expect(smoothCubic.control1).toEqual({
x: 2 * smoothCubic.from.x - firstCubic.control2.x,
y: 2 * smoothCubic.from.y - firstCubic.control2.y,
});
expect(smoothCubic).toMatchObject({
derivedControl1: true,
sourceForm: { command: "s", relative: true },
});
const derived = pathHandles(model).find(
(handle) => handle.segmentIndex === 5 && handle.role === "control-1",
)!;
expect(derived.derived).toBe(true);
const explicit = movePathHandle(model, derived, {
x: derived.point.x + 1,
y: derived.point.y,
});
expect(explicit.segments[5]).toMatchObject({ derivedControl1: false });
}
expect(model.segments[7]).toMatchObject({
derivedControl: true,
sourceForm: { command: "t" },
});
expect(
model.segments.every(
({ sourceForm }) =>
sourceForm !== undefined &&
sourceForm.sourceRange.to > sourceForm.sourceRange.from,
),
).toBe(true);
});
it("supports compact numbers, exponent notation and packed arc flags", () => {
const model = parsePathData("M.5.6L10-5e-1A5 6 0 0110 20");
expect(model.segments).toHaveLength(3);
expect(model.segments[0]).toMatchObject({ to: { x: 0.5, y: 0.6 } });
expect(model.segments[1]).toMatchObject({ to: { x: 10, y: -0.5 } });
expect(model.segments[2]).toMatchObject({
kind: "A",
largeArc: false,
sweep: true,
to: { x: 10, y: 20 },
});
});
it("round-trips normalized geometry and rejects malformed data", () => {
const model = parsePathData(
"M0 0 C1 2 3 4 5 6 Q7 8 9 10 A4 3 20 1 0 12 13 Z",
);
expect(serializePathData(parsePathData(serializePathData(model)))).toBe(
serializePathData(model),
);
expect(() => parsePathData("L 1 2")).toThrow(/begin with a moveto/u);
expect(() => parsePathData("M 0 0 A 5 5 0 2 0 10 10")).toThrow(
/flag must be 0 or 1/u,
);
expect(() => parsePathData("M 0,")).toThrow(/comma/iu);
expect(() => parsePathData("M0 0 1 1 2 2", 2)).toThrow(/segment limit/u);
});
it("splits cubic geometry with de Casteljau and reverses arc sweep", () => {
const cubic = parsePathData("M0 0 C10 0 10 10 20 10");
const split = splitSegment(cubic, 1, 0.5);
expect(split.segments).toHaveLength(3);
expect(split.segments[1]).toMatchObject({ kind: "C", to: { x: 10, y: 5 } });
expect(split.segments[2]).toMatchObject({
kind: "C",
from: { x: 10, y: 5 },
});
const arc = parsePathData("M0 0 A10 5 20 0 1 20 0");
const reversed = reversePath(arc);
expect(reversed.segments[1]).toMatchObject({ kind: "A", sweep: false });
expect(serializePathData(reversePath(reversed))).toBe(
serializePathData(arc),
);
});
it("derives arc center/radius controls and moves path handles", () => {
const model = parsePathData("M0 0 A4 3 30 0 1 12 0");
const segment = model.segments[1];
expect(segment?.kind).toBe("A");
if (segment?.kind !== "A") return;
const center = arcEndpointToCenter(segment)!;
expect(pointOnArc(center, center.startAngle).x).toBeCloseTo(segment.from.x);
const anchor = pathHandles(model).find(
(handle) => handle.role === "anchor" && handle.segmentIndex === 1,
)!;
expect(
movePathHandle(model, anchor, { x: 14, y: 2 }).segments[1],
).toMatchObject({ to: { x: 14, y: 2 } });
});
it("bakes nonsingular affine matrices and flips arc sweep under reflection", () => {
const model = parsePathData("M0 0 A10 5 20 0 1 20 0");
const transformed = transformPath(model, {
a: -2,
b: 0.5,
c: 0.25,
d: 3,
e: 4,
f: 8,
});
expect(transformed.segments[1]).toMatchObject({ kind: "A", sweep: false });
expect(() =>
transformPath(model, { a: 1, b: 0, c: 0, d: 0, e: 0, f: 0 }),
).toThrow(/singular/u);
});
it("describes source form, endpoints, controls, derived shorthand and arc flags", () => {
const source = "m 1 2 3 4 s 5 6 7 8 t 9 10 a 11 12 30 1 0 13 14";
const segments = parsePathData(source).segments;
expect(describePathCommand(segments[1]!, source)).toMatchObject({
sourceCommand: "m",
normalizedCommand: "L",
sourceFragment: "3 4",
form: "relative · implicit repeat",
endpoint: "4, 6",
});
expect(describePathCommand(segments[2]!, source).details).toEqual([
{ label: "C1", value: "4, 6", derived: true },
{ label: "C2", value: "9, 12", derived: false },
]);
expect(describePathCommand(segments[3]!, source).details[0]).toMatchObject({
label: "C",
derived: true,
});
expect(describePathCommand(segments[4]!, source).details).toEqual([
{ label: "Radii", value: "11 × 12", derived: false },
{ label: "Rotation", value: "30°", derived: false },
{ label: "Flags", value: "large 1 · sweep 0", derived: false },
]);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { parseSvgSource } from "../../src/document/source-parser";
import { applyToPoint } from "../../src/domain/affine";
import { resolveTransformChain } from "../../src/domain/transform-chain";
function semantic(source: string) {
const result = parseSvgSource(source, 1);
expect(result.semantic).not.toBeNull();
return result.semantic!;
}
describe("ancestor transform chains", () => {
it("composes root, ancestor and local transforms in SVG order", () => {
const document = semantic(
'<svg xmlns="http://www.w3.org/2000/svg" transform="translate(2 3)"><g id="parent" transform="rotate(90)"><path id="child" transform="scale(2 3)" d="M0 0L1 1"/></g></svg>',
);
const child = [...document.nodes.values()].find(
(node) => node.id === "child",
)!;
const chain = resolveTransformChain(document, child.key);
expect(chain.entries).toHaveLength(3);
expect(applyToPoint(chain.matrix, { x: 1, y: 1 })).toMatchObject({
x: -1,
y: 5,
});
const restored = applyToPoint(
chain.inverse!,
applyToPoint(chain.matrix, { x: 7, y: -4 }),
);
expect(restored.x).toBeCloseTo(7);
expect(restored.y).toBeCloseTo(-4);
});
it("refuses deterministic coordinate editing through singular or CSS transforms", () => {
const document = semantic(
'<svg xmlns="http://www.w3.org/2000/svg"><g transform="scale(1 0)"><path id="p" style="transform: rotate(2deg)" d="M0 0L1 1"/></g></svg>',
);
const path = [...document.nodes.values()].find((node) => node.id === "p")!;
const chain = resolveTransformChain(document, path.key);
expect(chain.inverse).toBeNull();
expect(chain.diagnostics).toContainEqual(
expect.stringContaining("CSS transforms"),
);
});
});