Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
81db549
refactor(document-schema.js): drop a1 range-reference's redundant ter…
Mearman Sep 12, 2026
668a9ee
refactor(document-schema.js): strip a hex colour's leading # by posit…
Mearman Sep 12, 2026
512a064
refactor(document-schema.js): drop isGroupWrapper's unreachable node-…
Mearman Sep 12, 2026
bf6b253
refactor(document-schema.js): collapse per-kind wrapper dispatch to s…
Mearman Sep 12, 2026
ef532b5
refactor(document-schema.js): drop isImageFormat's redundant typeof g…
Mearman Sep 12, 2026
fa47c2d
test(document-schema.js): pin decompose's heading/list pop-loop bound…
Mearman Sep 12, 2026
68c8223
test(document-schema.js): pin the styles-table entry schemas' field-l…
Mearman Sep 12, 2026
e97eeb2
test(document-schema.js): pin flatten's style-resolution chain bounda…
Mearman Sep 12, 2026
07fd391
test(document-schema.js): pin math/mathml schema boundaries
Mearman Sep 12, 2026
b0dea87
test(document-schema.js): pin schema-io's JSON round-trip error paths
Mearman Sep 12, 2026
e9812a3
test(document-schema.js): add canonicalise's own dedicated unit suite
Mearman Sep 12, 2026
359fbe1
test(document-schema.js): add style.ts's own dedicated unit suite
Mearman Sep 12, 2026
96b1e9d
fix(document-schema.js): disable ignoreStatic so module-load-time mut…
Mearman Sep 12, 2026
04888b4
refactor(document-schema.js): drop mathMlDef's unreachable undefined …
Mearman Sep 12, 2026
49593c5
refactor(document-schema.js): drop factor-styles's unreachable defens…
Mearman Sep 12, 2026
ebbb259
refactor(document-schema.js): make rebuildParagraph generic over the …
Mearman Sep 12, 2026
5fd88c8
test(document-schema.js): raise the mutation break threshold to 100
Mearman Sep 12, 2026
87cf0c2
Merge branch 'main' into feat/100-percent-mutation-document-schema.js
Mearman Sep 12, 2026
639d907
Merge branch 'main' into feat/100-percent-mutation-document-schema.js
Mearman Sep 12, 2026
65d0391
Merge branch 'main' into feat/100-percent-mutation-document-schema.js
Mearman Sep 12, 2026
d43ef10
Merge branch 'main' into feat/100-percent-mutation-document-schema.js
Mearman Sep 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/document-schema.js/src/a1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ describe("columnLettersToIndex / columnIndexToLetters", () => {
expect(columnLettersToIndex("A1")).toBeUndefined();
expect(columnLettersToIndex("")).toBeUndefined();
});

it("rejects the character immediately past 'Z' in code-point order, not just past it", () => {
// '[' is charCode 91, exactly ALPHABET_START_CODE (65) + ALPHABET_SIZE (26) -- the boundary itself, not one past it. 'Z' (90) must still be accepted.
expect(columnLettersToIndex("Z")).toBe(25);
expect(columnLettersToIndex("[")).toBeUndefined();
expect(columnLettersToIndex("A[")).toBeUndefined();
});
});

describe("parseCellReference / cellReference", () => {
Expand All @@ -52,6 +59,16 @@ describe("parseCellReference / cellReference", () => {
expect(parseCellReference("A0")).toBeUndefined();
expect(parseCellReference("")).toBeUndefined();
});

it("requires the letters to start at the very beginning of the string", () => {
// Without the regex's leading anchor, "A1" embedded after a leading digit would still match.
expect(parseCellReference("1A1")).toBeUndefined();
});

it("requires the digits to run to the very end of the string", () => {
// Without the regex's trailing anchor, a leading "A1" would still match despite trailing junk.
expect(parseCellReference("A1B")).toBeUndefined();
});
});

