feat: introduce local-first SVG workbench
This commit is contained in:
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user