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
+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 },
]);
});
});