describe("parseRangeReference / rangeReference", () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/document-schema.js/src/a1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ export interface CellRange {
export function parseRangeReference(ref: string): CellRange | undefined {
const separatorIndex = ref.indexOf(":");
const startRaw = separatorIndex === -1 ? ref : ref.slice(0, separatorIndex);
const endRaw = separatorIndex === -1 ? ref : ref.slice(separatorIndex + 1);
// Unconditional, unlike startRaw above: when separatorIndex is -1, separatorIndex + 1 is 0, so ref.slice(separatorIndex + 1) is ref.slice(0), which is ref itself -- the same value the startRaw-style ternary would have picked for this half anyway.
const endRaw = ref.slice(separatorIndex + 1);
const start = parseCellReference(startRaw);
const end = parseCellReference(endRaw);
if (start === undefined || end === undefined) {
Expand Down
72 changes: 72 additions & 0 deletions packages/document-schema.js/src/canonicalise.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { canonicalise, canonicalKey } from "./canonicalise";

describe("canonicalise", () => {
it("rebuilds a plain object with its keys sorted ascending", () => {
const input = { c: 1, a: 2, b: 3 };
expect(canonicalise(input)).toStrictEqual({ a: 2, b: 3, c: 1 });
expect(Object.keys(canonicalise(input) as object)).toStrictEqual([
"a",
"b",
"c",
]);
});

it("recurses into nested object values, sorting their keys too", () => {
const input = { z: { y: 1, x: 2 } };
expect(Object.keys((canonicalise(input) as { z: object }).z)).toStrictEqual(
["x", "y"],
);
});

it("maps over arrays in order without sorting elements", () => {
const input = [{ b: 1, a: 2 }, 3, "text"];
expect(canonicalise(input)).toStrictEqual([{ a: 2, b: 1 }, 3, "text"]);
});

it("does not treat an array as a record -- array elements are mapped, never treated as object keys", () => {
const input = [1, 2, 3];
const result = canonicalise(input);
expect(Array.isArray(result)).toBe(true);
expect(result).toStrictEqual([1, 2, 3]);
});

it("returns null as-is rather than as an empty sorted object", () => {
expect(canonicalise(null)).toBeNull();
});

it("returns primitives unchanged", () => {
expect(canonicalise(42)).toBe(42);
expect(canonicalise("hello")).toBe("hello");
expect(canonicalise(true)).toBe(true);
expect(canonicalise(undefined)).toBeUndefined();
});

it("produces a fresh structure, never the same object reference", () => {
const input = { a: 1 };
expect(canonicalise(input)).not.toBe(input);
});
});

describe("canonicalKey", () => {
it("is insensitive to the input object's own key construction order", () => {
const first = { a: 1, b: 2 };
const second = { b: 2, a: 1 };
expect(canonicalKey(first)).toBe(canonicalKey(second));
});

it("distinguishes values that actually differ", () => {
expect(canonicalKey({ a: 1 })).not.toBe(canonicalKey({ a: 2 }));
});

it("treats an absent optional key and an explicit undefined value as identical", () => {
const absent: { a: number; b?: number } = { a: 1 };
const explicitUndefined: { a: number; b?: number } = { a: 1, b: undefined };
expect(canonicalKey(absent)).toBe(canonicalKey(explicitUndefined));
});

it("round-trips through JSON.stringify of the canonicalised value", () => {
const value = { z: 1, a: [3, 2, 1] };
expect(canonicalKey(value)).toBe(JSON.stringify(canonicalise(value)));
});
});
108 changes: 108 additions & 0 deletions packages/document-schema.js/src/color.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import {
COLOR_BLACK,
ColorSchema,
colorToRgbHex,
rgbHexToColor,
} from "./color";

describe("ColorSchema", () => {
it("accepts a colour whose components are within 0..1", () => {
expect(ColorSchema.safeParse({ r: 0, g: 0.5, b: 1 }).success).toBe(true);
});

it("rejects a component below 0 or above 1", () => {
expect(ColorSchema.safeParse({ r: -0.01, g: 0, b: 0 }).success).toBe(false);
expect(ColorSchema.safeParse({ r: 1.01, g: 0, b: 0 }).success).toBe(false);
});
});

describe("COLOR_BLACK", () => {
it("is exactly r=0, g=0, b=0", () => {
expect(COLOR_BLACK).toStrictEqual({ r: 0, g: 0, b: 0 });
});
});

describe("rgbHexToColor", () => {
it("parses a 6-digit hex colour with a leading '#'", () => {
expect(rgbHexToColor("#ff0080")).toStrictEqual({
r: 1,
g: 0,
b: 128 / 255,
});
});

it("parses a 6-digit hex colour with no leading '#'", () => {
expect(rgbHexToColor("ff0080")).toStrictEqual({
r: 1,
g: 0,
b: 128 / 255,
});
});

it("is case-insensitive", () => {
expect(rgbHexToColor("FF0080")).toStrictEqual(rgbHexToColor("ff0080"));
});

it("reads each byte from its own two-digit slice, not the whole 6-digit string", () => {
// A value that would produce a completely different result if slice(0,2)/(2,4)/(4,6) collapsed to parsing the whole "digits" string for every channel.
const color = rgbHexToColor("010203");
expect(color.r).toBe(0x01 / 255);
expect(color.g).toBe(0x02 / 255);
expect(color.b).toBe(0x03 / 255);
});

it("divides each byte by 255, not multiplies", () => {
expect(rgbHexToColor("ffffff")).toStrictEqual({ r: 1, g: 1, b: 1 });
expect(rgbHexToColor("000000")).toStrictEqual({ r: 0, g: 0, b: 0 });
});

it("throws with the offending value named, on a string too short to be 6 hex digits", () => {
expect(() => rgbHexToColor("#fff")).toThrow(
"not a 6-digit hex colour: #fff",
);
});

it("throws on a string too long to be 6 hex digits", () => {
expect(() => rgbHexToColor("#ff00801")).toThrow(
"not a 6-digit hex colour: #ff00801",
);
});

it("throws on non-hex characters", () => {
expect(() => rgbHexToColor("#gggggg")).toThrow(
"not a 6-digit hex colour: #gggggg",
);
});

it("throws on a bare '#' with no digits at all", () => {
expect(() => rgbHexToColor("#")).toThrow("not a 6-digit hex colour: #");
});
});

describe("colorToRgbHex", () => {
it("is the exact inverse of rgbHexToColor for a value that divides evenly", () => {
expect(colorToRgbHex({ r: 1, g: 0, b: 128 / 255 })).toBe("ff0080");
});

it("round-trips through rgbHexToColor for black and white", () => {
expect(colorToRgbHex(rgbHexToColor("000000"))).toBe("000000");
expect(colorToRgbHex(rgbHexToColor("ffffff"))).toBe("ffffff");
});

it("multiplies each component by 255 before rounding, not divides", () => {
expect(colorToRgbHex({ r: 1, g: 1, b: 1 })).toBe("ffffff");
});

it("zero-pads a byte that hex-encodes to a single digit", () => {
// 1/255 * 255 = 1, which toString(16) renders as the single character "1" -- this only reads "01" back out if padStart actually pads with a leading zero.
expect(colorToRgbHex({ r: 1 / 255, g: 0, b: 0 })).toBe("010000");
});

it("produces a lowercase 6-digit string with no leading '#'", () => {
const hex = colorToRgbHex({ r: 1, g: 0, b: 0 });
expect(hex).toBe("ff0000");
expect(hex).not.toContain("#");
expect(hex).toBe(hex.toLowerCase());
});
});
12 changes: 4 additions & 8 deletions packages/document-schema.js/src/color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,13 @@ export type Color = z.infer<typeof ColorSchema>;

export const COLOR_BLACK: Color = { r: 0, g: 0, b: 0 };

const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/;
const HEX_DIGITS_PATTERN = /^[0-9a-fA-F]{6}$/;
const HEX_BYTE_MAX = 255;

// Parses a 6-digit hex colour (OOXML's w:color/@w:val, a:srgbClr/@val; ODF's fo:color), with or without a leading '#', into a Color. Throws on malformed input rather than substituting a default -- callers are expected to have already validated the attribute is present.
// Parses a 6-digit hex colour (OOXML's w:color/@w:val, a:srgbClr/@val; ODF's fo:color), with or without a leading '#', into a Color. Throws on malformed input rather than substituting a default -- callers are expected to have already validated the attribute is present. Strips a leading '#' by its own literal position rather than a regex capture group, so there is no capture-group result to separately check for absence -- `digits` is validated as a plain string, never indexed out of a match array.
export function rgbHexToColor(hex: string): Color {
const match = HEX_COLOR_PATTERN.exec(hex);
if (match === null) {
throw new Error(`not a 6-digit hex colour: ${hex}`);
}
const digits = match[1];
if (digits === undefined) {
const digits = hex.startsWith("#") ? hex.slice(1) : hex;
if (!HEX_DIGITS_PATTERN.test(digits)) {
throw new Error(`not a 6-digit hex colour: ${hex}`);
}
const r = Number.parseInt(digits.slice(0, 2), 16);
Expand Down
Loading