Files
svg-tools/tests/domain/path.test.ts
T

169 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 },
]);
});
});