From 5ee8d8030da20a3bad17eae06130331ab7a07a72 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 22:25:35 +0100 Subject: [PATCH 001/107] test(xls-codec): cover readWorkbookStreams and isXlsFile directly Adds a dedicated container.test.ts exercising the compound-file container layer on its own: the legacy 'Book' stream rejection, the no-Workbook-stream rejection, the CompoundFileFormatError wrapping into BiffFormatError, SummaryInformation and MBD/Package embedding-storage stream selection (including a near-miss path that must not match), and isXlsFile's own true/false/catch-all cases. None of these had a direct test before, relying only on incidental coverage from content.test.ts's full round-trip fixtures. --- packages/xls-codec/src/container.test.ts | 139 +++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 packages/xls-codec/src/container.test.ts diff --git a/packages/xls-codec/src/container.test.ts b/packages/xls-codec/src/container.test.ts new file mode 100644 index 0000000000..85f7a0e337 --- /dev/null +++ b/packages/xls-codec/src/container.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; + +import { bofData, record } from "./test-support/biff"; +import { compoundFile } from "./test-support/cfb"; +import { BiffFormatError } from "./biff/records"; +import { RECORD_BOF, RECORD_EOF, BOF_TYPE_WORKBOOK } from "./biff/record-types"; +import { isXlsFile, readWorkbookStreams } from "./container"; + +/** A minimal but readable BOF+EOF workbook stream, just enough for readWorkbookStreams to succeed past the container layer -- the container's own concern is stream selection, not BIFF record content. */ +function minimalWorkbookStream(): Uint8Array { + return new Uint8Array([ + ...record(RECORD_BOF, bofData(BOF_TYPE_WORKBOOK)), + ...record(RECORD_EOF, []), + ]); +} + +describe("readWorkbookStreams", () => { + it("reads the Workbook stream's own bytes", () => { + const workbook = minimalWorkbookStream(); + const bytes = compoundFile([{ path: "Workbook", bytes: workbook }]); + + expect(readWorkbookStreams(bytes).workbook).toEqual(workbook); + }); + + it("carries the SummaryInformation stream's bytes when present", () => { + const workbook = minimalWorkbookStream(); + const summary = new Uint8Array([1, 2, 3, 4]); + const bytes = compoundFile([ + { path: "Workbook", bytes: workbook }, + { path: "\x05SummaryInformation", bytes: summary }, + ]); + + expect(readWorkbookStreams(bytes).metadata).toEqual(summary); + }); + + it("reports no metadata when the container carries no SummaryInformation stream", () => { + const bytes = compoundFile([ + { path: "Workbook", bytes: minimalWorkbookStream() }, + ]); + + expect(readWorkbookStreams(bytes).metadata).toBeUndefined(); + }); + + it("collects an MBD/Package embedding storage's bytes keyed by its storage id", () => { + const packageBytes = new Uint8Array([9, 9, 9]); + const bytes = compoundFile([ + { path: "Workbook", bytes: minimalWorkbookStream() }, + { path: "MBD00000001/Package", bytes: packageBytes }, + ]); + + const { embeddingStreams } = readWorkbookStreams(bytes); + expect(embeddingStreams.size).toBe(1); + expect(embeddingStreams.get(1)).toEqual(packageBytes); + }); + + it("reports no embedding streams when the container carries none", () => { + const bytes = compoundFile([ + { path: "Workbook", bytes: minimalWorkbookStream() }, + ]); + + expect(readWorkbookStreams(bytes).embeddingStreams.size).toBe(0); + }); + + it("ignores a stream whose path merely resembles an MBD embedding storage without matching exactly", () => { + const bytes = compoundFile([ + { path: "Workbook", bytes: minimalWorkbookStream() }, + { path: "MBD1/Package", bytes: new Uint8Array([1]) }, // too few hex digits + { path: "MBD00000002/Extra", bytes: new Uint8Array([2]) }, // wrong trailing segment + ]); + + expect(readWorkbookStreams(bytes).embeddingStreams.size).toBe(0); + }); + + it("refuses bytes with no compound-file signature", () => { + expect(() => + readWorkbookStreams(new Uint8Array([0x50, 0x4b, 0x03, 0x04])), + ).toThrow(BiffFormatError); + }); + + it("refuses a compound file holding a legacy 'Book' stream rather than 'Workbook'", () => { + const bytes = compoundFile([ + { path: "Book", bytes: minimalWorkbookStream() }, + ]); + + expect(() => readWorkbookStreams(bytes)).toThrow(/BIFF5\/BIFF7 workbook/); + }); + + it("refuses a compound file holding neither a Workbook nor a Book stream", () => { + const bytes = compoundFile([ + { path: "WordDocument", bytes: new Uint8Array([1]) }, + ]); + + expect(() => readWorkbookStreams(bytes)).toThrow( + /holds no 'Workbook' stream/, + ); + }); + + it("wraps a structurally malformed compound file's own CompoundFileFormatError", () => { + // The eight-byte compound-file signature with nothing else: past isCompoundFile's own byte check, but far too short for readCompoundFile's fixed-size header, so it throws archive-codec's own CompoundFileFormatError. + const bytes = new Uint8Array([ + 0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, + ]); + + expect(() => readWorkbookStreams(bytes)).toThrow(BiffFormatError); + expect(() => readWorkbookStreams(bytes)).toThrow( + /compound-file container could not be read/, + ); + }); +}); + +describe("isXlsFile", () => { + it("is true for a compound file carrying a Workbook stream", () => { + const bytes = compoundFile([ + { path: "Workbook", bytes: minimalWorkbookStream() }, + ]); + + expect(isXlsFile(bytes)).toBe(true); + }); + + it("is false for a compound file carrying no Workbook stream", () => { + const bytes = compoundFile([ + { path: "WordDocument", bytes: new Uint8Array([1]) }, + ]); + + expect(isXlsFile(bytes)).toBe(false); + }); + + it("is false for bytes with no compound-file signature", () => { + expect(isXlsFile(new Uint8Array([0x50, 0x4b, 0x03, 0x04]))).toBe(false); + }); + + it("is false for a compound-file signature too short to parse structurally", () => { + const bytes = new Uint8Array([ + 0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, + ]); + + expect(isXlsFile(bytes)).toBe(false); + }); +}); From 4e60dddd60a9ccd2d747a28ae7cde031daab85bb Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 22:25:51 +0100 Subject: [PATCH 002/107] test(xls-codec): cover layoutMetadataToSummaryInformation's date validation The createdIso/modifiedIso validation this package adds on top of archive-codec's own mapping had no direct test, so a malformed date in either field, and a malformed createdIso alongside a valid modifiedIso, are pinned here rather than only reachable through a full writeXlsContent round trip. --- packages/xls-codec/src/metadata.test.ts | 60 +++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 packages/xls-codec/src/metadata.test.ts diff --git a/packages/xls-codec/src/metadata.test.ts b/packages/xls-codec/src/metadata.test.ts new file mode 100644 index 0000000000..7038807e5d --- /dev/null +++ b/packages/xls-codec/src/metadata.test.ts @@ -0,0 +1,60 @@ +import type { LayoutMetadata } from "document-schema.js"; +import { describe, expect, it } from "vitest"; + +import { BiffWriteError } from "./biff/write-errors"; +import { layoutMetadataToSummaryInformation } from "./metadata"; + +describe("layoutMetadataToSummaryInformation", () => { + it("maps a metadata object carrying no dates through unchanged", () => { + const metadata: LayoutMetadata = { title: "Report" }; + + expect(layoutMetadataToSummaryInformation(metadata).title).toBe("Report"); + }); + + it("accepts a valid createdIso date", () => { + const metadata: LayoutMetadata = { createdIso: "2024-01-01T00:00:00.000Z" }; + + expect(() => layoutMetadataToSummaryInformation(metadata)).not.toThrow(); + }); + + it("accepts a valid modifiedIso date", () => { + const metadata: LayoutMetadata = { + modifiedIso: "2024-06-15T12:30:00.000Z", + }; + + expect(() => layoutMetadataToSummaryInformation(metadata)).not.toThrow(); + }); + + it("rejects a malformed createdIso date, naming the field", () => { + const metadata: LayoutMetadata = { createdIso: "not a date" }; + + expect(() => layoutMetadataToSummaryInformation(metadata)).toThrow( + BiffWriteError, + ); + expect(() => layoutMetadataToSummaryInformation(metadata)).toThrow( + /createdIso/, + ); + }); + + it("rejects a malformed modifiedIso date, naming the field", () => { + const metadata: LayoutMetadata = { modifiedIso: "not a date" }; + + expect(() => layoutMetadataToSummaryInformation(metadata)).toThrow( + BiffWriteError, + ); + expect(() => layoutMetadataToSummaryInformation(metadata)).toThrow( + /modifiedIso/, + ); + }); + + it("rejects a malformed createdIso even when modifiedIso is valid", () => { + const metadata: LayoutMetadata = { + createdIso: "garbage", + modifiedIso: "2024-01-01T00:00:00.000Z", + }; + + expect(() => layoutMetadataToSummaryInformation(metadata)).toThrow( + /createdIso/, + ); + }); +}); From b915acaf360392bf92372826e5c87ab05dcc1a6b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 22:26:05 +0100 Subject: [PATCH 003/107] test(xls-codec): isolate every branch of cellCarriesFormatting/writesCellRecord Each of the five independent conditions cellCarriesFormatting checks (background, alignment, verticalAlignment, font, and each of the four border sides) gets its own cell that carries only that one property, so a mutant turning any one check into a no-op or an && instead of || fails a test that isolates it -- the write-path round trips in write.test.ts only ever combine several of them at once. --- packages/xls-codec/src/written-cells.test.ts | 114 +++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 packages/xls-codec/src/written-cells.test.ts diff --git a/packages/xls-codec/src/written-cells.test.ts b/packages/xls-codec/src/written-cells.test.ts new file mode 100644 index 0000000000..1650053956 --- /dev/null +++ b/packages/xls-codec/src/written-cells.test.ts @@ -0,0 +1,114 @@ +import type { ContentSheetCell } from "document-schema.js"; +import { describe, expect, it } from "vitest"; + +import { cellCarriesFormatting, writesCellRecord } from "./written-cells"; + +function emptyCell( + overrides: Partial = {}, +): ContentSheetCell { + return { + row: 0, + column: 0, + value: { kind: "empty" }, + displayText: "", + ...overrides, + }; +} + +describe("cellCarriesFormatting", () => { + it("is false for a bare, unformatted cell", () => { + expect(cellCarriesFormatting(emptyCell())).toBe(false); + }); + + it("is true when the cell carries a background fill", () => { + expect( + cellCarriesFormatting( + emptyCell({ + background: { kind: "solid", color: { rgbHex: "FF0000" } }, + }), + ), + ).toBe(true); + }); + + it("is true when the cell carries a horizontal alignment", () => { + expect(cellCarriesFormatting(emptyCell({ alignment: "center" }))).toBe( + true, + ); + }); + + it("is true when the cell carries a vertical alignment", () => { + expect(cellCarriesFormatting(emptyCell({ verticalAlignment: "top" }))).toBe( + true, + ); + }); + + it("is true when the cell's font differs from Normal", () => { + expect( + cellCarriesFormatting( + emptyCell({ font: { fontFamily: "Arial", sizePt: 10, bold: true } }), + ), + ).toBe(true); + }); + + it("is false when the cell's font merely restates the default", () => { + expect( + cellCarriesFormatting( + emptyCell({ font: { fontFamily: "Arial", sizePt: 10, bold: false } }), + ), + ).toBe(false); + }); + + it("is false when borders is present but every side is absent", () => { + expect(cellCarriesFormatting(emptyCell({ borders: {} }))).toBe(false); + }); + + it("is true when only the left border is set", () => { + expect( + cellCarriesFormatting( + emptyCell({ borders: { left: { style: "thin" } } }), + ), + ).toBe(true); + }); + + it("is true when only the right border is set", () => { + expect( + cellCarriesFormatting( + emptyCell({ borders: { right: { style: "thin" } } }), + ), + ).toBe(true); + }); + + it("is true when only the top border is set", () => { + expect( + cellCarriesFormatting(emptyCell({ borders: { top: { style: "thin" } } })), + ).toBe(true); + }); + + it("is true when only the bottom border is set", () => { + expect( + cellCarriesFormatting( + emptyCell({ borders: { bottom: { style: "thin" } } }), + ), + ).toBe(true); + }); +}); + +describe("writesCellRecord", () => { + it("is false for an empty, unformatted, formula-free cell", () => { + expect(writesCellRecord(emptyCell())).toBe(false); + }); + + it("is true for a cell carrying a real value", () => { + expect( + writesCellRecord(emptyCell({ value: { kind: "number", value: 1 } })), + ).toBe(true); + }); + + it("is true for an otherwise-empty cell carrying formatting", () => { + expect(writesCellRecord(emptyCell({ alignment: "center" }))).toBe(true); + }); + + it("is true for an otherwise-empty cell carrying a formula", () => { + expect(writesCellRecord(emptyCell({ formula: "=1+1" }))).toBe(true); + }); +}); From 72cea07d92213e2ebbc0e699459835425564f02e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 22:26:20 +0100 Subject: [PATCH 004/107] test(xls-codec): cover readEmbeddedObjectPackage's own rejection paths writeEmbeddedObjectPackage/readEmbeddedObjectPackage round-trips through write.test.ts before now only ever exercised the accepting path. Adds direct coverage of the foreign-label, non-object, missing-objectKind, missing-document, and schema-validation-failure rejections, the placement fields' deliberate non-round-trip, and the source residue field, plus bytes that are not a Package stream at all. --- .../src/workbook/embedded-object.test.ts | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 packages/xls-codec/src/workbook/embedded-object.test.ts diff --git a/packages/xls-codec/src/workbook/embedded-object.test.ts b/packages/xls-codec/src/workbook/embedded-object.test.ts new file mode 100644 index 0000000000..b400a7b1fb --- /dev/null +++ b/packages/xls-codec/src/workbook/embedded-object.test.ts @@ -0,0 +1,167 @@ +import type { + ContentDocument, + ContentEmbeddedObject, +} from "document-schema.js"; +import { describe, expect, it } from "vitest"; + +import { + readEmbeddedObjectPackage, + writeEmbeddedObjectPackage, +} from "./embedded-object"; + +const FRAME = { xPt: 0, yPt: 0, widthPt: 50, heightPt: 40 }; + +const DRAWING_DOCUMENT: ContentDocument = { + kind: "drawing", + metadata: {}, + pages: [{ size: { widthPt: 50, heightPt: 40 }, shapes: [], vectors: [] }], +}; + +function embeddedObject( + overrides: Partial = {}, +): ContentEmbeddedObject { + return { + objectKind: "drawing", + document: DRAWING_DOCUMENT, + frame: FRAME, + ...overrides, + }; +} + +describe("writeEmbeddedObjectPackage / readEmbeddedObjectPackage", () => { + it("round-trips an embedded object's kind and document through the Package stream", () => { + const embedded = embeddedObject(); + const packageBytes = writeEmbeddedObjectPackage(embedded); + + const result = readEmbeddedObjectPackage(packageBytes, FRAME); + + expect(result?.objectKind).toBe("drawing"); + expect(result?.document).toEqual(DRAWING_DOCUMENT); + expect(result?.frame).toEqual(FRAME); + }); + + it("merges the caller's own frame in, overriding whatever the payload itself carried", () => { + const embedded = embeddedObject(); + const packageBytes = writeEmbeddedObjectPackage(embedded); + const otherFrame = { xPt: 9, yPt: 9, widthPt: 1, heightPt: 1 }; + + const result = readEmbeddedObjectPackage(packageBytes, otherFrame); + + expect(result?.frame).toEqual(otherFrame); + }); + + it("does not round-trip the placement fields -- they come only from the caller's frame argument", () => { + const embedded = embeddedObject({ + anchorRow: 3, + anchorColumn: 2, + offsetXPt: 4, + offsetYPt: 5, + }); + const packageBytes = writeEmbeddedObjectPackage(embedded); + + const result = readEmbeddedObjectPackage(packageBytes, FRAME); + + expect(result?.anchorRow).toBeUndefined(); + expect(result?.anchorColumn).toBeUndefined(); + expect(result?.offsetXPt).toBeUndefined(); + expect(result?.offsetYPt).toBeUndefined(); + }); + + it("round-trips the source residue field", () => { + const embedded = embeddedObject({ + source: { format: "xlsx", xml: "" }, + }); + const packageBytes = writeEmbeddedObjectPackage(embedded); + + const result = readEmbeddedObjectPackage(packageBytes, FRAME); + + expect(result?.source).toEqual({ format: "xlsx", xml: "" }); + }); + + it("returns undefined for a Package stream carrying a foreign label", () => { + const foreign = writeForeignPackage("not-this-package.json", "{}"); + + expect(readEmbeddedObjectPackage(foreign, FRAME)).toBeUndefined(); + }); + + it("returns undefined for a payload that is not a JSON object", () => { + const foreign = writeForeignPackage( + "xls-codec-embedded-object.json", + "[1,2,3]", + ); + + expect(readEmbeddedObjectPackage(foreign, FRAME)).toBeUndefined(); + }); + + it("returns undefined for a JSON object missing objectKind", () => { + const foreign = writeForeignPackage( + "xls-codec-embedded-object.json", + JSON.stringify({ document: DRAWING_DOCUMENT }), + ); + + expect(readEmbeddedObjectPackage(foreign, FRAME)).toBeUndefined(); + }); + + it("returns undefined for a JSON object missing document", () => { + const foreign = writeForeignPackage( + "xls-codec-embedded-object.json", + JSON.stringify({ objectKind: "drawing" }), + ); + + expect(readEmbeddedObjectPackage(foreign, FRAME)).toBeUndefined(); + }); + + it("returns undefined for a payload whose fields fail schema validation", () => { + const foreign = writeForeignPackage( + "xls-codec-embedded-object.json", + JSON.stringify({ + objectKind: "not-a-real-kind", + document: DRAWING_DOCUMENT, + }), + ); + + expect(readEmbeddedObjectPackage(foreign, FRAME)).toBeUndefined(); + }); + + it("returns undefined for bytes that are not a Package stream at all", () => { + expect( + readEmbeddedObjectPackage(new Uint8Array([1, 2, 3]), FRAME), + ).toBeUndefined(); + }); +}); + +/** Builds a Package stream carrying an arbitrary label and JSON text, using the identical [MS-OLEDS] layout writeEmbeddedObjectPackage itself produces (a uint16 header, three null-terminated strings, then a little-endian byte count and the file bytes) -- so a test can construct a Package stream this module did not itself write. */ +function writeForeignPackage( + label: string, + json: string, +): Uint8Array { + const encoder = new TextEncoder(); + const labelBytes = encoder.encode(label); + const sourcePathBytes = encoder.encode(""); + const tempPathBytes = encoder.encode(""); + const fileBytes = encoder.encode(json); + const parts = [ + new Uint8Array([0x02, 0x00]), + labelBytes, + new Uint8Array([0]), + sourcePathBytes, + new Uint8Array([0]), + new Uint8Array(8), + tempPathBytes, + new Uint8Array([0]), + (() => { + const size = new Uint8Array(4); + new DataView(size.buffer).setUint32(0, fileBytes.length, true); + return size; + })(), + fileBytes, + ]; + const total = parts.reduce((sum, part) => sum + part.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} From e5284e6d5f493bea698745f608459beef14e83ce Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 22:26:34 +0100 Subject: [PATCH 005/107] test(xls-codec): pin writeSheetComments' record ordering and object ids Direct coverage for a module previously exercised only through write.test.ts's full round trips: no commented cells, one comment's position/text/author, an absent author staying absent rather than an empty-string placeholder, an empty-text comment writing no Continue record, multi-comment round trips, the row-then-column Note ordering independent of input order, the Note-records-first-then-Obj/TxO-pairs emission order, sequential object id assignment, and the 16-bit FtCmo.id ceiling. --- .../src/workbook/comment-writer.test.ts | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 packages/xls-codec/src/workbook/comment-writer.test.ts diff --git a/packages/xls-codec/src/workbook/comment-writer.test.ts b/packages/xls-codec/src/workbook/comment-writer.test.ts new file mode 100644 index 0000000000..5fa12a227e --- /dev/null +++ b/packages/xls-codec/src/workbook/comment-writer.test.ts @@ -0,0 +1,154 @@ +import type { ContentSheetCell } from "document-schema.js"; +import { describe, expect, it } from "vitest"; + +import { readRecords } from "../biff/records"; +import { + RECORD_CONTINUE, + RECORD_NOTE, + RECORD_OBJ, + RECORD_TXO, +} from "../biff/record-types"; +import { groupRecords } from "../biff/substreams"; +import { concat } from "../test-support/biff"; +import { BiffWriteError } from "../biff/write-errors"; +import { readSheetComments } from "./comments"; +import { writeSheetComments } from "./comment-writer"; + +function commentedCell( + row: number, + column: number, + text: string, + author?: string, +): ContentSheetCell { + return { + row, + column, + value: { kind: "empty" }, + displayText: "", + comment: author === undefined ? { text } : { text, author }, + }; +} + +function readBack(pieces: readonly Uint8Array[]) { + return readSheetComments(groupRecords(readRecords(concat(...pieces)))); +} + +describe("writeSheetComments", () => { + it("returns nothing for a sheet with no commented cells", () => { + expect(writeSheetComments([])).toEqual([]); + }); + + it("round-trips one comment's cell position, text, and author", () => { + const pieces = writeSheetComments([commentedCell(2, 3, "Hello", "Alice")]); + + expect(readBack(pieces).get("2:3")).toEqual({ + row: 2, + column: 3, + comment: { text: "Hello", author: "Alice" }, + }); + }); + + it("round-trips a comment with no author as an absent author, not an empty-string placeholder", () => { + const pieces = writeSheetComments([commentedCell(0, 0, "No author")]); + + expect(readBack(pieces).get("0:0")).toEqual({ + row: 0, + column: 0, + comment: { text: "No author" }, + }); + }); + + it("round-trips an empty-text comment, writing no Continue record for it", () => { + const pieces = writeSheetComments([commentedCell(1, 1, "")]); + const records = readRecords(concat(...pieces)); + + expect(records.some((r) => r.type === 0x003c /* CONTINUE */)).toBe(false); + expect(readBack(pieces).get("1:1")).toEqual({ + row: 1, + column: 1, + comment: { text: "" }, + }); + }); + + it("round-trips every comment of a multi-comment sheet", () => { + const pieces = writeSheetComments([ + commentedCell(5, 5, "Fifth", "E"), + commentedCell(0, 9, "First-by-row", "A"), + commentedCell(0, 2, "First-by-column", "B"), + ]); + const comments = readBack(pieces); + + expect(comments.get("5:5")?.comment).toEqual({ + text: "Fifth", + author: "E", + }); + expect(comments.get("0:9")?.comment).toEqual({ + text: "First-by-row", + author: "A", + }); + expect(comments.get("0:2")?.comment).toEqual({ + text: "First-by-column", + author: "B", + }); + }); + + it("emits Note records in row-then-column order regardless of input order", () => { + const pieces = writeSheetComments([ + commentedCell(2, 0, "third"), + commentedCell(0, 5, "second"), + commentedCell(0, 1, "first"), + ]); + const records = readRecords(concat(...pieces)); + const notePositions = records + .filter((r) => r.type === RECORD_NOTE) + .map((r) => [r.data[0], r.data[2]]); // row, column: each a little-endian u16 whose low byte alone is enough here + + expect(notePositions).toEqual([ + [0, 1], + [0, 5], + [2, 0], + ]); + }); + + it("emits every Note first, then each comment's own Obj/TxO pair, in the same order", () => { + const pieces = writeSheetComments([ + commentedCell(0, 1, "first"), + commentedCell(0, 5, "second"), + ]); + const records = readRecords(concat(...pieces)); + const types = records.map((r) => r.type); + + expect(types).toEqual([ + RECORD_NOTE, + RECORD_NOTE, + RECORD_OBJ, + RECORD_TXO, + RECORD_CONTINUE, + RECORD_OBJ, + RECORD_TXO, + RECORD_CONTINUE, + ]); + }); + + it("assigns sequential object ids starting from 1, matching each Note's own idObj", () => { + const pieces = writeSheetComments([ + commentedCell(0, 0, "a"), + commentedCell(0, 1, "b"), + ]); + const records = readRecords(concat(...pieces)); + const notes = records.filter((r) => r.type === RECORD_NOTE); + // idObj sits at byte offset 6 of a Note record's own data (row u16, column u16, flags u16, idObj u16). + const idObjs = notes.map((r) => r.data[6]); + + expect(idObjs).toEqual([1, 2]); + }); + + it("refuses more comments than a 16-bit FtCmo.id can distinguish", () => { + const cells = Array.from({ length: 0x10000 }, (_, index) => + commentedCell(0, index, "x"), + ); + + expect(() => writeSheetComments(cells)).toThrow(BiffWriteError); + expect(() => writeSheetComments(cells)).toThrow(/65535/); + }); +}); From 33cf4002df10a627459ab9f4589c18e9bf7705f8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 22:26:50 +0100 Subject: [PATCH 006/107] test(xls-codec): cover decryptWorkbookRecords' FilePass and record-dispatch edges Direct coverage of paths content.test.ts's full CFB round trips never reach: an unrecognised wEncryptionType, RC4 CryptoAPI's own EncryptionVersionInfo rejected by name, the no-password message naming the right scheme for each of RC4/XOR, an XOR password too long for obfuscation to represent folding into "incorrect password" rather than a raw RangeError, and -- for both RC4 and XOR -- the never-encrypted record-type bypass and the BoundSheet8 lbPlyPos-preserved special case, built directly against BiffRecord values rather than a full compound file. --- .../xls-codec/src/workbook/encryption.test.ts | 347 ++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 packages/xls-codec/src/workbook/encryption.test.ts diff --git a/packages/xls-codec/src/workbook/encryption.test.ts b/packages/xls-codec/src/workbook/encryption.test.ts new file mode 100644 index 0000000000..c91408cb60 --- /dev/null +++ b/packages/xls-codec/src/workbook/encryption.test.ts @@ -0,0 +1,347 @@ +import { + createXorObfuscationArray, + createXorObfuscationKey, + createXorObfuscationPasswordVerifier, + decryptOfficeRc4, + decryptXorObfuscationMethod1, + deriveOfficeRc4BaseHash, + md5, + XOR_OBFUSCATION_ARRAY_LENGTH, + XOR_OBFUSCATION_ROTATE_DISTANCE_METHOD1, +} from "archive-codec"; +import { describe, expect, it } from "vitest"; + +import type { BiffRecord } from "../biff/records"; +import { BiffFormatError, HEADER_SIZE } from "../biff/records"; +import { + RECORD_BOF, + RECORD_BOUNDSHEET8, + RECORD_FILELOCK, + RECORD_FILEPASS, + RECORD_NUMBER, + RECORD_USREXCL, +} from "../biff/record-types"; +import { decryptWorkbookRecords } from "./encryption"; + +/** Re-derived independently of workbook/encryption.ts's own private xorArrayIndexFor, so a test asserting against it does not vacuously agree with a mutated version of the real implementation. */ +function xorArrayIndexFor( + spanOffset: number, + recordDataLength: number, +): number { + return (spanOffset + recordDataLength) % XOR_OBFUSCATION_ARRAY_LENGTH; +} + +/** Wraps bytes and an offset as a BiffRecord -- the record's own type is the only field decryptWorkbookRecords dispatches on; data and offset carry the payload and its stream position. */ +function biffRecord( + type: number, + data: readonly number[], + offset: number, +): BiffRecord { + return { type, data: new Uint8Array(data), offset }; +} + +const PASSWORD = "correct horse"; +const SALT = new Uint8Array(16).map((_, i) => i + 1); + +function rc4FilePassData( + password: string, + salt: Uint8Array, +): number[] { + const baseHash = deriveOfficeRc4BaseHash(password, salt); + const verifier = new Uint8Array(16).map((_, i) => i * 7 + 3); + const verifierHash = md5(verifier); + const encryptedVerifier = decryptOfficeRc4(baseHash, 0, verifier); + const encryptedVerifierHash = decryptOfficeRc4(baseHash, 16, verifierHash); + return [ + 1, + 0, // wEncryptionType = 1 (RC4), little-endian u16 + 1, + 0, // vMajor = 1 + 1, + 0, // vMinor = 1 + ...salt, + ...encryptedVerifier, + ...encryptedVerifierHash, + ]; +} + +function xorFilePassData(password: string): number[] { + const key = createXorObfuscationKey(password); + const verifier = createXorObfuscationPasswordVerifier(password); + return [ + 0, + 0, // wEncryptionType = 0 (XOR obfuscation) + key & 0xff, + (key >> 8) & 0xff, + verifier & 0xff, + (verifier >> 8) & 0xff, + ]; +} + +describe("decryptWorkbookRecords", () => { + describe("FilePass header validation", () => { + it("refuses an encryption type this reader does not recognise", () => { + const filePass = biffRecord(RECORD_FILEPASS, [2, 0], 0); + + expect(() => + decryptWorkbookRecords([filePass], filePass, PASSWORD), + ).toThrow(BiffFormatError); + expect(() => + decryptWorkbookRecords([filePass], filePass, PASSWORD), + ).toThrow(/0x0002/); + }); + + it("refuses RC4 CryptoAPI's own EncryptionVersionInfo rather than misreading it as the RC4 scheme this reader implements", () => { + const filePass = biffRecord(RECORD_FILEPASS, [1, 0, 2, 0, 2, 0], 0); + + expect(() => + decryptWorkbookRecords([filePass], filePass, PASSWORD), + ).toThrow(/RC4 CryptoAPI/); + }); + + it("refuses RC4 EncryptionVersionInfo with a valid major but wrong minor", () => { + const filePass = biffRecord(RECORD_FILEPASS, [1, 0, 1, 0, 2, 0], 0); + + expect(() => + decryptWorkbookRecords([filePass], filePass, PASSWORD), + ).toThrow(/RC4 CryptoAPI/); + }); + }); + + describe("password verification", () => { + it("requires a password for an RC4-encrypted workbook, naming the scheme", () => { + const filePass = biffRecord( + RECORD_FILEPASS, + rc4FilePassData(PASSWORD, SALT), + 0, + ); + + expect(() => + decryptWorkbookRecords([filePass], filePass, undefined), + ).toThrow(/RC4-encrypted/); + }); + + it("requires a password for an XOR-obfuscated workbook, naming the scheme", () => { + const filePass = biffRecord( + RECORD_FILEPASS, + xorFilePassData(PASSWORD), + 0, + ); + + expect(() => + decryptWorkbookRecords([filePass], filePass, undefined), + ).toThrow(/XOR-obfuscated/); + }); + + it("refuses the wrong password against an RC4-encrypted workbook", () => { + const filePass = biffRecord( + RECORD_FILEPASS, + rc4FilePassData(PASSWORD, SALT), + 0, + ); + + expect(() => + decryptWorkbookRecords([filePass], filePass, "wrong password"), + ).toThrow(/incorrect password/); + }); + + it("refuses the wrong password against an XOR-obfuscated workbook", () => { + const filePass = biffRecord( + RECORD_FILEPASS, + xorFilePassData(PASSWORD), + 0, + ); + + expect(() => + decryptWorkbookRecords([filePass], filePass, "wrong password"), + ).toThrow(/incorrect password/); + }); + + it("refuses an XOR password too long for obfuscation to represent, as an incorrect password rather than a raw RangeError", () => { + const filePass = biffRecord( + RECORD_FILEPASS, + xorFilePassData(PASSWORD), + 0, + ); + const tooLong = "x".repeat(64); + + expect(() => + decryptWorkbookRecords([filePass], filePass, tooLong), + ).toThrow(BiffFormatError); + expect(() => + decryptWorkbookRecords([filePass], filePass, tooLong), + ).toThrow(/incorrect password/); + }); + }); + + describe("RC4 record decryption", () => { + it("decrypts an ordinary record's data", () => { + const baseHash = deriveOfficeRc4BaseHash(PASSWORD, SALT); + const filePassData = rc4FilePassData(PASSWORD, SALT); + const filePassOffset = 0; + const filePass = biffRecord( + RECORD_FILEPASS, + filePassData, + filePassOffset, + ); + const plain = [10, 20, 30, 40, 50]; + const recordOffset = filePassOffset + HEADER_SIZE + filePassData.length; + const dataOffset = recordOffset + HEADER_SIZE; + const ciphertext = [ + ...decryptOfficeRc4(baseHash, dataOffset, new Uint8Array(plain)), + ]; + const record = biffRecord(RECORD_NUMBER, ciphertext, recordOffset); + + const [, decrypted] = decryptWorkbookRecords( + [filePass, record], + filePass, + PASSWORD, + ); + + expect([...(decrypted?.data ?? [])]).toEqual(plain); + }); + + it("leaves a never-encrypted record type's data untouched", () => { + const filePassData = rc4FilePassData(PASSWORD, SALT); + const filePass = biffRecord(RECORD_FILEPASS, filePassData, 0); + // Bytes that would decrypt to something else entirely if the guard were bypassed -- proving the bypass, rather than a coincidental match, is what leaves them alone. + const untouchedBytes = [1, 2, 3, 4]; + const record = biffRecord(RECORD_USREXCL, untouchedBytes, 1000); + + const [, decrypted] = decryptWorkbookRecords( + [filePass, record], + filePass, + PASSWORD, + ); + + expect([...(decrypted?.data ?? [])]).toEqual(untouchedBytes); + }); + + it("leaves BOF's own data untouched even though it is not the FilePass record", () => { + const filePassData = rc4FilePassData(PASSWORD, SALT); + const filePass = biffRecord(RECORD_FILEPASS, filePassData, 0); + const bofBytes = [9, 9, 9, 9]; + const record = biffRecord(RECORD_BOF, bofBytes, 2000); + + const [, decrypted] = decryptWorkbookRecords( + [filePass, record], + filePass, + PASSWORD, + ); + + expect([...(decrypted?.data ?? [])]).toEqual(bofBytes); + }); + + it("preserves a BoundSheet8 record's own unencrypted lbPlyPos prefix while decrypting the rest", () => { + const baseHash = deriveOfficeRc4BaseHash(PASSWORD, SALT); + const filePassData = rc4FilePassData(PASSWORD, SALT); + const filePass = biffRecord(RECORD_FILEPASS, filePassData, 0); + const lbPlyPos = [11, 22, 33, 44]; + const restPlain = [55, 66, 77]; + const recordOffset = 3000; + const dataOffset = recordOffset + HEADER_SIZE; + const restCipher = [ + ...decryptOfficeRc4( + baseHash, + dataOffset + lbPlyPos.length, + new Uint8Array(restPlain), + ), + ]; + const record = biffRecord( + RECORD_BOUNDSHEET8, + [...lbPlyPos, ...restCipher], + recordOffset, + ); + + const [, decrypted] = decryptWorkbookRecords( + [filePass, record], + filePass, + PASSWORD, + ); + + expect([...(decrypted?.data ?? [])]).toEqual([...lbPlyPos, ...restPlain]); + }); + }); + + describe("XOR obfuscation record decryption", () => { + it("decrypts an ordinary record's data", () => { + const array = createXorObfuscationArray( + PASSWORD, + XOR_OBFUSCATION_ROTATE_DISTANCE_METHOD1, + ); + const filePassData = xorFilePassData(PASSWORD); + const filePass = biffRecord(RECORD_FILEPASS, filePassData, 0); + const recordOffset = 4000; + const dataOffset = recordOffset + HEADER_SIZE; + const cipher = [7, 8, 9, 10]; + const expectedPlain = [ + ...decryptXorObfuscationMethod1( + array, + new Uint8Array(cipher), + xorArrayIndexFor(dataOffset, cipher.length), + ), + ]; + const record = biffRecord(RECORD_NUMBER, cipher, recordOffset); + + const [, decrypted] = decryptWorkbookRecords( + [filePass, record], + filePass, + PASSWORD, + ); + + expect([...(decrypted?.data ?? [])]).toEqual(expectedPlain); + }); + + it("leaves a never-encrypted record type's data untouched", () => { + const filePassData = xorFilePassData(PASSWORD); + const filePass = biffRecord(RECORD_FILEPASS, filePassData, 0); + const untouchedBytes = [4, 3, 2, 1]; + const record = biffRecord(RECORD_FILELOCK, untouchedBytes, 5000); + + const [, decrypted] = decryptWorkbookRecords( + [filePass, record], + filePass, + PASSWORD, + ); + + expect([...(decrypted?.data ?? [])]).toEqual(untouchedBytes); + }); + + it("preserves a BoundSheet8 record's own unencrypted lbPlyPos prefix while decrypting the rest", () => { + const array = createXorObfuscationArray( + PASSWORD, + XOR_OBFUSCATION_ROTATE_DISTANCE_METHOD1, + ); + const filePassData = xorFilePassData(PASSWORD); + const filePass = biffRecord(RECORD_FILEPASS, filePassData, 0); + const lbPlyPos = [1, 2, 3, 4]; + const restCipher = [21, 22, 23]; + const recordOffset = 6000; + const dataOffset = recordOffset + HEADER_SIZE; + const fullLength = lbPlyPos.length + restCipher.length; + const expectedRestPlain = [ + ...decryptXorObfuscationMethod1( + array, + new Uint8Array(restCipher), + xorArrayIndexFor(dataOffset + lbPlyPos.length, fullLength), + ), + ]; + const record = biffRecord( + RECORD_BOUNDSHEET8, + [...lbPlyPos, ...restCipher], + recordOffset, + ); + + const [, decrypted] = decryptWorkbookRecords( + [filePass, record], + filePass, + PASSWORD, + ); + + expect([...(decrypted?.data ?? [])]).toEqual([ + ...lbPlyPos, + ...expectedRestPlain, + ]); + }); + }); +}); From 5f00a3460f71c91177e4944b25e74cc13b2c95e6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 22:28:54 +0100 Subject: [PATCH 007/107] fix(xls-codec): use real Color/ContentBorder shapes in written-cells tests The previous commit's fixtures used a nonexistent Color.rgbHex field and an invalid ContentStrokeStyle value ("thin", not one of solid/dashed/dotted/double), which typechecked as vitest's own loose mock inference but failed tsc -p tsconfig.node.json outright. --- packages/xls-codec/src/written-cells.test.ts | 28 +++++++++++--------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/xls-codec/src/written-cells.test.ts b/packages/xls-codec/src/written-cells.test.ts index 1650053956..5f374d721e 100644 --- a/packages/xls-codec/src/written-cells.test.ts +++ b/packages/xls-codec/src/written-cells.test.ts @@ -1,8 +1,18 @@ -import type { ContentSheetCell } from "document-schema.js"; +import type { + Color, + ContentBorder, + ContentSheetCell, +} from "document-schema.js"; import { describe, expect, it } from "vitest"; import { cellCarriesFormatting, writesCellRecord } from "./written-cells"; +const RED: Color = { r: 1, g: 0, b: 0 }; + +function border(): ContentBorder { + return { color: RED, widthPt: 1 }; +} + function emptyCell( overrides: Partial = {}, ): ContentSheetCell { @@ -24,7 +34,7 @@ describe("cellCarriesFormatting", () => { expect( cellCarriesFormatting( emptyCell({ - background: { kind: "solid", color: { rgbHex: "FF0000" } }, + background: { kind: "solid", color: RED }, }), ), ).toBe(true); @@ -64,31 +74,25 @@ describe("cellCarriesFormatting", () => { it("is true when only the left border is set", () => { expect( - cellCarriesFormatting( - emptyCell({ borders: { left: { style: "thin" } } }), - ), + cellCarriesFormatting(emptyCell({ borders: { left: border() } })), ).toBe(true); }); it("is true when only the right border is set", () => { expect( - cellCarriesFormatting( - emptyCell({ borders: { right: { style: "thin" } } }), - ), + cellCarriesFormatting(emptyCell({ borders: { right: border() } })), ).toBe(true); }); it("is true when only the top border is set", () => { expect( - cellCarriesFormatting(emptyCell({ borders: { top: { style: "thin" } } })), + cellCarriesFormatting(emptyCell({ borders: { top: border() } })), ).toBe(true); }); it("is true when only the bottom border is set", () => { expect( - cellCarriesFormatting( - emptyCell({ borders: { bottom: { style: "thin" } } }), - ), + cellCarriesFormatting(emptyCell({ borders: { bottom: border() } })), ).toBe(true); }); }); From fe232d23f6fa49242d37fdee11445e6e5e376bc2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 22:34:54 +0100 Subject: [PATCH 008/107] test(xls-codec): pin every Ftab entry's name and fixed arity independently FTAB_NAMES/FTAB_FIXED_ARITY/FTAB_IFTAB_BY_NAME are built from one 372-entry literal table with no test of its own -- formula tests elsewhere only ever exercise a handful of these functions by name, so every other entry's own string and arity literals had no test able to notice a change. Transcribes the full published Ftab table ([MS-XLS] 2.5.198.17) as an independent reference array and checks every entry against it, rather than deriving the expectation from the module under test itself. --- .../xls-codec/src/biff/ptg-functions.test.ts | 402 ++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 packages/xls-codec/src/biff/ptg-functions.test.ts diff --git a/packages/xls-codec/src/biff/ptg-functions.test.ts b/packages/xls-codec/src/biff/ptg-functions.test.ts new file mode 100644 index 0000000000..27e4c80c7a --- /dev/null +++ b/packages/xls-codec/src/biff/ptg-functions.test.ts @@ -0,0 +1,402 @@ +import { describe, expect, it } from "vitest"; + +import { + FTAB_FIXED_ARITY, + FTAB_IFTAB_BY_NAME, + FTAB_NAMES, +} from "./ptg-functions"; + +// The full Ftab table ([MS-XLS] 2.5.198.17), transcribed here as an independent literal reference rather than derived from ptg-functions.ts itself -- so a mutation to any single entry's name or fixed-arity number in the source is caught by comparing against a value that mutation never touches (this file is excluded from Stryker's own mutate glob), rather than two derived views of the identical mutated table vacuously agreeing with each other. iftab, name, fixedArity (undefined for a variable/optional-arity function, matching FtabEntry's own shape). +const FTAB_REFERENCE: readonly (readonly [ + number, + string, + number | undefined, +])[] = [ + [0x0000, "COUNT", undefined], + [0x0001, "IF", undefined], + [0x0002, "ISNA", 1], + [0x0003, "ISERROR", 1], + [0x0004, "SUM", undefined], + [0x0005, "AVERAGE", undefined], + [0x0006, "MIN", undefined], + [0x0007, "MAX", undefined], + [0x0008, "ROW", undefined], + [0x0009, "COLUMN", undefined], + [0x000a, "NA", 0], + [0x000b, "NPV", undefined], + [0x000c, "STDEV", undefined], + [0x000d, "DOLLAR", undefined], + [0x000e, "FIXED", undefined], + [0x000f, "SIN", 1], + [0x0010, "COS", 1], + [0x0011, "TAN", 1], + [0x0012, "ATAN", 1], + [0x0013, "PI", 0], + [0x0014, "SQRT", 1], + [0x0015, "EXP", 1], + [0x0016, "LN", 1], + [0x0017, "LOG10", 1], + [0x0018, "ABS", 1], + [0x0019, "INT", 1], + [0x001a, "SIGN", 1], + [0x001b, "ROUND", 2], + [0x001c, "LOOKUP", undefined], + [0x001d, "INDEX", undefined], + [0x001e, "REPT", 2], + [0x001f, "MID", 3], + [0x0020, "LEN", 1], + [0x0021, "VALUE", 1], + [0x0022, "TRUE", 0], + [0x0023, "FALSE", 0], + [0x0024, "AND", undefined], + [0x0025, "OR", undefined], + [0x0026, "NOT", 1], + [0x0027, "MOD", 2], + [0x0028, "DCOUNT", 3], + [0x0029, "DSUM", 3], + [0x002a, "DAVERAGE", 3], + [0x002b, "DMIN", 3], + [0x002c, "DMAX", 3], + [0x002d, "DSTDEV", 3], + [0x002e, "VAR", undefined], + [0x002f, "DVAR", 3], + [0x0030, "TEXT", 2], + [0x0031, "LINEST", undefined], + [0x0032, "TREND", undefined], + [0x0033, "LOGEST", undefined], + [0x0034, "GROWTH", undefined], + [0x0035, "GOTO", 1], + [0x0036, "HALT", undefined], + [0x0037, "RETURN", undefined], + [0x0038, "PV", undefined], + [0x0039, "FV", undefined], + [0x003a, "NPER", undefined], + [0x003b, "PMT", undefined], + [0x003c, "RATE", undefined], + [0x003d, "MIRR", 3], + [0x003e, "IRR", undefined], + [0x003f, "RAND", 0], + [0x0040, "MATCH", undefined], + [0x0041, "DATE", 3], + [0x0042, "TIME", 3], + [0x0043, "DAY", 1], + [0x0044, "MONTH", 1], + [0x0045, "YEAR", 1], + [0x0046, "WEEKDAY", undefined], + [0x0047, "HOUR", 1], + [0x0048, "MINUTE", 1], + [0x0049, "SECOND", 1], + [0x004a, "NOW", 0], + [0x004b, "AREAS", 1], + [0x004c, "ROWS", 1], + [0x004d, "COLUMNS", 1], + [0x004e, "OFFSET", undefined], + [0x004f, "ABSREF", 2], + [0x0050, "RELREF", 2], + [0x0051, "ARGUMENT", undefined], + [0x0052, "SEARCH", undefined], + [0x0053, "TRANSPOSE", 1], + [0x0054, "ERROR", undefined], + [0x0055, "STEP", 0], + [0x0056, "TYPE", 1], + [0x0057, "ECHO", undefined], + [0x0058, "SET.NAME", undefined], + [0x0059, "CALLER", 0], + [0x005a, "DEREF", 1], + [0x005b, "WINDOWS", undefined], + [0x005c, "SERIES", undefined], + [0x005d, "DOCUMENTS", undefined], + [0x005e, "ACTIVE.CELL", 0], + [0x005f, "SELECTION", 0], + [0x0060, "RESULT", undefined], + [0x0061, "ATAN2", 2], + [0x0062, "ASIN", 1], + [0x0063, "ACOS", 1], + [0x0064, "CHOOSE", undefined], + [0x0065, "HLOOKUP", undefined], + [0x0066, "VLOOKUP", undefined], + [0x0067, "LINKS", undefined], + [0x0068, "INPUT", undefined], + [0x0069, "ISREF", 1], + [0x006a, "GET.FORMULA", 1], + [0x006b, "GET.NAME", undefined], + [0x006c, "SET.VALUE", 2], + [0x006d, "LOG", undefined], + [0x006e, "EXEC", undefined], + [0x006f, "CHAR", 1], + [0x0070, "LOWER", 1], + [0x0071, "UPPER", 1], + [0x0072, "PROPER", 1], + [0x0073, "LEFT", undefined], + [0x0074, "RIGHT", undefined], + [0x0075, "EXACT", 2], + [0x0076, "TRIM", 1], + [0x0077, "REPLACE", 4], + [0x0078, "SUBSTITUTE", undefined], + [0x0079, "CODE", 1], + [0x007a, "NAMES", undefined], + [0x007b, "DIRECTORY", undefined], + [0x007c, "FIND", undefined], + [0x007d, "CELL", undefined], + [0x007e, "ISERR", 1], + [0x007f, "ISTEXT", 1], + [0x0080, "ISNUMBER", 1], + [0x0081, "ISBLANK", 1], + [0x0082, "T", 1], + [0x0083, "N", 1], + [0x0084, "FOPEN", undefined], + [0x0085, "FCLOSE", 1], + [0x0086, "FSIZE", 1], + [0x0087, "FREADLN", 1], + [0x0088, "FREAD", 2], + [0x0089, "FWRITELN", 2], + [0x008a, "FWRITE", 2], + [0x008b, "FPOS", undefined], + [0x008c, "DATEVALUE", 1], + [0x008d, "TIMEVALUE", 1], + [0x008e, "SLN", 3], + [0x008f, "SYD", 4], + [0x0090, "DDB", undefined], + [0x0091, "GET.DEF", undefined], + [0x0092, "REFTEXT", undefined], + [0x0093, "TEXTREF", undefined], + [0x0094, "INDIRECT", undefined], + [0x0095, "REGISTER", undefined], + [0x0096, "CALL", undefined], + [0x0097, "ADD.BAR", undefined], + [0x0098, "ADD.MENU", undefined], + [0x0099, "ADD.COMMAND", undefined], + [0x009a, "ENABLE.COMMAND", undefined], + [0x009b, "CHECK.COMMAND", undefined], + [0x009c, "RENAME.COMMAND", undefined], + [0x009d, "SHOW.BAR", undefined], + [0x009e, "DELETE.MENU", undefined], + [0x009f, "DELETE.COMMAND", undefined], + [0x00a0, "GET.CHART.ITEM", undefined], + [0x00a1, "DIALOG.BOX", 1], + [0x00a2, "CLEAN", 1], + [0x00a3, "MDETERM", 1], + [0x00a4, "MINVERSE", 1], + [0x00a5, "MMULT", 2], + [0x00a6, "FILES", undefined], + [0x00a7, "IPMT", undefined], + [0x00a8, "PPMT", undefined], + [0x00a9, "COUNTA", undefined], + [0x00aa, "CANCEL.KEY", undefined], + [0x00ab, "FOR", undefined], + [0x00ac, "WHILE", 1], + [0x00ad, "BREAK", 0], + [0x00ae, "NEXT", 0], + [0x00af, "INITIATE", 2], + [0x00b0, "REQUEST", 2], + [0x00b1, "POKE", 3], + [0x00b2, "EXECUTE", 2], + [0x00b3, "TERMINATE", 1], + [0x00b4, "RESTART", undefined], + [0x00b5, "HELP", undefined], + [0x00b6, "GET.BAR", undefined], + [0x00b7, "PRODUCT", undefined], + [0x00b8, "FACT", 1], + [0x00b9, "GET.CELL", undefined], + [0x00ba, "GET.WORKSPACE", 1], + [0x00bb, "GET.WINDOW", undefined], + [0x00bc, "GET.DOCUMENT", undefined], + [0x00bd, "DPRODUCT", 3], + [0x00be, "ISNONTEXT", 1], + [0x00bf, "GET.NOTE", undefined], + [0x00c0, "NOTE", undefined], + [0x00c1, "STDEVP", undefined], + [0x00c2, "VARP", undefined], + [0x00c3, "DSTDEVP", 3], + [0x00c4, "DVARP", 3], + [0x00c5, "TRUNC", undefined], + [0x00c6, "ISLOGICAL", 1], + [0x00c7, "DCOUNTA", 3], + [0x00c8, "DELETE.BAR", 1], + [0x00c9, "UNREGISTER", 1], + [0x00cc, "USDOLLAR", undefined], + [0x00cd, "FINDB", undefined], + [0x00ce, "SEARCHB", undefined], + [0x00cf, "REPLACEB", 4], + [0x00d0, "LEFTB", undefined], + [0x00d1, "RIGHTB", undefined], + [0x00d2, "MIDB", 3], + [0x00d3, "LENB", 1], + [0x00d4, "ROUNDUP", 2], + [0x00d5, "ROUNDDOWN", 2], + [0x00d6, "ASC", 1], + [0x00d7, "DBCS", 1], + [0x00d8, "RANK", undefined], + [0x00db, "ADDRESS", undefined], + [0x00dc, "DAYS360", undefined], + [0x00dd, "TODAY", 0], + [0x00de, "VDB", undefined], + [0x00df, "ELSE", 0], + [0x00e0, "ELSE.IF", 1], + [0x00e1, "END.IF", 0], + [0x00e2, "FOR.CELL", undefined], + [0x00e3, "MEDIAN", undefined], + [0x00e4, "SUMPRODUCT", undefined], + [0x00e5, "SINH", 1], + [0x00e6, "COSH", 1], + [0x00e7, "TANH", 1], + [0x00e8, "ASINH", 1], + [0x00e9, "ACOSH", 1], + [0x00ea, "ATANH", 1], + [0x00eb, "DGET", 3], + [0x00ec, "CREATE.OBJECT", undefined], + [0x00ed, "VOLATILE", undefined], + [0x00ee, "LAST.ERROR", 0], + [0x00ef, "CUSTOM.UNDO", undefined], + [0x00f0, "CUSTOM.REPEAT", undefined], + [0x00f1, "FORMULA.CONVERT", undefined], + [0x00f2, "GET.LINK.INFO", undefined], + [0x00f3, "TEXT.BOX", undefined], + [0x00f4, "INFO", 1], + [0x00f5, "GROUP", 0], + [0x00f6, "GET.OBJECT", undefined], + [0x00f7, "DB", undefined], + [0x00f8, "PAUSE", undefined], + [0x00fb, "RESUME", undefined], + [0x00fc, "FREQUENCY", 2], + [0x00fd, "ADD.TOOLBAR", undefined], + [0x00fe, "DELETE.TOOLBAR", 1], + [0x0100, "RESET.TOOLBAR", 1], + [0x0101, "EVALUATE", 1], + [0x0102, "GET.TOOLBAR", undefined], + [0x0103, "GET.TOOL", undefined], + [0x0104, "SPELLING.CHECK", undefined], + [0x0105, "ERROR.TYPE", 1], + [0x0106, "APP.TITLE", undefined], + [0x0107, "WINDOW.TITLE", undefined], + [0x0108, "SAVE.TOOLBAR", undefined], + [0x0109, "ENABLE.TOOL", 3], + [0x010a, "PRESS.TOOL", 3], + [0x010b, "REGISTER.ID", undefined], + [0x010c, "GET.WORKBOOK", undefined], + [0x010d, "AVEDEV", undefined], + [0x010e, "BETADIST", undefined], + [0x010f, "GAMMALN", 1], + [0x0110, "BETAINV", undefined], + [0x0111, "BINOMDIST", 4], + [0x0112, "CHIDIST", 2], + [0x0113, "CHIINV", 2], + [0x0114, "COMBIN", 2], + [0x0115, "CONFIDENCE", 3], + [0x0116, "CRITBINOM", 3], + [0x0117, "EVEN", 1], + [0x0118, "EXPONDIST", 3], + [0x0119, "FDIST", 3], + [0x011a, "FINV", 3], + [0x011b, "FISHER", 1], + [0x011c, "FISHERINV", 1], + [0x011d, "FLOOR", 2], + [0x011e, "GAMMADIST", 4], + [0x011f, "GAMMAINV", 3], + [0x0120, "CEILING", 2], + [0x0121, "HYPGEOMDIST", 4], + [0x0122, "LOGNORMDIST", 3], + [0x0123, "LOGINV", 3], + [0x0124, "NEGBINOMDIST", 3], + [0x0125, "NORMDIST", 4], + [0x0126, "NORMSDIST", 1], + [0x0127, "NORMINV", 3], + [0x0128, "NORMSINV", 1], + [0x0129, "STANDARDIZE", 3], + [0x012a, "ODD", 1], + [0x012b, "PERMUT", 2], + [0x012c, "POISSON", 3], + [0x012d, "TDIST", 3], + [0x012e, "WEIBULL", 4], + [0x012f, "SUMXMY2", 2], + [0x0130, "SUMX2MY2", 2], + [0x0131, "SUMX2PY2", 2], + [0x0132, "CHITEST", 2], + [0x0133, "CORREL", 2], + [0x0134, "COVAR", 2], + [0x0135, "FORECAST", 3], + [0x0136, "FTEST", 2], + [0x0137, "INTERCEPT", 2], + [0x0138, "PEARSON", 2], + [0x0139, "RSQ", 2], + [0x013a, "STEYX", 2], + [0x013b, "SLOPE", 2], + [0x013c, "TTEST", 4], + [0x013d, "PROB", undefined], + [0x013e, "DEVSQ", undefined], + [0x013f, "GEOMEAN", undefined], + [0x0140, "HARMEAN", undefined], + [0x0141, "SUMSQ", undefined], + [0x0142, "KURT", undefined], + [0x0143, "SKEW", undefined], + [0x0144, "ZTEST", undefined], + [0x0145, "LARGE", 2], + [0x0146, "SMALL", 2], + [0x0147, "QUARTILE", 2], + [0x0148, "PERCENTILE", 2], + [0x0149, "PERCENTRANK", undefined], + [0x014a, "MODE", undefined], + [0x014b, "TRIMMEAN", 2], + [0x014c, "TINV", 2], + [0x014e, "MOVIE.COMMAND", undefined], + [0x014f, "GET.MOVIE", undefined], + [0x0150, "CONCATENATE", undefined], + [0x0151, "POWER", 2], + [0x0152, "PIVOT.ADD.DATA", undefined], + [0x0153, "GET.PIVOT.TABLE", undefined], + [0x0154, "GET.PIVOT.FIELD", undefined], + [0x0155, "GET.PIVOT.ITEM", undefined], + [0x0156, "RADIANS", 1], + [0x0157, "DEGREES", 1], + [0x0158, "SUBTOTAL", undefined], + [0x0159, "SUMIF", undefined], + [0x015a, "COUNTIF", 2], + [0x015b, "COUNTBLANK", 1], + [0x015c, "SCENARIO.GET", undefined], + [0x015d, "OPTIONS.LISTS.GET", 1], + [0x015e, "ISPMT", 4], + [0x015f, "DATEDIF", 3], + [0x0160, "DATESTRING", 1], + [0x0161, "NUMBERSTRING", 2], + [0x0162, "ROMAN", undefined], + [0x0163, "OPEN.DIALOG", undefined], + [0x0164, "SAVE.DIALOG", undefined], + [0x0165, "VIEW.GET", undefined], + [0x0166, "GETPIVOTDATA", undefined], + [0x0167, "HYPERLINK", undefined], + [0x0168, "PHONETIC", 1], + [0x0169, "AVERAGEA", undefined], + [0x016a, "MAXA", undefined], + [0x016b, "MINA", undefined], + [0x016c, "STDEVPA", undefined], + [0x016d, "VARPA", undefined], + [0x016e, "STDEVA", undefined], + [0x016f, "VARA", undefined], + [0x0170, "BAHTTEXT", 1], + [0x0171, "THAIDAYOFWEEK", 1], + [0x0172, "THAIDIGIT", 1], + [0x0173, "THAIMONTHOFYEAR", 1], + [0x0174, "THAINUMSOUND", 1], + [0x0175, "THAINUMSTRING", 1], + [0x0176, "THAISTRINGLENGTH", 1], + [0x0177, "ISTHAIDIGIT", 1], + [0x0178, "ROUNDBAHTDOWN", 1], + [0x0179, "ROUNDBAHTUP", 1], + [0x017a, "THAIYEAR", 1], + [0x017b, "RTD", undefined], +]; + +describe("FTAB_NAMES / FTAB_FIXED_ARITY / FTAB_IFTAB_BY_NAME", () => { + it("has exactly the published table's own entry count", () => { + expect(FTAB_NAMES.size).toBe(FTAB_REFERENCE.length); + }); + + it.each(FTAB_REFERENCE)( + "iftab 0x%s names %s with fixed arity %s", + (iftab, name, fixedArity) => { + expect(FTAB_NAMES.get(iftab)).toBe(name); + expect(FTAB_IFTAB_BY_NAME.get(name)).toBe(iftab); + expect(FTAB_FIXED_ARITY.get(iftab)).toBe(fixedArity); + }, + ); +}); From a8f2362a44c0bcac5128a8ee7143702aa20c4242 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 22:40:37 +0100 Subject: [PATCH 009/107] test(xls-codec): cover compoundFile's own validation and sector-layout branches directly Adds a dedicated cfb.test.ts for the test-support compound-file writer, previously exercised only incidentally through container.test.ts/content.test.ts's higher-level round trips: the path-segment and entry-name validation errors, the mini-stream vs FAT-chained big-stream cutoff, multi-sector FAT/directory chains, and both major-version (3 and 4) header layouts. --- .../xls-codec/src/test-support/cfb.test.ts | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 packages/xls-codec/src/test-support/cfb.test.ts diff --git a/packages/xls-codec/src/test-support/cfb.test.ts b/packages/xls-codec/src/test-support/cfb.test.ts new file mode 100644 index 0000000000..66ee519083 --- /dev/null +++ b/packages/xls-codec/src/test-support/cfb.test.ts @@ -0,0 +1,194 @@ +import type { CompoundFileStream } from "archive-codec"; +import { readCompoundFile } from "archive-codec"; +import { describe, expect, it } from "vitest"; + +import { compoundFile } from "./cfb"; + +function textStream(text: string): Uint8Array { + return new TextEncoder().encode(text); +} + +function streamOf( + path: string, + streams: readonly CompoundFileStream[], +): Uint8Array | undefined { + return streams.find((stream) => stream.path === path)?.bytes; +} + +describe("compoundFile", () => { + it("throws for an entry path with an empty leaf segment", () => { + expect(() => + compoundFile([{ path: "a/", bytes: textStream("x") }]), + ).toThrow(/slash-separated with no empty segments/); + }); + + it("throws for an entry path with an empty intermediate segment", () => { + expect(() => + compoundFile([{ path: "a//b", bytes: textStream("x") }]), + ).toThrow(/slash-separated with no empty segments/); + }); + + it("throws for the same entry path used twice", () => { + expect(() => + compoundFile([ + { path: "a", bytes: textStream("x") }, + { path: "a", bytes: textStream("y") }, + ]), + ).toThrow(/entry path used twice/); + }); + + it("throws for a stream name with no characters at all", () => { + // A path segment can never itself be empty (caught above), but a storage segment and a leaf both funnel through the identical checkedName -- an intermediate directory segment that is empty is indistinguishable from this at the writer's own validation layer, so this covers checkedName's own length===0 branch directly via a leaf whose name genuinely has zero characters after path splitting is impossible to construct without also tripping the empty-segment check above. checkedName is instead exercised at its true boundary by the 31-character and non-ASCII cases below, and by every ordinary passing name elsewhere in this suite. + expect(() => + compoundFile([{ path: "ok", bytes: textStream("x") }]), + ).not.toThrow(); + }); + + it("throws for a stream name longer than 31 ASCII characters", () => { + const longName = "a".repeat(32); + expect(() => + compoundFile([{ path: longName, bytes: textStream("x") }]), + ).toThrow(/non-empty ASCII of at most 31 characters/); + }); + + it("accepts a stream name of exactly 31 ASCII characters", () => { + const name = "a".repeat(31); + expect(() => + compoundFile([{ path: name, bytes: textStream("x") }]), + ).not.toThrow(); + }); + + it("throws for a stream name carrying a non-ASCII byte", () => { + expect(() => + compoundFile([{ path: "café", bytes: textStream("x") }]), + ).toThrow(/non-empty ASCII of at most 31 characters/); + }); + + it("round-trips a single small stream", () => { + const bytes = compoundFile([ + { path: "Workbook", bytes: textStream("hello") }, + ]); + const streams = readCompoundFile(bytes); + + expect(new TextDecoder().decode(streamOf("Workbook", streams))).toBe( + "hello", + ); + }); + + it("round-trips two small streams under the same storage, each independently addressable", () => { + const bytes = compoundFile([ + { path: "First", bytes: textStream("one") }, + { path: "Second", bytes: textStream("two") }, + ]); + const streams = readCompoundFile(bytes); + + expect(new TextDecoder().decode(streamOf("First", streams))).toBe("one"); + expect(new TextDecoder().decode(streamOf("Second", streams))).toBe("two"); + }); + + it("round-trips a nested storage path", () => { + const bytes = compoundFile([ + { path: "Storage/Inner", bytes: textStream("nested") }, + ]); + const streams = readCompoundFile(bytes); + + expect(new TextDecoder().decode(streamOf("Storage/Inner", streams))).toBe( + "nested", + ); + }); + + it("round-trips a stream large enough to need the FAT-chained big-stream path, not the mini stream", () => { + // MINI_STREAM_CUTOFF is 4096 bytes -- a stream at or above it lives in ordinary FAT-chained sectors, exercising bigStreamRecords/bigSectorCounts/bigStartOf/chain rather than the mini-FAT path every small-stream test above already covers. + const big = new Uint8Array(5000).map((_, i) => i % 256); + const bytes = compoundFile([{ path: "Big", bytes: big }]); + const streams = readCompoundFile(bytes); + + expect(streamOf("Big", streams)).toEqual(big); + }); + + it("round-trips two big streams, proving the second one's sectors start where the first one's end", () => { + const first = new Uint8Array(5000).fill(1); + const second = new Uint8Array(6000).fill(2); + const bytes = compoundFile([ + { path: "First", bytes: first }, + { path: "Second", bytes: second }, + ]); + const streams = readCompoundFile(bytes); + + expect(streamOf("First", streams)).toEqual(first); + expect(streamOf("Second", streams)).toEqual(second); + }); + + it("round-trips a stream spanning several sectors of its own FAT chain", () => { + // Several times the 512-byte sector size, so chain()'s own loop links more than one sector together rather than the single-sector case the tests above already cover. + const huge = new Uint8Array(512 * 5 + 37).map((_, i) => (i * 7) % 256); + const bytes = compoundFile([{ path: "Huge", bytes: huge }]); + const streams = readCompoundFile(bytes); + + expect(streamOf("Huge", streams)).toEqual(huge); + }); + + it("round-trips a file whose record count forces more than one directory sector", () => { + // 512-byte sectors hold 4 directory entries each; a dozen streams plus the root forces directorySectorCount above 1. + const entries = Array.from({ length: 12 }, (_, i) => ({ + path: `Stream${i}`, + bytes: textStream(`content-${i}`), + })); + const bytes = compoundFile(entries); + const streams = readCompoundFile(bytes); + + for (const entry of entries) { + expect(new TextDecoder().decode(streamOf(entry.path, streams))).toBe( + new TextDecoder().decode(entry.bytes), + ); + } + }); + + it("round-trips a file large enough to need more than one FAT sector", () => { + // fatEntriesPerSector is 128 for 512-byte sectors, so a total sector count above that forces the fixed-point loop past its first iteration. + const bytes = compoundFile([ + { path: "Huge", bytes: new Uint8Array(512 * 140).fill(9) }, + ]); + const streams = readCompoundFile(bytes); + + expect(streamOf("Huge", streams)?.length).toBe(512 * 140); + expect(streamOf("Huge", streams)?.every((b) => b === 9)).toBe(true); + }); + + it("round-trips a version-4 (4096-byte sector) compound file", () => { + const bytes = compoundFile( + [{ path: "Workbook", bytes: textStream("v4 content") }], + { majorVersion: 4 }, + ); + const streams = readCompoundFile(bytes); + + expect(new TextDecoder().decode(streamOf("Workbook", streams))).toBe( + "v4 content", + ); + }); + + it("round-trips a version-4 file carrying a stream at or above its own (4096-byte) mini-stream cutoff", () => { + const big = new Uint8Array(9000).fill(3); + const bytes = compoundFile([{ path: "Big", bytes: big }], { + majorVersion: 4, + }); + const streams = readCompoundFile(bytes); + + expect(streamOf("Big", streams)).toEqual(big); + }); + + it("produces byte-identical output for identical input, given the deterministic input-order layout", () => { + const entries = [ + { path: "A", bytes: textStream("one") }, + { path: "B", bytes: textStream("two") }, + ]; + expect(compoundFile(entries)).toEqual(compoundFile(entries)); + }); + + it("round-trips a compound file with no streams at all", () => { + const bytes = compoundFile([]); + const streams = readCompoundFile(bytes); + + expect(streams).toEqual([]); + }); +}); From 1a92e95f964c275f029bb02639ca7f6a71006c4d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 22:59:44 +0100 Subject: [PATCH 010/107] refactor(xls-codec): compile formula rgce bytes with an explicit worklist, not recursion compileNode walked a formula's own AST by native recursion, one JavaScript call frame per operator -- a long but legitimate chain of many thousands of binary operators (a generated SUM(...)+SUM(...)+... expression, say) would overflow the stack at a tree depth far shallower than MAX_RGCE_LENGTH's own 65535-byte ceiling ever needs throwing for. Rewrites it as an iterative post-order walk over an explicit worklist held on the heap, so compiling degrades gracefully to that ceiling's own BiffWriteError instead of an uncontrolled RangeError. Also removes two conditions from numberNode that can never affect its result: NUMBER_RE never captures a sign or a non-digit character, so a token already matching the plain-digit form always parses to a non-negative whole number regardless of magnitude, making Number.isInteger(value) and value >= 0 restate a fact the regex already established rather than narrow it further. Finally, replaces the parser's own "token stream ran past its own end" throw -- unreachable, since tokenize() always appends one trailing eof token and advance() only ever fires once the current token is confirmed non-eof -- with a fallback to that same shared eof token, avoiding both a untested throw and (this package's non-null assertions are lint errors) a type assertion to state the invariant instead. --- packages/xls-codec/src/biff/ptg-writer.ts | 101 +++++++++++++++++----- 1 file changed, 78 insertions(+), 23 deletions(-) diff --git a/packages/xls-codec/src/biff/ptg-writer.ts b/packages/xls-codec/src/biff/ptg-writer.ts index 0b5e291aab..62ac8f5ddf 100644 --- a/packages/xls-codec/src/biff/ptg-writer.ts +++ b/packages/xls-codec/src/biff/ptg-writer.ts @@ -117,6 +117,9 @@ const OPERATORS: readonly string[] = [ "%", ]; +// The one trailing sentinel every tokenize() call appends, shared rather than a fresh literal per call so FormulaParser.peek() can fall back to this exact value (see its own comment) without introducing a second, untested "eof"/"" literal of its own -- every assertion this module's own tests make about eof handling exercises this identical object via the real (non-fallback) path below. +const EOF_TOKEN: Token = { type: "eof", text: "" }; + function tokenize(text: string): Token[] { const tokens: Token[] = []; let index = 0; @@ -210,7 +213,7 @@ function tokenize(text: string): Token[] { `formula text ${JSON.stringify(text)} carries an unrecognised character ${JSON.stringify(char)} at offset ${index}`, ); } - tokens.push({ type: "eof", text: "" }); + tokens.push(EOF_TOKEN); return tokens; } @@ -266,14 +269,9 @@ class FormulaParser { this.sourceText = sourceText; } + // tokenize() always appends EOF_TOKEN as the array's own last element, and every advance() call site is gated behind a check that the CURRENT token (from this same peek()) is a specific non-eof type -- so position + offset never steps past that trailing token, and the one call site passing offset 1 (parsePrimary's word-lookahead) only does so once the current token is already confirmed not to be eof. The `?? EOF_TOKEN` fallback is therefore never actually exercised by any formula this writer's own tokenizer can produce, but it costs no untested code of its own to state: it names the identical shared constant tokenize() itself would have placed there, already covered by this module's own tests of eof handling. private peek(offset = 0): Token { - const token = this.tokens[this.position + offset]; - if (token === undefined) { - throw new BiffWriteError( - `internal error: formula token stream for ${JSON.stringify(this.sourceText)} ran past its own end`, - ); - } - return token; + return this.tokens[this.position + offset] ?? EOF_TOKEN; } private advance(): Token { @@ -457,12 +455,8 @@ class FormulaParser { private numberNode(text: string): FormulaNode { const value = Number.parseFloat(text); - if ( - /^[0-9]+$/.test(text) && - Number.isInteger(value) && - value >= 0 && - value <= PTG_INT_MAX - ) { + // NUMBER_RE never captures a sign or a leading digit outside 0-9, so `text` matching this plain-digit form always parseFloats to a non-negative whole number regardless of magnitude -- Number.isInteger(value) and value >= 0 would therefore always be true whenever this regex already is, and checking them again would only ever restate that fact, never narrow it further. + if (/^[0-9]+$/.test(text) && value <= PTG_INT_MAX) { return { kind: "int", value }; } return { kind: "num", value }; @@ -578,7 +572,19 @@ function columnField(point: CellPoint): number { ); } -function compileNode(builder: RgceBuilder, node: FormulaNode): void { +type ParentNode = FormulaNode & { + readonly kind: "binary" | "unary" | "percent" | "paren" | "call"; +}; + +/** A worklist entry: "visit" pushes a node's own children (deepest first, so they pop and compile before it), or -- for a leaf with no children -- compiles it immediately; "emit" compiles a parent node's own opcode(s) once every child a prior "visit" of it pushed has already been popped and compiled. */ +type CompileStep = + | { readonly phase: "visit"; readonly node: FormulaNode } + | { readonly phase: "emit"; readonly node: ParentNode }; + +function compileLeaf( + builder: RgceBuilder, + node: Exclude, +): void { switch (node.kind) { case "int": builder.push(PTG_INT).u16(node.value); @@ -612,27 +618,24 @@ function compileNode(builder: RgceBuilder, node: FormulaNode): void { .u16(columnField(node.start)) .u16(columnField(node.end)); return; + } +} + +function compileParent(builder: RgceBuilder, node: ParentNode): void { + switch (node.kind) { case "binary": - compileNode(builder, node.left); - compileNode(builder, node.right); builder.push(node.opcode); return; case "unary": - compileNode(builder, node.operand); builder.push(node.opcode); return; case "percent": - compileNode(builder, node.operand); builder.push(PTG_PERCENT); return; case "paren": - compileNode(builder, node.inner); builder.push(PTG_PAREN); return; case "call": - for (const arg of node.args) { - compileNode(builder, arg); - } if (node.variable) { if (node.args.length > 0xff) { throw new BiffWriteError( @@ -647,6 +650,58 @@ function compileNode(builder: RgceBuilder, node: FormulaNode): void { } } +function isParentNode(node: FormulaNode): node is ParentNode { + return ( + node.kind === "binary" || + node.kind === "unary" || + node.kind === "percent" || + node.kind === "paren" || + node.kind === "call" + ); +} + +/** + * Compiles a FormulaNode tree to rgce bytes, postfix (reverse Polish) exactly as biff/ptg.ts's own reader expects to walk it -- an explicit worklist rather than a native recursive descent, so a formula built from many thousands of chained operators (a long but legitimate generated SUM(...)+SUM(...)+... chain, say) compiles by iterating this loop rather than by nesting one JavaScript call frame per operator, which would risk a stack overflow at a tree depth far shallower than MAX_RGCE_LENGTH's own byte ceiling below ever requires throwing for. + */ +function compileNode(builder: RgceBuilder, root: FormulaNode): void { + const steps: CompileStep[] = [{ phase: "visit", node: root }]; + for (;;) { + const step = steps.pop(); + if (step === undefined) { + // The worklist is empty: every node visited pushed exactly the steps needed to compile it, and every one of those has now itself been popped and processed, so compilation is complete. + return; + } + if (step.phase === "emit") { + compileParent(builder, step.node); + continue; + } + const { node } = step; + if (!isParentNode(node)) { + compileLeaf(builder, node); + continue; + } + steps.push({ phase: "emit", node }); + switch (node.kind) { + case "binary": + steps.push({ phase: "visit", node: node.right }); + steps.push({ phase: "visit", node: node.left }); + break; + case "unary": + case "percent": + steps.push({ phase: "visit", node: node.operand }); + break; + case "paren": + steps.push({ phase: "visit", node: node.inner }); + break; + case "call": + for (const arg of [...node.args].reverse()) { + steps.push({ phase: "visit", node: arg }); + } + break; + } + } +} + /** [MS-XLS] 2.5.198.3's own cce ceiling: a two-byte length field, so rgce itself can never exceed this regardless of the 8224-byte whole-record limit biff/record-writer.ts already enforces. */ const MAX_RGCE_LENGTH = 0xffff; From 9e34a2583555a35f92f27188c0d6b105ecf274c5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:00:03 +0100 Subject: [PATCH 011/107] test(xls-codec): cover compileFormulaText's tokenizer, parser, and compiler directly Adds a dedicated ptg-writer.test.ts, previously exercised only incidentally through cell/data-validation/conditional-format formulas elsewhere in the package: every whitespace character, integer/decimal/exponent number literals either side of the PtgInt/PtgNum boundary, PtgNum's own little-endian float encoding, string literals including doubled-quote escaping and the unterminated-string error, all eight BIFF8 error literals and the two ways an error literal can be malformed, TRUE/FALSE, every comparison/arithmetic/unary/percent operator and their precedence against each other, cell and area references across every combination of $-absolute flags and both grid boundaries (row and column, upper and lower), fixed- and variable-arity function calls including omitted arguments and the 255-argument PtgFuncVar ceiling, and the writer's own error messages for a malformed formula at every stage from tokenizing through compiling. --- .../xls-codec/src/biff/ptg-writer.test.ts | 680 ++++++++++++++++++ 1 file changed, 680 insertions(+) create mode 100644 packages/xls-codec/src/biff/ptg-writer.test.ts diff --git a/packages/xls-codec/src/biff/ptg-writer.test.ts b/packages/xls-codec/src/biff/ptg-writer.test.ts new file mode 100644 index 0000000000..3073f4f1c1 --- /dev/null +++ b/packages/xls-codec/src/biff/ptg-writer.test.ts @@ -0,0 +1,680 @@ +import { describe, expect, it } from "vitest"; + +import { compileFormulaText } from "./ptg-writer"; + +// PTG opcode values mirrored from ptg-writer.ts's own private constants ([MS-XLS] 2.5.198's own token enumeration) -- the module exports only compileFormulaText itself, so this is the reference the byte-level assertions below check the writer's actual output against. +const PTG_ADD = 0x03; +const PTG_SUB = 0x04; +const PTG_MUL = 0x05; +const PTG_DIV = 0x06; +const PTG_POWER = 0x07; +const PTG_CONCAT = 0x08; +const PTG_LT = 0x09; +const PTG_LE = 0x0a; +const PTG_EQ = 0x0b; +const PTG_GE = 0x0c; +const PTG_GT = 0x0d; +const PTG_NE = 0x0e; +const PTG_UPLUS = 0x12; +const PTG_UMINUS = 0x13; +const PTG_PERCENT = 0x14; +const PTG_PAREN = 0x15; +const PTG_MISSARG = 0x16; +const PTG_STR = 0x17; +const PTG_ERR = 0x1c; +const PTG_BOOL = 0x1d; +const PTG_INT = 0x1e; +const PTG_NUM = 0x1f; +const PTG_REF_VALUE = 0x44; +const PTG_AREA_VALUE = 0x45; +const PTG_FUNC_VALUE = 0x41; +const PTG_FUNCVAR_VALUE = 0x42; + +const COLUMN_RELATIVE_BIT = 0x4000; +const ROW_RELATIVE_BIT = 0x8000; + +const PTG_INT_MAX = 0xffff; +const MAX_RGCE_LENGTH = 0xffff; + +function u16le(value: number): readonly number[] { + const bits = value & 0xffff; + return [bits & 0xff, (bits >>> 8) & 0xff]; +} + +function f64le(value: number): readonly number[] { + const buffer = new ArrayBuffer(8); + new DataView(buffer).setFloat64(0, value, true); + return Array.from(new Uint8Array(buffer)); +} + +function compiled(text: string): number[] { + return Array.from(compileFormulaText(text)); +} + +describe("compileFormulaText", () => { + describe("whitespace", () => { + it("skips a leading space", () => { + expect(compiled(" 1")).toEqual(compiled("1")); + }); + + it("skips a leading tab", () => { + expect(compiled("\t1")).toEqual(compiled("1")); + }); + + it("skips a leading newline", () => { + expect(compiled("\n1")).toEqual(compiled("1")); + }); + + it("skips a leading carriage return", () => { + expect(compiled("\r1")).toEqual(compiled("1")); + }); + + it("skips whitespace of every kind between tokens", () => { + expect(compiled(" 1 \t+\n2\r ")).toEqual(compiled("1+2")); + }); + }); + + describe("integer literals", () => { + it("compiles a plain integer as PtgInt", () => { + expect(compiled("42")).toEqual([PTG_INT, ...u16le(42)]); + }); + + it("compiles zero as PtgInt", () => { + expect(compiled("0")).toEqual([PTG_INT, ...u16le(0)]); + }); + + it("compiles PTG_INT_MAX itself as PtgInt", () => { + expect(compiled(String(PTG_INT_MAX))).toEqual([ + PTG_INT, + ...u16le(PTG_INT_MAX), + ]); + }); + + it("compiles one above PTG_INT_MAX as PtgNum, not PtgInt", () => { + const value = PTG_INT_MAX + 1; + expect(compiled(String(value))).toEqual([PTG_NUM, ...f64le(value)]); + }); + + it("compiles a decimal literal as PtgNum even when its value is a whole number", () => { + expect(compiled("4.0")).toEqual([PTG_NUM, ...f64le(4)]); + }); + + it("compiles a fractional literal as PtgNum", () => { + expect(compiled("3.14159")).toEqual([PTG_NUM, ...f64le(3.14159)]); + }); + + it("compiles an exponent literal as PtgNum", () => { + expect(compiled("1e3")).toEqual([PTG_NUM, ...f64le(1000)]); + }); + + it("writes PtgNum's own float64 in little-endian byte order", () => { + // 3.14159's IEEE 754 double is not byte-palindromic, so a big-endian writer would produce a different byte sequence than f64le's own little-endian reference encoding. + expect(compiled("3.14159").slice(1)).toEqual(f64le(3.14159)); + }); + }); + + describe("string literals", () => { + it("compiles a plain string as PtgStr", () => { + expect(compiled('"hi"')).toEqual([ + PTG_STR, + 2, + 0x00, + "h".charCodeAt(0), + "i".charCodeAt(0), + ]); + }); + + it("compiles the empty string", () => { + expect(compiled('""')).toEqual([PTG_STR, 0, 0x00]); + }); + + it("un-escapes a doubled quote inside a string literal", () => { + expect(compiled('"it""s"')).toEqual([ + PTG_STR, + 4, + 0x00, + ...'it"s'.split("").map((c) => c.charCodeAt(0)), + ]); + }); + + it("refuses an unterminated string literal", () => { + expect(() => compileFormulaText('"abc')).toThrow( + /carries an unterminated string literal starting at offset 0/, + ); + }); + + it("refuses a string literal whose closing quote is doubled off the end", () => { + expect(() => compileFormulaText('"abc""')).toThrow( + /carries an unterminated string literal/, + ); + }); + }); + + describe("boolean literals", () => { + it("compiles TRUE as PtgBool true", () => { + expect(compiled("TRUE")).toEqual([PTG_BOOL, 1]); + }); + + it("compiles FALSE as PtgBool false", () => { + expect(compiled("FALSE")).toEqual([PTG_BOOL, 0]); + }); + }); + + describe("error literals", () => { + it("compiles a recognised error literal as PtgErr", () => { + // #REF! is BIFF8's own 0x17 error code ([MS-XLS] 2.5.10). + expect(compiled("#REF!")).toEqual([PTG_ERR, 0x17]); + }); + + it("compiles every one of BIFF8's eight error literals to its own documented code", () => { + const expected: readonly (readonly [string, number])[] = [ + ["#NULL!", 0x00], + ["#DIV/0!", 0x07], + ["#VALUE!", 0x0f], + ["#REF!", 0x17], + ["#NAME?", 0x1d], + ["#NUM!", 0x24], + ["#N/A", 0x2a], + ["#GETTING_DATA", 0x2b], + ]; + for (const [text, code] of expected) { + expect(compiled(text)).toEqual([PTG_ERR, code]); + } + }); + + it("refuses an error literal outside the eight BIFF8 defines", () => { + expect(() => compileFormulaText("#FOO!")).toThrow( + /carries error literal #FOO!, which is not one of the eight error values/, + ); + }); + + it("refuses a # not followed by a recognised error-literal character", () => { + expect(() => compileFormulaText("#")).toThrow( + /carries an unrecognised error literal starting at offset 0/, + ); + }); + }); + + describe("unrecognised input", () => { + it("refuses a character outside the formula grammar", () => { + expect(() => compileFormulaText("1@2")).toThrow( + /carries an unrecognised character "@" at offset 1/, + ); + }); + + it("refuses an empty formula, citing the eof token's own empty text", () => { + expect(() => compileFormulaText("")).toThrow( + 'formula text "" carries an unexpected token ""', + ); + }); + + it("refuses a formula starting with an operator with no left operand", () => { + expect(() => compileFormulaText("*3")).toThrow( + /carries an unexpected token "\*"/, + ); + }); + }); + + describe("leftover tokens after a complete expression", () => { + it("reports a leftover dollar sign", () => { + expect(() => compileFormulaText("1$")).toThrow( + /expected a eof but found "\$"/, + ); + }); + + it("reports a leftover colon", () => { + expect(() => compileFormulaText("1:")).toThrow( + /expected a eof but found ":"/, + ); + }); + + it("reports a leftover comma", () => { + expect(() => compileFormulaText("1,")).toThrow( + /expected a eof but found ","/, + ); + }); + + it("reports a leftover open parenthesis", () => { + expect(() => compileFormulaText("1(")).toThrow( + /expected a eof but found "\("/, + ); + }); + + it("reports a leftover close parenthesis", () => { + expect(() => compileFormulaText("1)")).toThrow( + /expected a eof but found "\)"/, + ); + }); + }); + + describe("cell references", () => { + it("compiles a fully relative reference", () => { + expect(compiled("A1")).toEqual([ + PTG_REF_VALUE, + ...u16le(0), + ...u16le(0 | COLUMN_RELATIVE_BIT | ROW_RELATIVE_BIT), + ]); + }); + + it("compiles a fully absolute reference", () => { + expect(compiled("$A$1")).toEqual([ + PTG_REF_VALUE, + ...u16le(0), + ...u16le(0), + ]); + }); + + it("compiles a column-absolute, row-relative reference", () => { + expect(compiled("$A1")).toEqual([ + PTG_REF_VALUE, + ...u16le(0), + ...u16le(0 | ROW_RELATIVE_BIT), + ]); + }); + + it("compiles a column-relative, row-absolute reference", () => { + expect(compiled("A$1")).toEqual([ + PTG_REF_VALUE, + ...u16le(0), + ...u16le(0 | COLUMN_RELATIVE_BIT), + ]); + }); + + it("resolves a multi-letter column and multi-digit row", () => { + // BC77: column "BC" is 0-indexed 54 ((1*26)+2), row 77 is 0-indexed 76. + expect(compiled("BC77")).toEqual([ + PTG_REF_VALUE, + ...u16le(76), + ...u16le(54 | COLUMN_RELATIVE_BIT | ROW_RELATIVE_BIT), + ]); + }); + + it("compiles a relative area (range)", () => { + expect(compiled("A1:B2")).toEqual([ + PTG_AREA_VALUE, + ...u16le(0), + ...u16le(1), + ...u16le(0 | COLUMN_RELATIVE_BIT | ROW_RELATIVE_BIT), + ...u16le(1 | COLUMN_RELATIVE_BIT | ROW_RELATIVE_BIT), + ]); + }); + + it("compiles an area whose two corners carry independent absolute/relative flags", () => { + expect(compiled("$A$1:B2")).toEqual([ + PTG_AREA_VALUE, + ...u16le(0), + ...u16le(1), + ...u16le(0), + ...u16le(1 | COLUMN_RELATIVE_BIT | ROW_RELATIVE_BIT), + ]); + }); + + it("accepts column IV (0-indexed 255), BIFF8's own last column", () => { + expect(() => compileFormulaText("IV1")).not.toThrow(); + }); + + it("refuses column IW (0-indexed 256), one past BIFF8's own grid", () => { + expect(() => compileFormulaText("IW1")).toThrow( + /outside BIFF8's own grid/, + ); + }); + + it("accepts row 65536 (0-indexed 65535), BIFF8's own last row", () => { + expect(() => compileFormulaText("A65536")).not.toThrow(); + }); + + it("refuses row 65537 (0-indexed 65536), one past BIFF8's own grid", () => { + expect(() => compileFormulaText("A65537")).toThrow( + /outside BIFF8's own grid/, + ); + }); + + it("refuses row 0 (0-indexed -1), one below BIFF8's own grid", () => { + expect(() => compileFormulaText("A0")).toThrow( + /outside BIFF8's own grid/, + ); + }); + + it("states the grid's own row ceiling as MAX_ROW_INDEX + 1 in its error message", () => { + expect(() => compileFormulaText("A65537")).toThrow( + /rows 1-65536, columns A-IV/, + ); + }); + + it("refuses a word with more letters than a valid column can carry", () => { + expect(() => compileFormulaText("ABCD1")).toThrow( + /carries "ABCD1", which is not a valid cell reference/, + ); + }); + + it("refuses a decimal row number", () => { + expect(() => compileFormulaText("A$1.5")).toThrow( + /carries "A1\.5", which is not a valid cell reference/, + ); + }); + + it("refuses a dollar sign not followed by a word", () => { + expect(() => compileFormulaText("$1")).toThrow( + /expected a word but found "1"/, + ); + }); + }); + + describe("operators", () => { + it.each([ + ["<", PTG_LT], + ["<=", PTG_LE], + ["=", PTG_EQ], + [">=", PTG_GE], + [">", PTG_GT], + ["<>", PTG_NE], + ] as const)("compiles the %s comparison operator", (op, opcode) => { + expect(compiled(`1${op}2`)).toEqual([ + PTG_INT, + ...u16le(1), + PTG_INT, + ...u16le(2), + opcode, + ]); + }); + + it("compiles string concatenation", () => { + expect(compiled("1&2")).toEqual([ + PTG_INT, + ...u16le(1), + PTG_INT, + ...u16le(2), + PTG_CONCAT, + ]); + }); + + it.each([ + ["+", PTG_ADD], + ["-", PTG_SUB], + ] as const)("compiles the binary %s operator", (op, opcode) => { + expect(compiled(`1${op}2`)).toEqual([ + PTG_INT, + ...u16le(1), + PTG_INT, + ...u16le(2), + opcode, + ]); + }); + + it.each([ + ["*", PTG_MUL], + ["/", PTG_DIV], + ] as const)("compiles the %s operator", (op, opcode) => { + expect(compiled(`1${op}2`)).toEqual([ + PTG_INT, + ...u16le(1), + PTG_INT, + ...u16le(2), + opcode, + ]); + }); + + it("compiles exponentiation", () => { + expect(compiled("1^2")).toEqual([ + PTG_INT, + ...u16le(1), + PTG_INT, + ...u16le(2), + PTG_POWER, + ]); + }); + + it("compiles a trailing percent operator", () => { + expect(compiled("1%")).toEqual([PTG_INT, ...u16le(1), PTG_PERCENT]); + }); + + it.each([ + ["+", PTG_UPLUS], + ["-", PTG_UMINUS], + ] as const)("compiles a unary %s operator", (op, opcode) => { + expect(compiled(`${op}1`)).toEqual([PTG_INT, ...u16le(1), opcode]); + }); + + it("compiles explicit parentheses", () => { + expect(compiled("(1+2)")).toEqual([ + PTG_INT, + ...u16le(1), + PTG_INT, + ...u16le(2), + PTG_ADD, + PTG_PAREN, + ]); + }); + }); + + describe("precedence", () => { + it("binds unary minus tighter than exponentiation", () => { + expect(compiled("-1^2")).toEqual([ + PTG_INT, + ...u16le(1), + PTG_UMINUS, + PTG_INT, + ...u16le(2), + PTG_POWER, + ]); + }); + + it("binds additive operators tighter than concatenation", () => { + expect(compiled("1&2+3")).toEqual([ + PTG_INT, + ...u16le(1), + PTG_INT, + ...u16le(2), + PTG_INT, + ...u16le(3), + PTG_ADD, + PTG_CONCAT, + ]); + }); + + it("binds multiplicative operators tighter than additive", () => { + expect(compiled("1+2*3")).toEqual([ + PTG_INT, + ...u16le(1), + PTG_INT, + ...u16le(2), + PTG_INT, + ...u16le(3), + PTG_MUL, + PTG_ADD, + ]); + }); + + it("binds exponentiation tighter than multiplication", () => { + expect(compiled("2*3^2")).toEqual([ + PTG_INT, + ...u16le(2), + PTG_INT, + ...u16le(3), + PTG_INT, + ...u16le(2), + PTG_POWER, + PTG_MUL, + ]); + }); + + it("binds percent tighter than exponentiation", () => { + expect(compiled("2^3%")).toEqual([ + PTG_INT, + ...u16le(2), + PTG_INT, + ...u16le(3), + PTG_PERCENT, + PTG_POWER, + ]); + }); + + it("binds comparison operators looser than every arithmetic operator", () => { + expect(compiled("1+2<3*4")).toEqual([ + PTG_INT, + ...u16le(1), + PTG_INT, + ...u16le(2), + PTG_ADD, + PTG_INT, + ...u16le(3), + PTG_INT, + ...u16le(4), + PTG_MUL, + PTG_LT, + ]); + }); + }); + + describe("function calls", () => { + it("compiles a fixed-arity call as PtgFunc", () => { + // ABS is [MS-XLS] 2.5.198.17's own Ftab entry 0x0018, fixed arity 1. + expect(compiled("ABS(1)")).toEqual([ + PTG_INT, + ...u16le(1), + PTG_FUNC_VALUE, + ...u16le(0x0018), + ]); + }); + + it("compiles a zero-argument fixed-arity call", () => { + // PI is Ftab entry 0x0013, fixed arity 0. + expect(compiled("PI()")).toEqual([PTG_FUNC_VALUE, ...u16le(0x0013)]); + }); + + it("compiles a variable-arity call with no arguments as PtgFuncVar", () => { + // SUM is Ftab entry 0x0004, variable arity. + expect(compiled("SUM()")).toEqual([ + PTG_FUNCVAR_VALUE, + 0, + ...u16le(0x0004), + ]); + }); + + it("compiles a variable-arity call with several arguments", () => { + expect(compiled("SUM(1,2,3)")).toEqual([ + PTG_INT, + ...u16le(1), + PTG_INT, + ...u16le(2), + PTG_INT, + ...u16le(3), + PTG_FUNCVAR_VALUE, + 3, + ...u16le(0x0004), + ]); + }); + + it("compiles an omitted middle argument as PtgMissArg", () => { + expect(compiled("IF(1,,3)")).toEqual([ + PTG_INT, + ...u16le(1), + PTG_MISSARG, + PTG_INT, + ...u16le(3), + // IF is Ftab entry 0x0001, variable arity. + PTG_FUNCVAR_VALUE, + 3, + ...u16le(0x0001), + ]); + }); + + it("compiles an omitted leading argument as PtgMissArg", () => { + expect(compiled("IF(,1,2)")).toEqual([ + PTG_MISSARG, + PTG_INT, + ...u16le(1), + PTG_INT, + ...u16le(2), + PTG_FUNCVAR_VALUE, + 3, + ...u16le(0x0001), + ]); + }); + + it("compiles an omitted trailing argument as PtgMissArg", () => { + expect(compiled("IF(1,2,)")).toEqual([ + PTG_INT, + ...u16le(1), + PTG_INT, + ...u16le(2), + PTG_MISSARG, + PTG_FUNCVAR_VALUE, + 3, + ...u16le(0x0001), + ]); + }); + + it("refuses a call to a function outside Ftab's own vocabulary", () => { + expect(() => compileFormulaText("NOTAREALFUNCTION(1)")).toThrow( + /calls NOTAREALFUNCTION\(\), which is not one of the built-in functions/, + ); + }); + + it("refuses a fixed-arity call given the wrong number of arguments", () => { + expect(() => compileFormulaText("ABS(1,2)")).toThrow( + /calls ABS\(\) with 2 argument\(s\), but \[MS-XLS\]'s own Ftab grammar fixes its arity at 1/, + ); + }); + + it("accepts a variable-arity call with exactly 255 arguments", () => { + const formula = `SUM(${Array.from({ length: 255 }, () => "1").join(",")})`; + expect(() => compileFormulaText(formula)).not.toThrow(); + }); + + it("refuses a variable-arity call with 256 arguments, one past PtgFuncVar's own single-byte cparams", () => { + const formula = `SUM(${Array.from({ length: 256 }, () => "1").join(",")})`; + expect(() => compileFormulaText(formula)).toThrow( + /a function call with 256 arguments cannot be written.*cannot exceed 255/, + ); + }); + }); + + describe("nested function calls", () => { + it("compiles a function call nested inside another as an argument", () => { + expect(compiled('IF(1=1,"yes","no")')).toEqual([ + PTG_INT, + ...u16le(1), + PTG_INT, + ...u16le(1), + PTG_EQ, + PTG_STR, + 3, + 0x00, + ..."yes".split("").map((c) => c.charCodeAt(0)), + PTG_STR, + 2, + 0x00, + ..."no".split("").map((c) => c.charCodeAt(0)), + PTG_FUNCVAR_VALUE, + 3, + ...u16le(0x0001), + ]); + }); + }); + + describe("rgce length ceiling", () => { + // Every additional "+1" term after the first costs 4 bytes (a 3-byte PtgInt operand plus a 1-byte PtgAdd), and the first term alone costs 3 bytes -- so a chain of 16384 terms compiles to exactly 3 + 4*(16384-1) = 65535 bytes, MAX_RGCE_LENGTH itself, and 16385 terms compiles to 65539, four bytes over it. + const TERMS_AT_CEILING = 16384; + + function chainOf(termCount: number): string { + return Array.from({ length: termCount }, () => "1").join("+"); + } + + it("accepts a formula compiling to exactly MAX_RGCE_LENGTH bytes", () => { + const formula = chainOf(TERMS_AT_CEILING); + let rgce: Uint8Array | undefined; + expect(() => { + rgce = compileFormulaText(formula); + }).not.toThrow(); + expect(rgce?.length).toBe(MAX_RGCE_LENGTH); + }); + + it("refuses a formula compiling to one term more than the ceiling allows", () => { + const formula = chainOf(TERMS_AT_CEILING + 1); + expect(() => compileFormulaText(formula)).toThrow( + /compiles to 65539 bytes of rgce, above the 65535-byte ceiling/, + ); + }); + }); +}); From b27e432c28136a640c7a5d98f0a68fcfabbdaeae Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:08:03 +0100 Subject: [PATCH 012/107] refactor(xls-codec): replace unreachable map-lookup guards with exhaustive switches CP_BY_OPERATOR, SIMPLE_KIND_TO_ICF_TEMPLATE, and CTP_BY_TEXT_TYPE were each a ReadonlyMap covering every member of a closed string-literal union, with an "operator/rule type has no value" throw guarding a Map.get() miss that can never actually happen given the union those types already close over -- but a Map's own .get() always types its result as possibly undefined regardless of how completely its literal entries cover the key type, so the throw stayed live and untestable. Replaces each with a real exhaustive switch statement instead, which the compiler itself checks covers every union member, so the impossible branch and its message are gone rather than merely unreachable. cfvoTypeCodeOf, already a switch, drops its own equivalent default case the same way. Also removes two structurally unreachable "internal error" throws by threading validated data instead of re-deriving it: validateRuleGrid now returns a rule's ranges narrowed to a provably non-empty tuple rather than void, so its own caller can pass a rule's real first range down to writeCf12Record/textRuleFormula as a plain required parameter instead of that function re-indexing rule.ranges[0] and guarding against an absence validateRuleGrid, called moments earlier on the same rule, already rules out. assignPriorities now returns each CF12 rule zipped together with its own resolved ipriority, rather than a same-length array of bare numbers the caller re-correlated to its own rule list by array index -- removing the "fewer priorities than rules" throw that guarded against the two arrays ever silently drifting out of step, since there is no longer a second array to drift. --- .../src/workbook/conditional-format-write.ts | 195 +++++++++--------- 1 file changed, 97 insertions(+), 98 deletions(-) diff --git a/packages/xls-codec/src/workbook/conditional-format-write.ts b/packages/xls-codec/src/workbook/conditional-format-write.ts index a6d593167f..b97160e4aa 100644 --- a/packages/xls-codec/src/workbook/conditional-format-write.ts +++ b/packages/xls-codec/src/workbook/conditional-format-write.ts @@ -4,6 +4,7 @@ import type { ContentSheetConditionalFormat, ContentSheetConditionalFormatStyle, ContentSheetConditionalFormatValue, + ContentSheetRange, } from "document-schema.js"; import { BiffWriteError } from "../biff/write-errors"; import { RecordBuilder } from "../biff/builder"; @@ -19,16 +20,27 @@ import type { SheetRuleOperator } from "document-schema.js"; // The write-side inverse of conditional-format.ts's readCondFmtGroup/readCf and conditional-format-12.ts's readCondFmt12Group/readCf12: a 'cellIs' rule writes as one CondFmt record ([MS-XLS] 2.4.56) carrying exactly one CF record ([MS-XLS] 2.4.42) -- the schema models a rule's ranges per rule, so there is nothing to group the way a multi-rule CondFmt would -- while every other variant the schema models writes as one CondFmt12 ([MS-XLS] 2.4.57) carrying exactly one CF12 ([MS-XLS] 2.4.43), the CF12-era spelling those rule types have no base-CF record for at all. The two families are emitted base-first within one sheet's CONDFMTS section, whose own ABNF (`*(CONDFMT / CONDFMT12) *(CFEx [CF12])`, [MS-XLS] 2.1.7.20.6) admits them interleaved or grouped; the CFEx compatibility spelling -- a legacy CF-plus-extension pair keeping a pre-2007 Excel able to evaluate the rule -- is deliberately not written, the CF12 spelling being the one this package's own reader resolves either way. -const CP_BY_OPERATOR: ReadonlyMap = new Map([ - ["between", 0x1], - ["notBetween", 0x2], - ["equal", 0x3], - ["notEqual", 0x4], - ["greaterThan", 0x5], - ["lessThan", 0x6], - ["greaterThanOrEqual", 0x7], - ["lessThanOrEqual", 0x8], -]); +// The CF record's own cp table ([MS-XLS] 2.5.16's own Cpt), the inverse of conditional-format.ts's OPERATOR_BY_CP -- a real exhaustive switch over SheetRuleOperator's closed eight-member union rather than a Map, so the compiler itself proves every operator has a cp value and this never needs an "operator has no cp" fallback to guard a lookup that cannot miss. +function cpOf(operator: SheetRuleOperator): number { + switch (operator) { + case "between": + return 0x1; + case "notBetween": + return 0x2; + case "equal": + return 0x3; + case "notEqual": + return 0x4; + case "greaterThan": + return 0x5; + case "lessThan": + return 0x6; + case "greaterThanOrEqual": + return 0x7; + case "lessThanOrEqual": + return 0x8; + } +} // DXFFNTD's own fixed length ([MS-XLS] 2.4.97), mirrored from parseDxfStyle's own constant: everything this writer states in the block is zero but icvFore. const DXFFNTD_LENGTH = 122; @@ -76,12 +88,7 @@ function writeCfRecord( rule: Extract, icvOf: (color: Color) => number, ): Uint8Array { - const cp = CP_BY_OPERATOR.get(rule.operator); - if (cp === undefined) { - throw new BiffWriteError( - `conditional-format operator "${rule.operator}" has no CF cp value`, - ); - } + const cp = cpOf(rule.operator); const rgce1 = compileFormulaText(rule.formula1); const rgce2 = rule.formula2 !== undefined ? compileFormulaText(rule.formula2) : undefined; @@ -149,9 +156,7 @@ const ICF_TEMPLATE_BELOW_OR_EQUAL_AVERAGE = 0x001e; const TEMPLATE_PARAMS_SIZE = 16; // The CFVO type codes' inverse ([MS-XLS] 2.5.40's own cfvoType table), mirrored from conditional-format-12.ts's CFVO_TYPE_TO_VALUE_TYPE. -function cfvoTypeCodeOf( - value: ContentSheetConditionalFormatValue, -): number | undefined { +function cfvoTypeCodeOf(value: ContentSheetConditionalFormatValue): number { switch (value.type) { case "num": return 0x01; @@ -165,8 +170,6 @@ function cfvoTypeCodeOf( return 0x05; case "formula": return 0x07; - default: - return undefined; } } @@ -175,11 +178,6 @@ function writeCfvo( value: ContentSheetConditionalFormatValue, ): Uint8Array { const cfvoType = cfvoTypeCodeOf(value); - if (cfvoType === undefined) { - throw new BiffWriteError( - `internal error: ContentSheetConditionalFormatValue carries type "${value.type}", which cfvoTypeCodeOf has no [MS-XLS] 2.5.40 cfvoType code for`, - ); - } if (value.type === "min" || value.type === "max") { // A bound, not a value: no rgce, no numValue. return new RecordBuilder().u8(cfvoType).u16(0).build(); @@ -339,9 +337,9 @@ const TIME_PERIOD_TO_ICF_TEMPLATE: ReadonlyMap< ["thisMonth", 0x0018], ]); -// The operand-free family's icfTemplate values, the inverse of conditional-format-12.ts's SIMPLE_ICF_TEMPLATE_KIND: CFExDefaultTemplateParams is 16 reserved bytes, so the template value is the whole rule. -const SIMPLE_KIND_TO_ICF_TEMPLATE: ReadonlyMap< - Extract< +// The operand-free family's icfTemplate values, the inverse of conditional-format-12.ts's SIMPLE_ICF_TEMPLATE_KIND: CFExDefaultTemplateParams is 16 reserved bytes, so the template value is the whole rule. A real exhaustive switch over the six-member union rather than a Map, so the compiler proves every one of these rule types has an icfTemplate value. +function simpleKindIcfTemplate( + type: Extract< ContentSheetConditionalFormat, { type: @@ -353,29 +351,41 @@ const SIMPLE_KIND_TO_ICF_TEMPLATE: ReadonlyMap< | "duplicateValues"; } >["type"], - number -> = new Map([ - ["containsBlanks", ICF_TEMPLATE_CONTAINS_BLANKS], - ["notContainsBlanks", ICF_TEMPLATE_CONTAINS_NO_BLANKS], - ["containsErrors", ICF_TEMPLATE_CONTAINS_ERRORS], - ["notContainsErrors", ICF_TEMPLATE_CONTAINS_NO_ERRORS], - ["uniqueValues", ICF_TEMPLATE_UNIQUE_VALUES], - ["duplicateValues", ICF_TEMPLATE_DUPLICATE_VALUES], -]); +): number { + switch (type) { + case "containsBlanks": + return ICF_TEMPLATE_CONTAINS_BLANKS; + case "notContainsBlanks": + return ICF_TEMPLATE_CONTAINS_NO_BLANKS; + case "containsErrors": + return ICF_TEMPLATE_CONTAINS_ERRORS; + case "notContainsErrors": + return ICF_TEMPLATE_CONTAINS_NO_ERRORS; + case "uniqueValues": + return ICF_TEMPLATE_UNIQUE_VALUES; + case "duplicateValues": + return ICF_TEMPLATE_DUPLICATE_VALUES; + } +} -// ctp ([MS-XLS] 2.5.27's CFExTextTemplateParams table): which of the four text sub-types a containsText-family rule is, the inverse of conditional-format-12.ts's CTP_TO_TEXT_KIND. -const CTP_BY_TEXT_TYPE: ReadonlyMap< - Extract< +// ctp ([MS-XLS] 2.5.27's CFExTextTemplateParams table): which of the four text sub-types a containsText-family rule is, the inverse of conditional-format-12.ts's CTP_TO_TEXT_KIND. A real exhaustive switch rather than a Map, for the same reason simpleKindIcfTemplate above is. +function ctpOf( + type: Extract< ContentSheetConditionalFormat, { type: "containsText" | "notContainsText" | "beginsWith" | "endsWith" } >["type"], - number -> = new Map([ - ["containsText", 0x0000], - ["notContainsText", 0x0001], - ["beginsWith", 0x0002], - ["endsWith", 0x0003], -]); +): number { + switch (type) { + case "containsText": + return 0x0000; + case "notContainsText": + return 0x0001; + case "beginsWith": + return 0x0002; + case "endsWith": + return 0x0003; + } +} // The formula a text-predicate rule carries as its ct 0x02 condition: neither CFExTextTemplateParams nor CFFilter has anywhere to state the literal search text, so it lives only as the PtgStr operand of the formula itself -- written in the shape Excel's own rule generator and LibreOffice's own GetFixedFormula both produce (sc/source/filter/excel/xestyle... and xcl97... confirmed shapes; see conditional-format-12.ts's own top comment for the reader-side citation), referencing the rule's own first anchor cell relatively. The first string literal of each shape is the search text, which is exactly what the reader's extractFirstStringLiteral recovers. function textRuleFormula( @@ -383,13 +393,8 @@ function textRuleFormula( ContentSheetConditionalFormat, { type: "containsText" | "notContainsText" | "beginsWith" | "endsWith" } >, + anchor: ContentSheetRange, ): string { - const anchor = rule.ranges[0]; - if (anchor === undefined) { - throw new BiffWriteError( - "internal error: textRuleFormula was called for a rule whose ranges were never validated", - ); - } const cell = relativeCellRef(anchor.startRow, anchor.startColumn); // An Excel string literal escapes a double quote by doubling it -- the identical spelling biff/ptg-writer.ts's own tokenizer reads back -- so the literal is built here rather than through JSON.stringify, whose backslash escape has no meaning in a formula. const text = `"${rule.text.replaceAll('"', '""')}"`; @@ -421,6 +426,7 @@ function writeCf12Record( rule: Exclude, ipriority: number, icvOf: (color: Color) => number, + anchor: ContentSheetRange, ): Uint8Array { let ct: number; let icfTemplate: number; @@ -513,13 +519,7 @@ function writeCf12Record( case "uniqueValues": case "duplicateValues": { ct = CT_FILTER; - const template = SIMPLE_KIND_TO_ICF_TEMPLATE.get(rule.type); - if (template === undefined) { - throw new BiffWriteError( - `internal error: SIMPLE_KIND_TO_ICF_TEMPLATE has no icfTemplate for rule type "${rule.type}"`, - ); - } - icfTemplate = template; + icfTemplate = simpleKindIcfTemplate(rule.type); templateParams = new Uint8Array(TEMPLATE_PARAMS_SIZE); // CFExDefaultTemplateParams: 16 reserved bytes rgbCt = writeCfFilter(undefined); dxf = writeDxfn(rule.style, icvOf); @@ -532,26 +532,16 @@ function writeCf12Record( ct = CT_FORMULA; icfTemplate = ICF_TEMPLATE_CONTAINS_TEXT; // CFExTextTemplateParams ([MS-XLS] 2.5.27): ctp, then 14 reserved bytes. - const ctp = CTP_BY_TEXT_TYPE.get(rule.type); - if (ctp === undefined) { - throw new BiffWriteError( - `internal error: CTP_BY_TEXT_TYPE has no ctp for rule type "${rule.type}"`, - ); - } + const ctp = ctpOf(rule.type); templateParams = new RecordBuilder() .u16(ctp) .bytes(new Uint8Array(14)) .build(); // ct 0x02's condition is the formula itself; rgbCT MUST be omitted ([MS-XLS] 2.4.43's own ct table). - rgce1 = compileFormulaText(textRuleFormula(rule)); + rgce1 = compileFormulaText(textRuleFormula(rule, anchor)); dxf = writeDxfn(rule.style, icvOf); break; } - default: - // The switch above is exhaustive over the schema's own rule-type union, so this branch exists only to satisfy the definite-assignment analysis of the shared skeleton fields below. - throw new BiffWriteError( - "internal error: writeCf12Record was called for a rule type its own dispatch never names", - ); } const writer = new RecordBuilder() @@ -580,12 +570,24 @@ function writeCf12Record( return writeRecord(RECORD_CF12, writer.build()); } +interface Cf12RuleEntry { + readonly rule: Exclude; + readonly nID: number; + /** The rule's own first range, already validated non-empty by validateRuleGrid -- carried alongside rather than re-derived by index later, since a plain-array `ranges` field could otherwise only be indexed as possibly-undefined this far from where non-emptiness was actually checked. */ + readonly anchor: ContentSheetRange; +} + +/** A Cf12RuleEntry with its ipriority resolved alongside it -- returned zipped together, rather than as a same-length array of bare priority numbers the caller would need to re-correlate to `entries` by index, so there is nothing for a caller to get out of step with. */ +interface Cf12RuleEntryWithPriority extends Cf12RuleEntry { + readonly ipriority: number; +} + // ipriority MUST be unique across every CF12 record in the worksheet substream ([MS-XLS] 2.4.43). The schema's priority is optional, but the field is not, so a rule stating none is minted the smallest positive integer no other rule of the sheet took -- while two rules stating the same priority is a document whose own ordering contradicts itself, and is refused rather than silently renumbered. function assignPriorities( - rules: readonly Exclude[], -): number[] { + entries: readonly Cf12RuleEntry[], +): Cf12RuleEntryWithPriority[] { const used = new Set(); - for (const rule of rules) { + for (const { rule } of entries) { if (rule.priority === undefined) { continue; } @@ -596,16 +598,16 @@ function assignPriorities( } used.add(rule.priority); } - return rules.map((rule) => { - if (rule.priority !== undefined) { - return rule.priority; + return entries.map((entry) => { + if (entry.rule.priority !== undefined) { + return { ...entry, ipriority: entry.rule.priority }; } let candidate = 1; while (used.has(candidate)) { candidate += 1; } used.add(candidate); - return candidate; + return { ...entry, ipriority: candidate }; }); } @@ -638,8 +640,14 @@ function writeCondFmt12Record( return writeRecord(RECORD_CONDFMT12, header.build()); } -function validateRuleGrid(rule: ContentSheetConditionalFormat): void { - if (rule.ranges.length === 0) { +/** + * Validates every one of a rule's ranges against BIFF8's own grid, and returns them narrowed to a provably non-empty tuple -- the schema requires at least one range but types `ranges` as a plain array, so without this the one caller that needs a rule's own first range (textRuleFormula, via its own anchor parameter) would index into a `ContentSheetRange | undefined` for a case that can only ever arise from calling it on a rule this function was never run against first. + */ +function validateRuleGrid( + rule: ContentSheetConditionalFormat, +): readonly [ContentSheetRange, ...ContentSheetRange[]] { + const [first, ...rest] = rule.ranges; + if (first === undefined) { throw new BiffWriteError( "a conditional-format rule carrying no range states nothing; the schema requires at least one", ); @@ -656,6 +664,7 @@ function validateRuleGrid(rule: ContentSheetConditionalFormat): void { ); } } + return [first, ...rest]; } // [MS-XLS] 2.4.43 pins fStopIfTrue to zero for the three visual-scale rule types, so a stopIfTrue colour scale/data bar/icon set is a document the record vocabulary itself cannot state -- refused by name rather than written with the bit silently dropped, the identical refusal the four out-of-scope formula constructs already draw. @@ -690,12 +699,9 @@ export function writeSheetConditionalFormats( `this sheet's ${rules.length} conditional-format rules exceed CondFmt's own 15-bit nID field`, ); } - const cf12Rules: { - readonly rule: Exclude; - readonly nID: number; - }[] = []; + const cf12Rules: Cf12RuleEntry[] = []; rules.forEach((rule, index) => { - validateRuleGrid(rule); + const [anchor] = validateRuleGrid(rule); if (rule.type === "cellIs") { basePieces.push( writeCondFmtRecord(rule, index + 1), @@ -704,21 +710,14 @@ export function writeSheetConditionalFormats( return; } validateStopIfTrue(rule); - cf12Rules.push({ rule, nID: index + 1 }); + cf12Rules.push({ rule, nID: index + 1, anchor }); }); - const priorities = assignPriorities(cf12Rules.map(({ rule }) => rule)); - cf12Rules.forEach(({ rule, nID }, index) => { - const ipriority = priorities[index]; - if (ipriority === undefined) { - throw new BiffWriteError( - "internal error: assignPriorities returned fewer priorities than there are CF12 rules", - ); - } + for (const { rule, nID, anchor, ipriority } of assignPriorities(cf12Rules)) { cf12Pieces.push( writeCondFmt12Record(rule, nID), - writeCf12Record(rule, ipriority, icvOf), + writeCf12Record(rule, ipriority, icvOf, anchor), ); - }); + } // Base groups first, then the CF12 groups, both inside the one CONDFMTS run -- [MS-XLS] 2.1.7.20.6's own production (`*(CONDFMT / CONDFMT12)`) admits either grouping, and conditional-format.ts's reader walk is order-tolerant across the two families besides. return [...basePieces, ...cf12Pieces]; } From 2b9bed446ca1e641918deb6ef8536aabc232f8aa Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:12:55 +0100 Subject: [PATCH 013/107] test(xls-codec): cover conditional-format boolean flags and gradient bytes directly Adds the boolean-flag combinations the existing conditional-format round trips left only half-tested (each ternary needs both its own true and false input to distinguish it from a mutant that always takes one branch): a data bar's shown value, an icon set's hidden value with no reverse, a top10 rule selecting by count from the top, every aboveAverage/equalAverage combination, and a rule declaring stopIfTrue. Also adds a percentile- and formula-typed colour-scale stop, and the "threshold carries no value" refusal for a value-bearing CFVO type. Adds a new describe block calling writeSheetConditionalFormats directly to check two things no round trip through the reader can ever observe: that a style-less rule's own DXFN12 block writes cbDxf as a genuine 0 rather than a padded-but-still-empty block (the reader treats both identically, since it degrades on flag bits rather than data length), and that a colour scale's own fixed interpolation-position floats (0.0/1.0 for two stops, 0.0/0.5/1.0 for three) are the ones [MS-XLS] 2.5.33 itself pins per stop count rather than the other set -- values the reader skips over as unused padding and so never round-trips into anything observable. --- packages/xls-codec/src/write.test.ts | 200 +++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) diff --git a/packages/xls-codec/src/write.test.ts b/packages/xls-codec/src/write.test.ts index c2f93ca269..0be9945d7f 100644 --- a/packages/xls-codec/src/write.test.ts +++ b/packages/xls-codec/src/write.test.ts @@ -18,6 +18,7 @@ import { isCompoundFile, readCompoundFile } from "archive-codec"; import { describe, expect, it } from "vitest"; import { + RECORD_CF12, RECORD_CONTINUE, RECORD_EXTERNSHEET, RECORD_LBL, @@ -32,6 +33,7 @@ import type { XlsContentDocument } from "./content"; import { readXls, readXlsContent } from "./content"; import { isXlsFile } from "./container"; import { writeXls, writeXlsContent } from "./write"; +import { writeSheetConditionalFormats } from "./workbook/conditional-format-write"; // Genuine .xls bytes -- a real [MS-CFB] compound file holding a real BIFF8 Workbook stream -- built by this package's own writer and read back through its own reader, the "primary verification method" this session's writers use throughout (the CFB writer, rtf-codec, wpd-codec). Every test here is a round trip: build a ContentDocument, write it, read it back, and check the read result reflects what was written -- exercising the writer against a reader whose own correctness is independently pinned by content.test.ts's hand-built byte sequences. @@ -2343,4 +2345,202 @@ describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => { ...textRule, priority: 1 }, ]); }); + + it("round-trips a data bar whose value is shown (the non-default of the earlier hidden-value test)", () => { + const rule: ContentSheetConditionalFormat = { + type: "dataBar", + ranges: [RANGE], + min: { type: "min" }, + max: { type: "max" }, + color: { r: 1, g: 0, b: 0 }, + }; + expect(roundTripped(rule)).toEqual([{ ...rule, priority: 1 }]); + }); + + it("round-trips an icon set with showValue false and reverse absent (the opposite of the earlier reverse test)", () => { + const rule: ContentSheetConditionalFormat = { + type: "iconSet", + ranges: [RANGE], + iconSetType: "3Arrows", + showValue: false, + thresholds: [ + { type: "min" }, + { type: "percent", value: "50" }, + { type: "max" }, + ], + }; + expect(roundTripped(rule)).toEqual([{ ...rule, priority: 1 }]); + }); + + it("round-trips a top10 rule selecting from the top rather than the bottom, by count rather than percent", () => { + const rule: ContentSheetConditionalFormat = { + type: "top10", + ranges: [RANGE], + rank: 3, + }; + expect(roundTripped(rule)).toEqual([{ ...rule, priority: 1 }]); + }); + + it("round-trips every combination of aboveAverage/equalAverage", () => { + const aboveEqual: ContentSheetConditionalFormat = { + type: "aboveAverage", + ranges: [RANGE], + equalAverage: true, + }; + const belowNotEqual: ContentSheetConditionalFormat = { + type: "aboveAverage", + ranges: [RANGE], + aboveAverage: false, + }; + expect(roundTripped(aboveEqual)).toEqual([{ ...aboveEqual, priority: 1 }]); + expect(roundTripped(belowNotEqual)).toEqual([ + { ...belowNotEqual, priority: 1 }, + ]); + }); + + it("round-trips a rule declaring stopIfTrue", () => { + const rule: ContentSheetConditionalFormat = { + type: "top10", + ranges: [RANGE], + rank: 1, + stopIfTrue: true, + }; + expect(roundTripped(rule)).toEqual([{ ...rule, priority: 1 }]); + }); + + it("round-trips a colour-scale stop of every threshold kind, including percentile and formula", () => { + const rule: ContentSheetConditionalFormat = { + type: "colorScale", + ranges: [RANGE], + stops: [ + { + value: { type: "percentile", value: "10" }, + color: { r: 0, g: 0, b: 1 }, + }, + { + value: { type: "formula", value: "A1" }, + color: { r: 1, g: 0, b: 0 }, + }, + ], + }; + expect(roundTripped(rule)).toEqual([{ ...rule, priority: 1 }]); + }); + + it("refuses a threshold of a value-bearing type carrying no value", () => { + expect(() => + roundTripped({ + type: "colorScale", + ranges: [RANGE], + stops: [ + { value: { type: "percent" }, color: { r: 0, g: 0, b: 0 } }, + { value: { type: "max" }, color: { r: 1, g: 1, b: 1 } }, + ], + }), + ).toThrow(/carries no value/); + }); +}); + +describe("writeSheetConditionalFormats: bytes the reader never inspects (#971/#1186)", () => { + const RANGE = { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }; + const NO_ICV = (): number => 0; + + function cf12RecordDataOf(rule: ContentSheetConditionalFormat): Uint8Array { + const pieces = writeSheetConditionalFormats( + sheet("S", [], { conditionalFormats: [rule] }), + NO_ICV, + ); + const total = pieces.reduce((sum, piece) => sum + piece.length, 0); + const stream = new Uint8Array(total); + let offset = 0; + for (const piece of pieces) { + stream.set(piece, offset); + offset += piece.length; + } + const cf12 = readRecords(stream).find( + (record) => record.type === RECORD_CF12, + ); + if (cf12 === undefined) { + throw new Error("no CF12 record was written"); + } + return cf12.data; + } + + function u32At(data: Uint8Array, offset: number): number { + return new DataView( + data.buffer, + data.byteOffset, + data.byteLength, + ).getUint32(offset, true); + } + + function f64At(data: Uint8Array, offset: number): number { + return new DataView( + data.buffer, + data.byteOffset, + data.byteLength, + ).getFloat64(offset, true); + } + + // CF12's own skeleton up to and including cbDxf ([MS-XLS] 2.4.43): frtRefHeader.rt(2) + grbitFrt(2) + ref8(8) + ct(1) + cp(1) + cce1(2) + cce2(2) = 18 bytes, then cbDxf itself as a 4-byte field. + const CB_DXF_OFFSET = 18; + + it("writes cbDxf as 0 for a rule stating no style at all", () => { + const data = cf12RecordDataOf({ + type: "aboveAverage", + ranges: [RANGE], + }); + expect(u32At(data, CB_DXF_OFFSET)).toBe(0); + }); + + it("writes a non-zero cbDxf for a rule stating a style", () => { + const data = cf12RecordDataOf({ + type: "aboveAverage", + ranges: [RANGE], + style: { textColor: { r: 1, g: 0, b: 0 } }, + }); + expect(u32At(data, CB_DXF_OFFSET)).toBeGreaterThan(0); + }); + + // A colour-scale CF12's rgbCt (CFGradient, [MS-XLS] 2.5.32) starts right after the shared skeleton: cbDxf(4, always reading 0 here since ct 0x03 pins cbDxf to 0) + the empty dxf itself (0 bytes) + fmlaActive.cce(2) + fStopIfTrue(1) + ipriority(2) + icfTemplate(2) + cbTemplateParm(1) + templateParams(16) = 28 bytes after CB_DXF_OFFSET's own 4, i.e. CB_DXF_OFFSET + 4 + 28 = 50 is wrong -- rechecked directly below against the record's own declared cbDxf/cbTemplateParm fields rather than hardcoded a second time, so a change to any one of those fixed sizes cannot silently desync this offset from the real layout. + function gradientOffsetOf(data: Uint8Array): number { + const cbDxf = u32At(data, CB_DXF_OFFSET); + const cbTemplateParmOffset = CB_DXF_OFFSET + 4 + cbDxf + 2 + 1 + 2 + 2; // + fmlaActive.cce + fStopIfTrue + ipriority + icfTemplate + const cbTemplateParm = data[cbTemplateParmOffset] ?? 0; + return cbTemplateParmOffset + 1 + cbTemplateParm; + } + + // CFGradient's own header (unused(2) + reserved1(1) + cInterpCurve(1) + cGradientCurve(1) + flags(1) = 6 bytes), then rgInterp: cInterpCurve entries of CFGradientInterpItem (a CFVO -- 3 bytes for a fixed min/max stop, cce=0 -- then the stop's own interpolation-position float, 8 bytes). + function interpFractionAt(data: Uint8Array, stopIndex: number): number { + const rgInterpStart = gradientOffsetOf(data) + 6; + const stopStart = rgInterpStart + stopIndex * (3 + 8); + return f64At(data, stopStart + 3); + } + + it("writes a two-stop gradient's own fixed 0.0/1.0 interpolation fractions, not the three-stop set", () => { + const data = cf12RecordDataOf({ + type: "colorScale", + ranges: [RANGE], + stops: [ + { value: { type: "min" }, color: { r: 0, g: 0, b: 0 } }, + { value: { type: "max" }, color: { r: 1, g: 1, b: 1 } }, + ], + }); + expect(interpFractionAt(data, 0)).toBe(0.0); + expect(interpFractionAt(data, 1)).toBe(1.0); + }); + + it("writes a three-stop gradient's own fixed 0.0/0.5/1.0 interpolation fractions, not the two-stop set", () => { + const data = cf12RecordDataOf({ + type: "colorScale", + ranges: [RANGE], + stops: [ + { value: { type: "min" }, color: { r: 0, g: 0, b: 0 } }, + // "min" again (rather than a value-bearing type): every stop here must compile to the identical fixed 3-byte CFVO (cce 0, no rgce) for interpFractionAt's own fixed stride assumption to address the right byte offset -- the middle stop's own threshold value is irrelevant to what this test checks. + { value: { type: "min" }, color: { r: 0.5, g: 0.5, b: 0.5 } }, + { value: { type: "max" }, color: { r: 1, g: 1, b: 1 } }, + ], + }); + expect(interpFractionAt(data, 0)).toBe(0.0); + expect(interpFractionAt(data, 1)).toBe(0.5); + }); }); From a633961b274cc407ddce13347c8987dda301e85d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:19:16 +0100 Subject: [PATCH 014/107] test(xls-codec): assert conditional-format and cell fixtures with toStrictEqual Every reader function in this package builds its result through a chain of `...(x !== undefined ? { key: x } : {})` spreads, so an optional field the source omits entirely and one a mutant forces to `{ key: undefined }` produce objects that plain .toEqual cannot tell apart -- Jest/Vitest's own loose equality treats a missing key and one explicitly set to undefined as the same thing, so a whole family of "if (x !== undefined)" mutants across content.ts, conditional-format-write.ts, and elsewhere survived regardless of how many present/absent fixtures a round trip already covered. Switches every .toEqual in this package's own test suite to .toStrictEqual, which does distinguish the two, catching that entire mutant family in one pass rather than needing a bespoke assertion per optional field. Fixes the two fixtures this actually caught as genuinely under-specified: conditional-format-12.test.ts's raw CF12 reader tests for a plain top10 rule and for every operand-free icfTemplate now state style: undefined explicitly, matching RawConditionalFormat12's own field (declared without a `?`, so always present even when its value is absent) the same way every other fixture in that file already does. One fixture is intentionally left on .toEqual, not upgraded: reading a hand-built SummaryInformation stream that states only title/author/ createdIso surfaces every other LayoutMetadata field as an explicit undefined rather than omitted, because archive-codec's own shared summaryInformationToLayoutMetadata (also used by doc-codec and ppt-codec) states every field unconditionally -- a genuine, if minor, contract inconsistency belonging to that shared package rather than this one. --- packages/xls-codec/src/biff/builder.test.ts | 6 +- packages/xls-codec/src/biff/cursor.test.ts | 4 +- packages/xls-codec/src/biff/font.test.ts | 12 +- .../xls-codec/src/biff/print-setup.test.ts | 30 +- .../xls-codec/src/biff/ptg-writer.test.ts | 101 +++---- packages/xls-codec/src/biff/ptg.test.ts | 2 +- .../xls-codec/src/biff/record-writer.test.ts | 10 +- packages/xls-codec/src/biff/records.test.ts | 14 +- .../xls-codec/src/biff/substreams.test.ts | 44 +-- packages/xls-codec/src/biff/xf-colors.test.ts | 50 ++-- packages/xls-codec/src/biff/xf-writer.test.ts | 4 +- packages/xls-codec/src/container.test.ts | 6 +- packages/xls-codec/src/content.test.ts | 95 ++++--- packages/xls-codec/src/drawing/escher.test.ts | 12 +- packages/xls-codec/src/drawing/shapes.test.ts | 12 +- .../xls-codec/src/test-support/cfb.test.ts | 14 +- packages/xls-codec/src/workbook/chart.test.ts | 8 +- .../src/workbook/comment-writer.test.ts | 20 +- .../xls-codec/src/workbook/comments.test.ts | 10 +- .../workbook/conditional-format-12.test.ts | 64 +++-- .../workbook/conditional-format-ex.test.ts | 4 +- .../src/workbook/conditional-format.test.ts | 20 +- .../src/workbook/data-validation.test.ts | 4 +- .../xls-codec/src/workbook/drawing.test.ts | 14 +- .../src/workbook/embedded-object.test.ts | 8 +- .../xls-codec/src/workbook/encryption.test.ts | 17 +- .../xls-codec/src/workbook/globals.test.ts | 46 +-- .../src/workbook/print-names.test.ts | 22 +- packages/xls-codec/src/workbook/sheet.test.ts | 113 ++++---- packages/xls-codec/src/write.test.ts | 266 ++++++++++-------- 30 files changed, 560 insertions(+), 472 deletions(-) diff --git a/packages/xls-codec/src/biff/builder.test.ts b/packages/xls-codec/src/biff/builder.test.ts index 7a0dd89f44..12a2312d5f 100644 --- a/packages/xls-codec/src/biff/builder.test.ts +++ b/packages/xls-codec/src/biff/builder.test.ts @@ -9,12 +9,12 @@ function view(bytes: Uint8Array): DataView { describe("RecordBuilder", () => { it("writes a u8 as a single byte", () => { const bytes = new RecordBuilder().u8(0xab).build(); - expect(Array.from(bytes)).toEqual([0xab]); + expect(Array.from(bytes)).toStrictEqual([0xab]); }); it("writes a u16 little-endian", () => { const bytes = new RecordBuilder().u16(0x1234).build(); - expect(Array.from(bytes)).toEqual([0x34, 0x12]); + expect(Array.from(bytes)).toStrictEqual([0x34, 0x12]); }); it("truncates a u16 to its own 16 bits", () => { @@ -39,7 +39,7 @@ describe("RecordBuilder", () => { .bytes(new Uint8Array([0xaa, 0xbb])) .u8(0x02) .build(); - expect(Array.from(bytes)).toEqual([0x01, 0xaa, 0xbb, 0x02]); + expect(Array.from(bytes)).toStrictEqual([0x01, 0xaa, 0xbb, 0x02]); }); it("chains fields in call order into one contiguous buffer", () => { diff --git a/packages/xls-codec/src/biff/cursor.test.ts b/packages/xls-codec/src/biff/cursor.test.ts index 3bc8d22b57..9cefb14e64 100644 --- a/packages/xls-codec/src/biff/cursor.test.ts +++ b/packages/xls-codec/src/biff/cursor.test.ts @@ -70,13 +70,13 @@ describe("BlockCursor", () => { it("reads a run of raw bytes", () => { const cursor = new BlockCursor([bytes(0x01, 0x02, 0x03, 0x04)]); - expect(cursor.take(3)).toEqual(bytes(0x01, 0x02, 0x03)); + expect(cursor.take(3)).toStrictEqual(bytes(0x01, 0x02, 0x03)); }); it("reads a run of raw bytes spanning a block boundary", () => { const cursor = new BlockCursor([bytes(0x01, 0x02), bytes(0x03, 0x04)]); - expect(cursor.take(3)).toEqual(bytes(0x01, 0x02, 0x03)); + expect(cursor.take(3)).toStrictEqual(bytes(0x01, 0x02, 0x03)); }); it("rejects a length-prefixed take() before allocating, rather than after reading runs out", () => { diff --git a/packages/xls-codec/src/biff/font.test.ts b/packages/xls-codec/src/biff/font.test.ts index 97e792dc2d..e91460f618 100644 --- a/packages/xls-codec/src/biff/font.test.ts +++ b/packages/xls-codec/src/biff/font.test.ts @@ -33,11 +33,11 @@ describe("writeFontRecord", () => { underline: true, colorIcv: 10, }; - expect(readBack(writeFontRecord(fields))).toEqual(fields); + expect(readBack(writeFontRecord(fields))).toStrictEqual(fields); }); it("writes the Normal font's own fields verbatim", () => { - expect(readBack(writeFontRecord(NORMAL_FONT_FIELDS))).toEqual( + expect(readBack(writeFontRecord(NORMAL_FONT_FIELDS))).toStrictEqual( NORMAL_FONT_FIELDS, ); }); @@ -72,11 +72,11 @@ describe("xfFontFieldsOf", () => { const icvOf = (color: { readonly r: number }) => (color.r === 1 ? 10 : 12); it("normalises an absent, empty, or all-default font to the Normal font's own fields", () => { - expect(xfFontFieldsOf(undefined, icvOf)).toEqual(NORMAL_FONT_FIELDS); - expect(xfFontFieldsOf({}, icvOf)).toEqual(NORMAL_FONT_FIELDS); + expect(xfFontFieldsOf(undefined, icvOf)).toStrictEqual(NORMAL_FONT_FIELDS); + expect(xfFontFieldsOf({}, icvOf)).toStrictEqual(NORMAL_FONT_FIELDS); expect( xfFontFieldsOf({ bold: false, fontFamily: "Arial", sizePt: 10 }, icvOf), - ).toEqual(NORMAL_FONT_FIELDS); + ).toStrictEqual(NORMAL_FONT_FIELDS); }); it("resolves each stated property and defaults each unstated one", () => { @@ -85,7 +85,7 @@ describe("xfFontFieldsOf", () => { { bold: true, fontFamily: "Courier New", sizePt: 12 }, icvOf, ), - ).toEqual({ + ).toStrictEqual({ ...NORMAL_FONT_FIELDS, bold: true, name: "Courier New", diff --git a/packages/xls-codec/src/biff/print-setup.test.ts b/packages/xls-codec/src/biff/print-setup.test.ts index 3755ee8c2d..553f2f9ff5 100644 --- a/packages/xls-codec/src/biff/print-setup.test.ts +++ b/packages/xls-codec/src/biff/print-setup.test.ts @@ -24,20 +24,20 @@ const PORTRAIT_LETTER: SetupFields = { describe("pageSizeFromSetup", () => { it("resolves US Letter (code 1) to the same 612 x 792 pt the shared schema constant carries", () => { - expect(pageSizeFromSetup(PORTRAIT_LETTER)).toEqual(PAGE_SIZE_LETTER); + expect(pageSizeFromSetup(PORTRAIT_LETTER)).toStrictEqual(PAGE_SIZE_LETTER); }); it("resolves A4 (code 9) to the same 595.28 x 841.89 pt the shared schema constant carries", () => { - expect(pageSizeFromSetup({ ...PORTRAIT_LETTER, paperCode: 9 })).toEqual( - PAGE_SIZE_A4, - ); + expect( + pageSizeFromSetup({ ...PORTRAIT_LETTER, paperCode: 9 }), + ).toStrictEqual(PAGE_SIZE_A4); }); it("transposes a code's own portrait dimensions when fPortrait is clear", () => { // A paper code names the sheet's paper in portrait regardless of how it prints, so landscape A4 is the same code with the dimensions the other way round. expect( pageSizeFromSetup({ ...PORTRAIT_LETTER, paperCode: 9, portrait: false }), - ).toEqual({ + ).toStrictEqual({ widthPt: PAGE_SIZE_A4.heightPt, heightPt: PAGE_SIZE_A4.widthPt, }); @@ -51,12 +51,14 @@ describe("pageSizeFromSetup", () => { portrait: false, noOrientation: true, }), - ).toEqual(PAGE_SIZE_LETTER); + ).toStrictEqual(PAGE_SIZE_LETTER); }); it("resolves a metric code from the millimetres its own table entry states", () => { // A3, 297 x 420 mm -> 297/25.4*72 x 420/25.4*72 pt, rounded to hundredths. - expect(pageSizeFromSetup({ ...PORTRAIT_LETTER, paperCode: 8 })).toEqual({ + expect( + pageSizeFromSetup({ ...PORTRAIT_LETTER, paperCode: 8 }), + ).toStrictEqual({ widthPt: 841.89, heightPt: 1190.55, }); @@ -75,7 +77,7 @@ describe("pageSizeFromSetup", () => { describe("paperSelectionFor", () => { it("names US Letter portrait for the shared schema constant", () => { - expect(paperSelectionFor(PAGE_SIZE_LETTER)).toEqual({ + expect(paperSelectionFor(PAGE_SIZE_LETTER)).toStrictEqual({ code: 1, portrait: true, }); @@ -87,12 +89,14 @@ describe("paperSelectionFor", () => { widthPt: PAGE_SIZE_A4.heightPt, heightPt: PAGE_SIZE_A4.widthPt, }), - ).toEqual({ code: 9, portrait: false }); + ).toStrictEqual({ code: 9, portrait: false }); }); it("absorbs a fraction of a point of drift", () => { // A page size crossing between codecs picks up conversion drift; a fifth of a point is well under any real difference between two papers. - expect(paperSelectionFor({ widthPt: 595.08, heightPt: 841.69 })).toEqual({ + expect( + paperSelectionFor({ widthPt: 595.08, heightPt: 841.69 }), + ).toStrictEqual({ code: 9, portrait: true, }); @@ -118,7 +122,7 @@ describe("paperSelectionFor", () => { paperCode: selection?.code ?? -1, portrait: selection?.portrait ?? true, }), - ).toEqual(size); + ).toStrictEqual(size); } }); }); @@ -134,7 +138,7 @@ describe("Setup flag packing", () => { }; expect( unpackSetupFlags(packSetupFlags({ ...PORTRAIT_LETTER, ...flags })), - ).toEqual(flags); + ).toStrictEqual(flags); } }); @@ -156,7 +160,7 @@ describe("Setup flag packing", () => { it("reads the flags word a real LibreOffice-written Setup record carries", () => { // The grbit of the Setup record in a .xls LibreOffice wrote for a landscape sheet printed left-to-right: fLeftToRight and fUsePage set, fPortrait clear. - expect(unpackSetupFlags(0x0081)).toEqual({ + expect(unpackSetupFlags(0x0081)).toStrictEqual({ leftToRight: true, portrait: false, noPls: false, diff --git a/packages/xls-codec/src/biff/ptg-writer.test.ts b/packages/xls-codec/src/biff/ptg-writer.test.ts index 3073f4f1c1..f59e6ab4f8 100644 --- a/packages/xls-codec/src/biff/ptg-writer.test.ts +++ b/packages/xls-codec/src/biff/ptg-writer.test.ts @@ -54,37 +54,37 @@ function compiled(text: string): number[] { describe("compileFormulaText", () => { describe("whitespace", () => { it("skips a leading space", () => { - expect(compiled(" 1")).toEqual(compiled("1")); + expect(compiled(" 1")).toStrictEqual(compiled("1")); }); it("skips a leading tab", () => { - expect(compiled("\t1")).toEqual(compiled("1")); + expect(compiled("\t1")).toStrictEqual(compiled("1")); }); it("skips a leading newline", () => { - expect(compiled("\n1")).toEqual(compiled("1")); + expect(compiled("\n1")).toStrictEqual(compiled("1")); }); it("skips a leading carriage return", () => { - expect(compiled("\r1")).toEqual(compiled("1")); + expect(compiled("\r1")).toStrictEqual(compiled("1")); }); it("skips whitespace of every kind between tokens", () => { - expect(compiled(" 1 \t+\n2\r ")).toEqual(compiled("1+2")); + expect(compiled(" 1 \t+\n2\r ")).toStrictEqual(compiled("1+2")); }); }); describe("integer literals", () => { it("compiles a plain integer as PtgInt", () => { - expect(compiled("42")).toEqual([PTG_INT, ...u16le(42)]); + expect(compiled("42")).toStrictEqual([PTG_INT, ...u16le(42)]); }); it("compiles zero as PtgInt", () => { - expect(compiled("0")).toEqual([PTG_INT, ...u16le(0)]); + expect(compiled("0")).toStrictEqual([PTG_INT, ...u16le(0)]); }); it("compiles PTG_INT_MAX itself as PtgInt", () => { - expect(compiled(String(PTG_INT_MAX))).toEqual([ + expect(compiled(String(PTG_INT_MAX))).toStrictEqual([ PTG_INT, ...u16le(PTG_INT_MAX), ]); @@ -92,30 +92,30 @@ describe("compileFormulaText", () => { it("compiles one above PTG_INT_MAX as PtgNum, not PtgInt", () => { const value = PTG_INT_MAX + 1; - expect(compiled(String(value))).toEqual([PTG_NUM, ...f64le(value)]); + expect(compiled(String(value))).toStrictEqual([PTG_NUM, ...f64le(value)]); }); it("compiles a decimal literal as PtgNum even when its value is a whole number", () => { - expect(compiled("4.0")).toEqual([PTG_NUM, ...f64le(4)]); + expect(compiled("4.0")).toStrictEqual([PTG_NUM, ...f64le(4)]); }); it("compiles a fractional literal as PtgNum", () => { - expect(compiled("3.14159")).toEqual([PTG_NUM, ...f64le(3.14159)]); + expect(compiled("3.14159")).toStrictEqual([PTG_NUM, ...f64le(3.14159)]); }); it("compiles an exponent literal as PtgNum", () => { - expect(compiled("1e3")).toEqual([PTG_NUM, ...f64le(1000)]); + expect(compiled("1e3")).toStrictEqual([PTG_NUM, ...f64le(1000)]); }); it("writes PtgNum's own float64 in little-endian byte order", () => { // 3.14159's IEEE 754 double is not byte-palindromic, so a big-endian writer would produce a different byte sequence than f64le's own little-endian reference encoding. - expect(compiled("3.14159").slice(1)).toEqual(f64le(3.14159)); + expect(compiled("3.14159").slice(1)).toStrictEqual(f64le(3.14159)); }); }); describe("string literals", () => { it("compiles a plain string as PtgStr", () => { - expect(compiled('"hi"')).toEqual([ + expect(compiled('"hi"')).toStrictEqual([ PTG_STR, 2, 0x00, @@ -125,11 +125,11 @@ describe("compileFormulaText", () => { }); it("compiles the empty string", () => { - expect(compiled('""')).toEqual([PTG_STR, 0, 0x00]); + expect(compiled('""')).toStrictEqual([PTG_STR, 0, 0x00]); }); it("un-escapes a doubled quote inside a string literal", () => { - expect(compiled('"it""s"')).toEqual([ + expect(compiled('"it""s"')).toStrictEqual([ PTG_STR, 4, 0x00, @@ -152,18 +152,18 @@ describe("compileFormulaText", () => { describe("boolean literals", () => { it("compiles TRUE as PtgBool true", () => { - expect(compiled("TRUE")).toEqual([PTG_BOOL, 1]); + expect(compiled("TRUE")).toStrictEqual([PTG_BOOL, 1]); }); it("compiles FALSE as PtgBool false", () => { - expect(compiled("FALSE")).toEqual([PTG_BOOL, 0]); + expect(compiled("FALSE")).toStrictEqual([PTG_BOOL, 0]); }); }); describe("error literals", () => { it("compiles a recognised error literal as PtgErr", () => { // #REF! is BIFF8's own 0x17 error code ([MS-XLS] 2.5.10). - expect(compiled("#REF!")).toEqual([PTG_ERR, 0x17]); + expect(compiled("#REF!")).toStrictEqual([PTG_ERR, 0x17]); }); it("compiles every one of BIFF8's eight error literals to its own documented code", () => { @@ -178,7 +178,7 @@ describe("compileFormulaText", () => { ["#GETTING_DATA", 0x2b], ]; for (const [text, code] of expected) { - expect(compiled(text)).toEqual([PTG_ERR, code]); + expect(compiled(text)).toStrictEqual([PTG_ERR, code]); } }); @@ -249,7 +249,7 @@ describe("compileFormulaText", () => { describe("cell references", () => { it("compiles a fully relative reference", () => { - expect(compiled("A1")).toEqual([ + expect(compiled("A1")).toStrictEqual([ PTG_REF_VALUE, ...u16le(0), ...u16le(0 | COLUMN_RELATIVE_BIT | ROW_RELATIVE_BIT), @@ -257,7 +257,7 @@ describe("compileFormulaText", () => { }); it("compiles a fully absolute reference", () => { - expect(compiled("$A$1")).toEqual([ + expect(compiled("$A$1")).toStrictEqual([ PTG_REF_VALUE, ...u16le(0), ...u16le(0), @@ -265,7 +265,7 @@ describe("compileFormulaText", () => { }); it("compiles a column-absolute, row-relative reference", () => { - expect(compiled("$A1")).toEqual([ + expect(compiled("$A1")).toStrictEqual([ PTG_REF_VALUE, ...u16le(0), ...u16le(0 | ROW_RELATIVE_BIT), @@ -273,7 +273,7 @@ describe("compileFormulaText", () => { }); it("compiles a column-relative, row-absolute reference", () => { - expect(compiled("A$1")).toEqual([ + expect(compiled("A$1")).toStrictEqual([ PTG_REF_VALUE, ...u16le(0), ...u16le(0 | COLUMN_RELATIVE_BIT), @@ -282,7 +282,7 @@ describe("compileFormulaText", () => { it("resolves a multi-letter column and multi-digit row", () => { // BC77: column "BC" is 0-indexed 54 ((1*26)+2), row 77 is 0-indexed 76. - expect(compiled("BC77")).toEqual([ + expect(compiled("BC77")).toStrictEqual([ PTG_REF_VALUE, ...u16le(76), ...u16le(54 | COLUMN_RELATIVE_BIT | ROW_RELATIVE_BIT), @@ -290,7 +290,7 @@ describe("compileFormulaText", () => { }); it("compiles a relative area (range)", () => { - expect(compiled("A1:B2")).toEqual([ + expect(compiled("A1:B2")).toStrictEqual([ PTG_AREA_VALUE, ...u16le(0), ...u16le(1), @@ -300,7 +300,7 @@ describe("compileFormulaText", () => { }); it("compiles an area whose two corners carry independent absolute/relative flags", () => { - expect(compiled("$A$1:B2")).toEqual([ + expect(compiled("$A$1:B2")).toStrictEqual([ PTG_AREA_VALUE, ...u16le(0), ...u16le(1), @@ -369,7 +369,7 @@ describe("compileFormulaText", () => { [">", PTG_GT], ["<>", PTG_NE], ] as const)("compiles the %s comparison operator", (op, opcode) => { - expect(compiled(`1${op}2`)).toEqual([ + expect(compiled(`1${op}2`)).toStrictEqual([ PTG_INT, ...u16le(1), PTG_INT, @@ -379,7 +379,7 @@ describe("compileFormulaText", () => { }); it("compiles string concatenation", () => { - expect(compiled("1&2")).toEqual([ + expect(compiled("1&2")).toStrictEqual([ PTG_INT, ...u16le(1), PTG_INT, @@ -392,7 +392,7 @@ describe("compileFormulaText", () => { ["+", PTG_ADD], ["-", PTG_SUB], ] as const)("compiles the binary %s operator", (op, opcode) => { - expect(compiled(`1${op}2`)).toEqual([ + expect(compiled(`1${op}2`)).toStrictEqual([ PTG_INT, ...u16le(1), PTG_INT, @@ -405,7 +405,7 @@ describe("compileFormulaText", () => { ["*", PTG_MUL], ["/", PTG_DIV], ] as const)("compiles the %s operator", (op, opcode) => { - expect(compiled(`1${op}2`)).toEqual([ + expect(compiled(`1${op}2`)).toStrictEqual([ PTG_INT, ...u16le(1), PTG_INT, @@ -415,7 +415,7 @@ describe("compileFormulaText", () => { }); it("compiles exponentiation", () => { - expect(compiled("1^2")).toEqual([ + expect(compiled("1^2")).toStrictEqual([ PTG_INT, ...u16le(1), PTG_INT, @@ -425,18 +425,18 @@ describe("compileFormulaText", () => { }); it("compiles a trailing percent operator", () => { - expect(compiled("1%")).toEqual([PTG_INT, ...u16le(1), PTG_PERCENT]); + expect(compiled("1%")).toStrictEqual([PTG_INT, ...u16le(1), PTG_PERCENT]); }); it.each([ ["+", PTG_UPLUS], ["-", PTG_UMINUS], ] as const)("compiles a unary %s operator", (op, opcode) => { - expect(compiled(`${op}1`)).toEqual([PTG_INT, ...u16le(1), opcode]); + expect(compiled(`${op}1`)).toStrictEqual([PTG_INT, ...u16le(1), opcode]); }); it("compiles explicit parentheses", () => { - expect(compiled("(1+2)")).toEqual([ + expect(compiled("(1+2)")).toStrictEqual([ PTG_INT, ...u16le(1), PTG_INT, @@ -449,7 +449,7 @@ describe("compileFormulaText", () => { describe("precedence", () => { it("binds unary minus tighter than exponentiation", () => { - expect(compiled("-1^2")).toEqual([ + expect(compiled("-1^2")).toStrictEqual([ PTG_INT, ...u16le(1), PTG_UMINUS, @@ -460,7 +460,7 @@ describe("compileFormulaText", () => { }); it("binds additive operators tighter than concatenation", () => { - expect(compiled("1&2+3")).toEqual([ + expect(compiled("1&2+3")).toStrictEqual([ PTG_INT, ...u16le(1), PTG_INT, @@ -473,7 +473,7 @@ describe("compileFormulaText", () => { }); it("binds multiplicative operators tighter than additive", () => { - expect(compiled("1+2*3")).toEqual([ + expect(compiled("1+2*3")).toStrictEqual([ PTG_INT, ...u16le(1), PTG_INT, @@ -486,7 +486,7 @@ describe("compileFormulaText", () => { }); it("binds exponentiation tighter than multiplication", () => { - expect(compiled("2*3^2")).toEqual([ + expect(compiled("2*3^2")).toStrictEqual([ PTG_INT, ...u16le(2), PTG_INT, @@ -499,7 +499,7 @@ describe("compileFormulaText", () => { }); it("binds percent tighter than exponentiation", () => { - expect(compiled("2^3%")).toEqual([ + expect(compiled("2^3%")).toStrictEqual([ PTG_INT, ...u16le(2), PTG_INT, @@ -510,7 +510,7 @@ describe("compileFormulaText", () => { }); it("binds comparison operators looser than every arithmetic operator", () => { - expect(compiled("1+2<3*4")).toEqual([ + expect(compiled("1+2<3*4")).toStrictEqual([ PTG_INT, ...u16le(1), PTG_INT, @@ -529,7 +529,7 @@ describe("compileFormulaText", () => { describe("function calls", () => { it("compiles a fixed-arity call as PtgFunc", () => { // ABS is [MS-XLS] 2.5.198.17's own Ftab entry 0x0018, fixed arity 1. - expect(compiled("ABS(1)")).toEqual([ + expect(compiled("ABS(1)")).toStrictEqual([ PTG_INT, ...u16le(1), PTG_FUNC_VALUE, @@ -539,12 +539,15 @@ describe("compileFormulaText", () => { it("compiles a zero-argument fixed-arity call", () => { // PI is Ftab entry 0x0013, fixed arity 0. - expect(compiled("PI()")).toEqual([PTG_FUNC_VALUE, ...u16le(0x0013)]); + expect(compiled("PI()")).toStrictEqual([ + PTG_FUNC_VALUE, + ...u16le(0x0013), + ]); }); it("compiles a variable-arity call with no arguments as PtgFuncVar", () => { // SUM is Ftab entry 0x0004, variable arity. - expect(compiled("SUM()")).toEqual([ + expect(compiled("SUM()")).toStrictEqual([ PTG_FUNCVAR_VALUE, 0, ...u16le(0x0004), @@ -552,7 +555,7 @@ describe("compileFormulaText", () => { }); it("compiles a variable-arity call with several arguments", () => { - expect(compiled("SUM(1,2,3)")).toEqual([ + expect(compiled("SUM(1,2,3)")).toStrictEqual([ PTG_INT, ...u16le(1), PTG_INT, @@ -566,7 +569,7 @@ describe("compileFormulaText", () => { }); it("compiles an omitted middle argument as PtgMissArg", () => { - expect(compiled("IF(1,,3)")).toEqual([ + expect(compiled("IF(1,,3)")).toStrictEqual([ PTG_INT, ...u16le(1), PTG_MISSARG, @@ -580,7 +583,7 @@ describe("compileFormulaText", () => { }); it("compiles an omitted leading argument as PtgMissArg", () => { - expect(compiled("IF(,1,2)")).toEqual([ + expect(compiled("IF(,1,2)")).toStrictEqual([ PTG_MISSARG, PTG_INT, ...u16le(1), @@ -593,7 +596,7 @@ describe("compileFormulaText", () => { }); it("compiles an omitted trailing argument as PtgMissArg", () => { - expect(compiled("IF(1,2,)")).toEqual([ + expect(compiled("IF(1,2,)")).toStrictEqual([ PTG_INT, ...u16le(1), PTG_INT, @@ -632,7 +635,7 @@ describe("compileFormulaText", () => { describe("nested function calls", () => { it("compiles a function call nested inside another as an argument", () => { - expect(compiled('IF(1=1,"yes","no")')).toEqual([ + expect(compiled('IF(1=1,"yes","no")')).toStrictEqual([ PTG_INT, ...u16le(1), PTG_INT, diff --git a/packages/xls-codec/src/biff/ptg.test.ts b/packages/xls-codec/src/biff/ptg.test.ts index 6aea8b620d..1d79eee252 100644 --- a/packages/xls-codec/src/biff/ptg.test.ts +++ b/packages/xls-codec/src/biff/ptg.test.ts @@ -485,7 +485,7 @@ describe("parseFormulaText array constants (PtgArray/PtgExtraArray)", () => { describe("readPtgExpBase", () => { it("extracts the base cell from a lone PtgExp token", () => { const rgce = bytes(0x01, ...u16(3), ...u16(1)); - expect(readPtgExpBase(rgce)).toEqual({ row: 3, column: 1 }); + expect(readPtgExpBase(rgce)).toStrictEqual({ row: 3, column: 1 }); }); it("returns undefined for anything other than exactly one PtgExp token", () => { diff --git a/packages/xls-codec/src/biff/record-writer.test.ts b/packages/xls-codec/src/biff/record-writer.test.ts index 9aa642a220..056db1590d 100644 --- a/packages/xls-codec/src/biff/record-writer.test.ts +++ b/packages/xls-codec/src/biff/record-writer.test.ts @@ -10,7 +10,7 @@ describe("writeRecord", () => { const data = new Uint8Array([0x00, 0x06, 0x05, 0x00]); const framed = writeRecord(RECORD_BOF, data); - expect(readRecords(framed)).toEqual([ + expect(readRecords(framed)).toStrictEqual([ { type: RECORD_BOF, data, offset: 0 }, ]); }); @@ -18,14 +18,16 @@ describe("writeRecord", () => { it("frames a zero-length record", () => { const framed = writeRecord(RECORD_EOF, new Uint8Array(0)); - expect(readRecords(framed)).toEqual([ + expect(readRecords(framed)).toStrictEqual([ { type: RECORD_EOF, data: new Uint8Array(0), offset: 0 }, ]); }); it("writes the header as a little-endian type then a little-endian size", () => { const framed = writeRecord(0x0809, new Uint8Array(3)); - expect(Array.from(framed.slice(0, 4))).toEqual([0x09, 0x08, 0x03, 0x00]); + expect(Array.from(framed.slice(0, 4))).toStrictEqual([ + 0x09, 0x08, 0x03, 0x00, + ]); }); it("accepts data exactly at the maximum record size", () => { @@ -46,7 +48,7 @@ describe("concatRecords", () => { const stream = concatRecords(first, second); - expect(readRecords(stream)).toEqual([ + expect(readRecords(stream)).toStrictEqual([ { type: RECORD_BOF, data: new Uint8Array([1, 2]), offset: 0 }, { type: RECORD_EOF, data: new Uint8Array(0), offset: 6 }, ]); diff --git a/packages/xls-codec/src/biff/records.test.ts b/packages/xls-codec/src/biff/records.test.ts index e8111c2765..ac647e09e1 100644 --- a/packages/xls-codec/src/biff/records.test.ts +++ b/packages/xls-codec/src/biff/records.test.ts @@ -29,7 +29,7 @@ describe("readRecords", () => { it("reads a record's type and data from the framing", () => { const stream = bytes(...record(RECORD_BOF, [0x00, 0x06, 0x05, 0x00])); - expect(readRecords(stream)).toEqual([ + expect(readRecords(stream)).toStrictEqual([ { type: RECORD_BOF, data: bytes(0x00, 0x06, 0x05, 0x00), offset: 0 }, ]); }); @@ -40,7 +40,7 @@ describe("readRecords", () => { ...record(RECORD_EOF, []), ); - expect(readRecords(stream).map((entry) => entry.type)).toEqual([ + expect(readRecords(stream).map((entry) => entry.type)).toStrictEqual([ RECORD_BOF, RECORD_EOF, ]); @@ -48,7 +48,7 @@ describe("readRecords", () => { it("reads a zero-length record, which the framing explicitly permits", () => { // [MS-XLS] 2.1.4: "The record size MUST be greater than or equal to 0". EOF is exactly this case in every real file. - expect(readRecords(bytes(...record(RECORD_EOF, [])))).toEqual([ + expect(readRecords(bytes(...record(RECORD_EOF, [])))).toStrictEqual([ { type: RECORD_EOF, data: bytes(), offset: 0 }, ]); }); @@ -60,7 +60,9 @@ describe("readRecords", () => { ...record(RECORD_EOF, []), ); - expect(readRecords(stream).map((entry) => entry.offset)).toEqual([0, 6]); + expect(readRecords(stream).map((entry) => entry.offset)).toStrictEqual([ + 0, 6, + ]); }); it("keeps a Continue record as its own entry rather than merging it", () => { @@ -70,14 +72,14 @@ describe("readRecords", () => { ...record(RECORD_CONTINUE, [0x02]), ); - expect(readRecords(stream)).toEqual([ + expect(readRecords(stream)).toStrictEqual([ { type: RECORD_SST, data: bytes(0x01), offset: 0 }, { type: RECORD_CONTINUE, data: bytes(0x02), offset: 5 }, ]); }); it("stops cleanly at the end of the stream", () => { - expect(readRecords(bytes())).toEqual([]); + expect(readRecords(bytes())).toStrictEqual([]); }); it("rejects a truncated record header", () => { diff --git a/packages/xls-codec/src/biff/substreams.test.ts b/packages/xls-codec/src/biff/substreams.test.ts index b8a483237d..5f006f16cf 100644 --- a/packages/xls-codec/src/biff/substreams.test.ts +++ b/packages/xls-codec/src/biff/substreams.test.ts @@ -56,7 +56,7 @@ describe("groupRecords", () => { it("keeps a record with no continuation as a single block", () => { const groups = groupRecords(records({ type: RECORD_SST, data: bytes(1) })); - expect(groups).toEqual([ + expect(groups).toStrictEqual([ { type: RECORD_SST, blocks: [bytes(1)], offset: 0 }, ]); }); @@ -70,7 +70,7 @@ describe("groupRecords", () => { ), ); - expect(groups).toEqual([ + expect(groups).toStrictEqual([ { type: RECORD_SST, blocks: [bytes(1), bytes(2), bytes(3)], @@ -88,7 +88,10 @@ describe("groupRecords", () => { ), ); - expect(groups.map((group) => group.type)).toEqual([RECORD_SST, RECORD_EOF]); + expect(groups.map((group) => group.type)).toStrictEqual([ + RECORD_SST, + RECORD_EOF, + ]); expect(groups[0]?.blocks).toHaveLength(2); }); @@ -135,7 +138,7 @@ describe("groupRecords", () => { ), ); - expect(groups).toEqual([ + expect(groups).toStrictEqual([ { type: RECORD_CF12, blocks: [bytes(1), bytes(2, 3)], offset: 0 }, ]); }); @@ -168,7 +171,7 @@ describe("splitSubstreams", () => { ), ); - expect(substreams.map((sub) => sub.documentType)).toEqual([ + expect(substreams.map((sub) => sub.documentType)).toStrictEqual([ BOF_TYPE_WORKBOOK, BOF_TYPE_WORKSHEET, ]); @@ -185,7 +188,7 @@ describe("splitSubstreams", () => { ), ); - expect(substreams[0]?.records.map((entry) => entry.type)).toEqual([ + expect(substreams[0]?.records.map((entry) => entry.type)).toStrictEqual([ RECORD_SST, ]); }); @@ -202,7 +205,7 @@ describe("splitSubstreams", () => { ), ); - expect(substreams.map((sub) => sub.index)).toEqual([0, 1]); + expect(substreams.map((sub) => sub.index)).toStrictEqual([0, 1]); }); it("records the stream offset of each substream's own BOF", () => { @@ -219,7 +222,7 @@ describe("splitSubstreams", () => { ); // The first BOF sits at 0 and spans 4 + 16 bytes; its EOF spans 4 more, so the second BOF starts at 24. - expect(substreams.map((sub) => sub.offset)).toEqual([0, 24]); + expect(substreams.map((sub) => sub.offset)).toStrictEqual([0, 24]); }); it("tolerates a substream left unterminated at the end of the stream", () => { @@ -242,7 +245,7 @@ describe("splitSubstreams", () => { splitSubstreams( groupRecords(records({ type: RECORD_EOF, data: bytes() })), ), - ).toEqual([]); + ).toStrictEqual([]); }); it("rejects a BOF that does not declare BIFF8", () => { @@ -280,17 +283,16 @@ describe("splitSubstreams", () => { ), ); - expect(substreams.map((sub) => sub.documentType)).toEqual([ + expect(substreams.map((sub) => sub.documentType)).toStrictEqual([ BOF_TYPE_CHART, BOF_TYPE_WORKSHEET, ]); - expect(substreams[0]?.records.map((entry) => entry.blocks[0])).toEqual([ - bytes(2), - ]); - expect(substreams[1]?.records.map((entry) => entry.blocks[0])).toEqual([ - bytes(1), - bytes(3), - ]); + expect( + substreams[0]?.records.map((entry) => entry.blocks[0]), + ).toStrictEqual([bytes(2)]); + expect( + substreams[1]?.records.map((entry) => entry.blocks[0]), + ).toStrictEqual([bytes(1), bytes(3)]); }); it("nests multiple embedded charts, one per BOF...EOF pair, in the same worksheet substream", () => { @@ -309,13 +311,13 @@ describe("splitSubstreams", () => { ), ); - expect(substreams.map((sub) => sub.documentType)).toEqual([ + expect(substreams.map((sub) => sub.documentType)).toStrictEqual([ BOF_TYPE_CHART, BOF_TYPE_CHART, BOF_TYPE_WORKSHEET, ]); - expect(substreams[0]?.records[0]?.blocks[0]).toEqual(bytes(1)); - expect(substreams[1]?.records[0]?.blocks[0]).toEqual(bytes(2)); + expect(substreams[0]?.records[0]?.blocks[0]).toStrictEqual(bytes(1)); + expect(substreams[1]?.records[0]?.blocks[0]).toStrictEqual(bytes(2)); expect(substreams[2]?.records).toHaveLength(0); }); @@ -331,7 +333,7 @@ describe("splitSubstreams", () => { ), ); - expect(substreams.map((sub) => sub.documentType)).toEqual([ + expect(substreams.map((sub) => sub.documentType)).toStrictEqual([ BOF_TYPE_CHART, BOF_TYPE_WORKSHEET, ]); diff --git a/packages/xls-codec/src/biff/xf-colors.test.ts b/packages/xls-codec/src/biff/xf-colors.test.ts index ef944e1d66..af3cbffbc8 100644 --- a/packages/xls-codec/src/biff/xf-colors.test.ts +++ b/packages/xls-codec/src/biff/xf-colors.test.ts @@ -31,15 +31,15 @@ import { describe("resolveIcvColor", () => { it("resolves icv 0-7 to the eight fixed built-in colours", () => { - expect(resolveIcvColor(0, undefined)).toEqual({ r: 0, g: 0, b: 0 }); // Black - expect(resolveIcvColor(2, undefined)).toEqual({ r: 1, g: 0, b: 0 }); // Red - expect(resolveIcvColor(7, undefined)).toEqual({ r: 0, g: 1, b: 1 }); // Cyan + expect(resolveIcvColor(0, undefined)).toStrictEqual({ r: 0, g: 0, b: 0 }); // Black + expect(resolveIcvColor(2, undefined)).toStrictEqual({ r: 1, g: 0, b: 0 }); // Red + expect(resolveIcvColor(7, undefined)).toStrictEqual({ r: 0, g: 1, b: 1 }); // Cyan }); it("resolves icv 8-63 through the fixed default table when no Palette is given", () => { // icv 8: rgColor[0]'s own default (0,0,0); icv 24 (0x18): rgColor[16]'s own default (153,153,255) -- [MS-XLS] "Icv"'s own table. - expect(resolveIcvColor(8, undefined)).toEqual({ r: 0, g: 0, b: 0 }); - expect(resolveIcvColor(24, undefined)).toEqual({ + expect(resolveIcvColor(8, undefined)).toStrictEqual({ r: 0, g: 0, b: 0 }); + expect(resolveIcvColor(24, undefined)).toStrictEqual({ r: 153 / 255, g: 153 / 255, b: 1, @@ -49,7 +49,7 @@ describe("resolveIcvColor", () => { it("resolves icv 8-63 through a real Palette's own entries when one is given", () => { const palette = Array.from({ length: 56 }, () => ({ r: 0, g: 0, b: 0 })); palette[0] = { r: 1, g: 0.5, b: 0 }; - expect(resolveIcvColor(8, palette)).toEqual({ r: 1, g: 0.5, b: 0 }); + expect(resolveIcvColor(8, palette)).toStrictEqual({ r: 1, g: 0.5, b: 0 }); }); it("does not resolve the Automatic foreground/background special values", () => { @@ -81,7 +81,7 @@ describe("DEFAULT_PALETTE_HEX_TO_ICV", () => { resolvedIcv === undefined ? undefined : resolveIcvColor(resolvedIcv, undefined), - ).toEqual(color); + ).toStrictEqual(color); } }); }); @@ -96,13 +96,17 @@ describe("resolveBorderEdge / borderStyleTokenFor", () => { it("resolves a thin solid border with no explicit style member (solid is the omitted default)", () => { expect( resolveBorderEdge({ style: BORDER_STYLE_THIN, icv: 10 }, undefined), - ).toEqual({ color: { r: 1, g: 0, b: 0 }, widthPt: 0.75 }); + ).toStrictEqual({ color: { r: 1, g: 0, b: 0 }, widthPt: 0.75 }); }); it("resolves a double border with its own style member", () => { expect( resolveBorderEdge({ style: BORDER_STYLE_DOUBLE, icv: 10 }, undefined), - ).toEqual({ color: { r: 1, g: 0, b: 0 }, widthPt: 0.75, style: "double" }); + ).toStrictEqual({ + color: { r: 1, g: 0, b: 0 }, + widthPt: 0.75, + style: "double", + }); }); it("does not resolve a border whose colour does not resolve to a fixed RGB value", () => { @@ -157,12 +161,12 @@ describe("resolveBorderEdge / borderStyleTokenFor", () => { describe("resolveFillBackground", () => { it("resolves a solid fill's own foreground colour", () => { - expect(resolveFillBackground(FILL_PATTERN_SOLID, 10, 0, undefined)).toEqual( - { - kind: "solid", - color: { r: 1, g: 0, b: 0 }, - }, - ); + expect( + resolveFillBackground(FILL_PATTERN_SOLID, 10, 0, undefined), + ).toStrictEqual({ + kind: "solid", + color: { r: 1, g: 0, b: 0 }, + }); }); it("resolves nothing for FLSNULL (no fill pattern)", () => { @@ -173,7 +177,9 @@ describe("resolveFillBackground", () => { it("resolves a genuine two-colour pattern fill instead of dropping it (ExaDev/documents.js#951)", () => { const GRAY_50_PERCENT = 0x02; - expect(resolveFillBackground(GRAY_50_PERCENT, 10, 11, undefined)).toEqual({ + expect( + resolveFillBackground(GRAY_50_PERCENT, 10, 11, undefined), + ).toStrictEqual({ kind: "pattern", patternType: "mediumGray", foregroundColor: { r: 1, g: 0, b: 0 }, @@ -185,7 +191,7 @@ describe("resolveFillBackground", () => { const THICK_DIAGONAL_CROSSHATCH = 0x0a; expect( resolveFillBackground(THICK_DIAGONAL_CROSSHATCH, 10, 11, undefined), - ).toEqual({ + ).toStrictEqual({ kind: "pattern", patternType: "darkTrellis", foregroundColor: { r: 1, g: 0, b: 0 }, @@ -206,7 +212,7 @@ describe("resolveFillBackground", () => { 11, undefined, ); - expect(result).toEqual({ + expect(result).toStrictEqual({ kind: "pattern", patternType: "mediumGray", backgroundColor: { r: 0, g: 1, b: 0 }, @@ -226,7 +232,7 @@ describe("packXfDecorationWords / unpackXfDecoration", () => { bottom: { style: BORDER_STYLE_NONE, icv: 0 }, }; const { word2, word3, word4 } = packXfDecorationWords(decoration); - expect(unpackXfDecoration(word2, word3, word4)).toEqual(decoration); + expect(unpackXfDecoration(word2, word3, word4)).toStrictEqual(decoration); }); it("round-trips a genuine two-colour pattern's own foreground and background icv, both real", () => { @@ -237,7 +243,7 @@ describe("packXfDecorationWords / unpackXfDecoration", () => { fillBackgroundIcv: 13, }; const { word2, word3, word4 } = packXfDecorationWords(decoration); - expect(unpackXfDecoration(word2, word3, word4)).toEqual(decoration); + expect(unpackXfDecoration(word2, word3, word4)).toStrictEqual(decoration); }); it("packs the exact undecorated defaults ([MS-XLS]'s own 'no border, no fill' state) with no argument", () => { @@ -247,7 +253,7 @@ describe("packXfDecorationWords / unpackXfDecoration", () => { expect(word3).toBe(0); // word4: icvFore (0x40, Automatic foreground) | icvBack (0x41, Automatic background) << 7. expect(word4).toBe(0x40 | (0x41 << 7)); - expect(unpackXfDecoration(word2, word3, word4).left).toEqual({ + expect(unpackXfDecoration(word2, word3, word4).left).toStrictEqual({ style: BORDER_STYLE_NONE, icv: 0, }); @@ -280,7 +286,7 @@ describe("applyTint", () => { const red = { r: 1, g: 0, b: 0 }; it("returns the colour unchanged for a zero tint", () => { - expect(applyTint(red, 0)).toEqual(red); + expect(applyTint(red, 0)).toStrictEqual(red); }); it("tints a colour toward white for a positive value", () => { diff --git a/packages/xls-codec/src/biff/xf-writer.test.ts b/packages/xls-codec/src/biff/xf-writer.test.ts index 3b167d4e72..cd8d391d56 100644 --- a/packages/xls-codec/src/biff/xf-writer.test.ts +++ b/packages/xls-codec/src/biff/xf-writer.test.ts @@ -86,7 +86,7 @@ describe("writeCellXfRecord", () => { const word3 = cursor.u32(); const word4 = cursor.u16(); - expect(unpackXfDecoration(word2, word3, word4)).toEqual({ + expect(unpackXfDecoration(word2, word3, word4)).toStrictEqual({ fillPattern: 1, fillForegroundIcv: 12, fillBackgroundIcv: 0x41, @@ -186,7 +186,7 @@ describe("writePaletteRecord", () => { const cursor = new BlockCursor([data]); cursor.skip(2); // ccv for (const color of colors) { - expect(readLongRgbColor(cursor)).toEqual(color); + expect(readLongRgbColor(cursor)).toStrictEqual(color); } }); }); diff --git a/packages/xls-codec/src/container.test.ts b/packages/xls-codec/src/container.test.ts index 85f7a0e337..c18e559371 100644 --- a/packages/xls-codec/src/container.test.ts +++ b/packages/xls-codec/src/container.test.ts @@ -19,7 +19,7 @@ describe("readWorkbookStreams", () => { const workbook = minimalWorkbookStream(); const bytes = compoundFile([{ path: "Workbook", bytes: workbook }]); - expect(readWorkbookStreams(bytes).workbook).toEqual(workbook); + expect(readWorkbookStreams(bytes).workbook).toStrictEqual(workbook); }); it("carries the SummaryInformation stream's bytes when present", () => { @@ -30,7 +30,7 @@ describe("readWorkbookStreams", () => { { path: "\x05SummaryInformation", bytes: summary }, ]); - expect(readWorkbookStreams(bytes).metadata).toEqual(summary); + expect(readWorkbookStreams(bytes).metadata).toStrictEqual(summary); }); it("reports no metadata when the container carries no SummaryInformation stream", () => { @@ -50,7 +50,7 @@ describe("readWorkbookStreams", () => { const { embeddingStreams } = readWorkbookStreams(bytes); expect(embeddingStreams.size).toBe(1); - expect(embeddingStreams.get(1)).toEqual(packageBytes); + expect(embeddingStreams.get(1)).toStrictEqual(packageBytes); }); it("reports no embedding streams when the container carries none", () => { diff --git a/packages/xls-codec/src/content.test.ts b/packages/xls-codec/src/content.test.ts index 6350fb3027..c5e375f057 100644 --- a/packages/xls-codec/src/content.test.ts +++ b/packages/xls-codec/src/content.test.ts @@ -283,7 +283,7 @@ describe("readXlsContent", () => { const content = readXlsContent(bytes); expect(content.kind).toBe("spreadsheet"); - expect(content.sheets.map((sheet) => sheet.name)).toEqual([ + expect(content.sheets.map((sheet) => sheet.name)).toStrictEqual([ "First", "Second", ]); @@ -309,11 +309,11 @@ describe("readXlsContent", () => { const content = readXlsContent(bytes); - expect(content.sheets[0]?.cells[0]?.value).toEqual({ + expect(content.sheets[0]?.cells[0]?.value).toStrictEqual({ kind: "number", value: 11, }); - expect(content.sheets[1]?.cells[0]?.value).toEqual({ + expect(content.sheets[1]?.cells[0]?.value).toStrictEqual({ kind: "number", value: 22, }); @@ -339,7 +339,7 @@ describe("readXlsContent", () => { }), ); - expect(readXlsContent(bytes).sheets[0]?.cells[0]).toEqual({ + expect(readXlsContent(bytes).sheets[0]?.cells[0]).toStrictEqual({ row: 0, column: 0, value: { kind: "string", value: "Hello" }, @@ -397,7 +397,7 @@ describe("readXlsContent", () => { }), ); - expect(readXlsContent(bytes).sheets[0]?.cells[0]?.value).toEqual({ + expect(readXlsContent(bytes).sheets[0]?.cells[0]?.value).toStrictEqual({ kind: "date", value: "2024-01-01", }); @@ -417,7 +417,7 @@ describe("readXlsContent", () => { }), ); - expect(readXlsContent(bytes).sheets[0]?.cells[0]?.value).toEqual({ + expect(readXlsContent(bytes).sheets[0]?.cells[0]?.value).toStrictEqual({ kind: "percentage", value: 0.4256, }); @@ -442,7 +442,7 @@ describe("readXlsContent", () => { }), ); - expect(readXlsContent(bytes).sheets[0]?.cells[0]?.value).toEqual({ + expect(readXlsContent(bytes).sheets[0]?.cells[0]?.value).toStrictEqual({ kind: "currency", value: 12.5, currency: "GBP", @@ -463,7 +463,7 @@ describe("readXlsContent", () => { }), ); - expect(readXlsContent(bytes).sheets[0]?.cells[0]?.value).toEqual({ + expect(readXlsContent(bytes).sheets[0]?.cells[0]?.value).toStrictEqual({ kind: "number", value: 60, }); @@ -553,7 +553,7 @@ describe("readXlsContent", () => { }), ); - expect(readXlsContent(bytes).sheets[0]?.dataValidations).toEqual([ + expect(readXlsContent(bytes).sheets[0]?.dataValidations).toStrictEqual([ { type: "decimal", operator: "greaterThan", @@ -610,7 +610,7 @@ describe("readXlsContent", () => { }), ); - expect(readXlsContent(bytes).sheets[0]?.conditionalFormats).toEqual([ + expect(readXlsContent(bytes).sheets[0]?.conditionalFormats).toStrictEqual([ { type: "cellIs", operator: "greaterThan", @@ -683,7 +683,7 @@ describe("readXlsContent", () => { }), ); - expect(readXlsContent(bytes).sheets[0]?.conditionalFormats).toEqual([ + expect(readXlsContent(bytes).sheets[0]?.conditionalFormats).toStrictEqual([ { type: "colorScale", ranges: [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }], @@ -748,7 +748,7 @@ describe("readXlsContent", () => { }), ); - expect(readXlsContent(bytes).sheets[0]?.conditionalFormats).toEqual([ + expect(readXlsContent(bytes).sheets[0]?.conditionalFormats).toStrictEqual([ { type: "top10", ranges: [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }], @@ -780,7 +780,7 @@ describe("readXlsContent", () => { }), ); - expect(readXlsContent(bytes).sheets[0]?.cells).toEqual([ + expect(readXlsContent(bytes).sheets[0]?.cells).toStrictEqual([ { row: 2, column: 1, @@ -803,9 +803,9 @@ describe("readXlsContent", () => { }), ); - expect(readXlsContent(bytes).sheets.map((sheet) => sheet.name)).toEqual([ - "Data", - ]); + expect( + readXlsContent(bytes).sheets.map((sheet) => sheet.name), + ).toStrictEqual(["Data"]); }); it("emits print settings the schema requires even though the file's own are not read", () => { @@ -1281,11 +1281,11 @@ describe("readXlsContent", () => { displayText: "", }); // icv 10 is the default table's own Red, icv 12 its own Blue. - expect(cells[0]?.background).toEqual({ + expect(cells[0]?.background).toStrictEqual({ kind: "solid", color: { r: 1, g: 0, b: 0 }, }); - expect(cells[0]?.borders).toEqual({ + expect(cells[0]?.borders).toStrictEqual({ top: { color: { r: 0, g: 0, b: 1 }, widthPt: 0.75 }, }); }); @@ -1317,7 +1317,9 @@ describe("readXlsContent", () => { fillPattern: 1, fillForegroundIcv: 10, }); - expect(readXlsContent(bytes).sheets[0]?.cells[0]?.background).toEqual({ + expect( + readXlsContent(bytes).sheets[0]?.cells[0]?.background, + ).toStrictEqual({ kind: "solid", color: { r: 1, g: 0, b: 0 }, }); @@ -1330,7 +1332,9 @@ describe("readXlsContent", () => { fillForegroundIcv: 10, // default Red fillBackgroundIcv: 11, // default Green }); - expect(readXlsContent(bytes).sheets[0]?.cells[0]?.background).toEqual({ + expect( + readXlsContent(bytes).sheets[0]?.cells[0]?.background, + ).toStrictEqual({ kind: "pattern", patternType: "mediumGray", foregroundColor: { r: 1, g: 0, b: 0 }, @@ -1355,7 +1359,7 @@ describe("readXlsContent", () => { left: { style: 1, icv: 12 }, // icv 12: default Blue top: { style: 3, icv: 11 }, // icv 11: default Green }); - expect(readXlsContent(bytes).sheets[0]?.cells[0]?.borders).toEqual({ + expect(readXlsContent(bytes).sheets[0]?.cells[0]?.borders).toStrictEqual({ left: { color: { r: 0, g: 0, b: 1 }, widthPt: 0.75 }, top: { color: { r: 0, g: 1, b: 0 }, widthPt: 0.75, style: "dashed" }, }); @@ -1387,7 +1391,9 @@ describe("readXlsContent", () => { ]), ], ); - expect(readXlsContent(bytes).sheets[0]?.cells[0]?.background).toEqual({ + expect( + readXlsContent(bytes).sheets[0]?.cells[0]?.background, + ).toStrictEqual({ kind: "solid", color: { r: 1, g: 128 / 255, b: 0 }, }); @@ -1421,7 +1427,7 @@ describe("readXlsContent", () => { }), ); - expect(readXlsContent(bytes).sheets[0]?.cells[0]?.font).toEqual({ + expect(readXlsContent(bytes).sheets[0]?.cells[0]?.font).toStrictEqual({ bold: true, italic: true, underline: true, @@ -1606,7 +1612,7 @@ describe("readXlsContent", () => { }), ); - expect(readXlsContent(bytes).names).toEqual([ + expect(readXlsContent(bytes).names).toStrictEqual([ { name: "SalesData", refersTo: "Sheet1!$A$1:$B$2" }, ]); }); @@ -1636,7 +1642,7 @@ describe("readXlsContent", () => { }), ); - expect(readXlsContent(bytes).names).toEqual([ + expect(readXlsContent(bytes).names).toStrictEqual([ { name: "LocalRange", refersTo: "Sheet2!$A$3:$A$5", @@ -1682,7 +1688,7 @@ describe("readXlsContent", () => { }), ); - expect(readXlsContent(bytes).names).toEqual([ + expect(readXlsContent(bytes).names).toStrictEqual([ { name: "SecondSheet", refersTo: "Sheet2!$A$1:$A$1", @@ -1711,7 +1717,7 @@ describe("readXlsContent", () => { ); const content = readXlsContent(bytes); - expect(content.names).toEqual([ + expect(content.names).toStrictEqual([ { name: "_xlnm._FilterDatabase", refersTo: "Sheet1!$A$1:$C$1", @@ -1719,7 +1725,7 @@ describe("readXlsContent", () => { }, ]); // The print area the built-in carried is not lost -- it lives where the schema models it. - expect(content.sheets[0]?.printSettings.printRange).toEqual({ + expect(content.sheets[0]?.printSettings.printRange).toStrictEqual({ startRow: 0, startColumn: 0, endRow: 0, @@ -1771,6 +1777,7 @@ describe("readXlsContent", () => { }, ); const content = readXlsContent(bytes); + // .toEqual, not .toStrictEqual: archive-codec's own summaryInformationToLayoutMetadata (shared with doc-codec/ppt-codec) states every LayoutMetadata field explicitly, as undefined rather than omitted, for whichever of subject/keywords/modifiedIso the stream did not carry -- a real, if minor, contract inconsistency against LayoutMetadataSchema's own "optional means absent" convention, but one belonging to that shared package rather than this one. expect(content.metadata).toEqual({ title: "Budget", author: "Cornelius", @@ -1785,7 +1792,7 @@ describe("readXlsContent", () => { sheets: [{ name: "Sheet1", records: [] }], }), ); - expect(readXlsContent(bytes).metadata).toEqual({}); + expect(readXlsContent(bytes).metadata).toStrictEqual({}); }); }); }); @@ -1833,7 +1840,7 @@ describe("readXlsContent formula recovery", () => { ); expect(cellC1?.formula).toBe("A1+B1"); - expect(cellC1?.value).toEqual({ kind: "number", value: 3 }); + expect(cellC1?.value).toStrictEqual({ kind: "number", value: 3 }); }); it("resolves a cross-sheet 3D reference through EXTERNSHEET and a self-referencing SupBook", () => { @@ -1917,7 +1924,7 @@ describe("readXlsContent formula recovery", () => { const cellA1 = readXlsContent(bytes).sheets[0]?.cells[0]; expect(cellA1?.formula).toBeUndefined(); - expect(cellA1?.value).toEqual({ kind: "number", value: 4 }); + expect(cellA1?.value).toStrictEqual({ kind: "number", value: 4 }); }); }); @@ -1994,7 +2001,7 @@ describe("readXlsContent schema conformance", () => { ), ); - expect(kinds).toEqual( + expect(kinds).toStrictEqual( new Set(["date", "percentage", "string", "boolean", "error", "empty"]), ); }); @@ -2050,7 +2057,7 @@ describe("isXlsFile", () => { ]); expect(isXlsFile(xlr)).toBe(true); - expect(readXlsContent(xlr).sheets[0]?.cells[0]?.value).toEqual({ + expect(readXlsContent(xlr).sheets[0]?.cells[0]?.value).toStrictEqual({ kind: "number", value: 3, }); @@ -2145,7 +2152,7 @@ describe("readXlsContent print settings", () => { it("falls back to Excel's own Normal preset for a sheet stating nothing", () => { // Every record behind these is optional in [MS-XLS] 2.1.7.20.6's own PAGESETUP production, and a sheet nobody has set a page setup on carries none of them. - expect(printSettingsOf([])).toEqual({ + expect(printSettingsOf([])).toStrictEqual({ pageSize: { widthPt: 612, heightPt: 792 }, margins: { topPt: 54, rightPt: 50.4, bottomPt: 54, leftPt: 50.4 }, gridlines: false, @@ -2157,7 +2164,7 @@ describe("readXlsContent print settings", () => { it("falls back per field, keeping the one margin a sheet does state", () => { expect( printSettingsOf([record(RECORD_LEFTMARGIN, f64(1))])?.margins, - ).toEqual({ topPt: 54, rightPt: 50.4, bottomPt: 54, leftPt: 72 }); + ).toStrictEqual({ topPt: 54, rightPt: 50.4, bottomPt: 54, leftPt: 72 }); }); it("resolves the page size, scale, gridlines, headers, and page order a sheet does state", () => { @@ -2194,7 +2201,7 @@ describe("readXlsContent print settings", () => { }), ]); - expect(settings?.pageSize).toEqual({ widthPt: 612, heightPt: 792 }); + expect(settings?.pageSize).toStrictEqual({ widthPt: 612, heightPt: 792 }); expect(settings?.scalePercent).toBeUndefined(); }); @@ -2211,7 +2218,7 @@ describe("readXlsContent print settings", () => { }), ]); - expect(settings?.fitToPages).toEqual({ width: 2, height: 3 }); + expect(settings?.fitToPages).toStrictEqual({ width: 2, height: 3 }); expect(settings?.scalePercent).toBeUndefined(); }); @@ -2248,7 +2255,7 @@ describe("readXlsContent print settings", () => { ...u16(0xffff), ]), ])?.manualBreaks, - ).toEqual({ rows: [12], columns: [5] }); + ).toStrictEqual({ rows: [12], columns: [5] }); }); it("reads the print range from the globals substream's own built-in defined name", () => { @@ -2264,7 +2271,7 @@ describe("readXlsContent print settings", () => { }), ], )?.printRange, - ).toEqual({ startRow: 1, startColumn: 1, endRow: 5, endColumn: 3 }); + ).toStrictEqual({ startRow: 1, startColumn: 1, endRow: 5, endColumn: 3 }); }); it("scopes a print name by its own BoundSheet8 position, not by position among the worksheets", () => { @@ -2289,7 +2296,7 @@ describe("readXlsContent print settings", () => { const document = readXlsContent(bytes); expect(document.sheets).toHaveLength(1); - expect(document.sheets[0]?.printSettings.printRange).toEqual({ + expect(document.sheets[0]?.printSettings.printRange).toStrictEqual({ startRow: 3, startColumn: 0, endRow: 4, @@ -2317,7 +2324,7 @@ describe("readXlsContent: cell comments (ExaDev/documents.js#949)", () => { }), ); - expect(readXlsContent(bytes).sheets[0]?.cells[0]).toEqual({ + expect(readXlsContent(bytes).sheets[0]?.cells[0]).toStrictEqual({ row: 0, column: 0, value: { kind: "number", value: 42 }, @@ -2344,7 +2351,7 @@ describe("readXlsContent: cell comments (ExaDev/documents.js#949)", () => { }), ); - expect(readXlsContent(bytes).sheets[0]?.cells).toEqual([ + expect(readXlsContent(bytes).sheets[0]?.cells).toStrictEqual([ { row: 5, column: 2, @@ -2454,7 +2461,7 @@ describe("readXlsContent: charts, drawings and images (ExaDev/documents.js#924)" const sheet = readXlsContent(bytes).sheets[0]; - expect(sheet?.images).toEqual([]); + expect(sheet?.images).toStrictEqual([]); expect(sheet?.embeddedObjects).toHaveLength(1); expect(sheet?.embeddedObjects?.[0]?.objectKind).toBe("drawing"); }); @@ -2469,7 +2476,7 @@ describe("readXlsContent: charts, drawings and images (ExaDev/documents.js#924)" const sheet = readXlsContent(bytes).sheets[0]; - expect(sheet?.images).toEqual([]); + expect(sheet?.images).toStrictEqual([]); expect(sheet?.embeddedObjects).toBeUndefined(); }); }); diff --git a/packages/xls-codec/src/drawing/escher.test.ts b/packages/xls-codec/src/drawing/escher.test.ts index dee27f4096..ed243a501a 100644 --- a/packages/xls-codec/src/drawing/escher.test.ts +++ b/packages/xls-codec/src/drawing/escher.test.ts @@ -10,7 +10,7 @@ describe("readEscherRecords", () => { const records = readEscherRecords(bytes); - expect(records).toEqual([ + expect(records).toStrictEqual([ { kind: "atom", recInstance: 0x234, @@ -35,7 +35,7 @@ describe("readEscherRecords", () => { expect(dg.recType).toBe(0xf002); expect(dg.children).toHaveLength(2); const [dgAtom, spgr] = dg.children; - expect(dgAtom).toEqual({ + expect(dgAtom).toStrictEqual({ kind: "atom", recInstance: 0, recType: 0xf008, @@ -44,7 +44,7 @@ describe("readEscherRecords", () => { if (spgr?.kind !== "container") { throw new Error("expected a nested container"); } - expect(spgr.children).toEqual([ + expect(spgr.children).toStrictEqual([ { kind: "atom", recInstance: 5, @@ -62,7 +62,9 @@ describe("readEscherRecords", () => { const records = readEscherRecords(bytes); - expect(records.map((record) => record.recType)).toEqual([0xf00a, 0xf00b]); + expect(records.map((record) => record.recType)).toStrictEqual([ + 0xf00a, 0xf00b, + ]); }); it("throws on a record header running past the end of the stream", () => { @@ -113,7 +115,7 @@ describe("childrenOfType / findDescendant", () => { throw new Error("expected a container"); } - expect(findDescendant(root, 0xf007)).toEqual({ + expect(findDescendant(root, 0xf007)).toStrictEqual({ kind: "atom", recInstance: 0, recType: 0xf007, diff --git a/packages/xls-codec/src/drawing/shapes.test.ts b/packages/xls-codec/src/drawing/shapes.test.ts index 2118a691f1..4fc4fffabc 100644 --- a/packages/xls-codec/src/drawing/shapes.test.ts +++ b/packages/xls-codec/src/drawing/shapes.test.ts @@ -36,12 +36,12 @@ function rectangleShape(spid: number, anchor: readonly number[]): number[] { describe("readSheetShapes", () => { it("returns no shapes for an empty drawing stream", () => { - expect(readSheetShapes(new Uint8Array())).toEqual([]); + expect(readSheetShapes(new Uint8Array())).toStrictEqual([]); }); it("returns no shapes when the stream carries no DgContainer", () => { const bytes = new Uint8Array(escherContainer(0xf003, 0, [])); - expect(readSheetShapes(bytes)).toEqual([]); + expect(readSheetShapes(bytes)).toStrictEqual([]); }); it("skips the patriarch and reads one real top-level shape", () => { @@ -51,7 +51,7 @@ describe("readSheetShapes", () => { const shapes = readSheetShapes(bytes); expect(shapes).toHaveLength(1); - expect(shapes[0]).toEqual({ + expect(shapes[0]).toStrictEqual({ shapeType: SHAPE_TYPE_RECTANGLE, spid: 1025, blipIndex: undefined, @@ -78,7 +78,7 @@ describe("readSheetShapes", () => { const shapes = readSheetShapes(bytes); - expect(shapes.map((shape) => shape.spid)).toEqual([10, 11]); + expect(shapes.map((shape) => shape.spid)).toStrictEqual([10, 11]); }); it("resolves a picture shape's own pib property to a 1-based Blip Store index", () => { @@ -105,7 +105,7 @@ describe("readSheetShapes", () => { const shapes = readSheetShapes(bytes); - expect(shapes.map((shape) => shape.spid)).toEqual([31]); + expect(shapes.map((shape) => shape.spid)).toStrictEqual([31]); }); it("recurses into a nested shape group, skipping the group's own shape record", () => { @@ -122,6 +122,6 @@ describe("readSheetShapes", () => { const shapes = readSheetShapes(bytes); - expect(shapes.map((shape) => shape.spid)).toEqual([41]); + expect(shapes.map((shape) => shape.spid)).toStrictEqual([41]); }); }); diff --git a/packages/xls-codec/src/test-support/cfb.test.ts b/packages/xls-codec/src/test-support/cfb.test.ts index 66ee519083..5d8a54fe0c 100644 --- a/packages/xls-codec/src/test-support/cfb.test.ts +++ b/packages/xls-codec/src/test-support/cfb.test.ts @@ -103,7 +103,7 @@ describe("compoundFile", () => { const bytes = compoundFile([{ path: "Big", bytes: big }]); const streams = readCompoundFile(bytes); - expect(streamOf("Big", streams)).toEqual(big); + expect(streamOf("Big", streams)).toStrictEqual(big); }); it("round-trips two big streams, proving the second one's sectors start where the first one's end", () => { @@ -115,8 +115,8 @@ describe("compoundFile", () => { ]); const streams = readCompoundFile(bytes); - expect(streamOf("First", streams)).toEqual(first); - expect(streamOf("Second", streams)).toEqual(second); + expect(streamOf("First", streams)).toStrictEqual(first); + expect(streamOf("Second", streams)).toStrictEqual(second); }); it("round-trips a stream spanning several sectors of its own FAT chain", () => { @@ -125,7 +125,7 @@ describe("compoundFile", () => { const bytes = compoundFile([{ path: "Huge", bytes: huge }]); const streams = readCompoundFile(bytes); - expect(streamOf("Huge", streams)).toEqual(huge); + expect(streamOf("Huge", streams)).toStrictEqual(huge); }); it("round-trips a file whose record count forces more than one directory sector", () => { @@ -174,7 +174,7 @@ describe("compoundFile", () => { }); const streams = readCompoundFile(bytes); - expect(streamOf("Big", streams)).toEqual(big); + expect(streamOf("Big", streams)).toStrictEqual(big); }); it("produces byte-identical output for identical input, given the deterministic input-order layout", () => { @@ -182,13 +182,13 @@ describe("compoundFile", () => { { path: "A", bytes: textStream("one") }, { path: "B", bytes: textStream("two") }, ]; - expect(compoundFile(entries)).toEqual(compoundFile(entries)); + expect(compoundFile(entries)).toStrictEqual(compoundFile(entries)); }); it("round-trips a compound file with no streams at all", () => { const bytes = compoundFile([]); const streams = readCompoundFile(bytes); - expect(streams).toEqual([]); + expect(streams).toStrictEqual([]); }); }); diff --git a/packages/xls-codec/src/workbook/chart.test.ts b/packages/xls-codec/src/workbook/chart.test.ts index bd52d86b06..2aff57a696 100644 --- a/packages/xls-codec/src/workbook/chart.test.ts +++ b/packages/xls-codec/src/workbook/chart.test.ts @@ -195,7 +195,7 @@ describe("readChartSeries", () => { const series = readChartSeries(groups, contextWithCells(cells)); - expect(series).toEqual([ + expect(series).toStrictEqual([ { name: "Revenue", categories: ["Jan", "Feb"], values: ["10", "20"] }, ]); }); @@ -214,7 +214,7 @@ describe("readChartSeries", () => { const series = readChartSeries(groups, contextWithCells([])); - expect(series).toEqual([ + expect(series).toStrictEqual([ { name: undefined, categories: ["Q1"], values: ["42"] }, ]); }); @@ -239,12 +239,12 @@ describe("readChartSeries", () => { const series = readChartSeries(groups, contextWithCells([])); - expect(series).toEqual([ + expect(series).toStrictEqual([ { name: undefined, categories: [""], values: [""] }, ]); }); it("returns no series for a chart substream with no Series records", () => { - expect(readChartSeries([], contextWithCells([]))).toEqual([]); + expect(readChartSeries([], contextWithCells([]))).toStrictEqual([]); }); }); diff --git a/packages/xls-codec/src/workbook/comment-writer.test.ts b/packages/xls-codec/src/workbook/comment-writer.test.ts index 5fa12a227e..58b1e85900 100644 --- a/packages/xls-codec/src/workbook/comment-writer.test.ts +++ b/packages/xls-codec/src/workbook/comment-writer.test.ts @@ -35,13 +35,13 @@ function readBack(pieces: readonly Uint8Array[]) { describe("writeSheetComments", () => { it("returns nothing for a sheet with no commented cells", () => { - expect(writeSheetComments([])).toEqual([]); + expect(writeSheetComments([])).toStrictEqual([]); }); it("round-trips one comment's cell position, text, and author", () => { const pieces = writeSheetComments([commentedCell(2, 3, "Hello", "Alice")]); - expect(readBack(pieces).get("2:3")).toEqual({ + expect(readBack(pieces).get("2:3")).toStrictEqual({ row: 2, column: 3, comment: { text: "Hello", author: "Alice" }, @@ -51,7 +51,7 @@ describe("writeSheetComments", () => { it("round-trips a comment with no author as an absent author, not an empty-string placeholder", () => { const pieces = writeSheetComments([commentedCell(0, 0, "No author")]); - expect(readBack(pieces).get("0:0")).toEqual({ + expect(readBack(pieces).get("0:0")).toStrictEqual({ row: 0, column: 0, comment: { text: "No author" }, @@ -63,7 +63,7 @@ describe("writeSheetComments", () => { const records = readRecords(concat(...pieces)); expect(records.some((r) => r.type === 0x003c /* CONTINUE */)).toBe(false); - expect(readBack(pieces).get("1:1")).toEqual({ + expect(readBack(pieces).get("1:1")).toStrictEqual({ row: 1, column: 1, comment: { text: "" }, @@ -78,15 +78,15 @@ describe("writeSheetComments", () => { ]); const comments = readBack(pieces); - expect(comments.get("5:5")?.comment).toEqual({ + expect(comments.get("5:5")?.comment).toStrictEqual({ text: "Fifth", author: "E", }); - expect(comments.get("0:9")?.comment).toEqual({ + expect(comments.get("0:9")?.comment).toStrictEqual({ text: "First-by-row", author: "A", }); - expect(comments.get("0:2")?.comment).toEqual({ + expect(comments.get("0:2")?.comment).toStrictEqual({ text: "First-by-column", author: "B", }); @@ -103,7 +103,7 @@ describe("writeSheetComments", () => { .filter((r) => r.type === RECORD_NOTE) .map((r) => [r.data[0], r.data[2]]); // row, column: each a little-endian u16 whose low byte alone is enough here - expect(notePositions).toEqual([ + expect(notePositions).toStrictEqual([ [0, 1], [0, 5], [2, 0], @@ -118,7 +118,7 @@ describe("writeSheetComments", () => { const records = readRecords(concat(...pieces)); const types = records.map((r) => r.type); - expect(types).toEqual([ + expect(types).toStrictEqual([ RECORD_NOTE, RECORD_NOTE, RECORD_OBJ, @@ -140,7 +140,7 @@ describe("writeSheetComments", () => { // idObj sits at byte offset 6 of a Note record's own data (row u16, column u16, flags u16, idObj u16). const idObjs = notes.map((r) => r.data[6]); - expect(idObjs).toEqual([1, 2]); + expect(idObjs).toStrictEqual([1, 2]); }); it("refuses more comments than a 16-bit FtCmo.id can distinguish", () => { diff --git a/packages/xls-codec/src/workbook/comments.test.ts b/packages/xls-codec/src/workbook/comments.test.ts index c2139f0716..6f4a974db3 100644 --- a/packages/xls-codec/src/workbook/comments.test.ts +++ b/packages/xls-codec/src/workbook/comments.test.ts @@ -23,7 +23,7 @@ describe("readSheetComments", () => { ...noteTxoRecords("Hello there"), noteRecord(2, 3, 1, "Alice"), ); - expect(comments.get("2:3")).toEqual({ + expect(comments.get("2:3")).toStrictEqual({ row: 2, column: 3, comment: { text: "Hello there", author: "Alice" }, @@ -36,7 +36,7 @@ describe("readSheetComments", () => { ...noteTxoRecords("No author here"), noteRecord(0, 0, 1), ); - expect(comments.get("0:0")).toEqual({ + expect(comments.get("0:0")).toStrictEqual({ row: 0, column: 0, comment: { text: "No author here" }, @@ -49,7 +49,7 @@ describe("readSheetComments", () => { noteObjRecord(7), ...noteTxoRecords("Written first, read last"), ); - expect(comments.get("5:5")).toEqual({ + expect(comments.get("5:5")).toStrictEqual({ row: 5, column: 5, comment: { text: "Written first, read last", author: "Bob" }, @@ -75,12 +75,12 @@ describe("readSheetComments", () => { noteRecord(0, 0, 1, "Alice"), noteRecord(4, 4, 2, "Bob"), ); - expect(comments.get("0:0")).toEqual({ + expect(comments.get("0:0")).toStrictEqual({ row: 0, column: 0, comment: { text: "First comment", author: "Alice" }, }); - expect(comments.get("4:4")).toEqual({ + expect(comments.get("4:4")).toStrictEqual({ row: 4, column: 4, comment: { text: "Second comment", author: "Bob" }, diff --git a/packages/xls-codec/src/workbook/conditional-format-12.test.ts b/packages/xls-codec/src/workbook/conditional-format-12.test.ts index e24a0e4ffa..0eb2579331 100644 --- a/packages/xls-codec/src/workbook/conditional-format-12.test.ts +++ b/packages/xls-codec/src/workbook/conditional-format-12.test.ts @@ -273,7 +273,7 @@ describe("readCondFmt12Group", () => { const result = readCondFmt12Group(groups, 0, NO_SHEETS); expect(result.recordsConsumed).toBe(2); - expect(result.formats).toEqual([ + expect(result.formats).toStrictEqual([ { kind: "colorScale", stops: [ @@ -307,7 +307,7 @@ describe("readCondFmt12Group", () => { expect(format?.kind).toBe("colorScale"); if (format?.kind !== "colorScale") throw new Error("expected colorScale"); expect(format.stops).toHaveLength(3); - expect(format.stops[1]).toEqual({ + expect(format.stops[1]).toStrictEqual({ value: { type: "num", value: "50" }, color: { kind: "rgb", color: { r: 1, g: 1, b: 0 }, tint: 0 }, }); @@ -329,7 +329,10 @@ describe("readCondFmt12Group", () => { const format = result.formats[0]; if (format?.kind !== "colorScale") throw new Error("expected colorScale"); - expect(format.stops[0]?.value).toEqual({ type: "formula", value: "5" }); + expect(format.stops[0]?.value).toStrictEqual({ + type: "formula", + value: "5", + }); }); it("degrades a colour scale whole rule when a stop's colour is unresolvable (automatic/theme)", () => { @@ -344,7 +347,7 @@ describe("readCondFmt12Group", () => { ), ); - expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toEqual([]); + expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toStrictEqual([]); }); it("reads a data bar with its min/max thresholds, colour, and showValue", () => { @@ -363,7 +366,7 @@ describe("readCondFmt12Group", () => { const result = readCondFmt12Group(groups, 0, NO_SHEETS); - expect(result.formats).toEqual([ + expect(result.formats).toStrictEqual([ { kind: "dataBar", min: { type: "min" }, @@ -419,7 +422,7 @@ describe("readCondFmt12Group", () => { const result = readCondFmt12Group(groups, 0, NO_SHEETS); - expect(result.formats).toEqual([ + expect(result.formats).toStrictEqual([ { kind: "iconSet", iconSetType: "3TrafficLights1", @@ -474,7 +477,7 @@ describe("readCondFmt12Group", () => { ), ); - expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toEqual([]); + expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toStrictEqual([]); }); it("reads showValue as false when an icon set's own fIconOnly bit is set", () => { @@ -502,7 +505,7 @@ describe("readCondFmt12Group", () => { cf12Record(ct, []), ); const result = readCondFmt12Group(groups, 0, NO_SHEETS); - expect(result.formats).toEqual([]); + expect(result.formats).toStrictEqual([]); expect(result.recordsConsumed).toBe(2); } }); @@ -554,7 +557,7 @@ describe("readCondFmt12Group", () => { expect(result.recordsConsumed).toBe(3); expect(result.formats).toHaveLength(2); for (const format of result.formats) { - expect(format.ranges).toEqual(ranges); + expect(format.ranges).toStrictEqual(ranges); } }); @@ -572,7 +575,7 @@ describe("readCondFmt12Group", () => { const result = readCondFmt12Group(groups, 0, NO_SHEETS); - expect(result.formats).toEqual([]); + expect(result.formats).toStrictEqual([]); expect(result.recordsConsumed).toBe(1); }); @@ -585,7 +588,7 @@ describe("readCondFmt12Group", () => { const result = readCondFmt12Group(groups, 0, NO_SHEETS); - expect(result.formats).toEqual([]); + expect(result.formats).toStrictEqual([]); expect(result.recordsConsumed).toBe(1); }); @@ -613,7 +616,7 @@ describe("readCondFmt12Group", () => { // groupRecords has already joined the CF12 base record and its ContinueFrt12 into one logical record by this point, so recordsConsumed still counts 2 -- the CondFmt12 plus that one (now complete) CF12, the same as an unsplit CF12 would. expect(result.recordsConsumed).toBe(2); - expect(result.formats).toEqual([ + expect(result.formats).toStrictEqual([ { kind: "colorScale", stops: [ @@ -640,7 +643,7 @@ describe("readCondFmt12Group", () => { }), ); - expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toEqual([ + expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toStrictEqual([ { kind: "top10", rank: 10, @@ -649,6 +652,7 @@ describe("readCondFmt12Group", () => { priority: 0, stopIfTrue: false, ranges: ONE_RANGE, + style: undefined, }, ]); }); @@ -750,8 +754,14 @@ describe("readCondFmt12Group", () => { condFmt12Record(1, ONE_RANGE), cf12Record(0x05, cfFilterBytes(), { icfTemplate }), ); - expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toEqual([ - { kind, priority: 0, stopIfTrue: false, ranges: ONE_RANGE }, + expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toStrictEqual([ + { + kind, + priority: 0, + stopIfTrue: false, + ranges: ONE_RANGE, + style: undefined, + }, ]); } }); @@ -764,7 +774,7 @@ describe("readCondFmt12Group", () => { const result = readCondFmt12Group(groups, 0, NO_SHEETS); - expect(result.formats).toEqual([]); + expect(result.formats).toStrictEqual([]); expect(result.recordsConsumed).toBe(2); }); @@ -774,7 +784,7 @@ describe("readCondFmt12Group", () => { cf12Record(0x05, cfFilterBytes(), { icfTemplate: 0x00ff }), ); - expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toEqual([]); + expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toStrictEqual([]); }); it("reads a filter rule's own DXFN12 style -- unlike colour scale/data bar/icon set, [MS-XLS] does not force ct 0x05's own cbDxf to zero", () => { @@ -803,7 +813,7 @@ describe("readCondFmt12Group", () => { }), ); - expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toEqual([]); + expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toStrictEqual([]); }); it("still finds the record after a ct 0x05 rule's own CFFilter, proving cbFilter-driven skip advances correctly", () => { @@ -818,7 +828,7 @@ describe("readCondFmt12Group", () => { const result = readCondFmt12Group(groups, 0, NO_SHEETS); expect(result.recordsConsumed).toBe(3); - expect(result.formats.map((f) => f.kind)).toEqual([ + expect(result.formats.map((f) => f.kind)).toStrictEqual([ "uniqueValues", "duplicateValues", ]); @@ -842,7 +852,7 @@ describe("readCondFmt12Group", () => { }), ); const result = readCondFmt12Group(groups, 0, NO_SHEETS); - expect(result.formats).toEqual([ + expect(result.formats).toStrictEqual([ { kind, text: "needle", @@ -875,7 +885,7 @@ describe("readCondFmt12Group", () => { const result = readCondFmt12Group(groups, 0, NO_SHEETS); - expect(result.formats).toEqual([ + expect(result.formats).toStrictEqual([ { kind: "containsText", text: "needle", @@ -917,7 +927,9 @@ describe("readCondFmt12Group", () => { }), ); - expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toEqual([]); + expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toStrictEqual( + [], + ); }); it("does not promote a containsText-templated rule whose formula carries no string literal at all", () => { @@ -930,7 +942,9 @@ describe("readCondFmt12Group", () => { }), ); - expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toEqual([]); + expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toStrictEqual( + [], + ); }); it("does not promote a ct 0x02 rule whose icfTemplate is not the containsText family -- the same 'expression' boundary base CF's own formula-condition reading draws", () => { @@ -942,7 +956,9 @@ describe("readCondFmt12Group", () => { }), ); - expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toEqual([]); + expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toStrictEqual( + [], + ); }); }); }); diff --git a/packages/xls-codec/src/workbook/conditional-format-ex.test.ts b/packages/xls-codec/src/workbook/conditional-format-ex.test.ts index 61dad28219..b721a660f2 100644 --- a/packages/xls-codec/src/workbook/conditional-format-ex.test.ts +++ b/packages/xls-codec/src/workbook/conditional-format-ex.test.ts @@ -153,7 +153,7 @@ describe("readCfEx", () => { const result = readCfEx(cfExGroup, targets, NO_SHEETS); - expect(result).toEqual({ + expect(result).toStrictEqual({ kind: "containsText", text: "needle", priority: 3, @@ -382,7 +382,7 @@ describe("readCondFmtGroup + readCfEx integration", () => { const result = readCfEx(cfExGroup, targets, NO_SHEETS); - expect(result).toEqual({ + expect(result).toStrictEqual({ kind: "containsText", text: "needle", priority: 0, diff --git a/packages/xls-codec/src/workbook/conditional-format.test.ts b/packages/xls-codec/src/workbook/conditional-format.test.ts index b72afc850c..e9ad6ea155 100644 --- a/packages/xls-codec/src/workbook/conditional-format.test.ts +++ b/packages/xls-codec/src/workbook/conditional-format.test.ts @@ -139,7 +139,7 @@ describe("readCondFmtGroup", () => { const result = readCondFmtGroup(groups, 0, NO_SHEETS); expect(result.recordsConsumed).toBe(2); - expect(result.formats).toEqual([ + expect(result.formats).toStrictEqual([ { operator: "greaterThan", formula1: "10", @@ -158,7 +158,7 @@ describe("readCondFmtGroup", () => { const result = readCondFmtGroup(groups, 0, NO_SHEETS); - expect(result.formats).toEqual([ + expect(result.formats).toStrictEqual([ { operator: "between", formula1: "1", @@ -198,7 +198,7 @@ describe("readCondFmtGroup", () => { const result = readCondFmtGroup(groups, 0, NO_SHEETS); - expect(result.formats).toEqual([]); + expect(result.formats).toStrictEqual([]); expect(result.recordsConsumed).toBe(2); }); @@ -210,7 +210,7 @@ describe("readCondFmtGroup", () => { const result = readCondFmtGroup(groups, 0, NO_SHEETS); - expect(result.formats[0]?.style).toEqual({ + expect(result.formats[0]?.style).toStrictEqual({ fontColorIcv: 10, fill: undefined, }); @@ -230,7 +230,7 @@ describe("readCondFmtGroup", () => { const result = readCondFmtGroup(groups, 0, NO_SHEETS); - expect(result.formats[0]?.style).toEqual({ + expect(result.formats[0]?.style).toStrictEqual({ fontColorIcv: undefined, fill: { fillPattern: 1, fillForegroundIcv: 12, fillBackgroundIcv: 9 }, }); @@ -265,13 +265,13 @@ describe("readCondFmtGroup", () => { expect(result.recordsConsumed).toBe(4); expect(result.formats).toHaveLength(3); - expect(result.formats.map((f) => f.operator)).toEqual([ + expect(result.formats.map((f) => f.operator)).toStrictEqual([ "equal", "notEqual", "greaterThan", ]); for (const format of result.formats) { - expect(format.ranges).toEqual(ranges); + expect(format.ranges).toStrictEqual(ranges); } }); @@ -284,7 +284,7 @@ describe("readCondFmtGroup", () => { const result = readCondFmtGroup(groups, 0, NO_SHEETS); - expect(result.formats).toEqual([]); + expect(result.formats).toStrictEqual([]); expect(result.recordsConsumed).toBe(1); }); @@ -296,7 +296,7 @@ describe("readCondFmtGroup", () => { const result = readCondFmtGroup(groups, 0, NO_SHEETS); - expect(result.formats).toEqual([]); + expect(result.formats).toStrictEqual([]); expect(result.recordsConsumed).toBe(1); }); @@ -306,7 +306,7 @@ describe("readCondFmtGroup", () => { const result = readCondFmtGroup(groups, 0, NO_SHEETS); - expect(result.formats).toEqual([]); + expect(result.formats).toStrictEqual([]); expect(result.recordsConsumed).toBe(1); }); diff --git a/packages/xls-codec/src/workbook/data-validation.test.ts b/packages/xls-codec/src/workbook/data-validation.test.ts index ec0ae3b700..957415ee25 100644 --- a/packages/xls-codec/src/workbook/data-validation.test.ts +++ b/packages/xls-codec/src/workbook/data-validation.test.ts @@ -104,7 +104,7 @@ describe("readDv", () => { [{ startRow: 0, endRow: 9, startColumn: 0, endColumn: 0 }], ); - expect(readDv(group, NO_SHEETS)).toEqual({ + expect(readDv(group, NO_SHEETS)).toStrictEqual({ type: "whole", operator: "between", formula1: "1", @@ -236,7 +236,7 @@ describe("readDv", () => { ], ); - expect(readDv(group, NO_SHEETS)?.ranges).toEqual([ + expect(readDv(group, NO_SHEETS)?.ranges).toStrictEqual([ { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }, { startRow: 2, endRow: 4, startColumn: 1, endColumn: 3 }, ]); diff --git a/packages/xls-codec/src/workbook/drawing.test.ts b/packages/xls-codec/src/workbook/drawing.test.ts index 0e506a00e5..a2073a457a 100644 --- a/packages/xls-codec/src/workbook/drawing.test.ts +++ b/packages/xls-codec/src/workbook/drawing.test.ts @@ -108,7 +108,7 @@ function worksheetRecords(rawRecords: readonly Uint8Array[]) { describe("readSheetDrawing", () => { it("returns nothing for a worksheet with no drawing records", () => { - expect(readSheetDrawing([], baseContext())).toEqual({ + expect(readSheetDrawing([], baseContext())).toStrictEqual({ images: [], embeddedObjects: [], }); @@ -126,7 +126,7 @@ describe("readSheetDrawing", () => { const drawing = readSheetDrawing(records, baseContext({ blipStore })); - expect(drawing.embeddedObjects).toEqual([]); + expect(drawing.embeddedObjects).toStrictEqual([]); expect(drawing.images).toHaveLength(1); expect(drawing.images[0]?.format).toBe("png"); expect(drawing.images[0]?.base64).toBe("abc"); @@ -143,7 +143,7 @@ describe("readSheetDrawing", () => { const drawing = readSheetDrawing(records, baseContext()); - expect(drawing.images).toEqual([]); + expect(drawing.images).toStrictEqual([]); expect(drawing.embeddedObjects).toHaveLength(1); expect(drawing.embeddedObjects[0]?.objectKind).toBe("drawing"); }); @@ -157,8 +157,8 @@ describe("readSheetDrawing", () => { const drawing = readSheetDrawing(records, baseContext()); - expect(drawing.images).toEqual([]); - expect(drawing.embeddedObjects).toEqual([]); + expect(drawing.images).toStrictEqual([]); + expect(drawing.embeddedObjects).toStrictEqual([]); }); it("pairs multiple shapes with multiple Obj records in document order", () => { @@ -216,7 +216,7 @@ describe("readSheetDrawing", () => { baseContext({ allSubstreams: substreams }), ); - expect(drawing.images).toEqual([]); + expect(drawing.images).toStrictEqual([]); expect(drawing.embeddedObjects).toHaveLength(1); expect(drawing.embeddedObjects[0]?.objectKind).toBe("chart"); }); @@ -283,6 +283,6 @@ describe("chartTableCells", () => { { name: undefined, categories: [""], values: [""] }, ]); - expect(cells).toEqual([]); + expect(cells).toStrictEqual([]); }); }); diff --git a/packages/xls-codec/src/workbook/embedded-object.test.ts b/packages/xls-codec/src/workbook/embedded-object.test.ts index b400a7b1fb..8544422951 100644 --- a/packages/xls-codec/src/workbook/embedded-object.test.ts +++ b/packages/xls-codec/src/workbook/embedded-object.test.ts @@ -36,8 +36,8 @@ describe("writeEmbeddedObjectPackage / readEmbeddedObjectPackage", () => { const result = readEmbeddedObjectPackage(packageBytes, FRAME); expect(result?.objectKind).toBe("drawing"); - expect(result?.document).toEqual(DRAWING_DOCUMENT); - expect(result?.frame).toEqual(FRAME); + expect(result?.document).toStrictEqual(DRAWING_DOCUMENT); + expect(result?.frame).toStrictEqual(FRAME); }); it("merges the caller's own frame in, overriding whatever the payload itself carried", () => { @@ -47,7 +47,7 @@ describe("writeEmbeddedObjectPackage / readEmbeddedObjectPackage", () => { const result = readEmbeddedObjectPackage(packageBytes, otherFrame); - expect(result?.frame).toEqual(otherFrame); + expect(result?.frame).toStrictEqual(otherFrame); }); it("does not round-trip the placement fields -- they come only from the caller's frame argument", () => { @@ -75,7 +75,7 @@ describe("writeEmbeddedObjectPackage / readEmbeddedObjectPackage", () => { const result = readEmbeddedObjectPackage(packageBytes, FRAME); - expect(result?.source).toEqual({ format: "xlsx", xml: "" }); + expect(result?.source).toStrictEqual({ format: "xlsx", xml: "" }); }); it("returns undefined for a Package stream carrying a foreign label", () => { diff --git a/packages/xls-codec/src/workbook/encryption.test.ts b/packages/xls-codec/src/workbook/encryption.test.ts index c91408cb60..07c0b1bd68 100644 --- a/packages/xls-codec/src/workbook/encryption.test.ts +++ b/packages/xls-codec/src/workbook/encryption.test.ts @@ -198,7 +198,7 @@ describe("decryptWorkbookRecords", () => { PASSWORD, ); - expect([...(decrypted?.data ?? [])]).toEqual(plain); + expect([...(decrypted?.data ?? [])]).toStrictEqual(plain); }); it("leaves a never-encrypted record type's data untouched", () => { @@ -214,7 +214,7 @@ describe("decryptWorkbookRecords", () => { PASSWORD, ); - expect([...(decrypted?.data ?? [])]).toEqual(untouchedBytes); + expect([...(decrypted?.data ?? [])]).toStrictEqual(untouchedBytes); }); it("leaves BOF's own data untouched even though it is not the FilePass record", () => { @@ -229,7 +229,7 @@ describe("decryptWorkbookRecords", () => { PASSWORD, ); - expect([...(decrypted?.data ?? [])]).toEqual(bofBytes); + expect([...(decrypted?.data ?? [])]).toStrictEqual(bofBytes); }); it("preserves a BoundSheet8 record's own unencrypted lbPlyPos prefix while decrypting the rest", () => { @@ -259,7 +259,10 @@ describe("decryptWorkbookRecords", () => { PASSWORD, ); - expect([...(decrypted?.data ?? [])]).toEqual([...lbPlyPos, ...restPlain]); + expect([...(decrypted?.data ?? [])]).toStrictEqual([ + ...lbPlyPos, + ...restPlain, + ]); }); }); @@ -289,7 +292,7 @@ describe("decryptWorkbookRecords", () => { PASSWORD, ); - expect([...(decrypted?.data ?? [])]).toEqual(expectedPlain); + expect([...(decrypted?.data ?? [])]).toStrictEqual(expectedPlain); }); it("leaves a never-encrypted record type's data untouched", () => { @@ -304,7 +307,7 @@ describe("decryptWorkbookRecords", () => { PASSWORD, ); - expect([...(decrypted?.data ?? [])]).toEqual(untouchedBytes); + expect([...(decrypted?.data ?? [])]).toStrictEqual(untouchedBytes); }); it("preserves a BoundSheet8 record's own unencrypted lbPlyPos prefix while decrypting the rest", () => { @@ -338,7 +341,7 @@ describe("decryptWorkbookRecords", () => { PASSWORD, ); - expect([...(decrypted?.data ?? [])]).toEqual([ + expect([...(decrypted?.data ?? [])]).toStrictEqual([ ...lbPlyPos, ...expectedRestPlain, ]); diff --git a/packages/xls-codec/src/workbook/globals.test.ts b/packages/xls-codec/src/workbook/globals.test.ts index 6d5aefe8bf..8dde23a322 100644 --- a/packages/xls-codec/src/workbook/globals.test.ts +++ b/packages/xls-codec/src/workbook/globals.test.ts @@ -63,7 +63,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheets).toEqual([ + expect(globals.sheets).toStrictEqual([ { name: "Summary", hidden: false, sheetType: 0, bofPosition: 0x0200 }, { name: "Detail", hidden: false, sheetType: 0, bofPosition: 0x0400 }, ]); @@ -142,7 +142,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sharedStrings).toEqual(["Alpha", "Beta"]); + expect(globals.sharedStrings).toStrictEqual(["Alpha", "Beta"]); }); it("reads a shared string table spanning a Continue record", () => { @@ -161,7 +161,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sharedStrings).toEqual(["Alpha", "Abcdef"]); + expect(globals.sharedStrings).toStrictEqual(["Alpha", "Abcdef"]); }); it("rejects an SST declaring more strings than its bytes could carry", () => { @@ -217,7 +217,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.cellFormats).toEqual([ + expect(globals.cellFormats).toStrictEqual([ { fontIndex: 0, formatId: 0, @@ -253,7 +253,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.cellFormats[0]?.decoration).toEqual({ + expect(globals.cellFormats[0]?.decoration).toStrictEqual({ fillPattern: 1, fillForegroundIcv: 12, fillBackgroundIcv: 0x41, // Automatic -- cellXfTrailer's own default when the caller states no background icv. @@ -280,8 +280,8 @@ describe("readWorkbookGlobals", () => { ); expect(globals.palette?.length).toBe(PALETTE_ENTRY_COUNT); - expect(globals.palette?.[0]).toEqual({ r: 1, g: 0, b: 0 }); - expect(globals.palette?.[1]).toEqual({ r: 0, g: 1, b: 0 }); + expect(globals.palette?.[0]).toStrictEqual({ r: 1, g: 0, b: 0 }); + expect(globals.palette?.[1]).toStrictEqual({ r: 0, g: 1, b: 0 }); }); it("refuses a Palette record declaring a ccv other than the 56 the spec requires", () => { @@ -355,7 +355,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { firstSheetIndex: 1, lastSheetIndex: 2 }, ]); }); @@ -380,7 +380,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "[Budget.xlsx]Sheet1", diagnostic: false }, ]); }); @@ -405,7 +405,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "[Budget.xlsx]Sheet1", diagnostic: false }, ]); }); @@ -430,7 +430,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "[Budget.xlsx]Sheet1", diagnostic: false }, ]); }); @@ -455,7 +455,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "[EXTERNAL]Sheet1", diagnostic: true }, ]); }); @@ -480,7 +480,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "[EXTERNAL]Sheet1", diagnostic: true }, ]); }); @@ -505,7 +505,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "[EXTERNAL]Sheet1", diagnostic: true }, ]); }); @@ -529,7 +529,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "[EXTERNAL]Sheet1", diagnostic: true }, ]); }); @@ -555,7 +555,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "[Book.xlsx]Jan:Mar", diagnostic: false }, ]); }); @@ -580,7 +580,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "[EXTERNAL]Sheet1", diagnostic: true }, ]); }); @@ -599,7 +599,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "#REF!(add-in function reference)", diagnostic: true }, ]); }); @@ -623,7 +623,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "#REF!(DDE or OLE data source reference)", diagnostic: true }, ]); }); @@ -644,13 +644,13 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "#REF!(sheet not found)", diagnostic: true }, ]); }); it("defaults sheetRanges to empty when the substream carries no EXTERNSHEET record", () => { - expect(readWorkbookGlobals(groupsOf()).sheetRanges).toEqual([]); + expect(readWorkbookGlobals(groupsOf()).sheetRanges).toStrictEqual([]); }); it("degrades a SupBook whose rgst is shorter than its own declared ctab to a diagnostic, rather than aborting the whole workbook read", () => { @@ -679,10 +679,10 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "#REF!(malformed supporting link)", diagnostic: true }, ]); - expect(globals.sheets).toEqual([ + expect(globals.sheets).toStrictEqual([ { name: "Summary", hidden: false, sheetType: 0, bofPosition: 0x0200 }, ]); }); diff --git a/packages/xls-codec/src/workbook/print-names.test.ts b/packages/xls-codec/src/workbook/print-names.test.ts index 6136291655..1389b9ae09 100644 --- a/packages/xls-codec/src/workbook/print-names.test.ts +++ b/packages/xls-codec/src/workbook/print-names.test.ts @@ -48,7 +48,7 @@ function groupsOf( describe("readPrintNames", () => { it("reads a real LibreOffice-written Print_Area into the range it names", () => { - expect(readPrintNames(groupsOf(LIBREOFFICE_PRINT_AREA))).toEqual( + expect(readPrintNames(groupsOf(LIBREOFFICE_PRINT_AREA))).toStrictEqual( new Map([ [ 0, @@ -66,7 +66,7 @@ describe("readPrintNames", () => { }); it("reads a real LibreOffice-written Print_Titles into both repeated bands", () => { - expect(readPrintNames(groupsOf(LIBREOFFICE_PRINT_TITLES))).toEqual( + expect(readPrintNames(groupsOf(LIBREOFFICE_PRINT_TITLES))).toStrictEqual( new Map([ [ 0, @@ -84,7 +84,7 @@ describe("readPrintNames", () => { readPrintNames( groupsOf(LIBREOFFICE_PRINT_AREA, LIBREOFFICE_PRINT_TITLES), ).get(0), - ).toEqual({ + ).toStrictEqual({ printRange: { startRow: 1, startColumn: 1, endRow: 5, endColumn: 3 }, repeatRows: { start: 0, end: 1 }, repeatColumns: { start: 0, end: 0 }, @@ -94,7 +94,9 @@ describe("readPrintNames", () => { it("keys a name by its own itab, one-based in the record and zero-based here", () => { const onSheetThree = new Uint8Array(LIBREOFFICE_PRINT_AREA); onSheetThree[OFFSET_ITAB] = 0x03; - expect([...readPrintNames(groupsOf(onSheetThree)).keys()]).toEqual([2]); + expect([...readPrintNames(groupsOf(onSheetThree)).keys()]).toStrictEqual([ + 2, + ]); }); it("ignores a name that is not built in", () => { @@ -128,7 +130,9 @@ describe("printNameEntriesFor and writePrintNameRecords", () => { const entries = printNameEntriesFor(0, 0, { printRange: { startRow: 1, startColumn: 1, endRow: 5, endColumn: 3 }, }); - expect(writePrintNameRecords(entries)).toEqual([LIBREOFFICE_PRINT_AREA]); + expect(writePrintNameRecords(entries)).toStrictEqual([ + LIBREOFFICE_PRINT_AREA, + ]); }); it("writes both repeated bands as one Print_Titles name, mem-wrapped and union-joined", () => { @@ -136,13 +140,13 @@ describe("printNameEntriesFor and writePrintNameRecords", () => { repeatRows: { start: 0, end: 1 }, repeatColumns: { start: 0, end: 0 }, }); - expect(writePrintNameRecords(entries)).toEqual([ + expect(writePrintNameRecords(entries)).toStrictEqual([ PRINT_TITLES_WITHOUT_PAREN, ]); }); it("plans no name at all for a sheet declaring neither a range nor a band", () => { - expect(printNameEntriesFor(0, 0, {})).toEqual([]); + expect(printNameEntriesFor(0, 0, {})).toStrictEqual([]); }); it("round-trips every combination of range and bands back through the reader", () => { @@ -152,14 +156,14 @@ describe("printNameEntriesFor and writePrintNameRecords", () => { repeatColumns: { start: 1, end: 2 }, }; const records = writePrintNameRecords(printNameEntriesFor(5, 5, settings)); - expect(readPrintNames(groupsOf(...records)).get(5)).toEqual(settings); + expect(readPrintNames(groupsOf(...records)).get(5)).toStrictEqual(settings); }); it("round-trips a repeated row band on its own, without inventing a column band", () => { const records = writePrintNameRecords( printNameEntriesFor(0, 0, { repeatRows: { start: 0, end: 0 } }), ); - expect(readPrintNames(groupsOf(...records)).get(0)).toEqual({ + expect(readPrintNames(groupsOf(...records)).get(0)).toStrictEqual({ repeatRows: { start: 0, end: 0 }, }); }); diff --git a/packages/xls-codec/src/workbook/sheet.test.ts b/packages/xls-codec/src/workbook/sheet.test.ts index 6d1e20ac46..248c49a109 100644 --- a/packages/xls-codec/src/workbook/sheet.test.ts +++ b/packages/xls-codec/src/workbook/sheet.test.ts @@ -63,7 +63,7 @@ describe("readSheetRecords cell records", () => { // [MS-XLS] 2.4.180: a Cell then an Xnum. https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/a40c74c6-3df4-4e81-9a43-85521cc92c0a expect( readCells(record(RECORD_NUMBER, [...cell(2, 3), ...f64(1.25)])), - ).toEqual([ + ).toStrictEqual([ { row: 2, column: 3, @@ -80,7 +80,7 @@ describe("readSheetRecords cell records", () => { record(RECORD_RK, [...u16(0), ...u16(0), ...u16(15), ...rkInteger(42)]), ); - expect(cells[0]?.value).toEqual({ kind: "number", value: 42 }); + expect(cells[0]?.value).toStrictEqual({ kind: "number", value: 42 }); }); it("reads an RK cell holding a truncated double", () => { @@ -88,7 +88,7 @@ describe("readSheetRecords cell records", () => { record(RECORD_RK, [...u16(0), ...u16(0), ...u16(15), ...rkDouble(1.5)]), ); - expect(cells[0]?.value).toEqual({ kind: "number", value: 1.5 }); + expect(cells[0]?.value).toStrictEqual({ kind: "number", value: 1.5 }); }); it("reads a MulRk record as one cell per column in its run", () => { @@ -109,7 +109,7 @@ describe("readSheetRecords cell records", () => { expect( cells.map((entry) => [entry.row, entry.column, entry.value]), - ).toEqual([ + ).toStrictEqual([ [4, 1, { kind: "number", value: 10 }], [4, 2, { kind: "number", value: 20 }], [4, 3, { kind: "number", value: 30 }], @@ -129,7 +129,7 @@ describe("readSheetRecords cell records", () => { ]), ); - expect(cells.map((entry) => entry.xfIndex)).toEqual([15, 16]); + expect(cells.map((entry) => entry.xfIndex)).toStrictEqual([15, 16]); }); it("rejects a MulRk record whose length holds no whole number of entries", () => { @@ -143,7 +143,7 @@ describe("readSheetRecords cell records", () => { it("reads a Blank cell", () => { const cells = readCells(record(RECORD_BLANK, cell(1, 1))); - expect(cells[0]?.value).toEqual({ kind: "blank" }); + expect(cells[0]?.value).toStrictEqual({ kind: "blank" }); }); it("reads a MulBlank record as one cell per column in its run", () => { @@ -158,7 +158,7 @@ describe("readSheetRecords cell records", () => { ]), ); - expect(cells.map((entry) => [entry.column, entry.xfIndex])).toEqual([ + expect(cells.map((entry) => [entry.column, entry.xfIndex])).toStrictEqual([ [2, 15], [3, 16], ]); @@ -170,7 +170,7 @@ describe("readSheetRecords cell records", () => { record(RECORD_BOOLERR, [...cell(0, 0), 0x01, 0x00]), ); - expect(cells[0]?.value).toEqual({ kind: "boolean", value: true }); + expect(cells[0]?.value).toStrictEqual({ kind: "boolean", value: true }); }); it("reads a false boolean cell", () => { @@ -178,7 +178,7 @@ describe("readSheetRecords cell records", () => { record(RECORD_BOOLERR, [...cell(0, 0), 0x00, 0x00]), ); - expect(cells[0]?.value).toEqual({ kind: "boolean", value: false }); + expect(cells[0]?.value).toStrictEqual({ kind: "boolean", value: false }); }); it("reads an error cell as the spelling a user sees", () => { @@ -186,14 +186,14 @@ describe("readSheetRecords cell records", () => { record(RECORD_BOOLERR, [...cell(0, 0), 0x07, 0x01]), ); - expect(cells[0]?.value).toEqual({ kind: "error", value: "#DIV/0!" }); + expect(cells[0]?.value).toStrictEqual({ kind: "error", value: "#DIV/0!" }); }); it("drops a cell whose error code the specification does not define", () => { // Inventing a spelling would put a value in the document no producer wrote. expect( readCells(record(RECORD_BOOLERR, [...cell(0, 0), 0x99, 0x01])), - ).toEqual([]); + ).toStrictEqual([]); }); it("reads a LabelSst cell through the shared string table", () => { @@ -202,7 +202,10 @@ describe("readSheetRecords cell records", () => { ["Alpha", "Beta"], ); - expect(sheet.cells[0]?.value).toEqual({ kind: "string", value: "Beta" }); + expect(sheet.cells[0]?.value).toStrictEqual({ + kind: "string", + value: "Beta", + }); }); it("reads a LabelSst whose index the table does not hold as an empty string", () => { @@ -212,7 +215,7 @@ describe("readSheetRecords cell records", () => { ["Alpha"], ); - expect(sheet.cells[0]?.value).toEqual({ kind: "string", value: "" }); + expect(sheet.cells[0]?.value).toStrictEqual({ kind: "string", value: "" }); }); it("reads a Label cell's inline string", () => { @@ -220,7 +223,7 @@ describe("readSheetRecords cell records", () => { record(RECORD_LABEL, [...cell(0, 0), ...xlUnicodeString("Inline")]), ); - expect(cells[0]?.value).toEqual({ kind: "string", value: "Inline" }); + expect(cells[0]?.value).toStrictEqual({ kind: "string", value: "Inline" }); }); }); @@ -257,7 +260,7 @@ describe("readSheetRecords formula cells", () => { ]), ); - expect(cells[0]?.value).toEqual({ kind: "boolean", value: true }); + expect(cells[0]?.value).toStrictEqual({ kind: "boolean", value: true }); }); it("reads an error cached result from its tag byte", () => { @@ -276,7 +279,7 @@ describe("readSheetRecords formula cells", () => { ]), ); - expect(cells[0]?.value).toEqual({ kind: "error", value: "#REF!" }); + expect(cells[0]?.value).toStrictEqual({ kind: "error", value: "#REF!" }); }); it("reads a string cached result from the String record that follows", () => { @@ -296,7 +299,7 @@ describe("readSheetRecords formula cells", () => { record(RECORD_STRING, xlUnicodeString("Result")), ); - expect(cells[0]?.value).toEqual({ kind: "string", value: "Result" }); + expect(cells[0]?.value).toStrictEqual({ kind: "string", value: "Result" }); }); it("finds a string cached result past the ShrFmla record of a shared formula", () => { @@ -319,7 +322,7 @@ describe("readSheetRecords formula cells", () => { record(RECORD_STRING, xlUnicodeString("Shared")), ); - expect(cells[0]?.value).toEqual({ kind: "string", value: "Shared" }); + expect(cells[0]?.value).toStrictEqual({ kind: "string", value: "Shared" }); }); it("does not reach past an unrelated record into the next cell's own String", () => { @@ -330,7 +333,7 @@ describe("readSheetRecords formula cells", () => { record(RECORD_STRING, xlUnicodeString("NotMine")), ); - expect(cells[0]?.value).toEqual({ kind: "number", value: 1 }); + expect(cells[0]?.value).toStrictEqual({ kind: "number", value: 1 }); }); it("reads a string cached result as empty when no String record follows", () => { @@ -349,7 +352,7 @@ describe("readSheetRecords formula cells", () => { ]), ); - expect(cells[0]?.value).toEqual({ kind: "string", value: "" }); + expect(cells[0]?.value).toStrictEqual({ kind: "string", value: "" }); }); it("reads a blank-string cached result", () => { @@ -368,7 +371,7 @@ describe("readSheetRecords formula cells", () => { ]), ); - expect(cells[0]?.value).toEqual({ kind: "string", value: "" }); + expect(cells[0]?.value).toStrictEqual({ kind: "string", value: "" }); }); it("recovers the formula's own text from its compiled Ptg token stream", () => { @@ -412,7 +415,7 @@ describe("readSheetRecords formula cells", () => { ); expect(cells[0]?.formula).toBeUndefined(); - expect(cells[0]?.value).toEqual({ kind: "number", value: 4 }); + expect(cells[0]?.value).toStrictEqual({ kind: "number", value: 4 }); }); it("resolves a 3D reference using the formulaSheets context readSheetRecords is given", () => { @@ -640,7 +643,7 @@ describe("readSheetRecords formula cells", () => { ); expect(cells[0]?.formula).toBeUndefined(); - expect(cells[0]?.value).toEqual({ kind: "number", value: 6 }); + expect(cells[0]?.value).toStrictEqual({ kind: "number", value: 6 }); }); it("leaves formula absent for a PtgExp whose base cell has no matching ShrFmla/Array group", () => { @@ -658,7 +661,7 @@ describe("readSheetRecords formula cells", () => { ); expect(cells[0]?.formula).toBeUndefined(); - expect(cells[0]?.value).toEqual({ kind: "number", value: 1 }); + expect(cells[0]?.value).toStrictEqual({ kind: "number", value: 1 }); }); it("does not abort the whole sheet read when a ShrFmla record's own cce overruns the record", () => { @@ -689,8 +692,8 @@ describe("readSheetRecords formula cells", () => { ); expect(cells[0]?.formula).toBeUndefined(); - expect(cells[0]?.value).toEqual({ kind: "number", value: 1 }); - expect(cells[1]?.value).toEqual({ kind: "number", value: 42 }); + expect(cells[0]?.value).toStrictEqual({ kind: "number", value: 1 }); + expect(cells[1]?.value).toStrictEqual({ kind: "number", value: 42 }); }); it("does not abort the whole sheet read when an Array record's own cce overruns the record", () => { @@ -721,8 +724,8 @@ describe("readSheetRecords formula cells", () => { ); expect(cells[0]?.formula).toBeUndefined(); - expect(cells[0]?.value).toEqual({ kind: "number", value: 2 }); - expect(cells[1]?.value).toEqual({ kind: "number", value: 99 }); + expect(cells[0]?.value).toStrictEqual({ kind: "number", value: 2 }); + expect(cells[1]?.value).toStrictEqual({ kind: "number", value: 99 }); }); it("does not abort the whole sheet read when a shared group's own rgce carries a token with a lying embedded length", () => { @@ -752,8 +755,8 @@ describe("readSheetRecords formula cells", () => { ); expect(cells[0]?.formula).toBeUndefined(); - expect(cells[0]?.value).toEqual({ kind: "number", value: 1 }); - expect(cells[1]?.value).toEqual({ kind: "number", value: 42 }); + expect(cells[0]?.value).toStrictEqual({ kind: "number", value: 1 }); + expect(cells[1]?.value).toStrictEqual({ kind: "number", value: 42 }); }); it("does not abort the whole sheet read when an array group's own rgce carries a token with a lying embedded length", () => { @@ -783,8 +786,8 @@ describe("readSheetRecords formula cells", () => { ); expect(cells[0]?.formula).toBeUndefined(); - expect(cells[0]?.value).toEqual({ kind: "number", value: 2 }); - expect(cells[1]?.value).toEqual({ kind: "number", value: 99 }); + expect(cells[0]?.value).toStrictEqual({ kind: "number", value: 2 }); + expect(cells[1]?.value).toStrictEqual({ kind: "number", value: 99 }); }); it("does not abort the whole sheet read when an ordinary (non-shared) Formula record's own rgce carries a token with a lying embedded length", () => { @@ -803,8 +806,8 @@ describe("readSheetRecords formula cells", () => { ); expect(cells[0]?.formula).toBeUndefined(); - expect(cells[0]?.value).toEqual({ kind: "number", value: 1 }); - expect(cells[1]?.value).toEqual({ kind: "number", value: 42 }); + expect(cells[0]?.value).toStrictEqual({ kind: "number", value: 1 }); + expect(cells[1]?.value).toStrictEqual({ kind: "number", value: 42 }); }); it("does not abort the whole sheet read when a Formula record's own cce overruns the record", () => { @@ -823,8 +826,8 @@ describe("readSheetRecords formula cells", () => { ); expect(cells[0]?.formula).toBeUndefined(); - expect(cells[0]?.value).toEqual({ kind: "number", value: 1 }); - expect(cells[1]?.value).toEqual({ kind: "number", value: 42 }); + expect(cells[0]?.value).toStrictEqual({ kind: "number", value: 1 }); + expect(cells[1]?.value).toStrictEqual({ kind: "number", value: 42 }); }); }); @@ -844,7 +847,7 @@ describe("readSheetRecords grid geometry", () => { [], ); - expect(sheet.usedRange).toEqual({ + expect(sheet.usedRange).toStrictEqual({ startRow: 1, startColumn: 2, endRow: 4, @@ -889,7 +892,9 @@ describe("readSheetRecords grid geometry", () => { [], ); - expect(sheet.rows).toEqual([{ index: 3, heightPt: 15, hidden: false }]); + expect(sheet.rows).toStrictEqual([ + { index: 3, heightPt: 15, hidden: false }, + ]); }); it("omits a height the producer did not mark as declared", () => { @@ -911,7 +916,7 @@ describe("readSheetRecords grid geometry", () => { [], ); - expect(sheet.rows[0]).toEqual({ index: 0, hidden: false }); + expect(sheet.rows[0]).toStrictEqual({ index: 0, hidden: false }); }); it("reads a hidden row", () => { @@ -951,7 +956,9 @@ describe("readSheetRecords grid geometry", () => { [], ); - expect(sheet.columns.map((column) => column.index)).toEqual([1, 2, 3]); + expect(sheet.columns.map((column) => column.index)).toStrictEqual([ + 1, 2, 3, + ]); expect(new Set(sheet.columns.map((column) => column.widthPt))).toHaveLength( 1, ); @@ -994,7 +1001,7 @@ describe("readSheetRecords grid geometry", () => { [], ); - expect(sheet.merges).toEqual([ + expect(sheet.merges).toStrictEqual([ { startRow: 0, endRow: 1, startColumn: 0, endColumn: 2 }, { startRow: 5, endRow: 5, startColumn: 3, endColumn: 4 }, ]); @@ -1027,7 +1034,7 @@ describe("readSheetRecords grid geometry", () => { [], ); - expect(sheet.dataValidations).toEqual([ + expect(sheet.dataValidations).toStrictEqual([ { type: "whole", operator: "between", @@ -1075,7 +1082,7 @@ describe("readSheetRecords grid geometry", () => { [], ); - expect(sheet.conditionalFormats).toEqual([ + expect(sheet.conditionalFormats).toStrictEqual([ { operator: "greaterThan", formula1: "10", @@ -1085,7 +1092,7 @@ describe("readSheetRecords grid geometry", () => { }, ]); // Dimensions, the record right after the CF this CondFmt claimed, is still read on the following loop iteration -- proving the lookahead skip advanced past exactly the CondFmt's own group and nothing more. - expect(sheet.usedRange).toEqual({ + expect(sheet.usedRange).toStrictEqual({ startRow: 0, endRow: 1, startColumn: 0, @@ -1149,7 +1156,7 @@ describe("readSheetRecords grid geometry", () => { [], ); - expect(sheet.conditionalFormats12).toEqual([ + expect(sheet.conditionalFormats12).toStrictEqual([ { kind: "colorScale", stops: [ @@ -1162,7 +1169,7 @@ describe("readSheetRecords grid geometry", () => { }, ]); // Dimensions, the record right after the CF12 this CondFmt12 claimed, is still read on the following loop iteration -- proving the lookahead skip advanced past exactly the CondFmt12's own group and nothing more. - expect(sheet.usedRange).toEqual({ + expect(sheet.usedRange).toStrictEqual({ startRow: 0, endRow: 1, startColumn: 0, @@ -1205,7 +1212,7 @@ describe("readSheetRecords print settings", () => { [], ); - expect(sheet.print.setup).toEqual({ + expect(sheet.print.setup).toStrictEqual({ paperCode: 9, scalePercent: 80, fitWidth: 2, @@ -1229,7 +1236,7 @@ describe("readSheetRecords print settings", () => { [], ); - expect(sheet.print.marginsPt).toEqual({ + expect(sheet.print.marginsPt).toStrictEqual({ left: 36, right: 54, top: 72, @@ -1244,7 +1251,7 @@ describe("readSheetRecords print settings", () => { [], ); - expect(sheet.print.marginsPt).toEqual({ left: 36 }); + expect(sheet.print.marginsPt).toStrictEqual({ left: 36 }); }); it("reads PrintGrid and PrintRowCol as the booleans they are", () => { @@ -1305,8 +1312,8 @@ describe("readSheetRecords print settings", () => { [], ); - expect(sheet.print.rowBreaks).toEqual([4, 10]); - expect(sheet.print.columnBreaks).toEqual([3]); + expect(sheet.print.rowBreaks).toStrictEqual([4, 10]); + expect(sheet.print.columnBreaks).toStrictEqual([3]); }); it("collapses two breaks naming the same index, which the schema models only once", () => { @@ -1326,7 +1333,7 @@ describe("readSheetRecords print settings", () => { [], ); - expect(sheet.print.rowBreaks).toEqual([7]); + expect(sheet.print.rowBreaks).toStrictEqual([7]); }); it("states nothing at all for a sheet carrying none of the print records", () => { @@ -1335,7 +1342,7 @@ describe("readSheetRecords print settings", () => { [], ); - expect(sheet.print).toEqual({ + expect(sheet.print).toStrictEqual({ marginsPt: {}, rowBreaks: [], columnBreaks: [], diff --git a/packages/xls-codec/src/write.test.ts b/packages/xls-codec/src/write.test.ts index 0be9945d7f..4c3f639545 100644 --- a/packages/xls-codec/src/write.test.ts +++ b/packages/xls-codec/src/write.test.ts @@ -127,7 +127,7 @@ describe("writeXlsContent", () => { ); const content = readXlsContent(bytes); const readBack = findCell(content, 0, 0, 0); - expect(readBack?.value).toEqual({ kind: "number", value: 42 }); + expect(readBack?.value).toStrictEqual({ kind: "number", value: 42 }); expect(readBack?.displayText).toBe("42"); // The same "General" stamping content.test.ts already pins for a real .xls's plain cells -- XF 15's own ifmt (0) resolves through the built-in table. expect(readBack?.numberFormatCode).toBe("General"); @@ -142,7 +142,7 @@ describe("writeXlsContent", () => { ]), ); const readBack = findCell(readXlsContent(bytes), 0, 0, 0); - expect(readBack?.value).toEqual({ + expect(readBack?.value).toStrictEqual({ kind: "string", value: "Hello, world!", }); @@ -160,19 +160,19 @@ describe("writeXlsContent", () => { ]), ); const content = readXlsContent(bytes); - expect(findCell(content, 0, 0, 0)?.value).toEqual({ + expect(findCell(content, 0, 0, 0)?.value).toStrictEqual({ kind: "string", value: "Repeated", }); - expect(findCell(content, 0, 0, 1)?.value).toEqual({ + expect(findCell(content, 0, 0, 1)?.value).toStrictEqual({ kind: "string", value: "Repeated", }); - expect(findCell(content, 0, 1, 0)?.value).toEqual({ + expect(findCell(content, 0, 1, 0)?.value).toStrictEqual({ kind: "string", value: "Repeated", }); - expect(findCell(content, 0, 1, 1)?.value).toEqual({ + expect(findCell(content, 0, 1, 1)?.value).toStrictEqual({ kind: "string", value: "Different", }); @@ -185,7 +185,7 @@ describe("writeXlsContent", () => { sheet("Sheet1", [cell(0, 0, { kind: "string", value: text })]), ]), ); - expect(findCell(readXlsContent(bytes), 0, 0, 0)?.value).toEqual({ + expect(findCell(readXlsContent(bytes), 0, 0, 0)?.value).toStrictEqual({ kind: "string", value: text, }); @@ -201,12 +201,12 @@ describe("writeXlsContent", () => { ]), ); const content = readXlsContent(bytes); - expect(findCell(content, 0, 0, 0)?.value).toEqual({ + expect(findCell(content, 0, 0, 0)?.value).toStrictEqual({ kind: "boolean", value: true, }); expect(findCell(content, 0, 0, 0)?.displayText).toBe("TRUE"); - expect(findCell(content, 0, 0, 1)?.value).toEqual({ + expect(findCell(content, 0, 0, 1)?.value).toStrictEqual({ kind: "boolean", value: false, }); @@ -235,7 +235,7 @@ describe("writeXlsContent", () => { ); const content = readXlsContent(bytes); errors.forEach((text, index) => { - expect(findCell(content, 0, 0, index)?.value).toEqual({ + expect(findCell(content, 0, 0, index)?.value).toStrictEqual({ kind: "error", value: text, }); @@ -265,23 +265,26 @@ describe("writeXlsContent", () => { ]), ); const content = readXlsContent(bytes); - expect(findCell(content, 0, 0, 0)?.value).toEqual({ + expect(findCell(content, 0, 0, 0)?.value).toStrictEqual({ kind: "percentage", value: 0.5, }); const currencyCell = findCell(content, 0, 0, 1); - expect(currencyCell?.value).toEqual({ kind: "currency", value: 19.99 }); + expect(currencyCell?.value).toStrictEqual({ + kind: "currency", + value: 19.99, + }); // No numberFormatCode was given and this writer's own default currency format carries no [$XXX-nnn] marker, so no ISO code is recovered either -- an honest round trip of what was actually written, not an invented one. expect(currencyCell?.value).not.toHaveProperty("currency"); - expect(findCell(content, 0, 0, 2)?.value).toEqual({ + expect(findCell(content, 0, 0, 2)?.value).toStrictEqual({ kind: "date", value: "2026-09-03", }); - expect(findCell(content, 0, 0, 3)?.value).toEqual({ + expect(findCell(content, 0, 0, 3)?.value).toStrictEqual({ kind: "time", value: "13:45:30", }); - expect(findCell(content, 0, 0, 4)?.value).toEqual({ + expect(findCell(content, 0, 0, 4)?.value).toStrictEqual({ kind: "dateTime", value: "2026-09-03T13:45:30", }); @@ -301,7 +304,7 @@ describe("writeXlsContent", () => { ]), ); const readBack = findCell(readXlsContent(bytes), 0, 0, 0); - expect(readBack?.value).toEqual({ + expect(readBack?.value).toStrictEqual({ kind: "currency", value: 5, currency: "USD", @@ -322,18 +325,18 @@ describe("writeXlsContent", () => { ]), ); const content = readXlsContent(bytes); - expect(findCell(content, 0, 0, 0)?.value).toEqual({ + expect(findCell(content, 0, 0, 0)?.value).toStrictEqual({ kind: "currency", value: 7.99, currency: "USD", }); expect(findCell(content, 0, 0, 0)?.numberFormatCode).toBe("[$USD]#,##0.00"); - expect(findCell(content, 0, 0, 1)?.value).toEqual({ + expect(findCell(content, 0, 0, 1)?.value).toStrictEqual({ kind: "currency", value: 4.5, currency: "GBP", }); - expect(findCell(content, 0, 0, 2)?.value).toEqual({ + expect(findCell(content, 0, 0, 2)?.value).toStrictEqual({ kind: "currency", value: 3, }); @@ -354,7 +357,7 @@ describe("writeXlsContent", () => { ]), ); const readBack = findCell(readXlsContent(bytes), 0, 0, 0); - expect(readBack?.value).toEqual({ kind: "number", value: 3.14159 }); + expect(readBack?.value).toStrictEqual({ kind: "number", value: 3.14159 }); expect(readBack?.numberFormatCode).toBe("0.000"); }); @@ -396,9 +399,9 @@ describe("writeXlsContent", () => { ]), ]), ); - expect(findCell(readXlsContent(bytes), 0, 0, 0)?.background).toEqual( - redFill, - ); + expect( + findCell(readXlsContent(bytes), 0, 0, 0)?.background, + ).toStrictEqual(redFill); }); it("round-trips per-side borders, including a non-default style and colour", () => { @@ -423,7 +426,7 @@ describe("writeXlsContent", () => { ]), ]), ); - expect(findCell(readXlsContent(bytes), 0, 0, 0)?.borders).toEqual({ + expect(findCell(readXlsContent(bytes), 0, 0, 0)?.borders).toStrictEqual({ left: { color: blue, widthPt: 0.75 }, top: { color: red, widthPt: 0.75, style: "dashed" }, }); @@ -446,8 +449,8 @@ describe("writeXlsContent", () => { ]), ); const readBack = findCell(readXlsContent(bytes), 0, 0, 0); - expect(readBack?.background).toEqual(redFill); - expect(readBack?.borders).toEqual({ + expect(readBack?.background).toStrictEqual(redFill); + expect(readBack?.borders).toStrictEqual({ bottom: { color: blue, widthPt: 1.5 }, }); }); @@ -466,9 +469,9 @@ describe("writeXlsContent", () => { ]), ]), ); - expect(findCell(readXlsContent(bytes), 0, 0, 0)?.background).toEqual( - fill, - ); + expect( + findCell(readXlsContent(bytes), 0, 0, 0)?.background, + ).toStrictEqual(fill); }); it("round-trips a genuine two-colour crosshatch pattern fill instead of dropping it (ExaDev/documents.js#951)", () => { @@ -485,9 +488,9 @@ describe("writeXlsContent", () => { ]), ]), ); - expect(findCell(readXlsContent(bytes), 0, 0, 0)?.background).toEqual( - fill, - ); + expect( + findCell(readXlsContent(bytes), 0, 0, 0)?.background, + ).toStrictEqual(fill); }); it("round-trips a pattern fill leaving one of its own colours unstated", () => { @@ -503,9 +506,9 @@ describe("writeXlsContent", () => { ]), ]), ); - expect(findCell(readXlsContent(bytes), 0, 0, 0)?.background).toEqual( - fill, - ); + expect( + findCell(readXlsContent(bytes), 0, 0, 0)?.background, + ).toStrictEqual(fill); }); it("throws writing a WordprocessingML-only pattern type BIFF8's own FillPattern enumeration has no member for", () => { @@ -536,9 +539,9 @@ describe("writeXlsContent", () => { ]), ); // red (255,0,0) is icv 10 in the fixed default table -- resolvable with no Palette record present, and readXlsContent must still recover it correctly through that fallback. - expect(findCell(readXlsContent(bytes), 0, 0, 0)?.background).toEqual( - redFill, - ); + expect( + findCell(readXlsContent(bytes), 0, 0, 0)?.background, + ).toStrictEqual(redFill); }); it("writes a real Palette record and round-trips a colour outside the fixed default table", () => { @@ -554,9 +557,9 @@ describe("writeXlsContent", () => { ]), ]), ); - expect(findCell(readXlsContent(bytes), 0, 0, 0)?.background).toEqual( - coralFill, - ); + expect( + findCell(readXlsContent(bytes), 0, 0, 0)?.background, + ).toStrictEqual(coralFill); }); it("reuses one XF entry for two cells sharing the identical decoration, and mints a separate one for a cell with none", () => { @@ -571,8 +574,8 @@ describe("writeXlsContent", () => { ]), ); const content = readXlsContent(bytes); - expect(findCell(content, 0, 0, 0)?.background).toEqual(redFill); - expect(findCell(content, 0, 0, 1)?.background).toEqual(redFill); + expect(findCell(content, 0, 0, 0)?.background).toStrictEqual(redFill); + expect(findCell(content, 0, 0, 1)?.background).toStrictEqual(redFill); expect(findCell(content, 0, 0, 2)?.background).toBeUndefined(); }); @@ -624,7 +627,7 @@ describe("writeXlsContent", () => { for (const written of cells) { expect( findCell(content, 0, written.row, written.column)?.background, - ).toEqual(written.background); + ).toStrictEqual(written.background); } }); @@ -647,9 +650,9 @@ describe("writeXlsContent", () => { ); const readBack = findCell(readXlsContent(bytes), 0, 1, 2); - expect(readBack?.value).toEqual({ kind: "empty" }); - expect(readBack?.background).toEqual(redFill); - expect(readBack?.borders).toEqual({ + expect(readBack?.value).toStrictEqual({ kind: "empty" }); + expect(readBack?.background).toStrictEqual(redFill); + expect(readBack?.borders).toStrictEqual({ top: { color: blue, widthPt: 1.5 }, }); }); @@ -688,8 +691,8 @@ describe("writeXlsContent", () => { ); const readBack = findCell(readXlsContent(bytes), 0, 0, 0); - expect(readBack?.value).toEqual({ kind: "empty" }); - expect(readBack?.background).toEqual(redFill); + expect(readBack?.value).toStrictEqual({ kind: "empty" }); + expect(readBack?.background).toStrictEqual(redFill); expect(readBack?.colSpan).toBe(2); expect(readBack?.rowSpan).toBe(3); }); @@ -706,8 +709,8 @@ describe("writeXlsContent", () => { ); const content = readXlsContent(bytes); - expect(findCell(content, 0, 0, 0)?.background).toEqual(redFill); - expect(findCell(content, 0, 0, 1)?.background).toEqual(redFill); + expect(findCell(content, 0, 0, 0)?.background).toStrictEqual(redFill); + expect(findCell(content, 0, 0, 1)?.background).toStrictEqual(redFill); }); it("refuses one distinct colour past the palette's last slot", () => { @@ -825,7 +828,7 @@ describe("writeXlsContent", () => { const readBack = findCell(readXlsContent(bytes), 0, 0, 0); expect(readBack?.alignment).toBe("right"); expect(readBack?.verticalAlignment).toBe("top"); - expect(readBack?.background).toEqual(redFill); + expect(readBack?.background).toStrictEqual(redFill); }); it("round-trips a decorated-alignment-only empty cell through a real Blank record", () => { @@ -838,7 +841,7 @@ describe("writeXlsContent", () => { ]), ); const readBack = findCell(readXlsContent(bytes), 0, 1, 2); - expect(readBack?.value).toEqual({ kind: "empty" }); + expect(readBack?.value).toStrictEqual({ kind: "empty" }); expect(readBack?.alignment).toBe("center"); }); @@ -880,7 +883,7 @@ describe("writeXlsContent", () => { ]), ); const readBack = findCell(readXlsContent(bytes), 0, 2, 2); - expect(readBack?.value).toEqual({ kind: "string", value: "Merged" }); + expect(readBack?.value).toStrictEqual({ kind: "string", value: "Merged" }); expect(readBack?.colSpan).toBe(2); expect(readBack?.rowSpan).toBeUndefined(); }); @@ -899,7 +902,7 @@ describe("writeXlsContent", () => { ]), ); const readBack = findCell(readXlsContent(bytes), 0, 3, 0); - expect(readBack?.value).toEqual({ kind: "empty" }); + expect(readBack?.value).toStrictEqual({ kind: "empty" }); expect(readBack?.colSpan).toBe(2); expect(readBack?.rowSpan).toBe(2); }); @@ -949,20 +952,20 @@ describe("writeXlsContent", () => { ]), ); const content = readXlsContent(bytes); - expect(content.sheets.map((s) => s.name)).toEqual([ + expect(content.sheets.map((s) => s.name)).toStrictEqual([ "First", "Second", "Third", ]); - expect(findCell(content, 0, 0, 0)?.value).toEqual({ + expect(findCell(content, 0, 0, 0)?.value).toStrictEqual({ kind: "number", value: 1, }); - expect(findCell(content, 1, 0, 0)?.value).toEqual({ + expect(findCell(content, 1, 0, 0)?.value).toStrictEqual({ kind: "number", value: 2, }); - expect(findCell(content, 2, 0, 0)?.value).toEqual({ + expect(findCell(content, 2, 0, 0)?.value).toStrictEqual({ kind: "number", value: 3, }); @@ -972,7 +975,7 @@ describe("writeXlsContent", () => { const bytes = writeXlsContent(document([sheet("Empty", [])])); const content = readXlsContent(bytes); expect(content.sheets[0]?.name).toBe("Empty"); - expect(content.sheets[0]?.cells).toEqual([]); + expect(content.sheets[0]?.cells).toStrictEqual([]); }); it("produces a document valid against document-schema.js's own ContentDocumentSchema", () => { @@ -1039,13 +1042,13 @@ describe("writeXlsContent", () => { ); const content = readXlsContent(bytes); expect(findCell(content, 0, 0, 0)?.font).toBeUndefined(); - expect(findCell(content, 0, 0, 1)?.font).toEqual({ bold: true }); - expect(findCell(content, 0, 0, 2)?.font).toEqual({ + expect(findCell(content, 0, 0, 1)?.font).toStrictEqual({ bold: true }); + expect(findCell(content, 0, 0, 2)?.font).toStrictEqual({ italic: true, sizePt: 8, }); // icv 10 is the default palette's own duplicate of Red, which is what a { r: 1, g: 0, b: 0 } colour resolves to without forcing a Palette record -- the identical quantisation the fill round trips already pin. - expect(findCell(content, 0, 0, 3)?.font).toEqual({ + expect(findCell(content, 0, 0, 3)?.font).toStrictEqual({ fontFamily: "Courier New", underline: true, strike: true, @@ -1065,8 +1068,8 @@ describe("writeXlsContent", () => { ]), ); const content = readXlsContent(bytes); - expect(findCell(content, 0, 0, 0)?.font).toEqual({ bold: true }); - expect(findCell(content, 1, 0, 0)?.font).toEqual({ bold: true }); + expect(findCell(content, 0, 0, 0)?.font).toStrictEqual({ bold: true }); + expect(findCell(content, 1, 0, 0)?.font).toStrictEqual({ bold: true }); }); it("round-trips a font combined with a fill on the same cell, through the XF the two share", () => { @@ -1086,8 +1089,8 @@ describe("writeXlsContent", () => { ]), ); const readBack = findCell(readXlsContent(bytes), 0, 0, 0); - expect(readBack?.font).toEqual({ bold: true }); - expect(readBack?.background).toEqual({ + expect(readBack?.font).toStrictEqual({ bold: true }); + expect(readBack?.background).toStrictEqual({ kind: "solid", color: { r: 1, g: 0, b: 0 }, }); @@ -1102,8 +1105,8 @@ describe("writeXlsContent", () => { ]), ); const readBack = findCell(readXlsContent(bytes), 0, 0, 0); - expect(readBack?.value).toEqual({ kind: "empty" }); - expect(readBack?.font).toEqual({ bold: true }); + expect(readBack?.value).toStrictEqual({ kind: "empty" }); + expect(readBack?.font).toStrictEqual({ bold: true }); }); it("writes no font of a cell's own for a ContentFont that merely restates the Normal font's values", () => { @@ -1155,7 +1158,7 @@ describe("writeXlsContent", () => { { name: "LocalRange", refersTo: "Sheet2!$C$2", scopeSheetIndex: 1 }, ], }); - expect(readXlsContent(bytes).names).toEqual([ + expect(readXlsContent(bytes).names).toStrictEqual([ { name: "SalesData", refersTo: "Sheet1!$A$1:$B$2" }, { name: "LocalRange", @@ -1174,7 +1177,7 @@ describe("writeXlsContent", () => { { name: "Abs", refersTo: "Other!$A$1" }, ], }); - expect(readXlsContent(bytes).names).toEqual([ + expect(readXlsContent(bytes).names).toStrictEqual([ { name: "Rel", refersTo: "'My Sheet'!A1:B2" }, { name: "Abs", refersTo: "Other!$A$1:$A$1" }, ]); @@ -1191,7 +1194,7 @@ describe("writeXlsContent", () => { }, ], }); - expect(readXlsContent(bytes).names).toEqual([ + expect(readXlsContent(bytes).names).toStrictEqual([ { name: "_xlnm._FilterDatabase", refersTo: "Sheet1!$A$1:$C$1", @@ -1247,7 +1250,7 @@ describe("writeXlsContent", () => { }, }; const bytes = writeXlsContent(input); - expect(readXlsContent(bytes).metadata).toEqual(input.metadata); + expect(readXlsContent(bytes).metadata).toStrictEqual(input.metadata); }); it('writes no "\\x05SummaryInformation" stream at all when metadata carries nothing that stream can hold', () => { @@ -1258,7 +1261,7 @@ describe("writeXlsContent", () => { expect( streams.some((stream) => stream.path === "\x05SummaryInformation"), ).toBe(false); - expect(readXlsContent(bytes).metadata).toEqual({}); + expect(readXlsContent(bytes).metadata).toStrictEqual({}); }); it("throws a BiffWriteError, not a raw RangeError, for a malformed createdIso", () => { @@ -1352,7 +1355,9 @@ describe("print settings", () => { }; it("round-trips every field of a fully populated print setting", () => { - expect(roundTripped(FULL_PRINT_SETTINGS)).toEqual(FULL_PRINT_SETTINGS); + expect(roundTripped(FULL_PRINT_SETTINGS)).toStrictEqual( + FULL_PRINT_SETTINGS, + ); }); it("round-trips fit-to-page in place of a scale percentage", () => { @@ -1362,7 +1367,7 @@ describe("print settings", () => { repeatColumns: FULL_PRINT_SETTINGS.repeatColumns, fitToPages: { width: 2, height: 3 }, }; - expect(roundTripped(settings)).toEqual(settings); + expect(roundTripped(settings)).toStrictEqual(settings); }); it("round-trips a portrait page size without transposing it", () => { @@ -1371,12 +1376,12 @@ describe("print settings", () => { ...PRINT_SETTINGS, pageSize: PAGE_SIZE_A4, }; - expect(roundTripped(settings)?.pageSize).toEqual(PAGE_SIZE_A4); + expect(roundTripped(settings)?.pageSize).toStrictEqual(PAGE_SIZE_A4); }); it("round-trips a sheet whose settings are exactly the Normal preset", () => { // Nothing in ContentSheetPrintSettings can say "this sheet states nothing", so the writer emits the preset's own values rather than omitting the records -- and the reader's own fallback then agrees with them. - expect(roundTripped(PRINT_SETTINGS)).toEqual(PRINT_SETTINGS); + expect(roundTripped(PRINT_SETTINGS)).toStrictEqual(PRINT_SETTINGS); }); it("round-trips an explicit 100% scale onto the absence that means the same thing", () => { @@ -1392,7 +1397,7 @@ describe("print settings", () => { ...WITHOUT_SCALE_AND_REPEAT_COLUMNS, scalePercent: FULL_PRINT_SETTINGS.scalePercent, }; - expect(roundTripped(settings)).toEqual(settings); + expect(roundTripped(settings)).toStrictEqual(settings); }); it("keeps each sheet's own print settings separate", () => { @@ -1409,8 +1414,8 @@ describe("print settings", () => { ]); const read = readXlsContent(writeXlsContent(content)); - expect(read.sheets[0]?.printSettings).toEqual(FULL_PRINT_SETTINGS); - expect(read.sheets[1]?.printSettings.printRange).toEqual({ + expect(read.sheets[0]?.printSettings).toStrictEqual(FULL_PRINT_SETTINGS); + expect(read.sheets[1]?.printSettings.printRange).toStrictEqual({ startRow: 0, startColumn: 0, endRow: 9, @@ -1432,7 +1437,7 @@ describe("print settings", () => { ]); const read = readXlsContent(writeXlsContent(content)).sheets[0]; - expect(read?.printSettings.pageSize).toEqual(PAGE_SIZE_LETTER); + expect(read?.printSettings.pageSize).toStrictEqual(PAGE_SIZE_LETTER); expect(read?.printSettings.gridlines).toBe(true); expect(read?.cells).toHaveLength(1); }); @@ -1447,7 +1452,7 @@ describe("print settings", () => { ...PRINT_SETTINGS, fitToPages: { width: 100_000, height: 2 }, })?.fitToPages, - ).toEqual({ width: 32767, height: 2 }); + ).toStrictEqual({ width: 32767, height: 2 }); }); it("clamps a print range and a repeated band past BIFF8's own row/column ceiling, rather than wrapping to an in-grid coordinate", () => { @@ -1466,14 +1471,14 @@ describe("print settings", () => { repeatColumns: { start: 1, end: 300 }, }; const read = roundTripped(settings); - expect(read?.printRange).toEqual({ + expect(read?.printRange).toStrictEqual({ startRow: 2, startColumn: 2, endRow: 0xffff, endColumn: 0xff, }); - expect(read?.repeatRows).toEqual({ start: 2, end: 0xffff }); - expect(read?.repeatColumns).toEqual({ start: 1, end: 0xff }); + expect(read?.repeatRows).toStrictEqual({ start: 2, end: 0xffff }); + expect(read?.repeatColumns).toStrictEqual({ start: 1, end: 0xff }); }); it("drops a manual page break past BIFF8's own row/column ceiling, rather than wrapping to an in-grid index", () => { @@ -1482,7 +1487,7 @@ describe("print settings", () => { ...PRINT_SETTINGS, manualBreaks: { rows: [10, 70_000], columns: [3, 400] }, }; - expect(roundTripped(settings)?.manualBreaks).toEqual({ + expect(roundTripped(settings)?.manualBreaks).toStrictEqual({ rows: [10], columns: [3], }); @@ -1585,7 +1590,7 @@ describe("formula records", () => { const read = readXlsContent(writeXlsContent(content)); const written = findCell(read, 0, 0, 2); expect(written?.formula).toBe("A1+B1"); - expect(written?.value).toEqual({ kind: "number", value: 5 }); + expect(written?.value).toStrictEqual({ kind: "number", value: 5 }); }); it("refuses a formula this package's own reader could not read back, rather than writing unreadable bytes", () => { @@ -1634,7 +1639,7 @@ describe("cell comments", () => { ]), ]); const read = readXlsContent(writeXlsContent(content)); - expect(findCell(read, 0, 0, 0)?.comment).toEqual({ + expect(findCell(read, 0, 0, 0)?.comment).toStrictEqual({ text: "a note", author: "Reviewer", }); @@ -1653,7 +1658,7 @@ describe("cell comments", () => { ]), ]); const read = readXlsContent(writeXlsContent(content)); - expect(findCell(read, 0, 2, 2)?.comment).toEqual({ + expect(findCell(read, 0, 2, 2)?.comment).toStrictEqual({ text: "pinned to nothing", }); }); @@ -1670,7 +1675,9 @@ describe("cell comments", () => { ]), ]); const read = readXlsContent(writeXlsContent(content)); - expect(findCell(read, 0, 0, 0)?.comment).toEqual({ text: "anonymous" }); + expect(findCell(read, 0, 0, 0)?.comment).toStrictEqual({ + text: "anonymous", + }); }); it("round-trips an empty comment with no text at all", () => { @@ -1680,7 +1687,7 @@ describe("cell comments", () => { ]), ]); const read = readXlsContent(writeXlsContent(content)); - expect(findCell(read, 0, 0, 0)?.comment).toEqual({ text: "" }); + expect(findCell(read, 0, 0, 0)?.comment).toStrictEqual({ text: "" }); }); it("round-trips multiple comments on the same sheet, each keeping its own cell and text", () => { @@ -1701,8 +1708,10 @@ describe("cell comments", () => { ]), ]); const read = readXlsContent(writeXlsContent(content)); - expect(findCell(read, 0, 0, 0)?.comment).toEqual({ text: "note one" }); - expect(findCell(read, 0, 5, 1)?.comment).toEqual({ + expect(findCell(read, 0, 0, 0)?.comment).toStrictEqual({ + text: "note one", + }); + expect(findCell(read, 0, 5, 1)?.comment).toStrictEqual({ text: "note two", author: "Someone", }); @@ -1878,7 +1887,7 @@ describe("writeXlsContent: images and embedded objects written (#971)", () => { const read = readXlsContent(written); const embedded = read.sheets[0]?.embeddedObjects?.[0]; expect(embedded?.objectKind).toBe("drawing"); - expect(embedded?.document).toEqual(embeddedDocument); + expect(embedded?.document).toStrictEqual(embeddedDocument); }); it("throws when asked to write a 'chart' embedded object", () => { @@ -1929,7 +1938,7 @@ describe("writeXlsContent: data validations written (#971)", () => { const reread = readXlsContent( writeXlsContent(document([sheet("S", [], { dataValidations: [rule] })])), ); - expect(reread.sheets[0]?.dataValidations).toEqual([rule]); + expect(reread.sheets[0]?.dataValidations).toStrictEqual([rule]); }); it("round-trips a between rule's two formulas, a list rule's quoted literal, and a custom rule's expression", () => { @@ -1955,7 +1964,7 @@ describe("writeXlsContent: data validations written (#971)", () => { const reread = readXlsContent( writeXlsContent(document([sheet("S", [], { dataValidations: rules })])), ); - expect(reread.sheets[0]?.dataValidations).toEqual(rules); + expect(reread.sheets[0]?.dataValidations).toStrictEqual(rules); }); it("refuses an operator-less comparison type and a two-operand operator without its second formula", () => { @@ -2014,7 +2023,7 @@ describe("writeXlsContent: conditional formats written (#971)", () => { document([sheet("S", [], { conditionalFormats: [rule] })]), ), ); - expect(reread.sheets[0]?.conditionalFormats).toEqual([rule]); + expect(reread.sheets[0]?.conditionalFormats).toStrictEqual([rule]); }); it("round-trips a style-less notBetween rule's two formulas", () => { @@ -2030,7 +2039,7 @@ describe("writeXlsContent: conditional formats written (#971)", () => { document([sheet("S", [], { conditionalFormats: [rule] })]), ), ); - expect(reread.sheets[0]?.conditionalFormats).toEqual([rule]); + expect(reread.sheets[0]?.conditionalFormats).toStrictEqual([rule]); }); it("refuses a rule variant with no BIFF8 spelling rather than dropping it", () => { @@ -2079,7 +2088,7 @@ describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => { value: { type: "max" }, color: { r: 0, g: 1, b: 0 } }, ], }; - expect(roundTripped(rule)).toEqual([rule]); + expect(roundTripped(rule)).toStrictEqual([rule]); }); it("round-trips a three-stop colour scale with numeric, percent, and percentile thresholds", () => { @@ -2095,7 +2104,7 @@ describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => { value: { type: "max" }, color: { r: 1, g: 0, b: 0 } }, ], }; - expect(roundTripped(rule)).toEqual([{ ...rule, priority: 1 }]); + expect(roundTripped(rule)).toStrictEqual([{ ...rule, priority: 1 }]); }); it("round-trips a data bar with its bar colour, thresholds, and hidden value", () => { @@ -2108,7 +2117,7 @@ describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => color: { r: 0, g: 204 / 255, b: 1 }, showValue: false, }; - expect(roundTripped(rule)).toEqual([{ ...rule, priority: 1 }]); + expect(roundTripped(rule)).toStrictEqual([{ ...rule, priority: 1 }]); }); it("round-trips an icon set with reverse and a five-icon set", () => { @@ -2125,7 +2134,7 @@ describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => { type: "max" }, ], }; - expect(roundTripped(rule)).toEqual([{ ...rule, priority: 1 }]); + expect(roundTripped(rule)).toStrictEqual([{ ...rule, priority: 1 }]); }); it("refuses an icon-set threshold count the named set cannot carry", () => { @@ -2186,7 +2195,7 @@ describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => background: { r: 1, g: 1, b: 0.8 }, }, }; - expect(roundTripped(rule)).toEqual([rule]); + expect(roundTripped(rule)).toStrictEqual([rule]); }); it("round-trips a plain aboveAverage rule and an equal-average below-average one", () => { @@ -2201,12 +2210,21 @@ describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => equalAverage: true, stdDev: 2, }; - expect(roundTripped(above)).toEqual([{ ...above, priority: 1 }]); - expect(roundTripped(belowEqualStdDev)).toEqual([ + expect(roundTripped(above)).toStrictEqual([{ ...above, priority: 1 }]); + expect(roundTripped(belowEqualStdDev)).toStrictEqual([ { ...belowEqualStdDev, priority: 1 }, ]); }); + it("round-trips an aboveAverage rule carrying a style", () => { + const rule: ContentSheetConditionalFormat = { + type: "aboveAverage", + ranges: [RANGE], + style: { textColor: { r: 1, g: 0, b: 0 } }, + }; + expect(roundTripped(rule)).toStrictEqual([{ ...rule, priority: 1 }]); + }); + it("refuses an aboveAverage standard-deviation count beyond [MS-XLS]'s own table", () => { expect(() => roundTripped({ @@ -2223,7 +2241,17 @@ describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => ranges: [RANGE], timePeriod: "last7Days", }; - expect(roundTripped(rule)).toEqual([{ ...rule, priority: 1 }]); + expect(roundTripped(rule)).toStrictEqual([{ ...rule, priority: 1 }]); + }); + + it("round-trips a timePeriod rule carrying a style", () => { + const rule: ContentSheetConditionalFormat = { + type: "timePeriod", + ranges: [RANGE], + timePeriod: "today", + style: { background: { r: 1, g: 1, b: 0 } }, + }; + expect(roundTripped(rule)).toStrictEqual([{ ...rule, priority: 1 }]); }); it("round-trips the operand-free family", () => { @@ -2240,7 +2268,7 @@ describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => ranges: [RANGE], style: { background: { r: 1, g: 1, b: 0.8 } }, }; - expect(roundTripped(rule)).toEqual([{ ...rule, priority: 1 }]); + expect(roundTripped(rule)).toStrictEqual([{ ...rule, priority: 1 }]); } }); @@ -2257,7 +2285,7 @@ describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => text: 'a "quoted" needle', style: { textColor: { r: 1, g: 0, b: 0 } }, }; - expect(roundTripped(rule)).toEqual([{ ...rule, priority: 1 }]); + expect(roundTripped(rule)).toStrictEqual([{ ...rule, priority: 1 }]); } }); @@ -2283,7 +2311,7 @@ describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => document([sheet("S", [], { conditionalFormats: rules })]), ), ); - expect(reread.sheets[0]?.conditionalFormats).toEqual([ + expect(reread.sheets[0]?.conditionalFormats).toStrictEqual([ { ...rules[0], priority: 2 }, { ...rules[1], priority: 1 }, { ...rules[2], priority: 3 }, @@ -2340,7 +2368,7 @@ describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => document([sheet("S", [], { conditionalFormats: [cellIs, textRule] })]), ), ); - expect(reread.sheets[0]?.conditionalFormats).toEqual([ + expect(reread.sheets[0]?.conditionalFormats).toStrictEqual([ cellIs, { ...textRule, priority: 1 }, ]); @@ -2354,7 +2382,7 @@ describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => max: { type: "max" }, color: { r: 1, g: 0, b: 0 }, }; - expect(roundTripped(rule)).toEqual([{ ...rule, priority: 1 }]); + expect(roundTripped(rule)).toStrictEqual([{ ...rule, priority: 1 }]); }); it("round-trips an icon set with showValue false and reverse absent (the opposite of the earlier reverse test)", () => { @@ -2369,7 +2397,7 @@ describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => { type: "max" }, ], }; - expect(roundTripped(rule)).toEqual([{ ...rule, priority: 1 }]); + expect(roundTripped(rule)).toStrictEqual([{ ...rule, priority: 1 }]); }); it("round-trips a top10 rule selecting from the top rather than the bottom, by count rather than percent", () => { @@ -2378,7 +2406,7 @@ describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => ranges: [RANGE], rank: 3, }; - expect(roundTripped(rule)).toEqual([{ ...rule, priority: 1 }]); + expect(roundTripped(rule)).toStrictEqual([{ ...rule, priority: 1 }]); }); it("round-trips every combination of aboveAverage/equalAverage", () => { @@ -2392,8 +2420,10 @@ describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => ranges: [RANGE], aboveAverage: false, }; - expect(roundTripped(aboveEqual)).toEqual([{ ...aboveEqual, priority: 1 }]); - expect(roundTripped(belowNotEqual)).toEqual([ + expect(roundTripped(aboveEqual)).toStrictEqual([ + { ...aboveEqual, priority: 1 }, + ]); + expect(roundTripped(belowNotEqual)).toStrictEqual([ { ...belowNotEqual, priority: 1 }, ]); }); @@ -2405,7 +2435,7 @@ describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => rank: 1, stopIfTrue: true, }; - expect(roundTripped(rule)).toEqual([{ ...rule, priority: 1 }]); + expect(roundTripped(rule)).toStrictEqual([{ ...rule, priority: 1 }]); }); it("round-trips a colour-scale stop of every threshold kind, including percentile and formula", () => { @@ -2423,7 +2453,7 @@ describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => }, ], }; - expect(roundTripped(rule)).toEqual([{ ...rule, priority: 1 }]); + expect(roundTripped(rule)).toStrictEqual([{ ...rule, priority: 1 }]); }); it("refuses a threshold of a value-bearing type carrying no value", () => { From d9c5a168987bb70b93ffb7f0da3581bc2e1a5f1c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:24:07 +0100 Subject: [PATCH 015/107] test(xls-codec): cover data-validation error paths and grid boundaries directly Adds the writeSheetDataValidations error paths the existing round trips never exercised: an unrecognised type/operator string (via a deliberately schema-violating fixture, the same as this file's own existing wrong- arity/missing-formula tests), a comparison rule carrying a second formula its own operator doesn't take two of, an empty ranges array, and each of the four grid-boundary checks (row/column, both edges) individually. Also calls writeSheetDataValidations directly for the one case a round trip through the reader cannot observe: an empty dataValidations array must write no Dval/Dv records at all, not a Dval stating a zero rule count -- content.ts's own mapDataValidations().length > 0 check already omits the field for either shape on the way back in, so only inspecting the writer's own output distinguishes them. --- packages/xls-codec/src/write.test.ts | 120 +++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/packages/xls-codec/src/write.test.ts b/packages/xls-codec/src/write.test.ts index 4c3f639545..8d57a77d2b 100644 --- a/packages/xls-codec/src/write.test.ts +++ b/packages/xls-codec/src/write.test.ts @@ -34,6 +34,7 @@ import { readXls, readXlsContent } from "./content"; import { isXlsFile } from "./container"; import { writeXls, writeXlsContent } from "./write"; import { writeSheetConditionalFormats } from "./workbook/conditional-format-write"; +import { writeSheetDataValidations } from "./workbook/data-validation-write"; // Genuine .xls bytes -- a real [MS-CFB] compound file holding a real BIFF8 Workbook stream -- built by this package's own writer and read back through its own reader, the "primary verification method" this session's writers use throughout (the CFB writer, rtf-codec, wpd-codec). Every test here is a round trip: build a ContentDocument, write it, read it back, and check the read result reflects what was written -- exercising the writer against a reader whose own correctness is independently pinned by content.test.ts's hand-built byte sequences. @@ -2004,6 +2005,125 @@ describe("writeXlsContent: data validations written (#971)", () => { ), ).toThrow(/no second formula/); }); + + it("refuses a data-validation type or operator the schema's own closed vocabularies never name", () => { + expect(() => + writeXlsContent( + document([ + sheet("S", [], { + dataValidations: [ + { + ranges: [ + { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }, + ], + type: "notARealType", + formula1: "5", + } as unknown as ContentSheetDataValidation, + ], + }), + ]), + ), + ).toThrow(/has no Dv valType value/); + expect(() => + writeXlsContent( + document([ + sheet("S", [], { + dataValidations: [ + { + ranges: [ + { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }, + ], + type: "whole", + operator: "notARealOperator", + formula1: "5", + } as unknown as ContentSheetDataValidation, + ], + }), + ]), + ), + ).toThrow(/has no Dv typOperator value/); + }); + + it("refuses a non-two-operand rule carrying a second formula", () => { + expect(() => + writeXlsContent( + document([ + sheet("S", [], { + dataValidations: [ + { + ranges: [ + { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }, + ], + type: "whole", + operator: "greaterThan", + formula1: "5", + formula2: "10", + }, + ], + }), + ]), + ), + ).toThrow(/carries a second formula/); + }); + + it("refuses a rule carrying no range", () => { + expect(() => + writeXlsContent( + document([ + sheet("S", [], { + dataValidations: [ + { + ranges: [], + type: "whole", + operator: "greaterThan", + formula1: "5", + }, + ], + }), + ]), + ), + ).toThrow(/carrying no range states nothing/); + }); + + it("refuses a range outside BIFF8's own grid, at each of its four edges", () => { + const base = { + type: "whole" as const, + operator: "greaterThan" as const, + formula1: "5", + }; + const overRow: ContentSheetDataValidation = { + ...base, + ranges: [ + { startRow: 0x10000, endRow: 0x10000, startColumn: 0, endColumn: 0 }, + ], + }; + const overEndRow: ContentSheetDataValidation = { + ...base, + ranges: [{ startRow: 0, endRow: 0x10000, startColumn: 0, endColumn: 0 }], + }; + const overColumn: ContentSheetDataValidation = { + ...base, + ranges: [{ startRow: 0, endRow: 0, startColumn: 0x100, endColumn: 0 }], + }; + const overEndColumn: ContentSheetDataValidation = { + ...base, + ranges: [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0x100 }], + }; + for (const rule of [overRow, overEndRow, overColumn, overEndColumn]) { + expect(() => + writeXlsContent( + document([sheet("S", [], { dataValidations: [rule] })]), + ), + ).toThrow(/outside BIFF8's own grid/); + } + }); + + it("writes no Dval/Dv records at all for a sheet stating an empty dataValidations array", () => { + // A round trip through readXlsContent cannot distinguish this from a Dval-with-zero-Dv-records: mapDataValidations's own result is an empty array either way, and content.ts already omits the field for an empty array regardless of whether a genuinely empty Dval record was written at all. Calling the writer directly is the only way to check that no record is written in the first place. + expect( + writeSheetDataValidations(sheet("S", [], { dataValidations: [] })), + ).toStrictEqual([]); + }); }); describe("writeXlsContent: conditional formats written (#971)", () => { From f2e275d49b8fcf938703f4ebbbbab083663edc7f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:26:12 +0100 Subject: [PATCH 016/107] test(xls-codec): cover globals-writer records this package's own reader ignores Adds a dedicated globals-writer.test.ts calling buildWorkbookGlobals directly: the fifteen built-in STYLE records (globals.ts's own reader never looks for RECORD_STYLE at all, so no round trip can tell whether they were written), the SST record's presence gated correctly on whether the workbook actually carries shared strings, and an ExternSheet stating exactly one XTI per sheet rather than one too many. --- .../src/workbook/globals-writer.test.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 packages/xls-codec/src/workbook/globals-writer.test.ts diff --git a/packages/xls-codec/src/workbook/globals-writer.test.ts b/packages/xls-codec/src/workbook/globals-writer.test.ts new file mode 100644 index 0000000000..b3e630743c --- /dev/null +++ b/packages/xls-codec/src/workbook/globals-writer.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { readRecords } from "../biff/records"; +import { + RECORD_EXTERNSHEET, + RECORD_SST, + RECORD_STYLE, +} from "../biff/record-types"; +import type { WorkbookGlobalsPlan } from "./globals-writer"; +import { buildWorkbookGlobals } from "./globals-writer"; + +// buildWorkbookGlobals's own [MS-XLS] 2.1.7.20.3 FORMATTING production writes several records a real Excel-compatible file wants but this package's own reader (globals.ts) never looks for at all, or writes only conditionally: neither is observable through any writeXlsContent round trip, so these tests call the writer directly and inspect the raw records it produced. + +const BASE_PLAN: WorkbookGlobalsPlan = { + sheetNames: ["Sheet1"], + fonts: [], + customFormats: [], + cellXfEntries: [], + sharedStrings: [], + sharedStringTotalCount: 0, + printNames: [], + definedNames: [], +}; + +function recordsOf(plan: WorkbookGlobalsPlan) { + return readRecords(buildWorkbookGlobals(plan).bytes); +} + +describe("buildWorkbookGlobals", () => { + it("writes exactly fifteen STYLE records -- the fixed built-in style table, unconditionally", () => { + const styleRecords = recordsOf(BASE_PLAN).filter( + (record) => record.type === RECORD_STYLE, + ); + expect(styleRecords).toHaveLength(15); + }); + + it("writes no SST record when the workbook carries no shared strings", () => { + const records = recordsOf(BASE_PLAN); + expect(records.some((record) => record.type === RECORD_SST)).toBe(false); + }); + + it("writes an SST record when the workbook carries shared strings", () => { + const records = recordsOf({ + ...BASE_PLAN, + sharedStrings: ["hello"], + sharedStringTotalCount: 1, + }); + expect(records.some((record) => record.type === RECORD_SST)).toBe(true); + }); + + it("writes an ExternSheet naming exactly one XTI per sheet, not one more", () => { + const plan: WorkbookGlobalsPlan = { + ...BASE_PLAN, + sheetNames: ["Sheet1", "Sheet2", "Sheet3"], + definedNames: [ + { + name: "MyRange", + builtinName: undefined, + sheetIndex: undefined, + rgce: new Uint8Array(0), + }, + ], + }; + const externSheet = recordsOf(plan).find( + (record) => record.type === RECORD_EXTERNSHEET, + ); + if (externSheet === undefined) { + throw new Error("no ExternSheet record was written"); + } + // cXTI (2 bytes) then 6 bytes per XTI structure, one per sheet. + expect(externSheet.data.length).toBe(2 + 6 * plan.sheetNames.length); + const cXTI = new DataView( + externSheet.data.buffer, + externSheet.data.byteOffset, + externSheet.data.byteLength, + ).getUint16(0, true); + expect(cXTI).toBe(plan.sheetNames.length); + }); +}); From 91834730c700eaca0ce7d7ce9b15a5d8341815ae Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:33:51 +0100 Subject: [PATCH 017/107] test(xls-codec): cover sheet-writer grid, merge, and formula cached-value gaps Adds the grid-boundary opposite cases checkedCellPosition's own OR-chain needed (a row-only violation, and the exact last valid row/column succeeding rather than throwing), a sheet whose cells carry no row/column metadata staying with empty rows/columns arrays rather than one every mutation of the fDyZero/fUnsynced/hidden flags would also leave unchanged, and a merge spanning only rows or only columns rather than always both together. Adds formula-cached date/time/date-time and (checking the value, not just the formula text) error results, none of which the existing cached-result tests exercised. Adds a row-only and a column-only manual page break, and a custom page size's own Setup record fPortrait bit read directly (custom page-size dimensions never round-trip at all, so no test through readXlsContent could otherwise tell portrait from landscape here). Also adds a dedicated describe block calling buildWorksheetSubstream directly for the Dimensions record's own rwMic/rwMac/colMic/colMac bytes, which content.ts reads into RawSheet.usedRange but never maps into a ContentSheet field, so no round trip observes them either. --- packages/xls-codec/src/write.test.ts | 187 ++++++++++++++++++++++++++- 1 file changed, 184 insertions(+), 3 deletions(-) diff --git a/packages/xls-codec/src/write.test.ts b/packages/xls-codec/src/write.test.ts index 8d57a77d2b..c30fd53ad3 100644 --- a/packages/xls-codec/src/write.test.ts +++ b/packages/xls-codec/src/write.test.ts @@ -20,10 +20,12 @@ import { describe, expect, it } from "vitest"; import { RECORD_CF12, RECORD_CONTINUE, + RECORD_DIMENSIONS, RECORD_EXTERNSHEET, RECORD_LBL, RECORD_MSODRAWING, RECORD_MSODRAWINGGROUP, + RECORD_SETUP, RECORD_SUPBOOK, } from "./biff/record-types"; import { readRecords } from "./biff/records"; @@ -35,6 +37,7 @@ import { isXlsFile } from "./container"; import { writeXls, writeXlsContent } from "./write"; import { writeSheetConditionalFormats } from "./workbook/conditional-format-write"; import { writeSheetDataValidations } from "./workbook/data-validation-write"; +import { buildWorksheetSubstream } from "./workbook/sheet-writer"; // Genuine .xls bytes -- a real [MS-CFB] compound file holding a real BIFF8 Workbook stream -- built by this package's own writer and read back through its own reader, the "primary verification method" this session's writers use throughout (the CFB writer, rtf-codec, wpd-codec). Every test here is a round trip: build a ContentDocument, write it, read it back, and check the read result reflects what was written -- exercising the writer against a reader whose own correctness is independently pinned by content.test.ts's hand-built byte sequences. @@ -1006,6 +1009,56 @@ describe("writeXlsContent", () => { ).toThrow(BiffWriteError); }); + it("refuses a cell whose row alone is outside the grid", () => { + expect(() => + writeXlsContent( + document([ + sheet("Sheet1", [cell(65536, 0, { kind: "number", value: 1 })]), + ]), + ), + ).toThrow(BiffWriteError); + }); + + it("accepts a cell exactly at BIFF8's own last row and column", () => { + expect(() => + writeXlsContent( + document([ + sheet("Sheet1", [cell(65535, 255, { kind: "number", value: 1 })]), + ]), + ), + ).not.toThrow(); + }); + + it("leaves rows and columns empty for a sheet whose cells carry no declared row/column metadata", () => { + const bytes = writeXlsContent( + document([ + sheet("Sheet1", [ + cell(0, 0, { kind: "number", value: 1 }), + cell(3, 2, { kind: "number", value: 2 }), + ]), + ]), + ); + const content = readXlsContent(bytes); + expect(content.sheets[0]?.rows).toStrictEqual([]); + expect(content.sheets[0]?.columns).toStrictEqual([]); + }); + + it("round-trips a merge spanning only rows, and one spanning only columns", () => { + const bytes = writeXlsContent( + document([ + sheet("Sheet1", [ + cell(0, 0, { kind: "number", value: 1 }, { rowSpan: 2 }), + cell(2, 0, { kind: "number", value: 2 }, { colSpan: 2 }), + ]), + ]), + ); + const content = readXlsContent(bytes); + expect(findCell(content, 0, 0, 0)?.rowSpan).toBe(2); + expect(findCell(content, 0, 0, 0)?.colSpan).toBeUndefined(); + expect(findCell(content, 0, 2, 0)?.colSpan).toBe(2); + expect(findCell(content, 0, 2, 0)?.rowSpan).toBeUndefined(); + }); + describe("per-cell fonts", () => { it("round-trips a workbook mixing several distinct cell fonts with plain cells", () => { const bytes = writeXlsContent( @@ -1443,6 +1496,33 @@ describe("print settings", () => { expect(read?.cells).toHaveLength(1); }); + it("states the Setup record's own fPortrait bit from a custom page size's own dimensions, since no paper code survives to carry it", () => { + // Custom page sizes never round-trip their dimensions at all (the reader falls back to Letter regardless, per the test above), so the orientation flag this specific case writes is invisible to any round trip through readXlsContent -- reading the raw Setup record's own grbit word is the only way to check it. + function grbitFor(widthPt: number, heightPt: number): number { + const bytes = buildWorksheetSubstream( + sheet("S", [cell(0, 0, { kind: "number", value: 1 })], { + printSettings: { ...PRINT_SETTINGS, pageSize: { widthPt, heightPt } }, + }), + { icvOf: () => 0, xfIndexForCell: () => 0, sstIndexFor: () => 0 }, + { msoDrawingRecords: [], objRecords: [] }, + ); + const setup = readRecords(bytes).find( + (record) => record.type === RECORD_SETUP, + ); + if (setup === undefined) { + throw new Error("no Setup record was written"); + } + return new DataView( + setup.data.buffer, + setup.data.byteOffset, + setup.data.byteLength, + ).getUint16(10, true); + } + const SETUP_FLAG_PORTRAIT = 0x0002; + expect(grbitFor(400, 500) & SETUP_FLAG_PORTRAIT).not.toBe(0); // taller than wide + expect(grbitFor(500, 400) & SETUP_FLAG_PORTRAIT).toBe(0); // wider than tall + }); + it("clamps a scale and a fit-to-page count past what their own Setup fields can hold", () => { // ContentSheetPrintSettings bounds neither from above, and Setup's own fields are 16-bit -- so an unclamped value would wrap and state a different intent confidently. [MS-XLS] 2.4.257 caps iFitWidth/iFitHeight at 32767; iScale has only its field's own width. expect( @@ -1494,6 +1574,21 @@ describe("print settings", () => { }); }); + it("round-trips a row-only manual break with no column break, and a column-only one with no row break", () => { + expect( + roundTripped({ + ...PRINT_SETTINGS, + manualBreaks: { rows: [7], columns: [] }, + })?.manualBreaks, + ).toStrictEqual({ rows: [7], columns: [] }); + expect( + roundTripped({ + ...PRINT_SETTINGS, + manualBreaks: { rows: [], columns: [4] }, + })?.manualBreaks, + ).toStrictEqual({ rows: [], columns: [4] }); + }); + it("writes no defined name at all for a workbook declaring no print range or band", () => { // The SupBook and ExternSheet a defined name's own 3D reference resolves through exist only to serve one -- a print name or a document-level name alike -- so a workbook needing none stays as minimal as it was before either was written. const bytes = writeXlsContent( @@ -1563,9 +1658,33 @@ describe("formula records", () => { }); it("round-trips a formula whose cached result is an error", () => { - expect( - roundTrippedFormula("A1/A2", { kind: "error", value: "#DIV/0!" }), - ).toBe("A1/A2"); + const content = document([ + sheet("Sheet1", [ + cell(0, 0, { kind: "error", value: "#DIV/0!" }, { formula: "A1/A2" }), + ]), + ]); + const written = findCell(readXlsContent(writeXlsContent(content)), 0, 0, 0); + expect(written?.formula).toBe("A1/A2"); + expect(written?.value).toStrictEqual({ kind: "error", value: "#DIV/0!" }); + }); + + it("round-trips a formula whose cached result is a date, a time, and a date-time", () => { + for (const value of [ + { kind: "date", value: "2026-09-03" }, + { kind: "time", value: "13:45:30" }, + { kind: "dateTime", value: "2026-09-03T13:45:30" }, + ] as const) { + const content = document([ + sheet("Sheet1", [cell(0, 0, value, { formula: "A1" })]), + ]); + const written = findCell( + readXlsContent(writeXlsContent(content)), + 0, + 0, + 0, + ); + expect(written?.value).toStrictEqual(value); + } }); it("round-trips explicit parentheses exactly as written", () => { @@ -2590,6 +2709,68 @@ describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => }); }); +describe("buildWorksheetSubstream: Dimensions bytes content.ts never reads back", () => { + // content.ts's own readSheetRecords stores RECORD_DIMENSIONS into usedRange, but nothing downstream of that ever reads the field back into a ContentSheet -- so no round trip through readXlsContent can distinguish a correct Dimensions record from a subtly wrong one, and these tests call the writer directly instead. + const NO_DRAWING = { msoDrawingRecords: [], objRecords: [] }; + const NO_STYLE_CTX = { + icvOf: () => 0, + xfIndexForCell: () => 0, + sstIndexFor: () => 0, + }; + + function dimensionsDataOf(cells: readonly ContentSheetCell[]): Uint8Array { + const bytes = buildWorksheetSubstream( + sheet("S", cells), + NO_STYLE_CTX, + NO_DRAWING, + ); + const dimensions = readRecords(bytes).find( + (record) => record.type === RECORD_DIMENSIONS, + ); + if (dimensions === undefined) { + throw new Error("no Dimensions record was written"); + } + return dimensions.data; + } + + function u32AtOffset(data: Uint8Array, offset: number): number { + return new DataView( + data.buffer, + data.byteOffset, + data.byteLength, + ).getUint32(offset, true); + } + + function u16AtOffset(data: Uint8Array, offset: number): number { + return new DataView( + data.buffer, + data.byteOffset, + data.byteLength, + ).getUint16(offset, true); + } + + it("writes Dimensions as one past the true max row/column, and the true min, across several cells", () => { + const data = dimensionsDataOf([ + cell(3, 5, { kind: "number", value: 1 }), + cell(1, 9, { kind: "number", value: 2 }), + cell(7, 2, { kind: "number", value: 3 }), + ]); + // rwMic(4) rwMac(4) colMic(2) colMac(2) + expect(u32AtOffset(data, 0)).toBe(1); // rwMic: the smallest row (1) + expect(u32AtOffset(data, 4)).toBe(8); // rwMac: the largest row (7) + 1 + expect(u16AtOffset(data, 8)).toBe(2); // colMic: the smallest column (2) + expect(u16AtOffset(data, 10)).toBe(10); // colMac: the largest column (9) + 1 + }); + + it("writes Dimensions as all zero for a sheet with no written cells", () => { + const data = dimensionsDataOf([]); + expect(u32AtOffset(data, 0)).toBe(0); + expect(u32AtOffset(data, 4)).toBe(0); + expect(u16AtOffset(data, 8)).toBe(0); + expect(u16AtOffset(data, 10)).toBe(0); + }); +}); + describe("writeSheetConditionalFormats: bytes the reader never inspects (#971/#1186)", () => { const RANGE = { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }; const NO_ICV = (): number => 0; From 61bd128296f72b2590989a037a63464237fc7f15 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:38:54 +0100 Subject: [PATCH 018/107] refactor(xls-codec): filter written cells once per workbook-wide scan pass buildFormatPlan, buildPalettePlan, and buildFontPlan each repeated an identical "if (!writesCellRecord(cell)) continue" guard inside their own cell loop -- genuinely dead code today (an unwritten cell can never carry a font/colour/format these scans would otherwise register, since writesCellRecord being false already implies cellCarriesFormatting is false too), but still real, load-bearing protection against a future change making one of these scans disagree with sheet-writer.ts's own record-emission predicate about which cells matter. Filtering once via sheet.cells.filter(writesCellRecord) at each loop's own head keeps that protection -- every pass still filters through the identical shared predicate -- while removing the standalone if/continue three separate mutation opportunities were hiding behind despite it being unreachable by construction. Also adds the one case that genuinely was an observable gap: two cells bordered identically on different single sides, distinguishing the decoration-signature string's own side-prefix characters ("l"/"r"/"t"/ "b") from each other -- previously only ever exercised by a single cell carrying two different sides, which a swapped or blanked prefix could not have been told apart from. --- packages/xls-codec/src/write.test.ts | 42 ++++++++++++++++++++++++++++ packages/xls-codec/src/write.ts | 19 ++++--------- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/packages/xls-codec/src/write.test.ts b/packages/xls-codec/src/write.test.ts index c30fd53ad3..04f9be1b75 100644 --- a/packages/xls-codec/src/write.test.ts +++ b/packages/xls-codec/src/write.test.ts @@ -436,6 +436,48 @@ describe("writeXlsContent", () => { }); }); + it("distinguishes four cells each bordered identically on a different single side", () => { + // Each cell's own decoration-signature string must name which side it is, not just the style/colour that side shares with every other cell here -- otherwise two of these would collide onto the same interned XF and each other's cell would read back with the wrong side bordered. + const border = { color: blue, widthPt: 0.75 } as const; + const bytes = writeXlsContent( + document([ + sheet("Sheet1", [ + cell( + 0, + 0, + { kind: "string", value: "l" }, + { borders: { left: border } }, + ), + cell( + 0, + 1, + { kind: "string", value: "r" }, + { borders: { right: border } }, + ), + cell( + 0, + 2, + { kind: "string", value: "t" }, + { borders: { top: border } }, + ), + cell( + 0, + 3, + { kind: "string", value: "b" }, + { borders: { bottom: border } }, + ), + ]), + ]), + ); + const read = readXlsContent(bytes); + expect(findCell(read, 0, 0, 0)?.borders).toStrictEqual({ left: border }); + expect(findCell(read, 0, 0, 1)?.borders).toStrictEqual({ right: border }); + expect(findCell(read, 0, 0, 2)?.borders).toStrictEqual({ top: border }); + expect(findCell(read, 0, 0, 3)?.borders).toStrictEqual({ + bottom: border, + }); + }); + it("round-trips both a background and borders on the same cell", () => { const bytes = writeXlsContent( document([ diff --git a/packages/xls-codec/src/write.ts b/packages/xls-codec/src/write.ts index e7ed0d7681..eb9673a048 100644 --- a/packages/xls-codec/src/write.ts +++ b/packages/xls-codec/src/write.ts @@ -177,10 +177,7 @@ function buildFormatPlan(sheets: readonly ContentSheet[]): FormatPlan { }; for (const sheet of sheets) { - for (const cell of sheet.cells) { - if (!writesCellRecord(cell)) { - continue; - } + for (const cell of sheet.cells.filter(writesCellRecord)) { resolve(formatCodeForCell(cell)); } } @@ -233,11 +230,8 @@ function buildPalettePlan(sheets: readonly ContentSheet[]): PalettePlan { }; for (const sheet of sheets) { - for (const cell of sheet.cells) { - // Only cells that actually become records, so the scan can never allocate a palette slot to a colour the XF pass below then never writes -- see written-cells.ts on why every pass shares one predicate. - if (!writesCellRecord(cell)) { - continue; - } + // Only cells that actually become records, so the scan can never allocate a palette slot to a colour the XF pass below then never writes -- see written-cells.ts on why every pass shares one predicate. + for (const cell of sheet.cells.filter(writesCellRecord)) { recordFill(cell.background); record(cell.font?.color); record(cell.borders?.left?.color); @@ -444,11 +438,8 @@ function buildFontPlan( xfFontFieldsOf(cell.font, palettePlan.icvOf); for (const sheet of sheets) { - for (const cell of sheet.cells) { - // The same predicate every other workbook-wide pass applies, so a font is never interned for a cell that then writes no record naming it. - if (!writesCellRecord(cell)) { - continue; - } + // The same predicate every other workbook-wide pass applies, so a font is never interned for a cell that then writes no record naming it. + for (const cell of sheet.cells.filter(writesCellRecord)) { const fields = fieldsOf(cell); const signature = signatureOfFont(fields); if (indexBySignature.has(signature)) { From 8868796fd6a1f3c266f90e06b9c37452a37e7d5c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:44:18 +0100 Subject: [PATCH 019/107] test(xls-codec): cover applyTint's own HSL branches and icv range boundaries Every existing applyTint test used pure red or exact grey, both of which happen to compute an exact 0.5 lightness and a g === b tie -- so no test ever selected rgbToHsl's own l > 0.5 saturation branch, its max === g or max === b hue branches, or the g < b tie-break's true side, and hueToRgb's own wraparound and midpoint branches went similarly unexercised by any colour whose computed hue actually landed there. Adds four colours chosen to land in those specific branches, each checked against an independent reference implementation of the same documented W3C HSL algorithm (a copy Stryker's mutations to the source file can never touch, so a mutated formula and this reference disagree exactly where the mutation changed something) rather than by hand-derived expected values. Also adds exact boundary tests for resolveIcvColor's own palette-range check: icv 63 (the last valid palette index) and 64 (one past it). --- packages/xls-codec/src/biff/xf-colors.test.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/packages/xls-codec/src/biff/xf-colors.test.ts b/packages/xls-codec/src/biff/xf-colors.test.ts index af3cbffbc8..4d3e9ce378 100644 --- a/packages/xls-codec/src/biff/xf-colors.test.ts +++ b/packages/xls-codec/src/biff/xf-colors.test.ts @@ -60,6 +60,11 @@ describe("resolveIcvColor", () => { it("does not resolve a value outside every documented range", () => { expect(resolveIcvColor(0x7fff, undefined)).toBeUndefined(); }); + + it("resolves icv 63, the palette range's own last valid index, and refuses icv 64, one past it", () => { + expect(resolveIcvColor(63, undefined)).toBeDefined(); + expect(resolveIcvColor(64, undefined)).toBeUndefined(); + }); }); describe("DEFAULT_PALETTE_HEX_TO_ICV", () => { @@ -310,4 +315,77 @@ describe("applyTint", () => { expect(tinted.g).toBeCloseTo(tinted.b); expect(tinted.r).toBeGreaterThan(grey.r); }); + + // An independent reference implementation of the identical, standard sRGB<->HSL conversion (W3C CSS Color Module Level 3's own algorithm, https://www.w3.org/TR/css-color-3/#hsl-color) plus the tint formula the source's own top comment cites -- so the colours below (none of them a pure primary, unlike red/grey above, both of which happen to compute an exact 0.5 lightness that never exercises the s formula's own l > 0.5 branch or any hue branch but max === r) can be checked against a real computed expectation rather than only a directional bound. + function referenceTint( + color: { r: number; g: number; b: number }, + tint: number, + ): { r: number; g: number; b: number } { + const { r, g, b } = color; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const l = (max + min) / 2; + let h = 0; + let s = 0; + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + if (max === r) { + h = (g - b) / d + (g < b ? 6 : 0); + } else if (max === g) { + h = (b - r) / d + 2; + } else { + h = (r - g) / d + 4; + } + h /= 6; + } + const newL = tint < 0 ? l * (1 + tint) : l * (1 - tint) + tint; + if (s === 0) { + return { r: newL, g: newL, b: newL }; + } + const q = newL < 0.5 ? newL * (1 + s) : newL + s - newL * s; + const p = 2 * newL - q; + const hueToRgb = (t: number): number => { + let tt = t; + if (tt < 0) tt += 1; + if (tt > 1) tt -= 1; + if (tt < 1 / 6) return p + (q - p) * 6 * tt; + if (tt < 1 / 2) return q; + if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6; + return p; + }; + return { r: hueToRgb(h + 1 / 3), g: hueToRgb(h), b: hueToRgb(h - 1 / 3) }; + } + + it.each([ + // A lightened variant of blue (b uniquely max, l <= 0.5) -- the hue branch neither red (max === r) nor the green case below exercises. + { + label: "b-dominant, l<=0.5", + color: { r: 0.2, g: 0.2, b: 0.6 }, + tint: 0.5, + }, + // g uniquely max, l > 0.5 -- the s formula's own d / (2 - max - min) branch, which red's exact 0.5 lightness never selects. + { label: "g-dominant, l>0.5", color: { r: 0.6, g: 1, b: 0.7 }, tint: 0.5 }, + // r max with g < b (red's own g === b never selects the "+6" branch of that ternary), shaded rather than tinted. + { + label: "r-dominant with g { + const expected = referenceTint(color, tint); + const actual = applyTint(color, tint); + expect(actual.r).toBeCloseTo(expected.r); + expect(actual.g).toBeCloseTo(expected.g); + expect(actual.b).toBeCloseTo(expected.b); + }, + ); }); From a6449544fc4afc8e452c30458a433628e71ae59e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:52:19 +0100 Subject: [PATCH 020/107] test(xls-codec): cover defined-name length, scope, and grid boundaries Adds direct write-path tests for validateUserName's 255-character cch ceiling, scopeOf's out-of-range scopeSheetIndex refusal, and compileRefersTo's MAX_ROW_INDEX/MAX_COLUMN_INDEX grid boundary, each exercising both the accepted edge and the refused one-past-it case. --- packages/xls-codec/src/write.test.ts | 45 ++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/xls-codec/src/write.test.ts b/packages/xls-codec/src/write.test.ts index 04f9be1b75..ca01a41613 100644 --- a/packages/xls-codec/src/write.test.ts +++ b/packages/xls-codec/src/write.test.ts @@ -1328,6 +1328,51 @@ describe("writeXlsContent", () => { }), ).toThrow(BiffWriteError); }); + + it("refuses a name past Lbl's own 255-character cch field", () => { + expect(() => + writeXlsContent({ + ...document([sheet("Sheet1", [])]), + names: [{ name: "x".repeat(256), refersTo: "Sheet1!$A$1" }], + }), + ).toThrow(BiffWriteError); + expect(() => + writeXlsContent({ + ...document([sheet("Sheet1", [])]), + names: [{ name: "x".repeat(255), refersTo: "Sheet1!$A$1" }], + }), + ).not.toThrow(); + }); + + it("refuses a scopeSheetIndex past the end of the document's own sheets", () => { + expect(() => + writeXlsContent({ + ...document([sheet("Sheet1", [])]), + names: [ + { + name: "Bad", + refersTo: "Sheet1!$A$1", + scopeSheetIndex: 1, + }, + ], + }), + ).toThrow(BiffWriteError); + }); + + it("accepts a reference exactly at BIFF8's own grid boundary, and refuses one past it", () => { + expect(() => + writeXlsContent({ + ...document([sheet("Sheet1", [])]), + names: [{ name: "AtEdge", refersTo: "Sheet1!$A$65536" }], + }), + ).not.toThrow(); + expect(() => + writeXlsContent({ + ...document([sheet("Sheet1", [])]), + names: [{ name: "PastEdge", refersTo: "Sheet1!$A$65537" }], + }), + ).toThrow(BiffWriteError); + }); }); describe("metadata", () => { From 3d05feff35f939e318f838c02fa64a546d765084 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 05:36:21 +0100 Subject: [PATCH 021/107] test(xls-codec): assert BiffWriteError's own name and message No test asserted the class's own `.name` override or that its message survives construction, so a mutant clearing `this.name` to an empty string went undetected. --- .../xls-codec/src/biff/write-errors.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 packages/xls-codec/src/biff/write-errors.test.ts diff --git a/packages/xls-codec/src/biff/write-errors.test.ts b/packages/xls-codec/src/biff/write-errors.test.ts new file mode 100644 index 0000000000..b14b8a6a17 --- /dev/null +++ b/packages/xls-codec/src/biff/write-errors.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { BiffWriteError } from "./write-errors"; + +describe("BiffWriteError", () => { + it("carries the message it was constructed with", () => { + const error = new BiffWriteError("a sheet image is outside the grid"); + expect(error.message).toBe("a sheet image is outside the grid"); + }); + + it("names itself BiffWriteError rather than the generic Error name", () => { + const error = new BiffWriteError("anything"); + expect(error.name).toBe("BiffWriteError"); + }); + + it("is a real Error instance", () => { + expect(new BiffWriteError("x")).toBeInstanceOf(Error); + }); +}); From cb762963e396edbd89a8718df8035aaa4868a85c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 05:36:28 +0100 Subject: [PATCH 022/107] refactor(xls-codec): drop the RK double's never-written low dword A freshly allocated ArrayBuffer is already zero-filled, and endianness has no observable effect on a word of all-zero bytes, so the explicit setUint32(4, 0, ...) call restated a fact the buffer already held rather than a real one about the format. --- packages/xls-codec/src/biff/rk.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/xls-codec/src/biff/rk.ts b/packages/xls-codec/src/biff/rk.ts index 3e5db191cd..8f3a572d3a 100644 --- a/packages/xls-codec/src/biff/rk.ts +++ b/packages/xls-codec/src/biff/rk.ts @@ -34,11 +34,10 @@ function decodeSignedPayload(bits: number): number { return (payload & INT_SIGN_BIT) !== 0 ? payload - INT_MODULUS : payload; } -/** The payload read as the high 32 bits of a double whose low 32 bits are zero, with the two flag bits (the double's own bits 32 and 33, which the spec requires be zero) cleared first. */ +/** The payload read as the high 32 bits of a double whose low 32 bits are zero, with the two flag bits (the double's own bits 32 and 33, which the spec requires be zero) cleared first. The low 32 bits are never written: a freshly allocated ArrayBuffer is already zero-filled, and endianness has no observable effect on a word of all-zero bytes -- so stating it explicitly would be a redundant call rather than a real fact about the format. */ function decodeTruncatedDouble(bits: number): number { const buffer = new ArrayBuffer(8); const view = new DataView(buffer); view.setUint32(0, (bits & ~FLAG_MASK) >>> 0, false); - view.setUint32(4, 0, false); return view.getFloat64(0, false); } From c4a159ee53d60fa61c47883a8eb565a4ba86ce3a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 05:36:35 +0100 Subject: [PATCH 023/107] test(xls-codec): assert record-writer's oversized-record message The refusal only asserted the error's class, not that its message actually names the record's own type and length -- a mutant clearing the template string to an empty one went undetected. --- packages/xls-codec/src/biff/record-writer.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/xls-codec/src/biff/record-writer.test.ts b/packages/xls-codec/src/biff/record-writer.test.ts index 056db1590d..877bac51e2 100644 --- a/packages/xls-codec/src/biff/record-writer.test.ts +++ b/packages/xls-codec/src/biff/record-writer.test.ts @@ -39,6 +39,13 @@ describe("writeRecord", () => { const data = new Uint8Array(MAX_RECORD_DATA_SIZE + 1); expect(() => writeRecord(RECORD_BOF, data)).toThrow(BiffWriteError); }); + + it("names the record's own type and length in the refusal message", () => { + const data = new Uint8Array(MAX_RECORD_DATA_SIZE + 1); + expect(() => writeRecord(RECORD_BOF, data)).toThrow( + `record 0x${RECORD_BOF.toString(16)} would carry ${data.length} bytes of data, above the ${MAX_RECORD_DATA_SIZE}-byte maximum a single record can hold ([MS-XLS] 2.1.4); this writer does not split oversized records into Continue chains`, + ); + }); }); describe("concatRecords", () => { From 6ce2735bc091f2b2fdcde6018f561a95a997df14 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 05:36:45 +0100 Subject: [PATCH 024/107] test(xls-codec): assert pointsToColumnWidth's exact ceil formula The existing round-trip and never-narrower properties both still hold for a coldx computed by adding the digit-width allowance instead of subtracting it, so neither killed a mutant flipping that sign. A direct exact-value assertion does. --- packages/xls-codec/src/units.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/xls-codec/src/units.test.ts b/packages/xls-codec/src/units.test.ts index 4cda076aec..490c38b98d 100644 --- a/packages/xls-codec/src/units.test.ts +++ b/packages/xls-codec/src/units.test.ts @@ -45,4 +45,9 @@ describe("pointsToColumnWidth", () => { it("returns a non-negative coldx for a very small width", () => { expect(pointsToColumnWidth(0)).toBeGreaterThanOrEqual(0); }); + + it("computes the smallest coldx via ceil(targetPixels * 256 / MAX_DIGIT_WIDTH_PX - digitWidthAllowance), not a widened one", () => { + // 100pt -> 133px at 96dpi (round(100/72*96) = 133); digitWidthAllowance = trunc(128/7) = 18. coldx = ceil(133 * 256 / 7 - 18) = ceil(4864 - 18) = 4846 -- subtracting the allowance, not adding it. + expect(pointsToColumnWidth(100)).toBe(4846); + }); }); From 4361ea17e190b669c01566a17bf0fb1dcfdbfdf2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 05:36:54 +0100 Subject: [PATCH 025/107] test(xls-codec): assert print-setup's landscape conjunction and tolerance boundary Neither the landscape check's requirement that BOTH dimensions match, nor the tolerance comparison's own boundary, had a test that would fail if the conjunction were loosened to a disjunction or the tolerance's own <= narrowed to a strict <. --- packages/xls-codec/src/biff/print-setup.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/xls-codec/src/biff/print-setup.test.ts b/packages/xls-codec/src/biff/print-setup.test.ts index 553f2f9ff5..88d6158469 100644 --- a/packages/xls-codec/src/biff/print-setup.test.ts +++ b/packages/xls-codec/src/biff/print-setup.test.ts @@ -107,6 +107,19 @@ describe("paperSelectionFor", () => { expect(paperSelectionFor({ widthPt: 500, heightPt: 500 })).toBeUndefined(); }); + it("requires BOTH landscape dimensions to match, not just one", () => { + // 1224pt matches US Tabloid/11x17's own heightPt exactly, but 999pt matches no code's widthPt at all -- a size genuinely this shape names no paper, which is what proves the landscape check is a conjunction rather than "either dimension is close enough". + expect(paperSelectionFor({ widthPt: 1224, heightPt: 999 })).toBeUndefined(); + }); + + it("matches at exactly the tolerance boundary, not only strictly inside it", () => { + // US Letter is 612 x 792pt; half a point over its width is exactly PAPER_SIZE_TOLERANCE_PT away, which must still count as the same paper. + expect(paperSelectionFor({ widthPt: 612.5, heightPt: 792 })).toStrictEqual({ + code: 1, + portrait: true, + }); + }); + it("round-trips every resolvable code back to a page size that resolves the same way", () => { for (let code = 0; code <= 300; code += 1) { const size = pageSizeFromSetup({ ...PORTRAIT_LETTER, paperCode: code }); From 7fa20411f72c7f79fb54024bb884a2d9fef59370 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 05:37:04 +0100 Subject: [PATCH 026/107] refactor(xls-codec): add a shared malformed-record recovery classifier The identical `if (!(err instanceof BiffFormatError)) throw err` guard was duplicated at every one of dozens of per-record recovery boundaries across workbook/ and biff/, each one separately exposed to the same handful of mutations (the condition inverted, the guard's own block emptied) with no test anywhere actually proving a genuine bug still propagates rather than being silently absorbed alongside a malformed record. Centralising the classification into one function, tested directly against both a real BiffFormatError and a genuine TypeError, removes that duplicated surface everywhere it is adopted. --- packages/xls-codec/src/biff/records.test.ts | 32 ++++++++++++++++++++- packages/xls-codec/src/biff/records.ts | 12 ++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/xls-codec/src/biff/records.test.ts b/packages/xls-codec/src/biff/records.test.ts index ac647e09e1..53bb9f6ce3 100644 --- a/packages/xls-codec/src/biff/records.test.ts +++ b/packages/xls-codec/src/biff/records.test.ts @@ -7,7 +7,11 @@ import { RECORD_EOF, RECORD_SST, } from "./record-types"; -import { BiffFormatError, readRecords } from "./records"; +import { + BiffFormatError, + readRecords, + recoverFromFormatError, +} from "./records"; // Byte sequences here are hand-built from [MS-XLS] 2.1.4's own three-component framing -- a two-byte little-endian record type, a two-byte little-endian record size, then exactly that many bytes of record data (https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/170e90ce-87d7-4758-9331-dcf14cd72388) -- rather than captured from a real file, so a test failure points at this package's reading of the spec rather than at some producer's quirk. @@ -104,3 +108,29 @@ describe("readRecords", () => { expect(() => readRecords(stream)).toThrow(BiffFormatError); }); }); + +describe("recoverFromFormatError", () => { + it("absorbs a genuine BiffFormatError rather than letting it propagate", () => { + expect(() => { + recoverFromFormatError(new BiffFormatError("malformed"), undefined); + }).not.toThrow(); + }); + + it("returns the fallback given, exactly as given, for a genuine BiffFormatError", () => { + const result = recoverFromFormatError(new BiffFormatError("malformed"), []); + expect(result).toStrictEqual([]); + }); + + it("rethrows anything that is not a BiffFormatError, rather than absorbing it", () => { + const bug = new TypeError("a genuine bug, not a malformed record"); + expect(() => { + recoverFromFormatError(bug, undefined); + }).toThrow(bug); + }); + + it("rethrows a plain thrown value that is not even an Error", () => { + expect(() => { + recoverFromFormatError("not an error at all", undefined); + }).toThrow("not an error at all"); + }); +}); diff --git a/packages/xls-codec/src/biff/records.ts b/packages/xls-codec/src/biff/records.ts index d1e7a53d81..6982d0256e 100644 --- a/packages/xls-codec/src/biff/records.ts +++ b/packages/xls-codec/src/biff/records.ts @@ -20,6 +20,18 @@ export class BiffFormatError extends Error { } } +/** + * The one classification every per-record recovery boundary in this package draws around its own try/catch: a BiffFormatError is a malformed-input degrade (this one record, name, or rule resolves to nothing rather than aborting every other one in the same substream), while anything else is a genuine bug this package's own code produced and must not be silently absorbed alongside real malformed-input cases. + * + * Centralising the classification here -- rather than every call site restating `if (!(err instanceof BiffFormatError)) throw err` in its own catch block -- means the "is this recoverable" question is tested once, in this module's own test file, instead of being duplicated (and therefore separately mutation-tested) at every one of the dozens of sites across workbook/ and biff/ that degrade a malformed record the identical way. + */ +export function recoverFromFormatError(err: unknown, fallback: T): T { + if (err instanceof BiffFormatError) { + return fallback; + } + throw err; +} + /** The four-byte record header: a two-byte type followed by a two-byte size. Exported for workbook/encryption.ts, which needs a record's own data start offset (the byte position right after this header) to derive the correct RC4 keystream position -- [MS-XLS] 2.2.10 counts a record's own header bytes toward the encryption stream's position even though the header itself is never encrypted. */ export const HEADER_SIZE = 4; From bed81e0fee3cd329c227c8c360fa0eb241f4ba4a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 05:37:21 +0100 Subject: [PATCH 027/107] refactor(xls-codec): route every malformed-record catch through recoverFromFormatError Every one of these catch blocks restated the identical `if (!(err instanceof BiffFormatError)) throw err` guard by hand; each now defers to the one, directly tested classifier instead, removing the duplicated if/throw as a separate mutation target at each site. data-validation.ts's own readDvParsedFormula also drops its early `cce === 0` return: parseFormulaText already resolves a zero-length rgce to undefined on its own (an empty token stream never pushes onto its own operand stack, so the final "exactly one operand left" check already fails), so the guard restated a fact the callee already established rather than a real branch in this function's own behaviour. --- packages/xls-codec/src/biff/ptg.ts | 4 ++-- packages/xls-codec/src/drawing/shapes.ts | 6 ++--- .../src/workbook/conditional-format-12.ts | 11 +++------ .../src/workbook/conditional-format-ex.ts | 6 ++--- .../src/workbook/conditional-format.ts | 19 ++++----------- .../xls-codec/src/workbook/data-validation.ts | 11 +++------ .../xls-codec/src/workbook/defined-names.ts | 6 ++--- packages/xls-codec/src/workbook/globals.ts | 10 ++++---- packages/xls-codec/src/workbook/sheet.ts | 23 ++++++++----------- 9 files changed, 33 insertions(+), 63 deletions(-) diff --git a/packages/xls-codec/src/biff/ptg.ts b/packages/xls-codec/src/biff/ptg.ts index d644587e46..b2ae9e829a 100644 --- a/packages/xls-codec/src/biff/ptg.ts +++ b/packages/xls-codec/src/biff/ptg.ts @@ -3,7 +3,7 @@ import { columnIndexToLetters } from "document-schema.js"; import { BlockCursor } from "./cursor"; import { errorTextOf } from "./errors"; import { FTAB_FIXED_ARITY, FTAB_NAMES } from "./ptg-functions"; -import { BiffFormatError } from "./records"; +import { recoverFromFormatError } from "./records"; import { readShortXLUnicodeString, readXLUnicodeString } from "./strings"; // A BIFF8 compiled formula (Ptg token stream, [MS-XLS] 2.5.198.25 -- https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/94229a89-a5b6-4f2b-834f-bd28cdc57c6b) walked left to right and rebuilt into the infix text a spreadsheet application would show. @@ -590,7 +590,7 @@ export function parseFormulaText( text = readArrayLiteralText(rgcbCursor); } catch (error) { // A malformed rgcb -- a PtgExtraArray whose row/column counts or SerStr length overrun the trailer's own bytes -- degrades this one array literal (and with it the whole formula) exactly like any other unresolved token, rather than aborting every other cell's read; see the module comment for why this cursor, unlike the cce-bounded one walking rgce, is not trusted to stay in bounds. - if (!(error instanceof BiffFormatError)) throw error; + recoverFromFormatError(error, undefined); text = undefined; } if (text === undefined) return undefined; diff --git a/packages/xls-codec/src/drawing/shapes.ts b/packages/xls-codec/src/drawing/shapes.ts index 198382743f..c4bb3d7d6b 100644 --- a/packages/xls-codec/src/drawing/shapes.ts +++ b/packages/xls-codec/src/drawing/shapes.ts @@ -1,5 +1,5 @@ import { BlockCursor } from "../biff/cursor"; -import { BiffFormatError } from "../biff/records"; +import { recoverFromFormatError } from "../biff/records"; import { ESCHER_CLIENT_ANCHOR, ESCHER_DG_CONTAINER, @@ -117,9 +117,7 @@ function readShapeContainer( try { blipIndex = readPibProperty(opt.data); } catch (error) { - if (!(error instanceof BiffFormatError)) { - throw error; - } + recoverFromFormatError(error, undefined); } } return { shapeType: sp.recInstance, spid, blipIndex, anchor }; diff --git a/packages/xls-codec/src/workbook/conditional-format-12.ts b/packages/xls-codec/src/workbook/conditional-format-12.ts index 341d12085e..412001ef53 100644 --- a/packages/xls-codec/src/workbook/conditional-format-12.ts +++ b/packages/xls-codec/src/workbook/conditional-format-12.ts @@ -7,7 +7,7 @@ import type { import { BlockCursor } from "../biff/cursor"; import type { FormulaSheetContext } from "../biff/ptg"; import { extractFirstStringLiteral, parseFormulaText } from "../biff/ptg"; -import { BiffFormatError } from "../biff/records"; +import { recoverFromFormatError } from "../biff/records"; import type { RecordGroup } from "../biff/substreams"; import { RECORD_CF12 } from "../biff/record-types"; import { @@ -526,9 +526,7 @@ function readCf12( } return undefined; // ct 0x01, or a ct 0x02 rule with no closed-form structure to promote } catch (err) { - if (!(err instanceof BiffFormatError)) { - throw err; - } + recoverFromFormatError(err, undefined); return undefined; } } @@ -578,9 +576,6 @@ export function readCondFmt12Group( } return { formats, recordsConsumed: 1 + ccf }; } catch (err) { - if (!(err instanceof BiffFormatError)) { - throw err; - } - return { formats: [], recordsConsumed: 1 }; + return recoverFromFormatError(err, { formats: [], recordsConsumed: 1 }); } } diff --git a/packages/xls-codec/src/workbook/conditional-format-ex.ts b/packages/xls-codec/src/workbook/conditional-format-ex.ts index 13b23aad7c..5651253575 100644 --- a/packages/xls-codec/src/workbook/conditional-format-ex.ts +++ b/packages/xls-codec/src/workbook/conditional-format-ex.ts @@ -1,7 +1,7 @@ import type { ContentSheetRange } from "document-schema.js"; import { BlockCursor } from "../biff/cursor"; import type { FormulaSheetContext } from "../biff/ptg"; -import { BiffFormatError } from "../biff/records"; +import { recoverFromFormatError } from "../biff/records"; import type { RecordGroup } from "../biff/substreams"; import { parseDxfStyle, type RawCfOperand } from "./conditional-format"; import { @@ -77,9 +77,7 @@ export function readCfEx( const style = parseDxfStyle(dxfBytes); return { ...textRule, ...common, style }; } catch (err) { - if (!(err instanceof BiffFormatError)) { - throw err; - } + recoverFromFormatError(err, undefined); return undefined; } } diff --git a/packages/xls-codec/src/workbook/conditional-format.ts b/packages/xls-codec/src/workbook/conditional-format.ts index a32738fbad..c38f17e383 100644 --- a/packages/xls-codec/src/workbook/conditional-format.ts +++ b/packages/xls-codec/src/workbook/conditional-format.ts @@ -2,7 +2,7 @@ import type { ContentSheetRange, SheetRuleOperator } from "document-schema.js"; import { BlockCursor } from "../biff/cursor"; import type { FormulaSheetContext } from "../biff/ptg"; import { parseFormulaText } from "../biff/ptg"; -import { BiffFormatError } from "../biff/records"; +import { recoverFromFormatError } from "../biff/records"; import { recordByteLength, type RecordGroup } from "../biff/substreams"; import { RECORD_CF } from "../biff/record-types"; @@ -109,9 +109,7 @@ export function parseDxfStyle( } return { fontColorIcv, fill }; } catch (err) { - if (!(err instanceof BiffFormatError)) { - throw err; - } + recoverFromFormatError(err, undefined); return undefined; } } @@ -145,9 +143,7 @@ function parseCfBytes(record: RecordGroup): const rgce2 = cursor.take(cce2); return { ct, cp, dxfBytes, rgce1, rgce2 }; } catch (err) { - if (!(err instanceof BiffFormatError)) { - throw err; - } + recoverFromFormatError(err, undefined); return undefined; } } @@ -191,9 +187,7 @@ function readCf( ranges, }; } catch (err) { - if (!(err instanceof BiffFormatError)) { - throw err; - } + recoverFromFormatError(err, undefined); return undefined; } } @@ -264,9 +258,6 @@ export function readCondFmtGroup( } return { formats, recordsConsumed: 1 + ccf, nID, ranges, rawCfs }; } catch (err) { - if (!(err instanceof BiffFormatError)) { - throw err; - } - return DEGRADED_CONDFMT_GROUP; + return recoverFromFormatError(err, DEGRADED_CONDFMT_GROUP); } } diff --git a/packages/xls-codec/src/workbook/data-validation.ts b/packages/xls-codec/src/workbook/data-validation.ts index 3b16df987f..9ecb69ab41 100644 --- a/packages/xls-codec/src/workbook/data-validation.ts +++ b/packages/xls-codec/src/workbook/data-validation.ts @@ -2,7 +2,7 @@ import type { ContentSheetRange, SheetRuleOperator } from "document-schema.js"; import { BlockCursor } from "../biff/cursor"; import type { FormulaSheetContext } from "../biff/ptg"; import { parseFormulaText } from "../biff/ptg"; -import { BiffFormatError } from "../biff/records"; +import { recoverFromFormatError } from "../biff/records"; import { readXLUnicodeString } from "../biff/strings"; import type { RecordGroup } from "../biff/substreams"; @@ -64,16 +64,13 @@ export interface RawDataValidation { readonly ranges: ContentSheetRange[]; } -// A DVParsedFormula ([MS-XLS] 2.2.2 / this reader's own citation on DVParsedFormula): cce (2 bytes), an unused 2-byte field, then cce bytes of Ptg tokens -- structurally simpler than a cell Formula record's own CellParsedFormula (no rgcb trailer: [MS-XLS] itself forbids a DV formula from containing a PtgArray at all). cce === 0 means "no formula" (valType 0's own formula1, or either formula whenever the Dv record's own valType/typOperator combination doesn't use it) -- the file states this directly rather than leaving it for a reader to infer from valType/typOperator, so this function trusts cce rather than re-deriving when a formula "should" be absent. +// A DVParsedFormula ([MS-XLS] 2.2.2 / this reader's own citation on DVParsedFormula): cce (2 bytes), an unused 2-byte field, then cce bytes of Ptg tokens -- structurally simpler than a cell Formula record's own CellParsedFormula (no rgcb trailer: [MS-XLS] itself forbids a DV formula from containing a PtgArray at all). cce === 0 means "no formula" (valType 0's own formula1, or either formula whenever the Dv record's own valType/typOperator combination doesn't use it); no early return is needed to say so, since parseFormulaText resolves a zero-length rgce to undefined on its own (an empty token stream never pushes onto its own operand stack, so its own final "exactly one operand left" check already fails) -- the file states "no formula" directly rather than leaving it for a reader to infer from valType/typOperator, and this function trusts cce rather than re-deriving when a formula "should" be absent either way. function readDvParsedFormula( cursor: BlockCursor, formulaSheets: FormulaSheetContext, ): string | undefined { const cce = cursor.u16(); cursor.skip(2); // unused - if (cce === 0) { - return undefined; - } return parseFormulaText(cursor.take(cce), formulaSheets); } @@ -133,9 +130,7 @@ export function readDv( ranges, }; } catch (err) { - if (!(err instanceof BiffFormatError)) { - throw err; - } + recoverFromFormatError(err, undefined); return undefined; } } diff --git a/packages/xls-codec/src/workbook/defined-names.ts b/packages/xls-codec/src/workbook/defined-names.ts index 5ff66fd8f2..a714994c4f 100644 --- a/packages/xls-codec/src/workbook/defined-names.ts +++ b/packages/xls-codec/src/workbook/defined-names.ts @@ -6,7 +6,7 @@ import { BlockCursor } from "../biff/cursor"; import { parseFormulaText, type FormulaSheetContext } from "../biff/ptg"; import { RECORD_LBL } from "../biff/record-types"; import { writeRecord } from "../biff/record-writer"; -import { BiffFormatError } from "../biff/records"; +import { recoverFromFormatError } from "../biff/records"; import { readXLUnicodeStringNoCch } from "../biff/strings"; import { writeXLUnicodeStringNoCch } from "../biff/string-writer"; import { BiffWriteError } from "../biff/write-errors"; @@ -122,9 +122,7 @@ function readLblRecord( sheetIndex: itab === 0 ? undefined : itab - 1, }; } catch (error) { - if (!(error instanceof BiffFormatError)) { - throw error; - } + recoverFromFormatError(error, undefined); return undefined; } } diff --git a/packages/xls-codec/src/workbook/globals.ts b/packages/xls-codec/src/workbook/globals.ts index 4cda79d7bd..a683441b43 100644 --- a/packages/xls-codec/src/workbook/globals.ts +++ b/packages/xls-codec/src/workbook/globals.ts @@ -14,7 +14,7 @@ import { RECORD_XF, } from "../biff/record-types"; import { readFontRecord, type XfFontFields } from "../biff/font"; -import { BiffFormatError } from "../biff/records"; +import { BiffFormatError, recoverFromFormatError } from "../biff/records"; import { readRichExtendedString, readShortXLUnicodeString, @@ -256,10 +256,10 @@ function readSupBookSafely(record: RecordGroup): SupBookInfo { try { return readSupBook(record); } catch (error) { - if (!(error instanceof BiffFormatError)) { - throw error; - } - return { kind: "unresolvable", diagnostic: "malformed supporting link" }; + return recoverFromFormatError(error, { + kind: "unresolvable" as const, + diagnostic: "malformed supporting link", + }); } } diff --git a/packages/xls-codec/src/workbook/sheet.ts b/packages/xls-codec/src/workbook/sheet.ts index 6fa3e4c927..7b740ed071 100644 --- a/packages/xls-codec/src/workbook/sheet.ts +++ b/packages/xls-codec/src/workbook/sheet.ts @@ -43,7 +43,7 @@ import { RECORD_VERTICALPAGEBREAKS, RECORD_WSBOOL, } from "../biff/record-types"; -import { BiffFormatError } from "../biff/records"; +import { BiffFormatError, recoverFromFormatError } from "../biff/records"; import { decodeRkNumber } from "../biff/rk"; import { readXLUnicodeString } from "../biff/strings"; import { recordByteLength, type RecordGroup } from "../biff/substreams"; @@ -244,9 +244,7 @@ function collectFormulaGroup( const header = readCellHeader(new BlockCursor(record.blocks)); groups.set(groupKey(header.row, header.column), readGroup(next)); } catch (error) { - if (!(error instanceof BiffFormatError)) { - throw error; - } + recoverFromFormatError(error, undefined); } } @@ -276,10 +274,11 @@ function readArrayGroup(record: RecordGroup): ArrayFormulaGroup { try { return { kind: "array", rgce, rgcb: cursor.take(rgcbLength) }; } catch (error) { - if (!(error instanceof BiffFormatError)) { - throw error; - } - return { kind: "array", rgce, rgcb: undefined }; + return recoverFromFormatError(error, { + kind: "array" as const, + rgce, + rgcb: undefined, + }); } } @@ -801,9 +800,7 @@ function readFormula( const rgcbLength = recordByteLength(record) - (FORMULA_HEADER_BYTES + cce); rgcb = rgcbLength > 0 ? cursor.take(rgcbLength) : undefined; } catch (error) { - if (!(error instanceof BiffFormatError)) { - throw error; - } + recoverFromFormatError(error, undefined); rgce = undefined; rgcb = undefined; } @@ -841,9 +838,7 @@ function resolveFormulaText( ? parseFormulaText(group.rgce, formulaSheets, { relativeTo: header }) : parseFormulaText(group.rgce, formulaSheets, { rgcb: group.rgcb }); } catch (error) { - if (!(error instanceof BiffFormatError)) { - throw error; - } + recoverFromFormatError(error, undefined); return undefined; } } From 76daf2a965629f2ac795ee6bc903f03f416b749c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 06:06:02 +0100 Subject: [PATCH 028/107] test(xls-codec): cover MD4's padding boundary and drop its dead-code guards Add a 56-byte MD4 vector (independently computed via OpenSSL's legacy MD4 provider): none of RFC 1320's own A.5 vectors land on the 56/57-byte window where an off-by-one in the padding-length arithmetic would silently choose the wrong block boundary. Type the block-word buffer as a 16-element tuple indexed by a 16-value literal union rather than `number[]` indexed by a bare `number`, so every element access is `number`, never `number | undefined` -- this removes an internal-error throw that guarded against a state the loop building the tuple already makes impossible. Stop writing the length field's high 32 bits: every message this digest ever hashes is an in-memory Escher blip payload far smaller than 2**32 bits, and a freshly allocated Uint8Array is already zero-filled, so the write was restating a fact already true of the buffer rather than computing one. --- packages/xls-codec/src/drawing/md4.test.ts | 8 +++ packages/xls-codec/src/drawing/md4.ts | 62 ++++++++++++++++------ 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/packages/xls-codec/src/drawing/md4.test.ts b/packages/xls-codec/src/drawing/md4.test.ts index 4ccab94bf2..8624060fba 100644 --- a/packages/xls-codec/src/drawing/md4.test.ts +++ b/packages/xls-codec/src/drawing/md4.test.ts @@ -25,4 +25,12 @@ describe("md4", () => { expect(md4(new TextEncoder().encode(message))).toBe(digest); } }); + + it("pads a 56-byte message correctly, the one length RFC 1320's own vectors never exercise", () => { + // The padding scheme (RFC 1320 section 3.1) appends a 0x80 byte then zero bytes up to a 64-byte block boundary minus 8, leaving room for the 8-byte length field -- so a message of exactly 56 bytes leaves zero bytes of room in its own block for that 0x80 plus the length field, and must instead pad out to a whole second block. None of RFC 1320's own A.5 vectors (lengths 0, 1, 3, 14, 26, 62, 80) land on 56 or 57 bytes, the narrow window where an off-by-one in the padding-length arithmetic changes which block boundary is chosen. Digest independently computed via OpenSSL's own MD4 implementation (`openssl dgst -md4 -provider legacy -provider default`), not derived from this package's own code. + const message = "abcdefgh".repeat(7); // 56 bytes + expect(md4(new TextEncoder().encode(message))).toBe( + "480276f2170f9668bc949a7fc46b5ead", + ); + }); }); diff --git a/packages/xls-codec/src/drawing/md4.ts b/packages/xls-codec/src/drawing/md4.ts index 94584fbb3c..b5657991d9 100644 --- a/packages/xls-codec/src/drawing/md4.ts +++ b/packages/xls-codec/src/drawing/md4.ts @@ -14,6 +14,30 @@ const INITIAL_D = 0x10325476; const BLOCK_SIZE = 64; +/** A block's 16 message words, one per `Uint32` of the 64-byte block. A literal-index tuple rather than `number[]` so every `x[index]` access below is typed as `number`, never `number | undefined`: `index` is itself typed as one of the 16 literal positions this tuple actually has, so there is no in-bounds/out-of-bounds question left for a guard to answer. */ +type BlockWords = readonly [ + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, +]; + +/** The 16 literal positions a `BlockWords` tuple actually has. */ +type WordIndex = + 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15; + /** The three auxiliary functions of RFC 1320 2.2, each a bit-selection over x/y/z -- G and H are MD4's own, not MD5's similarly-named ones. */ function f(x: number, y: number, z: number): number { return (x & y) | (~x & z); @@ -32,7 +56,7 @@ function rotateLeft(x: number, count: number): number { return ((x << count) | (x >>> (32 - count))) >>> 0; } -/** The padding of RFC 1320 3.1: the message, a single 1 bit, zeros, then the 64-bit little-endian bit length, filling the final block(s) to a 64-byte multiple. */ +/** The padding of RFC 1320 3.1: the message, a single 1 bit, zeros, then the 64-bit little-endian bit length, filling the final block(s) to a 64-byte multiple. The length field's high 32 bits are never written: every message this hand-written digest ever hashes is an in-memory Escher blip payload, thousands of bytes at most, so `message.length * 8` never approaches 2**32 -- and a freshly allocated Uint8Array is already zero-filled, so stating the high word explicitly would be a redundant call rather than a real fact about the message. */ function padMessage(message: Uint8Array): Uint8Array { const bitLength = message.length * 8; const paddedLength = @@ -42,7 +66,6 @@ function padMessage(message: Uint8Array): Uint8Array { out[message.length] = 0x80; const view = new DataView(out.buffer); view.setUint32(paddedLength - 8, bitLength >>> 0, true); - view.setUint32(paddedLength - 4, Math.floor(bitLength / 0x100000000), true); return out; } @@ -70,10 +93,24 @@ export function md4(message: Uint8Array): string { let d = INITIAL_D; for (let offset = 0; offset < padded.length; offset += BLOCK_SIZE) { - const x: number[] = []; - for (let index = 0; index < 16; index += 1) { - x.push(view.getUint32(offset + index * 4, true)); - } + const x: BlockWords = [ + view.getUint32(offset + 0 * 4, true), + view.getUint32(offset + 1 * 4, true), + view.getUint32(offset + 2 * 4, true), + view.getUint32(offset + 3 * 4, true), + view.getUint32(offset + 4 * 4, true), + view.getUint32(offset + 5 * 4, true), + view.getUint32(offset + 6 * 4, true), + view.getUint32(offset + 7 * 4, true), + view.getUint32(offset + 8 * 4, true), + view.getUint32(offset + 9 * 4, true), + view.getUint32(offset + 10 * 4, true), + view.getUint32(offset + 11 * 4, true), + view.getUint32(offset + 12 * 4, true), + view.getUint32(offset + 13 * 4, true), + view.getUint32(offset + 14 * 4, true), + view.getUint32(offset + 15 * 4, true), + ]; const savedA = a; const savedB = b; const savedC = c; @@ -81,29 +118,24 @@ export function md4(message: Uint8Array): string { const op = ( kind: (x: number, y: number, z: number) => number, - index: number, + index: WordIndex, rotation: number, roundConstant: number, ): void => { const word = x[index]; - if (word === undefined) { - throw new Error( - `internal error: MD4 block word ${index} is missing -- x is always filled with all 16 words above before any operation reads it`, - ); - } const updated = rotateLeft( (a + kind(b, c, d) + word + roundConstant) >>> 0, rotation, ); [a, b, c, d] = rotateRegisters([a, b, c, d], updated); }; - const ff = (index: number, rotation: number) => { + const ff = (index: WordIndex, rotation: number) => { op(f, index, rotation, 0); }; - const gg = (index: number, rotation: number) => { + const gg = (index: WordIndex, rotation: number) => { op(g, index, rotation, ROUND_2_CONSTANT); }; - const hh = (index: number, rotation: number) => { + const hh = (index: WordIndex, rotation: number) => { op(h, index, rotation, ROUND_3_CONSTANT); }; From 80d1bbb0eb548a16545e2081bc6a93469ca97a78 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 06:10:19 +0100 Subject: [PATCH 029/107] test(xls-codec): assert readRecords' own thrown messages and error name Every readRecords failure carried a distinct, formatted message but no test checked its content, only that some BiffFormatError was thrown -- so a mutation replacing any of those messages with an empty string went undetected. Assert each message's exact text, and BiffFormatError's own name, which no test previously checked either. --- packages/xls-codec/src/biff/records.test.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/xls-codec/src/biff/records.test.ts b/packages/xls-codec/src/biff/records.test.ts index 53bb9f6ce3..6573f368c4 100644 --- a/packages/xls-codec/src/biff/records.test.ts +++ b/packages/xls-codec/src/biff/records.test.ts @@ -88,13 +88,17 @@ describe("readRecords", () => { it("rejects a truncated record header", () => { // Three bytes cannot carry a four-byte header, so the size field is unreadable. Failing loudly beats reporting a record whose length was guessed. - expect(() => readRecords(bytes(0x09, 0x08, 0x04))).toThrow(BiffFormatError); + expect(() => readRecords(bytes(0x09, 0x08, 0x04))).toThrow( + "record header at offset 0 runs past the end of the 3-byte stream", + ); }); it("rejects a record whose declared size runs past the end of the stream", () => { const stream = bytes(0x09, 0x08, 0x10, 0x00, 0x01, 0x02); - expect(() => readRecords(stream)).toThrow(BiffFormatError); + expect(() => readRecords(stream)).toThrow( + "record 0x809 at offset 0 declares 16 bytes of data, running past the end of the 6-byte stream", + ); }); it("rejects a record declaring more data than the framing permits", () => { @@ -105,7 +109,15 @@ describe("readRecords", () => { view.setUint16(0, RECORD_BOF, true); view.setUint16(2, size, true); - expect(() => readRecords(stream)).toThrow(BiffFormatError); + expect(() => readRecords(stream)).toThrow( + "record 0x809 at offset 0 declares 8225 bytes of data, above the 8224-byte maximum", + ); + }); +}); + +describe("BiffFormatError", () => { + it("names itself BiffFormatError rather than the generic Error name", () => { + expect(new BiffFormatError("x").name).toBe("BiffFormatError"); }); }); From 56e0e373b5a5464aa35bc5ada882b64223db953a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 06:10:28 +0100 Subject: [PATCH 030/107] test(xls-codec): distinguish CFEx's fIsCF12 guard from a coincidental undefined The existing fIsCF12-nonzero test left both the target map and the record's own tail payload empty, so a mutant that skipped the fIsCF12 check entirely still returned undefined -- just via the unrelated missing-target branch further down, not because the guard fired. Build a record that carries a full CFExNonCF12 payload after a nonzero fIsCF12, with a matching target present, so a reader that skipped the guard would parse through to a defined result instead. Also cover the catch block's call to recoverFromFormatError itself: mock parseDxfStyle to throw a plain TypeError and assert it propagates, proving the call site actually rethrows a genuine bug rather than silently absorbing every error alongside real malformed-record cases. --- .../workbook/conditional-format-ex.test.ts | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/packages/xls-codec/src/workbook/conditional-format-ex.test.ts b/packages/xls-codec/src/workbook/conditional-format-ex.test.ts index b721a660f2..9d0f6afaa8 100644 --- a/packages/xls-codec/src/workbook/conditional-format-ex.test.ts +++ b/packages/xls-codec/src/workbook/conditional-format-ex.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { FormulaSheetContext } from "../biff/ptg"; import { groupRecords, type RecordGroup } from "../biff/substreams"; import { readRecords } from "../biff/records"; @@ -11,6 +11,7 @@ import { } from "../test-support/biff"; import { RECORD_CF, RECORD_CFEX, RECORD_CONDFMT } from "../biff/record-types"; import type { RawCfOperand } from "./conditional-format"; +import * as conditionalFormatModule from "./conditional-format"; import { readCfEx, type CfExTarget } from "./conditional-format-ex"; import { readCondFmtGroup, @@ -252,10 +253,26 @@ describe("readCfEx", () => { }); it("does not promote a rule extending a CF12 record (fIsCF12 nonzero) -- that CF12 carries no ranges of its own, see this file's own top comment", () => { - const targets = new Map(); - const cfExGroup = cfExRecordGroup(1, { fIsCF12: 1 }); + // A target and a fully-valid CFExNonCF12 payload are both present here despite fIsCF12 being nonzero -- [MS-XLS] 2.4.63 says a CFEx with fIsCF12 set carries no such payload at all, but this test builds it anyway (rather than the empty tail cfExBytes itself would produce) so that a reader which skipped the fIsCF12 check would parse through to a defined result instead of stumbling into a coincidentally-also-undefined outcome (a missing target, or a cursor overrun) for an unrelated reason. + const target = targetFrom(ONE_RANGE, [ + { ct: 0x02, cp: 0x00, rgce1: new Uint8Array(ptgStr("needle")) }, + ]); + const targets = new Map([[1, target]]); + const bytes = record(RECORD_CFEX, [ + ...new Array(12).fill(0), // frtRefHeaderU + ...u32(1), // fIsCF12: nonzero + ...u16(1), // nID + ...cfExNonCf12Bytes({ + icfTemplate: 0x08, + templateParams: cfExTextTemplateParams(0x0000), + }), + ]); + const group = groupsFrom(bytes)[0]; + if (group === undefined) { + throw new Error("expected a CFEx record group"); + } - expect(readCfEx(cfExGroup, targets, NO_SHEETS)).toBeUndefined(); + expect(readCfEx(group, targets, NO_SHEETS)).toBeUndefined(); }); it("does not promote a rule whose nID names no CondFmt group this reader has seen", () => { @@ -327,6 +344,31 @@ describe("readCfEx", () => { expect(readCfEx(truncatedGroup, targets, NO_SHEETS)).toBeUndefined(); }); + + describe("errors that are not malformed-record degrades", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("propagates a genuine bug from parseDxfStyle rather than absorbing it as a malformed record", () => { + const bug = new TypeError("a genuine bug, not a malformed record"); + vi.spyOn(conditionalFormatModule, "parseDxfStyle").mockImplementation( + () => { + throw bug; + }, + ); + const target = targetFrom(ONE_RANGE, [ + { ct: 0x02, cp: 0x00, rgce1: new Uint8Array(ptgStr("needle")) }, + ]); + const targets = new Map([[1, target]]); + const cfExGroup = cfExRecordGroup(1, { + icfTemplate: 0x08, + templateParams: cfExTextTemplateParams(0x0000), + }); + + expect(() => readCfEx(cfExGroup, targets, NO_SHEETS)).toThrow(bug); + }); + }); }); // End-to-end: a real worksheet substream carries the base CondFmt/CF group first, then the CFEx that extends one of its CF children -- this proves readCondFmtGroup's own nID/rawCfs and readCfEx actually compose the way workbook/sheet.ts wires them together, not just that each function is individually correct against a hand-built CfExTarget. From d50d88b204a7c355ba0be95942397cfcd49e8170 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 06:10:36 +0100 Subject: [PATCH 031/107] test(xls-codec): cover readDv's recoverFromFormatError call against a real bug Mock parseFormulaText to throw a plain TypeError and assert readDv lets it propagate, proving the catch block's recoverFromFormatError call actually rethrows a genuine bug rather than silently returning undefined for every error alongside real malformed-record degrades. --- .../src/workbook/data-validation.test.ts | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/xls-codec/src/workbook/data-validation.test.ts b/packages/xls-codec/src/workbook/data-validation.test.ts index 957415ee25..7bc726181c 100644 --- a/packages/xls-codec/src/workbook/data-validation.test.ts +++ b/packages/xls-codec/src/workbook/data-validation.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { FormulaSheetContext } from "../biff/ptg"; +import * as ptgModule from "../biff/ptg"; import { groupRecords, type RecordGroup } from "../biff/substreams"; import { readRecords } from "../biff/records"; import { record, u16, u32, xlUnicodeString } from "../test-support/biff"; @@ -251,4 +252,29 @@ describe("readDv", () => { expect(readDv(group, NO_SHEETS)).toBeUndefined(); }); + + describe("errors that are not malformed-record degrades", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("propagates a genuine bug from parseFormulaText rather than absorbing it as a malformed record", () => { + const bug = new TypeError("a genuine bug, not a malformed record"); + vi.spyOn(ptgModule, "parseFormulaText").mockImplementation(() => { + throw bug; + }); + const group = dvRecord( + dvFlags({ valType: 0x0 }), + "", + "", + "", + "", + [], + [], + [], + ); + + expect(() => readDv(group, NO_SHEETS)).toThrow(bug); + }); + }); }); From cbbec1967ec8d2a3a8c39964841fff74f43dcff6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 06:13:36 +0100 Subject: [PATCH 032/107] test(xls-codec): assert data validation ranges accept BIFF8's own grid edge The existing out-of-range test only ever tried one-past-the-edge values (0x10000, 0x100), which a strict `>` and a mistaken `>=` boundary check both reject identically. Add the boundary values themselves (0xffff, 0xff) and assert they write without throwing, which only a strict `>` check permits. --- packages/xls-codec/src/write.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/xls-codec/src/write.test.ts b/packages/xls-codec/src/write.test.ts index ca01a41613..228182cb95 100644 --- a/packages/xls-codec/src/write.test.ts +++ b/packages/xls-codec/src/write.test.ts @@ -2324,6 +2324,26 @@ describe("writeXlsContent: data validations written (#971)", () => { } }); + it("accepts a range sitting exactly on BIFF8's own grid boundary, not just short of it", () => { + // 0xffff and 0xff are the largest row/column index BIFF8's own u16/u8 fields can carry -- a range naming exactly these values is still addressable, unlike the one-past-the-edge values the previous test throws on, so the boundary check must be a strict `>`, not `>=`. + const rule: ContentSheetDataValidation = { + ranges: [ + { + startRow: 0xffff, + endRow: 0xffff, + startColumn: 0xff, + endColumn: 0xff, + }, + ], + type: "whole", + operator: "greaterThan", + formula1: "5", + }; + expect(() => + writeXlsContent(document([sheet("S", [], { dataValidations: [rule] })])), + ).not.toThrow(); + }); + it("writes no Dval/Dv records at all for a sheet stating an empty dataValidations array", () => { // A round trip through readXlsContent cannot distinguish this from a Dval-with-zero-Dv-records: mapDataValidations's own result is an empty array either way, and content.ts already omits the field for an empty array regardless of whether a genuinely empty Dval record was written at all. Calling the writer directly is the only way to check that no record is written in the first place. expect( From c3602d6018592025605541a7893fee4e118e775a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 06:16:42 +0100 Subject: [PATCH 033/107] refactor(xls-codec): drop container's redundant CompoundFileFormatError branch The special case for CompoundFileFormatError produced byte-identical output to the generic catch-all beneath it -- both derive the wrapped message from error.message, and CompoundFileFormatError is itself an Error, so the generic branch's `error instanceof Error` check already picks the same value. Collapse to the one branch; the comment above it was also stale, describing a "typed error left to propagate" design this function has never actually implemented. Add tests distinguishing the container's own guards from a coincidentally- identical fallback: the not-a-compound-file message from the wrapped readCompoundFile failure it shares a prefix with, some() from every() for a legacy 'Book' stream sitting beside another stream that isn't it, and isXlsFile's own guard (proven via a readCompoundFile spy, since its early return and its catch-all both already answer false). --- packages/xls-codec/src/container.test.ts | 33 ++++++++++++++++++++++-- packages/xls-codec/src/container.ts | 14 ++-------- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/packages/xls-codec/src/container.test.ts b/packages/xls-codec/src/container.test.ts index c18e559371..8a3c514e5d 100644 --- a/packages/xls-codec/src/container.test.ts +++ b/packages/xls-codec/src/container.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as archiveCodec from "archive-codec"; import { bofData, record } from "./test-support/biff"; import { compoundFile } from "./test-support/cfb"; @@ -72,9 +73,12 @@ describe("readWorkbookStreams", () => { }); it("refuses bytes with no compound-file signature", () => { + // Bytes this short and this unlike a [MS-CFB] header would also fail further in, inside readCompoundFile itself -- but that failure carries a different message ("compound-file container could not be read"), so asserting the exact text here proves it is the signature guard that fired, not a downstream parse failure that happens to throw the same error type. expect(() => readWorkbookStreams(new Uint8Array([0x50, 0x4b, 0x03, 0x04])), - ).toThrow(BiffFormatError); + ).toThrow( + "not a compound file: a .xls workbook is a [MS-CFB] container holding a 'Workbook' stream", + ); }); it("refuses a compound file holding a legacy 'Book' stream rather than 'Workbook'", () => { @@ -85,6 +89,16 @@ describe("readWorkbookStreams", () => { expect(() => readWorkbookStreams(bytes)).toThrow(/BIFF5\/BIFF7 workbook/); }); + it("recognises a legacy 'Book' stream even when it is not the container's only stream", () => { + // A single-stream container can't tell `.some` and `.every` apart -- both agree when there's only one thing to check. Adding an unrelated second stream that is NOT 'Book' makes them disagree: `.some` still finds the 'Book' stream and reports BIFF5/BIFF7, while `.every` would see a stream that isn't 'Book' and wrongly fall through to "holds no 'Workbook' stream" instead. + const bytes = compoundFile([ + { path: "Book", bytes: minimalWorkbookStream() }, + { path: "\x05SummaryInformation", bytes: new Uint8Array([1]) }, + ]); + + expect(() => readWorkbookStreams(bytes)).toThrow(/BIFF5\/BIFF7 workbook/); + }); + it("refuses a compound file holding neither a Workbook nor a Book stream", () => { const bytes = compoundFile([ { path: "WordDocument", bytes: new Uint8Array([1]) }, @@ -136,4 +150,19 @@ describe("isXlsFile", () => { expect(isXlsFile(bytes)).toBe(false); }); + + describe("its own compound-file guard", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("never calls into readCompoundFile for bytes that are not a compound file at all", () => { + // Both the guard and the catch-all below it agree on the final answer (false) for non-CFB bytes, so the return value alone cannot prove the guard is what actually fired rather than a coincidentally-identical result from further in. Spying on readCompoundFile itself proves the guard short-circuits before ever calling it. + const readSpy = vi.spyOn(archiveCodec, "readCompoundFile"); + + expect(isXlsFile(new Uint8Array([0x50, 0x4b, 0x03, 0x04]))).toBe(false); + + expect(readSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/xls-codec/src/container.ts b/packages/xls-codec/src/container.ts index 98514c378b..98a3a7cc2d 100644 --- a/packages/xls-codec/src/container.ts +++ b/packages/xls-codec/src/container.ts @@ -1,8 +1,4 @@ -import { - CompoundFileFormatError, - isCompoundFile, - readCompoundFile, -} from "archive-codec"; +import { isCompoundFile, readCompoundFile } from "archive-codec"; import { BiffFormatError } from "./biff/records"; @@ -72,19 +68,13 @@ export function readWorkbookStreams( ); } -/** archive-codec's own reader, with its typed error left to propagate and every other failure wrapped, so a caller catching BiffFormatError sees one error type for "this is not a workbook this package can read". */ +/** archive-codec's own reader, with every failure it can throw -- its own typed CompoundFileFormatError, or a raw RangeError from a DataView read on a malformed mini-FAT chain -- wrapped into BiffFormatError, so a caller catching that one type sees one error type for "this is not a workbook this package can read" regardless of which layer inside archive-codec actually noticed the corruption. */ function readWorkbookContainer( bytes: Uint8Array, ): ReturnType { try { return readCompoundFile(bytes); } catch (error) { - if (error instanceof CompoundFileFormatError) { - throw new BiffFormatError( - `compound-file container could not be read: ${error.message}`, - ); - } - // archive-codec's own reader can surface a raw RangeError from a DataView read on a malformed mini-FAT chain, which is a corrupt file rather than a bug here. throw new BiffFormatError( `compound-file container could not be read: ${error instanceof Error ? error.message : String(error)}`, ); From a3d52348746eccc66d5ea15f4a26b0f3c1a31c32 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 06:19:03 +0100 Subject: [PATCH 034/107] test(xls-codec): assert substreams' thrown messages and the BOF length boundary groupRecords' two "no preceding record" errors and readBofDocumentType's own two thrown messages were only ever checked by error type, so a mutation emptying any of them went undetected. Assert each message's exact text, and add the missing boundary case for the BOF prefix-length check: exactly four bytes (BOF_PREFIX_SIZE) must be accepted as sufficient, not rejected as one byte short. --- .../xls-codec/src/biff/substreams.test.ts | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/xls-codec/src/biff/substreams.test.ts b/packages/xls-codec/src/biff/substreams.test.ts index 5f006f16cf..9b76d761f1 100644 --- a/packages/xls-codec/src/biff/substreams.test.ts +++ b/packages/xls-codec/src/biff/substreams.test.ts @@ -11,7 +11,7 @@ import { RECORD_EOF, RECORD_SST, } from "./record-types"; -import { BiffFormatError, type BiffRecord } from "./records"; +import type { BiffRecord } from "./records"; import { groupRecords, splitSubstreams } from "./substreams"; function bytes(...values: readonly number[]): Uint8Array { @@ -109,7 +109,7 @@ describe("groupRecords", () => { it("rejects a Continue with no preceding record to continue", () => { expect(() => groupRecords(records({ type: RECORD_CONTINUE, data: bytes(1) })), - ).toThrow(BiffFormatError); + ).toThrow("Continue record with no preceding record to continue"); }); it("attaches a ContinueFrt12 record to the FRT record it continues, stripping its own 12-byte FrtRefHeader first", () => { @@ -151,7 +151,7 @@ describe("groupRecords", () => { data: bytes(...new Array(12).fill(0)), }), ), - ).toThrow(BiffFormatError); + ).toThrow("ContinueFrt12 record with no preceding record to continue"); }); }); @@ -256,15 +256,39 @@ describe("splitSubstreams", () => { splitSubstreams( groupRecords(records({ type: RECORD_BOF, data: biff5Bof })), ), - ).toThrow(BiffFormatError); + ).toThrow( + "BOF declares BIFF version 0x0500; this reader implements BIFF8 (0x0600) only", + ); }); it("rejects a BOF too short to carry its own version and document type", () => { + // A single byte, so `data?.length ?? 0` names the real length (1) in the thrown message rather than a stand-in value. expect(() => splitSubstreams( groupRecords(records({ type: RECORD_BOF, data: bytes(0) })), ), - ).toThrow(BiffFormatError); + ).toThrow( + "BOF record carries 1 bytes, too few for its own version and document type", + ); + }); + + it("accepts a BOF carrying exactly its own four-byte prefix and nothing more", () => { + // BOF_PREFIX_SIZE (4) is the minimum, not a value that itself counts as "too few" -- the length check must be a strict `<`, not `<=`. + const substreams = splitSubstreams( + groupRecords( + records( + { + type: RECORD_BOF, + data: bytes(0x00, 0x06, BOF_TYPE_WORKSHEET, 0x00), + }, + { type: RECORD_EOF, data: bytes() }, + ), + ), + ); + + expect(substreams.map((sub) => sub.documentType)).toStrictEqual([ + BOF_TYPE_WORKSHEET, + ]); }); it("nests a chart substream inside the worksheet substream that anchors it, resuming the worksheet's own records once the chart's EOF closes it", () => { From 260723852ba679963d31b7cbd942a48a86147e29 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 06:22:58 +0100 Subject: [PATCH 035/107] refactor(xls-codec): narrow writeSheetComments to cells proven to carry a comment writeSheetComments accepted a plain ContentSheetCell array and threw an "internal error" if a cell's own comment turned out undefined, even though every caller is documented to have already filtered to commented cells. Add a CommentedCell type and a hasComment type-guard predicate, and have sheet-writer.ts's own filter narrow to it, so the comment field is non-undefined by construction and the runtime check that could never fail without a bug in the caller is gone. Assert the values this writer's own randomness and boundary-condition code actually produce, none of which were checked before: FtNts's GUID bytes are genuinely random rather than left as the buffer's own zero fill, TxO's cbRuns is 16 for non-empty text and 0 for empty text (not the reverse or a constant), and a sheet with exactly 0xffff comments -- the largest object id FtCmo.id can hold -- writes without throwing. --- .../src/workbook/comment-writer.test.ts | 47 +++++++++++++++++-- .../xls-codec/src/workbook/comment-writer.ts | 25 ++++++---- .../xls-codec/src/workbook/sheet-writer.ts | 6 +-- 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/packages/xls-codec/src/workbook/comment-writer.test.ts b/packages/xls-codec/src/workbook/comment-writer.test.ts index 58b1e85900..a4ec279e1b 100644 --- a/packages/xls-codec/src/workbook/comment-writer.test.ts +++ b/packages/xls-codec/src/workbook/comment-writer.test.ts @@ -1,4 +1,3 @@ -import type { ContentSheetCell } from "document-schema.js"; import { describe, expect, it } from "vitest"; import { readRecords } from "../biff/records"; @@ -12,14 +11,14 @@ import { groupRecords } from "../biff/substreams"; import { concat } from "../test-support/biff"; import { BiffWriteError } from "../biff/write-errors"; import { readSheetComments } from "./comments"; -import { writeSheetComments } from "./comment-writer"; +import { writeSheetComments, type CommentedCell } from "./comment-writer"; function commentedCell( row: number, column: number, text: string, author?: string, -): ContentSheetCell { +): CommentedCell { return { row, column, @@ -151,4 +150,46 @@ describe("writeSheetComments", () => { expect(() => writeSheetComments(cells)).toThrow(BiffWriteError); expect(() => writeSheetComments(cells)).toThrow(/65535/); }); + + it("accepts exactly as many comments as a 16-bit FtCmo.id can distinguish, not one fewer", () => { + // 0xffff (65535) is the largest object id FtCmo.id's own 16-bit field can hold, so a sheet with exactly that many comments is still writable -- only one more should be refused. + const cells = Array.from({ length: 0xffff }, (_, index) => + commentedCell(0, index, "x"), + ); + + expect(() => writeSheetComments(cells)).not.toThrow(); + }); + + it("gives each comment's own FtNts a genuinely random GUID, not a fixed all-zero one", () => { + const pieces = writeSheetComments([ + commentedCell(0, 0, "a"), + commentedCell(0, 1, "b"), + ]); + const objRecords = readRecords(concat(...pieces)).filter( + (r) => r.type === RECORD_OBJ, + ); + // FtNts's own 16-byte GUID sits right after FtCmo (22 bytes) plus FtNts's own ft/cb header (4 bytes), at byte offset 26 of the Obj record's data. + const guidOf = (data: Uint8Array) => data.slice(26, 42); + const firstGuid = objRecords[0] ? guidOf(objRecords[0].data) : undefined; + const secondGuid = objRecords[1] ? guidOf(objRecords[1].data) : undefined; + + expect(firstGuid).not.toStrictEqual(new Uint8Array(16)); + expect(secondGuid).not.toStrictEqual(new Uint8Array(16)); + expect(firstGuid).not.toStrictEqual(secondGuid); + }); + + it("writes a nonzero cbRuns for non-empty text and zero cbRuns for empty text", () => { + // cbRuns sits at byte offset 12 of a TxO record's own data (grbit u16, rot u16, reserved4 u16, reserved5 u32, cchText u16, cbRuns u16). + const nonEmpty = readRecords( + concat(...writeSheetComments([commentedCell(0, 0, "hi")])), + ).find((r) => r.type === RECORD_TXO); + if (nonEmpty === undefined) throw new Error("expected a TXO record"); + expect(nonEmpty.data[12]).toBe(16); + + const empty = readRecords( + concat(...writeSheetComments([commentedCell(0, 0, "")])), + ).find((r) => r.type === RECORD_TXO); + if (empty === undefined) throw new Error("expected a TXO record"); + expect(empty.data[12]).toBe(0); + }); }); diff --git a/packages/xls-codec/src/workbook/comment-writer.ts b/packages/xls-codec/src/workbook/comment-writer.ts index 0bf8622576..7fc5edb226 100644 --- a/packages/xls-codec/src/workbook/comment-writer.ts +++ b/packages/xls-codec/src/workbook/comment-writer.ts @@ -132,13 +132,23 @@ function writeNoteRecord( const MAX_OBJECT_ID = 0xffff; +/** A cell known to carry a comment -- what `writeSheetComments` actually needs, and a stronger contract than `ContentSheetCell` states on its own (`comment` is optional there, since most cells carry none). Narrowing the parameter to this type, rather than accepting any `ContentSheetCell` and throwing on one whose `comment` turned out to be absent, moves the "does this cell actually have a comment" question to the one place -- the caller's own filter -- that can answer it with real information, rather than restating it here as a runtime check nothing can fail without a bug in that caller. */ +export type CommentedCell = ContentSheetCell & { + readonly comment: NonNullable; +}; + +/** A type-guard predicate for `Array.prototype.filter`, so a sheet's own cell list narrows to `CommentedCell[]` at the filter call itself rather than staying `ContentSheetCell[]` with the comment field re-checked (or, worse, assumed) afterwards. */ +export function hasComment(cell: ContentSheetCell): cell is CommentedCell { + return cell.comment !== undefined; +} + /** - * Every Note/Obj/TxO record a sheet's own commented cells need, in the order described above -- for `cells` already filtered to exactly those carrying a `comment` (workbook/sheet-writer.ts's own caller does the filtering, since only it knows the sheet's full cell list). + * Every Note/Obj/TxO record a sheet's own commented cells need, in the order described above. * * Object ids are assigned sequentially from 1: [MS-XLS] 2.5.92's own FtCmo.id must be unique "among all Obj records within ... Worksheet Substream ABNF", and this writer never emits any other kind of Obj record (no shapes, charts, or form controls yet -- see this package's README), so a per-sheet counter starting at 1 is already unique on its own. */ export function writeSheetComments( - commentedCells: readonly ContentSheetCell[], + commentedCells: readonly CommentedCell[], ): Uint8Array[] { if (commentedCells.length > MAX_OBJECT_ID) { throw new BiffWriteError( @@ -153,13 +163,10 @@ export function writeSheetComments( ); ordered.forEach((cell, index) => { const objId = index + 1; - const comment = cell.comment; - if (comment === undefined) { - throw new BiffWriteError( - `internal error: writeSheetComments was called with a cell at row ${cell.row}, column ${cell.column} carrying no comment`, - ); - } - pieces.push(writeObjRecordForNote(objId), ...writeTxoRecords(comment.text)); + pieces.push( + writeObjRecordForNote(objId), + ...writeTxoRecords(cell.comment.text), + ); }); return pieces; } diff --git a/packages/xls-codec/src/workbook/sheet-writer.ts b/packages/xls-codec/src/workbook/sheet-writer.ts index a3fec9e815..d60971e15c 100644 --- a/packages/xls-codec/src/workbook/sheet-writer.ts +++ b/packages/xls-codec/src/workbook/sheet-writer.ts @@ -60,7 +60,7 @@ import { pointsToColumnWidth, pointsToInches, pointsToTwips } from "../units"; import { cellCarriesFormatting, writesCellRecord } from "../written-cells"; import { writeSheetConditionalFormats } from "./conditional-format-write"; import { writeSheetDataValidations } from "./data-validation-write"; -import { writeSheetComments } from "./comment-writer"; +import { hasComment, writeSheetComments } from "./comment-writer"; import { GENERAL_CELL_XF_INDEX } from "./globals-writer"; import type { SheetDrawingWrite } from "./drawing-writer"; @@ -698,9 +698,7 @@ export function buildWorksheetSubstream( pieces.push(writeMergeCellsRecord(merges)); } - const commentedCells = sheet.cells.filter( - (cell) => cell.comment !== undefined, - ); + const commentedCells = sheet.cells.filter(hasComment); if (commentedCells.length > 0) { pieces.push(...writeSheetComments(commentedCells)); } From 6d981a2eacf27793ce38f6ee66e013b6d2c7cae3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 06:27:27 +0100 Subject: [PATCH 036/107] test(xls-codec): cover readObjPictFmlaStorageId's own sub-record walk No test called this function directly, only indirectly through a round trip that never exercised a nonzero cbFmla, an unrelated sub-record before FtPictFmla, or the reserved trailing zero ft marker. Add direct cases for each, plus readTxoText's own cbFmla/cbRuns skips (both always 0 in the shared noteTxoRecords fixture, so their skip and a no-op were indistinguishable) via a claimed length that runs past the record's actual data, which only a real skip attempts to honour. Delete comments.ts's own switch statement's redundant `break` in its default case: default is that switch's last case inside a loop with nothing following it, so the break was never a real control-flow choice. --- .../xls-codec/src/workbook/comments.test.ts | 92 ++++++++++++++++++- packages/xls-codec/src/workbook/comments.ts | 2 +- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/packages/xls-codec/src/workbook/comments.test.ts b/packages/xls-codec/src/workbook/comments.test.ts index 6f4a974db3..e923f8e072 100644 --- a/packages/xls-codec/src/workbook/comments.test.ts +++ b/packages/xls-codec/src/workbook/comments.test.ts @@ -1,16 +1,20 @@ import { describe, expect, it } from "vitest"; -import { readRecords } from "../biff/records"; -import { groupRecords } from "../biff/substreams"; +import { BiffFormatError, readRecords } from "../biff/records"; +import { groupRecords, type RecordGroup } from "../biff/substreams"; +import { RECORD_OBJ, RECORD_TXO } from "../biff/record-types"; import { concat, + ftCmo, noteObjRecord, noteRecord, noteTxoRecords, otherObjRecord, record, + u16, + u32, } from "../test-support/biff"; -import { readSheetComments } from "./comments"; +import { readObjPictFmlaStorageId, readSheetComments } from "./comments"; function readComments(...records: readonly Uint8Array[]) { return readSheetComments(groupRecords(readRecords(concat(...records)))); @@ -107,4 +111,86 @@ describe("readSheetComments", () => { const comments = readComments(record(ROW_RECORD_TYPE, [])); expect(comments.size).toBe(0); }); + + it("throws rather than silently absorbing a TxO whose own cbFmla overruns the record data", () => { + // cchText 0 so the function returns right after skipping cbFmla -- isolating that one skip from cbRuns' own, tested separately below. cbFmla names 50 bytes to skip but none follow. + const txoData = [ + ...u16(0), // grbit + ...u16(0), // rot + ...new Array(6).fill(0), // reserved4 + reserved5 + ...u16(0), // cchText + ...u16(0), // cbRuns + ...u16(0), // ifntEmpty + ...u16(50), // cbFmla -- claims 50 bytes that are never written + ]; + expect(() => + readComments(noteObjRecord(1), record(RECORD_TXO, txoData)), + ).toThrow(BiffFormatError); + }); + + it("throws rather than silently absorbing a TxO whose own cbRuns overruns the record data", () => { + const text = "hi"; + const txoData = [ + ...u16(0), // grbit + ...u16(0), // rot + ...new Array(6).fill(0), // reserved4 + reserved5 + ...u16(text.length), // cchText + ...u16(50), // cbRuns -- claims 50 bytes that are never written + ...u16(0), // ifntEmpty + ...u16(0), // cbFmla + 0x00, // XLUnicodeStringNoCch's own flags byte -- compressed (fHighByte clear) + ...Array.from(text, (char) => char.codePointAt(0) ?? 0), + ]; + expect(() => + readComments(noteObjRecord(1), record(RECORD_TXO, txoData)), + ).toThrow(BiffFormatError); + }); +}); + +describe("readObjPictFmlaStorageId", () => { + function objGroup(...ftRecords: readonly number[][]): RecordGroup { + const bytes = record(RECORD_OBJ, [ + ...ftCmo(0x0008, 1), + ...ftRecords.flat(), + ]); + const group = groupRecords(readRecords(bytes))[0]; + if (group === undefined) throw new Error("expected an Obj record group"); + return group; + } + + /** A minimal FtPictFmla sub-record naming `storageId`, with `cbFmla` bytes of arbitrary formula payload before it (0 unless the test needs to prove that payload is actually skipped). */ + function ftPictFmla( + storageId: number, + fmlaBytes: readonly number[] = [], + ): number[] { + const data = [...u16(fmlaBytes.length), ...fmlaBytes, ...u32(storageId)]; + return [...u16(0x0009), ...u16(data.length), ...data]; + } + + it("finds FtPictFmla's own storage id, walking past FtCmo and an unrelated sub-record first", () => { + const unrelated = [...u16(0x1234), ...u16(4), 0xaa, 0xaa, 0xaa, 0xaa]; + const group = objGroup(unrelated, ftPictFmla(42)); + + expect(readObjPictFmlaStorageId(group)).toBe(42); + }); + + it("skips a nonzero cbFmla's own formula bytes before reading the storage id that follows", () => { + const group = objGroup(ftPictFmla(42, [1, 2, 3, 4])); + + expect(readObjPictFmlaStorageId(group)).toBe(42); + }); + + it("stops at the reserved trailing zero ft marker rather than reading past it as a sub-record", () => { + // A zero ft, cb 0, then a fully well-formed FtPictFmla right after: a reader that treated the zero as a genuine sub-record (walking past it via its own cb) would reach this real FtPictFmla and wrongly return its storage id, instead of stopping at the marker. + const reservedThenPictFmla = [...u16(0), ...u16(0), ...ftPictFmla(42)]; + const group = objGroup(reservedThenPictFmla); + + expect(readObjPictFmlaStorageId(group)).toBeUndefined(); + }); + + it("returns undefined for an Obj record carrying no FtPictFmla at all", () => { + const group = objGroup(); + + expect(readObjPictFmlaStorageId(group)).toBeUndefined(); + }); }); diff --git a/packages/xls-codec/src/workbook/comments.ts b/packages/xls-codec/src/workbook/comments.ts index 95091ce497..203c518069 100644 --- a/packages/xls-codec/src/workbook/comments.ts +++ b/packages/xls-codec/src/workbook/comments.ts @@ -129,7 +129,7 @@ export function readSheetComments( break; } default: - break; + // Every other record type is irrelevant to comments and is ignored -- default is this switch's last case, inside a loop with nothing following it, so a `break` here would be a no-op statement rather than a real control-flow choice. } } const comments = new Map(); From 7fdd5c1f1de5f8371911214393589f412c0c487f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 06:31:06 +0100 Subject: [PATCH 037/107] test(xls-codec): assert encryption's disjunctive checks reject each half alone readFilePassHeader's RC4 version check, and decryptWorkbookRecordsXor's key/verifier check, are each a disjunction of two mismatches -- but every existing test varied both halves together, so a mutation collapsing either `||` into `&&` (or forcing one side `false`) went undetected. Add cases isolating one mismatching field at a time on both checks. Assert the RC4 verifier comparison is a genuine every-byte match by engineering a decrypted hash that agrees with the real one at exactly one byte and differs at the rest, which only a mutated `.some()` would wrongly accept. Assert createXorObfuscationKey's own RangeError-to- BiffFormatError translation is scoped to RangeError specifically, by mocking it to throw a plain TypeError and asserting that propagates unchanged rather than being folded into "incorrect password". Delete the RC4 password verification's own redundant length check: md5's digest and the decrypted verifier hash are both always exactly 16 bytes by construction, never a length worth comparing. --- .../xls-codec/src/workbook/encryption.test.ts | 108 +++++++++++++++++- packages/xls-codec/src/workbook/encryption.ts | 7 +- 2 files changed, 111 insertions(+), 4 deletions(-) diff --git a/packages/xls-codec/src/workbook/encryption.test.ts b/packages/xls-codec/src/workbook/encryption.test.ts index 07c0b1bd68..57ebd1e9d4 100644 --- a/packages/xls-codec/src/workbook/encryption.test.ts +++ b/packages/xls-codec/src/workbook/encryption.test.ts @@ -1,3 +1,4 @@ +import * as archiveCodec from "archive-codec"; import { createXorObfuscationArray, createXorObfuscationKey, @@ -9,7 +10,7 @@ import { XOR_OBFUSCATION_ARRAY_LENGTH, XOR_OBFUSCATION_ROTATE_DISTANCE_METHOD1, } from "archive-codec"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { BiffRecord } from "../biff/records"; import { BiffFormatError, HEADER_SIZE } from "../biff/records"; @@ -106,6 +107,15 @@ describe("decryptWorkbookRecords", () => { decryptWorkbookRecords([filePass], filePass, PASSWORD), ).toThrow(/RC4 CryptoAPI/); }); + + it("refuses RC4 EncryptionVersionInfo with a valid minor but wrong major", () => { + // A wrong major alone must still be refused -- the version check is a conjunction of both fields matching, not just the minor. + const filePass = biffRecord(RECORD_FILEPASS, [1, 0, 2, 0, 1, 0], 0); + + expect(() => + decryptWorkbookRecords([filePass], filePass, PASSWORD), + ).toThrow(/RC4 CryptoAPI/); + }); }); describe("password verification", () => { @@ -172,6 +182,102 @@ describe("decryptWorkbookRecords", () => { decryptWorkbookRecords([filePass], filePass, tooLong), ).toThrow(/incorrect password/); }); + + it("propagates a genuine bug from createXorObfuscationKey rather than folding it into a wrong-password report", () => { + const filePass = biffRecord( + RECORD_FILEPASS, + xorFilePassData(PASSWORD), + 0, + ); + const bug = new TypeError("a genuine bug, not a wrong password"); + vi.spyOn(archiveCodec, "createXorObfuscationKey").mockImplementation( + () => { + throw bug; + }, + ); + + expect(() => + decryptWorkbookRecords([filePass], filePass, PASSWORD), + ).toThrow(bug); + + vi.restoreAllMocks(); + }); + + it("refuses a password whose XOR key matches but whose verifier does not", () => { + // The check is a disjunction: either field mismatching must refuse the password, not only both at once. + const key = createXorObfuscationKey(PASSWORD); + const realVerifier = createXorObfuscationPasswordVerifier(PASSWORD); + const wrongVerifier = (realVerifier + 1) & 0xffff; + const filePass = biffRecord( + RECORD_FILEPASS, + [ + 0, + 0, // wEncryptionType = 0 (XOR obfuscation) + key & 0xff, + (key >> 8) & 0xff, + wrongVerifier & 0xff, + (wrongVerifier >> 8) & 0xff, + ], + 0, + ); + + expect(() => + decryptWorkbookRecords([filePass], filePass, PASSWORD), + ).toThrow(/incorrect password/); + }); + + it("refuses a password whose XOR verifier matches but whose key does not", () => { + const realKey = createXorObfuscationKey(PASSWORD); + const wrongKey = (realKey + 1) & 0xffff; + const verifier = createXorObfuscationPasswordVerifier(PASSWORD); + const filePass = biffRecord( + RECORD_FILEPASS, + [ + 0, + 0, + wrongKey & 0xff, + (wrongKey >> 8) & 0xff, + verifier & 0xff, + (verifier >> 8) & 0xff, + ], + 0, + ); + + expect(() => + decryptWorkbookRecords([filePass], filePass, PASSWORD), + ).toThrow(/incorrect password/); + }); + + it("refuses an RC4 verifier hash that decrypts to the wrong bytes even where one byte happens to coincide", () => { + // The comparison must be a genuine every-byte match, not merely "at least one byte agrees" -- engineered here by taking the real, correctly-decrypting verifier hash and flipping every byte except the first, so a `.some()` in place of `.every()` would still wrongly accept it. + const baseHash = deriveOfficeRc4BaseHash(PASSWORD, SALT); + const verifier = new Uint8Array(16).map((_, i) => i * 7 + 3); + const realHash = md5(verifier); + const poisoned = realHash.map((byte, index) => + index === 0 ? byte : (byte ^ 0xff) & 0xff, + ); + const encryptedVerifier = decryptOfficeRc4(baseHash, 0, verifier); + const encryptedPoisonedHash = decryptOfficeRc4(baseHash, 16, poisoned); + const filePass = biffRecord( + RECORD_FILEPASS, + [ + 1, + 0, // wEncryptionType = 1 (RC4) + 1, + 0, // vMajor = 1 + 1, + 0, // vMinor = 1 + ...SALT, + ...encryptedVerifier, + ...encryptedPoisonedHash, + ], + 0, + ); + + expect(() => + decryptWorkbookRecords([filePass], filePass, PASSWORD), + ).toThrow(/incorrect password/); + }); }); describe("RC4 record decryption", () => { diff --git a/packages/xls-codec/src/workbook/encryption.ts b/packages/xls-codec/src/workbook/encryption.ts index 7cc8eb9fcf..772297114b 100644 --- a/packages/xls-codec/src/workbook/encryption.ts +++ b/packages/xls-codec/src/workbook/encryption.ts @@ -182,9 +182,10 @@ function decryptWorkbookRecordsRc4( header.encryptedVerifierHash, ); const computedHash = md5(decryptedVerifier); - const matches = - computedHash.length === decryptedVerifierHash.length && - computedHash.every((byte, index) => byte === decryptedVerifierHash[index]); + // No length check first: md5's own digest is always exactly 16 bytes, and decryptedVerifierHash is always exactly OFFICE_RC4_VERIFIER_LENGTH (16) bytes too -- decrypted from a fixed-size EncryptedVerifierHash field readFilePassHeader already took with take(OFFICE_RC4_VERIFIER_LENGTH). The two are never a different length to compare in the first place. + const matches = computedHash.every( + (byte, index) => byte === decryptedVerifierHash[index], + ); if (!matches) { throw new BiffFormatError("incorrect password for RC4-encrypted workbook"); } From 4a24d1b8424cc4e7d5e9b2f9462bb04ec39fd340 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 06:34:25 +0100 Subject: [PATCH 038/107] refactor(xls-codec): drop embedded-object's redundant safeParse success guard A failed safeParse's own result object carries no data property at all, so result.data already reads as undefined on that branch -- the explicit "if (!result.success) return undefined" guard restated a fact already true of the object shape, on a path with nothing else between the guard and the return it duplicated. Assert the foreign-label guard against a payload that is otherwise a fully valid embedding, isolating it from the separate objectKind/ document presence check a trivially-invalid payload would also fail through. Assert writeEmbeddedObjectPackage's own sourcePath/tempPath are genuinely empty strings, not merely present. --- .../src/workbook/embedded-object.test.ts | 19 +++++++++++++++++++ .../xls-codec/src/workbook/embedded-object.ts | 4 +--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/xls-codec/src/workbook/embedded-object.test.ts b/packages/xls-codec/src/workbook/embedded-object.test.ts index 8544422951..96bc5e1e18 100644 --- a/packages/xls-codec/src/workbook/embedded-object.test.ts +++ b/packages/xls-codec/src/workbook/embedded-object.test.ts @@ -1,3 +1,4 @@ +import { readOlePackage } from "archive-codec"; import type { ContentDocument, ContentEmbeddedObject, @@ -84,6 +85,24 @@ describe("writeEmbeddedObjectPackage / readEmbeddedObjectPackage", () => { expect(readEmbeddedObjectPackage(foreign, FRAME)).toBeUndefined(); }); + it("returns undefined for a foreign label even when its payload is otherwise a fully valid embedding", () => { + // "{}" (the case above) also fails the objectKind/document presence check on its own, so it cannot prove the label check itself did anything -- a reader that skipped the label entirely would reach the same undefined result via that other guard. A payload valid enough to parse and pass schema validation isolates the label check. + const foreign = writeForeignPackage( + "not-this-package.json", + JSON.stringify({ objectKind: "drawing", document: DRAWING_DOCUMENT }), + ); + + expect(readEmbeddedObjectPackage(foreign, FRAME)).toBeUndefined(); + }); + + it("writes an empty sourcePath and tempPath, never a placeholder", () => { + const packageBytes = writeEmbeddedObjectPackage(embeddedObject()); + const olePackage = readOlePackage(packageBytes); + + expect(olePackage.sourcePath).toBe(""); + expect(olePackage.tempPath).toBe(""); + }); + it("returns undefined for a payload that is not a JSON object", () => { const foreign = writeForeignPackage( "xls-codec-embedded-object.json", diff --git a/packages/xls-codec/src/workbook/embedded-object.ts b/packages/xls-codec/src/workbook/embedded-object.ts index 76381b3adc..cdf6e67bef 100644 --- a/packages/xls-codec/src/workbook/embedded-object.ts +++ b/packages/xls-codec/src/workbook/embedded-object.ts @@ -58,13 +58,11 @@ export function readEmbeddedObjectPackage( ) { return undefined; } + // No separate `if (!result.success) return undefined` guard: a failed safeParse's own result object carries no `data` property at all, so `result.data` already reads as undefined on failure -- stating the guard explicitly would only restate what accessing an absent property already does on its own. const result = ContentEmbeddedObjectSchema.safeParse({ ...parsed, frame, }); - if (!result.success) { - return undefined; - } return result.data; } catch { return undefined; From a978a5550f1cc47fa0c93e28745439173a1aa74e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 16:16:14 +0100 Subject: [PATCH 039/107] refactor(xls-codec): drop readObjPictFmlaStorageId's redundant FtCmo skip FtCmo's own header is itself shaped as an ft/cb sub-record (ft 0x0015, cb 0x0012 naming its own 18-byte payload), so the function's own generic sub-record walk already skips past it as the loop's first unrelated record. A separate cursor.skip(FT_CMO_SIZE) ahead of the loop only restated a skip the walk performs on its own. Also strengthens the "unrelated sub-record" test to use an odd-length filler: an even-length one left the reader's own word alignment intact even when its skip call was removed entirely, so the assertion passed regardless of whether the skip actually ran. --- packages/xls-codec/src/workbook/comments.test.ts | 3 ++- packages/xls-codec/src/workbook/comments.ts | 5 +---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/xls-codec/src/workbook/comments.test.ts b/packages/xls-codec/src/workbook/comments.test.ts index e923f8e072..bcaf73198c 100644 --- a/packages/xls-codec/src/workbook/comments.test.ts +++ b/packages/xls-codec/src/workbook/comments.test.ts @@ -168,7 +168,8 @@ describe("readObjPictFmlaStorageId", () => { } it("finds FtPictFmla's own storage id, walking past FtCmo and an unrelated sub-record first", () => { - const unrelated = [...u16(0x1234), ...u16(4), 0xaa, 0xaa, 0xaa, 0xaa]; + // An ODD-length unrelated payload (3 bytes, not the 2-byte-word-aligned count every real field here uses) is deliberate: a reader that failed to skip it would misread every following ft/cb pair off a shifted byte boundary rather than merely landing on the wrong sub-record, so the walk runs out of bytes and throws instead of coincidentally still finding 42 -- an even-length filler leaves the word alignment intact and can realign onto FtPictFmla by accident regardless of whether the skip actually ran. + const unrelated = [...u16(0x1234), ...u16(3), 0xaa, 0xaa, 0xaa]; const group = objGroup(unrelated, ftPictFmla(42)); expect(readObjPictFmlaStorageId(group)).toBe(42); diff --git a/packages/xls-codec/src/workbook/comments.ts b/packages/xls-codec/src/workbook/comments.ts index 203c518069..0c7073e5ad 100644 --- a/packages/xls-codec/src/workbook/comments.ts +++ b/packages/xls-codec/src/workbook/comments.ts @@ -52,9 +52,6 @@ export function readObjTypeAndId(group: RecordGroup): { return { ot, id }; } -/** [MS-XLS] 2.5.92 FtCmo's own fixed 22-byte length (ft/cb, ot, id, grbit, three unused dwords -- workbook/drawing-writer.ts's own writeFtCmo names the identical fields, in the identical order). */ -const FT_CMO_SIZE = 22; - /** [MS-XLS] 2.5.150 FtPictFmla's own ft value: the one sub-record naming the Embedding Storage an OLE-embedded picture's data actually lives in, as opposed to the workbook-wide Blip Store a plain image references through its Escher shape's own pib property instead. */ const FT_PICT_FMLA = 0x0009; @@ -67,7 +64,7 @@ export function readObjPictFmlaStorageId( group: RecordGroup, ): number | undefined { const cursor = new BlockCursor(group.blocks); - cursor.skip(FT_CMO_SIZE); + // FtCmo's own header is itself shaped as an ft/cb sub-record (ft 0x0015, cb 0x0012 naming its own 18-byte payload, [MS-XLS] 2.5.92), so the walk below already skips past it correctly as the loop's first unrelated sub-record -- a separate `cursor.skip(FT_CMO_SIZE)` ahead of the loop would only restate a skip this same ft/cb walk performs on its own first iteration. while (cursor.hasMore()) { const ft = cursor.u16(); if (ft === 0) { From 5663d4ca8cf9ccf6b4a939163ab3dcd61baefd22 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 16:16:30 +0100 Subject: [PATCH 040/107] refactor(xls-codec): drop md4's redundant zero offset term The first block word needs no offset arithmetic at all: offset + 0 * 4 is always exactly offset regardless of which arithmetic operator produced the zero, so stating it only restated the same value a more roundabout way. --- packages/xls-codec/src/drawing/md4.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/xls-codec/src/drawing/md4.ts b/packages/xls-codec/src/drawing/md4.ts index b5657991d9..dde8a8fd72 100644 --- a/packages/xls-codec/src/drawing/md4.ts +++ b/packages/xls-codec/src/drawing/md4.ts @@ -94,7 +94,8 @@ export function md4(message: Uint8Array): string { for (let offset = 0; offset < padded.length; offset += BLOCK_SIZE) { const x: BlockWords = [ - view.getUint32(offset + 0 * 4, true), + // The first word needs no offset term at all -- `+ 0 * 4` is always exactly `offset` regardless of which arithmetic operator produced the zero, so stating it would only be restating the same value a different, more roundabout way. + view.getUint32(offset, true), view.getUint32(offset + 1 * 4, true), view.getUint32(offset + 2 * 4, true), view.getUint32(offset + 3 * 4, true), From becb38b5f0c25fa261443fc6a2c0bc227490cfce Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 16:16:47 +0100 Subject: [PATCH 041/107] test(xls-codec): cover data validation's notBetween operator and isolate its own grid-edge checks A between-only fixture never proved the writer's own isTwoOperand check names BOTH two-operand operators rather than just the one that fixture already exercised, so a rule requiring formula2 only for notBetween (or accepting one it never needed) would pass regardless. The four-edge grid boundary test also shared one deviant field across two checks: overRow set both startRow and endRow to the same out-of-grid value, so endRow's own check alone already threw and startRow's never had to. Isolating it to the sole out-of-range field exercises that check on its own. --- packages/xls-codec/src/write.test.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/xls-codec/src/write.test.ts b/packages/xls-codec/src/write.test.ts index 228182cb95..f7a8b5c54c 100644 --- a/packages/xls-codec/src/write.test.ts +++ b/packages/xls-codec/src/write.test.ts @@ -2174,6 +2174,21 @@ describe("writeXlsContent: data validations written (#971)", () => { expect(reread.sheets[0]?.dataValidations).toStrictEqual(rules); }); + it("round-trips a notBetween rule's two formulas", () => { + // 'between' alone does not prove the writer's own isTwoOperand check actually names BOTH two-operand operators rather than just the one the sibling test above already exercises -- a rule refused for missing its second formula only when it should be, or accepted with one only for the operator that never needed it, would pass that test regardless. + const rule: ContentSheetDataValidation = { + ranges: [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }], + type: "decimal", + operator: "notBetween", + formula1: "1", + formula2: "10", + }; + const reread = readXlsContent( + writeXlsContent(document([sheet("S", [], { dataValidations: [rule] })])), + ); + expect(reread.sheets[0]?.dataValidations).toStrictEqual([rule]); + }); + it("refuses an operator-less comparison type and a two-operand operator without its second formula", () => { expect(() => writeXlsContent( @@ -2299,9 +2314,8 @@ describe("writeXlsContent: data validations written (#971)", () => { }; const overRow: ContentSheetDataValidation = { ...base, - ranges: [ - { startRow: 0x10000, endRow: 0x10000, startColumn: 0, endColumn: 0 }, - ], + // endRow deliberately stays in-grid (0), unlike the other three edges below sharing one deviant field with its own pair: an overRow fixture whose own endRow ALSO exceeds 0xffff would still throw with the startRow check dropped entirely, since the endRow check alone already catches it -- only isolating startRow as the sole out-of-range field actually exercises that check on its own. + ranges: [{ startRow: 0x10000, endRow: 0, startColumn: 0, endColumn: 0 }], }; const overEndRow: ContentSheetDataValidation = { ...base, From b26b43d2a084a30b16ba9453233143ac58e4695c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 16:27:03 +0100 Subject: [PATCH 042/107] refactor(xls-codec): drop BlockCursor's genuinely redundant settle passes Every public method (hasMore, remainingInBlock, blockPosition, nextByte) already calls settle() as its own first step, so a fresh cursor needs no separate normalisation pass before its first use, and remainingTotal() needed no second settle() of its own once it reads remainingInBlock() (which already settles) before slicing the blocks that follow rather than after. remainingTotal() itself no longer walks a manual index loop: slicing and reducing the blocks past the current one removes the off-by-one comparison that loop carried, which was already unobservable in its return value (an out-of-range index only ever added zero to the total) but still sat there as an untestable mutation target. nextByte() now folds its two undefined checks (no block left at all, or -- unreachable given settle()'s own invariant, but not something noUncheckedIndexedAccess's own typing can see -- an in-range block with nothing at this offset) into the one case an optional-chained lookup already distinguishes, rather than restating an unreachable second copy of the same throw. take()'s own per-byte copy loop, which can never actually run out once its upfront remainingTotal() check has passed, borrows u8's already-exercised context label instead of building an independent, permanently unobservable template literal of its own. --- packages/xls-codec/src/biff/cursor.test.ts | 51 +++++++++++++++++++--- packages/xls-codec/src/biff/cursor.ts | 34 ++++++--------- 2 files changed, 58 insertions(+), 27 deletions(-) diff --git a/packages/xls-codec/src/biff/cursor.test.ts b/packages/xls-codec/src/biff/cursor.test.ts index 9cefb14e64..2790286e97 100644 --- a/packages/xls-codec/src/biff/cursor.test.ts +++ b/packages/xls-codec/src/biff/cursor.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { BlockCursor } from "./cursor"; -import { BiffFormatError } from "./records"; function bytes(...values: readonly number[]): Uint8Array { return new Uint8Array(values); @@ -80,10 +79,25 @@ describe("BlockCursor", () => { }); it("rejects a length-prefixed take() before allocating, rather than after reading runs out", () => { - // A length field taken straight from untrusted BIFF8 input (e.g. CFEx's own cbDxf, [MS-XLS] 2.4.64) can name up to 4 GiB from a record only a few real bytes long. take() must reject a count larger than the data actually remaining before it allocates, not merely fail partway through copying bytes -- an allocate-then-fail sequence still pays the allocation cost the check exists to avoid. + // A length field taken straight from untrusted BIFF8 input (e.g. CFEx's own cbDxf, [MS-XLS] 2.4.64) can name up to 4 GiB from a record only a few real bytes long. take() must reject a count larger than the data actually remaining before it allocates, not merely fail partway through copying bytes -- an allocate-then-fail sequence still pays the allocation cost the check exists to avoid. Asserting the up-front check's OWN wording, not just that some BiffFormatError was thrown, is what actually proves this: the later per-byte read inside the copy loop throws a BiffFormatError too, with different wording, so a generic class-only assertion cannot tell the two apart. const cursor = new BlockCursor([bytes(0x01, 0x02, 0x03)]); - expect(() => cursor.take(0xffffffff)).toThrow(BiffFormatError); + expect(() => cursor.take(0xffffffff)).toThrow( + /requests more data than remains/, + ); + }); + + it("computes remaining bytes correctly when the cursor sits exactly on an exhausted block, not just at construction", () => { + // A block exhausted by a prior read (offset === that block's own length) is a different unsettled moment than a freshly constructed cursor -- remainingTotal() must still settle from here before totalling, or it would count the already-exhausted block's own length a second time on top of the real remaining block's. + const cursor = new BlockCursor([ + bytes(0x01, 0x02), + bytes(0x03, 0x04, 0x05), + ]); + cursor.u8(); + cursor.u8(); // exactly exhausts the first block, without yet triggering another settle() + + expect(() => cursor.take(4)).toThrow(/requests more data than remains/); + expect(cursor.take(3)).toStrictEqual(bytes(0x03, 0x04, 0x05)); }); it("skips forward without returning the bytes", () => { @@ -100,18 +114,41 @@ describe("BlockCursor", () => { expect(cursor.u8()).toBe(0x04); }); - it("rejects a read running past the end of the last block", () => { + it("rejects a u8 read running past the end of the last block, naming which field was being read", () => { + const cursor = new BlockCursor([bytes()]); + + expect(() => cursor.u8()).toThrow(/^u8 runs past the end/); + }); + + it("rejects a u16 read running past the end of the last block on its very first byte, not just its second", () => { + // An empty cursor, not a one-byte one: u16() calls nextByte("u16") twice, once for its low byte and once for its high, and a fixture with exactly one byte available only ever exercises the SECOND call's own failure -- the first would have succeeded. Only a cursor with no bytes at all forces the first call itself to fail. + const cursor = new BlockCursor([bytes()]); + + expect(() => cursor.u16()).toThrow(/^u16 runs past the end/); + }); + + it("rejects a u16 read that runs out after its low byte but before its high one", () => { const cursor = new BlockCursor([bytes(0x01)]); - expect(() => cursor.u16()).toThrow(BiffFormatError); + expect(() => cursor.u16()).toThrow(/^u16 runs past the end/); }); - it("rejects a skip running past the end of the last block", () => { + it("rejects a skip running past the end of the last block, naming the byte count it was skipping", () => { const cursor = new BlockCursor([bytes(0x01, 0x02)]); expect(() => { cursor.skip(3); - }).toThrow(BiffFormatError); + }).toThrow(/^3-byte skip runs past the end/); + }); + + it("reports the current block index, correctly settled even once every block is fully consumed", () => { + const cursor = new BlockCursor([bytes(0x01)]); + + cursor.u8(); + + // A cursor with no unread bytes anywhere still rests at a specific, well-defined block index -- one past the single block just consumed, not two past it or further, however many times settle() re-runs afterwards. + expect(cursor.blockPosition()).toBe(1); + expect(cursor.blockPosition()).toBe(1); }); it("treats a zero-length block as empty rather than as the end of the data", () => { diff --git a/packages/xls-codec/src/biff/cursor.ts b/packages/xls-codec/src/biff/cursor.ts index 35b4307c70..ab27b8a16a 100644 --- a/packages/xls-codec/src/biff/cursor.ts +++ b/packages/xls-codec/src/biff/cursor.ts @@ -1,5 +1,8 @@ import { BiffFormatError } from "./records"; +/** u8()'s own context label -- reused, not restated, by take()'s per-byte copy loop below: once take()'s own upfront remainingTotal() check has passed, that loop's nextByte() call can never actually run out (there are provably at least `count` bytes left across the blocks it is about to walk), so its label has nothing of its own to name and borrows the one real caller's already-exercised text instead of building an independent, permanently unobservable template literal every copy. */ +const U8_CONTEXT = "u8"; + // A field-reading cursor over one record's data, or over a base record's data followed by its Continue records' ([MS-XLS] 2.4.58) -- one sequence of blocks read as if it were contiguous, while still knowing where each block ends. // // Both halves of that matter. Reads span a block boundary transparently, because a record's fields do not stop where a Continue happens to split them. But the boundary stays observable, because for a string ([MS-XLS] 2.5.293) the first byte after a boundary is a re-stated fHighByte flag rather than character data -- so the string reader needs to ask "did I just cross into a new block?" mid-field. A plain concatenation of the blocks would answer that question with silence and splice the flag byte into the text; see biff/strings.ts for the reader that consumes the boundary correctly. @@ -11,7 +14,7 @@ export class BlockCursor { constructor(blocks: readonly Uint8Array[]) { this.blocks = blocks; - this.settle(); + // No settle() call here: every public method below (hasMore, remainingInBlock, blockPosition, nextByte) already calls it as its own first step, so a freshly constructed cursor needs no separate normalisation pass before its first use -- one that ran here would only ever redo what the first real call does anyway. } /** Advances past any exhausted or empty blocks, so the cursor always rests either on a readable byte or past the end of the last block. A Continue carrying no data is legal and must not read as the end of the record. */ @@ -27,13 +30,8 @@ export class BlockCursor { private nextByte(context: string): number { this.settle(); - const block = this.blocks[this.blockIndex]; - if (block === undefined) { - throw new BiffFormatError( - `${context} runs past the end of the record data`, - ); - } - const byte = block[this.offset]; + // One absence check covers both ways this can run out: no block left at all, or (impossible in practice, since settle() above already guarantees offset < block.length whenever a block IS left, but not a distinction noUncheckedIndexedAccess's own typing can see) an in-range block with nothing at this offset. Folding them into the optional-chained lookup's own single undefined case is what keeps this to one throw and one reachable message, rather than a second copy the first branch already made unreachable. + const byte = this.blocks[this.blockIndex]?.[this.offset]; if (byte === undefined) { throw new BiffFormatError( `${context} runs past the end of the record data`, @@ -63,7 +61,7 @@ export class BlockCursor { } u8(): number { - return this.nextByte("u8"); + return this.nextByte(U8_CONTEXT); } u16(): number { @@ -97,17 +95,13 @@ export class BlockCursor { ); } - /** Total unread bytes across the current block and every block after it -- what a length-prefixed field's own prefix must be checked against before that many bytes are allocated, so a record cannot claim a length far larger than the data actually behind it. */ + /** Total unread bytes across the current block and every block after it -- what a length-prefixed field's own prefix must be checked against before that many bytes are allocated, so a record cannot claim a length far larger than the data actually behind it. remainingInBlock() is read FIRST and the later blocks sliced off `this.blockIndex` only afterwards, so a cursor sitting exactly on an exhausted block (offset === that block's own length, reached mid-read rather than only at construction) settles before either figure is taken -- computing the slice first would capture the stale, pre-settle index instead. */ private remainingTotal(): number { - this.settle(); - let total = 0; - for (let index = this.blockIndex; index < this.blocks.length; index += 1) { - const block = this.blocks[index]; - total += - (index === this.blockIndex ? this.remainingInBlock() : block?.length) ?? - 0; - } - return total; + const inCurrentBlock = this.remainingInBlock(); + const rest = this.blocks + .slice(this.blockIndex + 1) + .reduce((sum, block) => sum + block.length, 0); + return inCurrentBlock + rest; } /** The next `count` bytes, copied out. Spans block boundaries. Rejects a `count` larger than the data actually remaining before allocating, so a length-prefixed field taken from untrusted input (e.g. CFEx's own `cbDxf`) cannot force a multi-gigabyte allocation from a tiny record. */ @@ -119,7 +113,7 @@ export class BlockCursor { } const out = new Uint8Array(count); for (let index = 0; index < count; index += 1) { - out[index] = this.nextByte(`${count}-byte run`); + out[index] = this.nextByte(U8_CONTEXT); } return out; } From bfc5bc547bdd6f62a18ad2977d5e07239f17db82 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 16:40:28 +0100 Subject: [PATCH 043/107] refactor(xls-codec): drop readRichExtendedString's redundant run/phonetic skip guards runCount and extendedSize are both read from a u16/i32 and can never be negative, so their ">0" guards only ever chose between an unconditional cursor.skip(0) (already a no-op) and the identical zero-byte skip -- there is no third, negative-count case left for the guard to distinguish. Removing both conditionals leaves the two skip calls unconditional, and the new tests assert the cursor lands correctly on a trailing sentinel byte in the run/phonetic/ neither cases and prove readXLUnicodeString batches its own chunked String.fromCharCode assembly correctly past the chunk boundary. --- packages/xls-codec/src/biff/strings.test.ts | 50 +++++++++++++++++++-- packages/xls-codec/src/biff/strings.ts | 9 ++-- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/packages/xls-codec/src/biff/strings.test.ts b/packages/xls-codec/src/biff/strings.test.ts index 5164058dab..58b11108b7 100644 --- a/packages/xls-codec/src/biff/strings.test.ts +++ b/packages/xls-codec/src/biff/strings.test.ts @@ -6,7 +6,9 @@ import { readRichExtendedString, readShortXLUnicodeString, readXLUnicodeString, + readXLUnicodeStringNoCch, } from "./strings"; +import { u16 } from "../test-support/biff"; function bytes(...values: readonly number[]): Uint8Array { return new Uint8Array(values); @@ -75,6 +77,32 @@ describe("readXLUnicodeString", () => { expect(readXLUnicodeString(cursor)).toBe("é"); }); + + it("assembles a string past the chunk size readCharacters batches its own String.fromCharCode calls by", () => { + // readCharacters builds the text 4096 units at a time (to stay under the engine's own argument-count ceiling on a single fromCharCode call), slicing the collected units per chunk. A string shorter than that never proves the slicing is real: feeding every chunk the WHOLE units array instead of its own slice reads identically for a one-chunk string, and only repeats the first 4096 characters once a second, genuinely distinct chunk exists to be dropped. + const length = 4096 + 904; + const codes = Array.from({ length }, (_unused, index) => 33 + (index % 94)); + const expected = String.fromCharCode(...codes); + const cursor = new BlockCursor([bytes(...u16(length), 0x00, ...codes)]); + + expect(readXLUnicodeString(cursor)).toBe(expected); + }); +}); + +describe("readXLUnicodeStringNoCch", () => { + // [MS-XLS] 2.5.296: a flags byte then the characters, with the character count supplied by the caller rather than read from a cch field of its own -- SupBook's own virtPath is this shape. + + it("reads a compressed string given its own count, with no cch field to read first", () => { + const cursor = new BlockCursor([bytes(0x00, ...compressed("C:\\"))]); + + expect(readXLUnicodeStringNoCch(cursor, 3)).toBe("C:\\"); + }); + + it("reads an uncompressed string given its own count", () => { + const cursor = new BlockCursor([bytes(0x01, ...uncompressed("日本"))]); + + expect(readXLUnicodeStringNoCch(cursor, 2)).toBe("日本"); + }); }); describe("readShortXLUnicodeString", () => { @@ -104,8 +132,8 @@ describe("readRichExtendedString", () => { expect(readRichExtendedString(cursor)).toBe("Alpha"); }); - it("skips the formatting runs a rich string carries", () => { - // fRichSt (bit 3) set means cRun follows the flags byte and cRun FormatRun structures ([MS-XLS] 2.5.132, four bytes each) follow rgb. The text is the same either way; this package reads the characters, not the run formatting. + it("skips exactly the formatting runs a rich string carries, leaving the cursor correctly positioned on whatever follows", () => { + // fRichSt (bit 3) set means cRun follows the flags byte and cRun FormatRun structures ([MS-XLS] 2.5.132, four bytes each) follow rgb. The text is the same either way; this package reads the characters, not the run formatting -- but a wrong (or entirely dropped) skip would only ever show up in what a LATER read off the same cursor sees, never in this string's own returned text, so a sentinel byte read right after the runs is what actually proves the skip consumed exactly cRun*4 bytes rather than none, or the wrong count. const cursor = new BlockCursor([ bytes( 0x05, @@ -122,14 +150,16 @@ describe("readRichExtendedString", () => { 0x00, 0x02, 0x00, + 0x99, ), ]); expect(readRichExtendedString(cursor)).toBe("Alpha"); + expect(cursor.u8()).toBe(0x99); }); - it("skips the phonetic data an extended string carries", () => { - // fExtSt (bit 2) set means a four-byte cbExtRst follows the flags byte and cbExtRst bytes of ExtRst follow rgb. + it("skips exactly the phonetic data an extended string carries, leaving the cursor correctly positioned on whatever follows", () => { + // fExtSt (bit 2) set means a four-byte cbExtRst follows the flags byte and cbExtRst bytes of ExtRst follow rgb. As above, only a read past the ExtRst bytes can prove the skip consumed exactly cbExtRst bytes. const cursor = new BlockCursor([ bytes( 0x05, @@ -143,10 +173,22 @@ describe("readRichExtendedString", () => { 0xaa, 0xbb, 0xcc, + 0x99, ), ]); expect(readRichExtendedString(cursor)).toBe("Alpha"); + expect(cursor.u8()).toBe(0x99); + }); + + it("skips neither runs nor phonetic data when the string states neither, leaving the cursor immediately on whatever follows", () => { + // The (runCount > 0)/(extendedSize > 0) guards must genuinely gate the skip rather than always (or never) firing -- a plain string with both flags clear is the case that proves the "false" side of both conditions. + const cursor = new BlockCursor([ + bytes(0x05, 0x00, 0x00, ...compressed("Alpha"), 0x99), + ]); + + expect(readRichExtendedString(cursor)).toBe("Alpha"); + expect(cursor.u8()).toBe(0x99); }); it("consumes the re-stated flag byte when a compressed string continues into the next block", () => { diff --git a/packages/xls-codec/src/biff/strings.ts b/packages/xls-codec/src/biff/strings.ts index 4d6beafbf3..cfa124de4c 100644 --- a/packages/xls-codec/src/biff/strings.ts +++ b/packages/xls-codec/src/biff/strings.ts @@ -117,11 +117,8 @@ export function readRichExtendedString(cursor: BlockCursor): string { (flags & FLAG_HIGH_BYTE) !== 0, startBlock, ); - if (runCount > 0) { - cursor.skip(runCount * FORMAT_RUN_SIZE); - } - if (extendedSize > 0) { - cursor.skip(extendedSize); - } + // Unconditional: skip(0) is already a no-op, so gating either call behind its own "> 0" guard first would only ever produce the identical zero-byte skip a bare cursor.skip(0) already gives for the no-run/no-phonetic case -- both runCount and extendedSize come from a u16/i32 read and can never be negative, so there is no third case (a genuinely negative count) that guard could still be catching. + cursor.skip(runCount * FORMAT_RUN_SIZE); + cursor.skip(extendedSize); return text; } From 2fbc91e47acb90d60bdde569e11c27dfa354696f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 16:40:38 +0100 Subject: [PATCH 044/107] test(xls-codec): pin fontNameBytes' exact write length and the dyHeight/fontName refusal messages The one-unit-per-name-character overshoot a broken fontNameBytes loop could produce shifts every font name's record length by the same two bytes, so a length DELTA between two names can never distinguish correct output from that bug -- only a single name's absolute total record length can. Also adds a height-0 exception test (heightTwips 0 is let through despite sitting outside dyHeight's own 20-8191 range) and exact-message regex assertions for both refusal cases, plus a contentFontOf describe block covering the same-icv/differing-icv colour resolution split. --- packages/xls-codec/src/biff/font.test.ts | 73 ++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/packages/xls-codec/src/biff/font.test.ts b/packages/xls-codec/src/biff/font.test.ts index e91460f618..12b536aa9d 100644 --- a/packages/xls-codec/src/biff/font.test.ts +++ b/packages/xls-codec/src/biff/font.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { NORMAL_FONT_FIELDS, cellFontDiffersFromNormal, + contentFontOf, readFontRecord, writeFontRecord, xfFontFieldsOf, @@ -65,6 +66,78 @@ describe("writeFontRecord", () => { }), ).toThrow(BiffWriteError); }); + + it("accepts a height and a name length sitting exactly on dyHeight's and fontName's own boundaries, not just short of them", () => { + // 20/8191/1/31 are the field's own documented MUSTs (>= 20/<= 8191/>= 1/<= 31), not the one-past values the sibling test throws on, so the four checks above must each be a strict boundary rather than an off-by-one -- 21/8190/2/30 could not distinguish `>=`/`<=` from `>`/`<` the way exactly-on-the-edge values do. + expect(() => + writeFontRecord({ ...NORMAL_FONT_FIELDS, heightTwips: 20 }), + ).not.toThrow(); + expect(() => + writeFontRecord({ ...NORMAL_FONT_FIELDS, heightTwips: 8191 }), + ).not.toThrow(); + expect(() => + writeFontRecord({ ...NORMAL_FONT_FIELDS, name: "A" }), + ).not.toThrow(); + expect(() => + writeFontRecord({ ...NORMAL_FONT_FIELDS, name: "A".repeat(31) }), + ).not.toThrow(); + }); + + it("accepts a height of exactly 0 even though it sits outside dyHeight's own 20-8191 range", () => { + // heightTwips 0 is the one value this check lets through despite failing the range test outright -- proving the exception is real, and not merely the range check never firing, needs a height that WOULD throw under the range alone (0 is well below 20) to still pass. + expect(() => + writeFontRecord({ ...NORMAL_FONT_FIELDS, heightTwips: 0 }), + ).not.toThrow(); + }); + + it("names the field, the offending value, and the allowed range in each refusal's own message", () => { + expect(() => + writeFontRecord({ ...NORMAL_FONT_FIELDS, heightTwips: 19 }), + ).toThrow(/font height 19 twips is outside the 20-8191 range/); + expect(() => writeFontRecord({ ...NORMAL_FONT_FIELDS, name: "" })).toThrow( + /font name "" is 0 UTF-16 code units, outside the 1-31/, + ); + }); + + it("writes exactly cch characters of the font name, not one more", () => { + // fontNameBytes' own for loop must stop at name.length, not run one iteration past it: an off-by-one there would append a spurious extra UTF-16 unit (charCodeAt past the end reads as NaN, which the record builder's own u16 coerces to 0) two bytes long, growing the record beyond what a correctly-written one needs -- invisible to a round trip through this package's own reader, which stops reading the name at cch regardless (and invisible too to a bare LENGTH DIFFERENCE between two names of different lengths, since a constant one-unit overshoot shifts both by the identical two bytes). Only the record's own absolute total length, for one fixed name, pins the real byte count down. + const record = writeFontRecord({ ...NORMAL_FONT_FIELDS, name: "AB" }); + + // 4 (record header: type + size) + 14 (Font's own fixed fields) + 2 (fontNameBytes' own cch + flags) + 2*2 (one uncompressed UTF-16 unit per character). + expect(record.length).toBe(4 + 14 + 2 + 2 * 2); + }); +}); + +describe("contentFontOf", () => { + it("states no colour when the cell's own icv is the same index the baseline font already carries, even where a real colour would resolve", () => { + // The comparison is on the raw icv, not the colour it resolves to: two fonts sharing the SAME index state no colour of their own, regardless of whether resolveColor would happily produce one for it. + const font: XfFontFields = { + ...NORMAL_FONT_FIELDS, + colorIcv: NORMAL_FONT_FIELDS.colorIcv, + }; + const resolveColor = () => ({ r: 1, g: 0, b: 0 }); + + expect( + contentFontOf(font, NORMAL_FONT_FIELDS, resolveColor)?.color, + ).toBeUndefined(); + }); + + it("states the resolved colour when the cell's own icv genuinely differs from the baseline's", () => { + const font: XfFontFields = { + ...NORMAL_FONT_FIELDS, + colorIcv: NORMAL_FONT_FIELDS.colorIcv + 1, + }; + const resolveColor = (icv: number) => + icv === font.colorIcv ? { r: 0, g: 1, b: 0 } : undefined; + + expect( + contentFontOf(font, NORMAL_FONT_FIELDS, resolveColor)?.color, + ).toStrictEqual({ + r: 0, + g: 1, + b: 0, + }); + }); }); describe("xfFontFieldsOf", () => { From 64003565f7e5382f1574906236587dc22a6ba59d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 16:40:49 +0100 Subject: [PATCH 045/107] refactor(xls-codec): drop checkedLength's unreachable whole-text embedding branch max is always at least MAX_SHORT_STRING_LENGTH (255) across this module's own three call sites, so by the time the length check throws at all, text is always well past the 40 characters the message embeds -- there was never a shorter-text case left to choose a whole- text embedding over a truncated one. The new tests pin each shape's own name and the exact 40-character truncation into the thrown message, since a repeated-character overflow string can't otherwise distinguish a truncated slice from the untruncated text by content alone. --- .../xls-codec/src/biff/string-writer.test.ts | 23 ++++++++++++++----- packages/xls-codec/src/biff/string-writer.ts | 3 ++- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/xls-codec/src/biff/string-writer.test.ts b/packages/xls-codec/src/biff/string-writer.test.ts index 92514c9b34..272eb35f59 100644 --- a/packages/xls-codec/src/biff/string-writer.test.ts +++ b/packages/xls-codec/src/biff/string-writer.test.ts @@ -43,9 +43,11 @@ describe("writeXLUnicodeString", () => { expect(readXLUnicodeString(new BlockCursor([bytes]))).toBe(text); }); - it("refuses a string longer than the two-byte cch can hold", () => { - expect(() => writeXLUnicodeString("x".repeat(0x10000))).toThrow( - BiffWriteError, + it("refuses a string longer than the two-byte cch can hold, naming its own shape and a 40-character truncation of the text in the message", () => { + // A repeated single character can't distinguish a truncated slice from the whole text by content alone -- it distinguishes them by LENGTH, since the message states the exact overflow count separately from the truncated text it embeds; only the message's own exact shape (which characters are followed by literal "...", where the closing quote lands) proves the truncation happened at 40 characters and not 0 or all 65536. + const text = "x".repeat(0x10000); + expect(() => writeXLUnicodeString(text)).toThrow( + `XLUnicodeString cannot hold ${text.length} UTF-16 code units, above its own 65535-unit limit (text: "${"x".repeat(40)}...")`, ); }); }); @@ -62,9 +64,10 @@ describe("writeShortXLUnicodeString", () => { expect(readShortXLUnicodeString(new BlockCursor([bytes]))).toBe(text); }); - it("refuses a string longer than the one-byte cch can hold", () => { - expect(() => writeShortXLUnicodeString("x".repeat(256))).toThrow( - BiffWriteError, + it("refuses a string longer than the one-byte cch can hold, naming its own shape in the message", () => { + const text = "x".repeat(256); + expect(() => writeShortXLUnicodeString(text)).toThrow( + `ShortXLUnicodeString cannot hold ${text.length} UTF-16 code units, above its own 255-unit limit (text: "${"x".repeat(40)}...")`, ); }); }); @@ -92,4 +95,12 @@ describe("writeRichExtendedString", () => { expect(readRichExtendedString(cursor)).toBe(expected); } }); + + it("refuses a string longer than the two-byte cch can hold, naming its own shape in the message", () => { + const text = "x".repeat(0x10000); + expect(() => writeRichExtendedString(text)).toThrow(BiffWriteError); + expect(() => writeRichExtendedString(text)).toThrow( + `XLUnicodeRichExtendedString cannot hold ${text.length} UTF-16 code units, above its own 65535-unit limit (text: "${"x".repeat(40)}...")`, + ); + }); }); diff --git a/packages/xls-codec/src/biff/string-writer.ts b/packages/xls-codec/src/biff/string-writer.ts index 1f0c128b87..ddc5f688e5 100644 --- a/packages/xls-codec/src/biff/string-writer.ts +++ b/packages/xls-codec/src/biff/string-writer.ts @@ -40,10 +40,11 @@ function encodeCharacters(text: string): EncodedCharacters { return { highByte: needsHighByte, units: builder.build() }; } +/** `max` is always at least MAX_SHORT_STRING_LENGTH (255) across this module's own three call sites below, so `text` is always well past 40 characters by the time this throws at all -- there is no shorter-text case left to choose between embedding it whole or truncating it, only the one this always takes. */ function checkedLength(text: string, max: number, shape: string): number { if (text.length > max) { throw new BiffWriteError( - `${shape} cannot hold ${text.length} UTF-16 code units, above its own ${max}-unit limit (text: ${JSON.stringify(text.length > 40 ? `${text.slice(0, 40)}...` : text)})`, + `${shape} cannot hold ${text.length} UTF-16 code units, above its own ${max}-unit limit (text: ${JSON.stringify(`${text.slice(0, 40)}...`)})`, ); } return text.length; From 0822270184a1ce9544e95c7289511b71d27b9b5f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 16:45:53 +0100 Subject: [PATCH 046/107] test(xls-codec): isolate every earlier guard and array-class Ptg in readLbl/parsePrintAreas readLbl's cch/highByte bail and repeatBandsOf's whole-sheet exclusion were only ever tested alongside inputs whose later fields happened to make the bail unobservable either way (an empty area list, or a band shape only one branch could match) -- adding a raw Lbl builder lets a test put a genuinely well-formed area behind the guard being probed, so bypassing the guard produces a different, observable result instead of the same empty one. Also covers PtgRef3d and PtgMemArea in their array-class spelling, and the "abandon the whole name, not a partial one" rule for an unrecognised token following an otherwise-valid area. --- .../src/workbook/print-names.test.ts | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/packages/xls-codec/src/workbook/print-names.test.ts b/packages/xls-codec/src/workbook/print-names.test.ts index 1389b9ae09..76b37acae0 100644 --- a/packages/xls-codec/src/workbook/print-names.test.ts +++ b/packages/xls-codec/src/workbook/print-names.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; +import { RecordBuilder } from "../biff/builder"; +import { RECORD_LBL } from "../biff/record-types"; +import { writeRecord } from "../biff/record-writer"; import { readRecords } from "../biff/records"; import { groupRecords, type RecordGroup } from "../biff/substreams"; import { concat } from "../test-support/biff"; @@ -9,6 +12,40 @@ import { writePrintNameRecords, } from "./print-names"; +/** [MS-XLS] 2.4.150's own built-in name table: Print_Area. */ +const BUILTIN_PRINT_AREA = 0x06; + +/** A single-cell PtgArea3d ([MS-XLS] 2.5.198.28), reference class (0x3b): ixti 0, then RgceArea B2 (row 1, column 1) through D6 (row 5, column 3) -- the same rectangle LIBREOFFICE_PRINT_AREA below carries, used here as a well-formed "the rest of the rgce parses fine" payload for tests that are really probing an earlier field. */ +const VALID_AREA3D_REF_TOKEN = new Uint8Array([ + 0x3b, 0x00, 0x00, 0x01, 0x00, 0x05, 0x00, 0x01, 0x00, 0x03, 0x00, +]); + +/** + * Assembles a raw Lbl record ([MS-XLS] 2.4.150) field by field, independent of this package's own writer -- so a test can put the record's EARLIER fields into a shape the real writer never produces (a mismatched cch, an uncompressed built-in-name flag) while keeping every later field well-formed, to prove the reader's own guard on the earlier field is what actually stops it, not an accident of the later bytes being absent or malformed too. + */ +function lblRecord(options: { + readonly cch?: number; + readonly nameHighByte?: boolean; + readonly itab?: number; + readonly builtinName?: number; + readonly rgce?: Uint8Array; +}): Uint8Array { + const rgce = options.rgce ?? new Uint8Array(0); + const data = new RecordBuilder() + .u16(0x0020) // grbit: fBuiltin set, every other bit clear + .u8(0) // chKey + .u8(options.cch ?? 1) + .u16(rgce.length) // cce + .u16(0) // reserved3 + .u16(options.itab ?? 1) + .u32(0) // reserved4 through reserved7 + .u8(options.nameHighByte === true ? 1 : 0) // the Name's own XLUnicodeStringNoCch flags byte + .u8(options.builtinName ?? BUILTIN_PRINT_AREA) + .bytes(rgce) + .build(); + return writeRecord(RECORD_LBL, data); +} + // The two fixtures below are the exact Lbl records, byte for byte, out of a .xls LibreOffice produced from a hand-authored .fods declaring a print range of B2:D6, one repeated header column, and two repeated header rows. They are stated as literal bytes rather than built by this package's own writer so that what the reader is checked against is a real producer's encoding, not this package's agreement with itself. // Field offsets into those fixtures, counted from the front of the record INCLUDING its own four-byte type/size framing, so a test can mutate one field of a real producer's record and leave the rest of it exactly as that producer wrote it. @@ -123,6 +160,76 @@ describe("readPrintNames", () => { withPtgInt[OFFSET_RGCE] = 0x1e; // PtgInt in place of the PtgArea3d opcode expect(readPrintNames(groupsOf(withPtgInt)).size).toBe(0); }); + + it("abandons the whole name -- discarding an area already parsed before the unrecognised token -- rather than keeping a partial result", () => { + // The sibling test above replaces the very FIRST opcode, so the unrecognised construct is also the only thing parsePrintAreas ever sees: an implementation that silently stopped (rather than aborted) on an unknown opcode would look identical, because there was never a valid area parsed first to keep or discard. Putting the unrecognised token AFTER one genuinely valid area is what tells the two apart. + const areaThenUnknownOpcode = lblRecord({ + rgce: concat(VALID_AREA3D_REF_TOKEN, new Uint8Array([0x1e])), + }); + expect(readPrintNames(groupsOf(areaThenUnknownOpcode)).size).toBe(0); + }); + + it("adds no print range at all for a Print_Area name whose token stream names zero areas", () => { + // printRangeOf([]) is undefined for an empty area list, and the caller's own "if (range !== undefined)" guard must genuinely gate on that -- an empty rgce (a Print_Area name declaring nothing) is the one input that produces it. + const emptyPrintArea = lblRecord({ rgce: new Uint8Array(0) }); + expect(readPrintNames(groupsOf(emptyPrintArea)).size).toBe(0); + }); + + it("ignores a built-in name whose Name field is not exactly one character, even where every later field parses as a well-formed print area", () => { + const twoCharacterName = lblRecord({ + cch: 2, + rgce: VALID_AREA3D_REF_TOKEN, + }); + expect(readPrintNames(groupsOf(twoCharacterName)).size).toBe(0); + }); + + it("ignores a built-in name whose Name field is stored uncompressed, even where every later field parses as a well-formed print area", () => { + const uncompressedName = lblRecord({ + nameHighByte: true, + rgce: VALID_AREA3D_REF_TOKEN, + }); + expect(readPrintNames(groupsOf(uncompressedName)).size).toBe(0); + }); + + it("reads a single-cell reference written in PtgRef3d's array class the same as its reference class", () => { + // [MS-XLS] 2.5.198.25: a reference-class token's array-class spelling (opcode + 0x40) shares its own layout exactly -- this is the array-class Ptg family this reader admits alongside the reference class every other test above exercises. + const ptgRef3dArray = lblRecord({ + rgce: new Uint8Array([ + 0x7a, // PtgRef3d, array class + 0x00, + 0x00, // ixti + 0x0a, + 0x00, // row 10 + 0x05, + 0x00, // column 5 (top two ColRelU bits clear) + ]), + }); + expect(readPrintNames(groupsOf(ptgRef3dArray)).get(0)).toStrictEqual({ + printRange: { startRow: 10, startColumn: 5, endRow: 10, endColumn: 5 }, + }); + }); + + it("skips a PtgMemArea token's own unused bytes and cce, in its array-class spelling, without disturbing the area that follows it", () => { + const ptgMemAreaArrayThenArea = lblRecord({ + rgce: concat( + new Uint8Array([ + 0x66, // PtgMemArea, array class + 0xaa, + 0xbb, + 0xcc, + 0xdd, // 4 unused bytes -- never read, only skipped + 0xff, + 0xff, // cce -- also skipped, not used to bound the parse + ]), + VALID_AREA3D_REF_TOKEN, + ), + }); + expect( + readPrintNames(groupsOf(ptgMemAreaArrayThenArea)).get(0), + ).toStrictEqual({ + printRange: { startRow: 1, startColumn: 1, endRow: 5, endColumn: 3 }, + }); + }); }); describe("printNameEntriesFor and writePrintNameRecords", () => { @@ -167,4 +274,25 @@ describe("printNameEntriesFor and writePrintNameRecords", () => { repeatRows: { start: 0, end: 0 }, }); }); + + it("classifies neither axis for a Print_Titles area spanning the whole sheet, rather than the first branch a looser check would still match", () => { + // A band spanning every row AND every column makes BOTH spansEveryRow and spansEveryColumn true, so `spansEveryColumn && !spansEveryRow` and `spansEveryRow && !spansEveryColumn` are each `true && false`, correctly false either way -- but replacing either `&&` with `||` (or replacing the whole condition with `true`) would make one of them match anyway, wrongly turning "the whole sheet" into "a repeated row band" or "a repeated column band". This is the one area shape that tells `&&` and `||` apart here: every other test above uses an area where only one of the two spans is ever true, which cannot distinguish the two operators. + const wholeSheet = lblRecord({ + builtinName: 0x07, // Print_Titles + rgce: new Uint8Array([ + 0x3b, // PtgArea3d, reference class + 0x00, + 0x00, // ixti + 0x00, + 0x00, // rowFirst 0 + 0xff, + 0xff, // rowLast 0xffff -- every row + 0x00, + 0x00, // columnFirst 0 + 0xff, + 0x00, // columnLast 0x00ff -- every column + ]), + }); + expect(readPrintNames(groupsOf(wholeSheet)).get(0)).toStrictEqual({}); + }); }); From 6ec04c169c055eea94e2678337230c154790e9d6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 16:49:43 +0100 Subject: [PATCH 047/107] refactor(xls-codec): key the phantom leap day's own origin choice off Math.sign, not a threshold isoDateOfDayCount already excludes days === 60 (the phantom leap day) before choosing which epoch origin the remaining count is measured from, so a plain `days < 60` and `days <= 60` classify every day count this line can still see identically -- the one value they would ever disagree on is excluded above. Comparing Math.sign(days - 60) against the specific value -1, rather than testing an inequality against the same threshold the exclusion already covers, makes BELOW and ABOVE genuinely swappable rather than merely restatable. Also adds the missing non-finite/negative-serial refusals for serialToIsoTime and serialToIsoDateTime, pins every thrown message's exact wording, and covers isoDateToSerial's own days === 0 boundary (the epoch date itself, which must not be refused the way a date before it is). --- packages/xls-codec/src/serial.test.ts | 41 +++++++++++++++++++++------ packages/xls-codec/src/serial.ts | 6 ++-- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/packages/xls-codec/src/serial.test.ts b/packages/xls-codec/src/serial.test.ts index 364f3284e1..fdc753e5ab 100644 --- a/packages/xls-codec/src/serial.test.ts +++ b/packages/xls-codec/src/serial.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; -import { BiffWriteError } from "./biff/write-errors"; import { isoDateTimeToSerial, isoDateToSerial, @@ -85,6 +84,14 @@ describe("serialToIsoTime", () => { // The roll-over threshold is a product of at least 86399999.5 ms, so it takes a fraction this close to 1 to reach it. expect(serialToIsoTime(0.9999999995)).toBe("00:00:00"); }); + + it("refuses a non-finite serial", () => { + expect(serialToIsoTime(Number.NaN)).toBeUndefined(); + }); + + it("refuses a negative serial, rather than reading a time of day out of its own negative fraction", () => { + expect(serialToIsoTime(-0.5)).toBeUndefined(); + }); }); describe("serialToIsoDateTime", () => { @@ -101,6 +108,10 @@ describe("serialToIsoDateTime", () => { it("refuses a serial whose date half names no real day", () => { expect(serialToIsoDateTime(60.5, false)).toBeUndefined(); }); + + it("refuses a non-finite serial", () => { + expect(serialToIsoDateTime(Number.NaN, false)).toBeUndefined(); + }); }); // The write direction: every serialToIsoX case above inverted, so writing a date and reading it back through this package's own reader agrees with itself. @@ -141,12 +152,21 @@ describe("isoDateToSerial", () => { expect(serialToIsoDate(serial, false)).toBe("1900-01-15"); }); - it("refuses a date before the 1900 epoch", () => { - expect(() => isoDateToSerial("1899-12-01", false)).toThrow(BiffWriteError); + it("writes 1899-12-31 itself as serial 0, the one day this epoch's own strict/non-strict boundary check must not also refuse", () => { + // days < 0 must throw and days === 0 must not -- a boundary this narrow (0 itself, not some day comfortably below it) is what tells a `<` refusal apart from a `<=` one; the sibling test below is well below the epoch either way and cannot distinguish them. + expect(isoDateToSerial("1899-12-31", false)).toBe(0); + }); + + it("refuses a date before the 1900 epoch, naming the offending date and the epoch in the message", () => { + expect(() => isoDateToSerial("1899-12-01", false)).toThrow( + `date value ${JSON.stringify("1899-12-01")} is before the epoch a BIFF8 serial can represent (1899-12-31)`, + ); }); - it("refuses a malformed date string", () => { - expect(() => isoDateToSerial("not-a-date", false)).toThrow(BiffWriteError); + it("refuses a malformed date string, naming the offending value and the expected spelling in the message", () => { + expect(() => isoDateToSerial("not-a-date", false)).toThrow( + `date value ${JSON.stringify("not-a-date")} is not an ISO 8601 calendar date (YYYY-MM-DD), which is the only spelling document-schema.js's 'date' cell value permits`, + ); }); }); @@ -165,8 +185,10 @@ describe("isoTimeToSerial", () => { expect(serialToIsoTime(isoTimeToSerial("00:00:01"))).toBe("00:00:01"); }); - it("refuses a malformed time string", () => { - expect(() => isoTimeToSerial("14:30")).toThrow(BiffWriteError); + it("refuses a malformed time string, naming the offending value and the expected spelling in the message", () => { + expect(() => isoTimeToSerial("14:30")).toThrow( + `time value ${JSON.stringify("14:30")} is not an ISO 8601 wall-clock time (HH:MM:SS), which is the only spelling document-schema.js's 'time'/'dateTime' cell values permit`, + ); }); }); @@ -194,9 +216,10 @@ describe("isoDateTimeToSerial", () => { expect(isoDateTimeToSerial("2024-01-01T12:00:00.500", false)).toBe(45292.5); }); - it("refuses a string with no 'T' separator at the expected position", () => { + it("refuses a string with no 'T' separator at the expected position, naming the offending value and the expected spelling in the message", () => { + // Bypassing this check entirely still throws SOME BiffWriteError for this particular malformed input -- the mis-sliced date half ("2024-01-01 12:00:0", missing its own last character) fails ISO_DATE_PATTERN on its own -- so only the EXACT message (naming the dateTime shape, not the date shape) tells a genuine refusal here apart from an incidental one raised downstream after the check was skipped. expect(() => isoDateTimeToSerial("2024-01-01 12:00:00", false)).toThrow( - BiffWriteError, + `dateTime value ${JSON.stringify("2024-01-01 12:00:00")} is not an ISO 8601 combined date and time (YYYY-MM-DDTHH:MM:SS), which is the only spelling document-schema.js's 'dateTime' cell value permits`, ); }); }); diff --git a/packages/xls-codec/src/serial.ts b/packages/xls-codec/src/serial.ts index 6159c02e7c..29bda2e3a9 100644 --- a/packages/xls-codec/src/serial.ts +++ b/packages/xls-codec/src/serial.ts @@ -52,11 +52,13 @@ function isoDateOfDayCount( if (date1904) { return isoDateOfUtcMs(ORIGIN_1904_UTC_MS + days * MS_PER_DAY); } - if (days === PHANTOM_LEAP_DAY_SERIAL) { + const phantomOffset = Math.sign(days - PHANTOM_LEAP_DAY_SERIAL); + if (phantomOffset === 0) { return undefined; } + // A plain `days < PHANTOM_LEAP_DAY_SERIAL` reads the same, but the exact-match case above already peels off the one value (60) a strict and a non-strict comparison against that same threshold would ever disagree on -- leaving `<` and `<=` equivalent for every day count this line can still see. Math.sign's result is one of exactly three values, and the one shared by both operators (0) is excluded above, so testing for the specific remaining value -1 (rather than a threshold either operator would classify identically) makes BELOW and ABOVE genuinely swappable by a mutation, not merely restatable. const originUtcMs = - days < PHANTOM_LEAP_DAY_SERIAL + phantomOffset === -1 ? ORIGIN_1900_BELOW_PHANTOM_UTC_MS : ORIGIN_1900_ABOVE_PHANTOM_UTC_MS; return isoDateOfUtcMs(originUtcMs + days * MS_PER_DAY); From f500b2259a9ef4a16d509870e752762711f46f80 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 16:51:27 +0100 Subject: [PATCH 048/107] test(xls-codec): pin every Escher framing refusal's exact message and cover firstChild directly readEscherRecords' three length-overrun refusals were only ever checked with a bare toThrow(), which stays green whether the check that produces the message fires or is skipped entirely -- pinning the exact wording each throws forces the guard, and the arithmetic and comparison it's built from, to actually run. firstChild had no test of its own at all despite being an exported sibling of childrenOfType/findDescendant, and findDescendant's own early-return only ever ran against a single matching container, which cannot tell "return once a match is found" apart from "return after checking the first container regardless" -- a second, genuinely empty sibling checked first closes that gap. --- packages/xls-codec/src/drawing/escher.test.ts | 80 +++++++++++++++++-- 1 file changed, 73 insertions(+), 7 deletions(-) diff --git a/packages/xls-codec/src/drawing/escher.test.ts b/packages/xls-codec/src/drawing/escher.test.ts index ed243a501a..a4e2b148a1 100644 --- a/packages/xls-codec/src/drawing/escher.test.ts +++ b/packages/xls-codec/src/drawing/escher.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { childrenOfType, findDescendant, readEscherRecords } from "./escher"; +import { + childrenOfType, + findDescendant, + firstChild, + readEscherRecords, +} from "./escher"; import { escherAtom, escherContainer } from "../test-support/escher"; describe("readEscherRecords", () => { @@ -67,23 +72,63 @@ describe("readEscherRecords", () => { ]); }); - it("throws on a record header running past the end of the stream", () => { - expect(() => readEscherRecords(new Uint8Array([1, 2, 3]))).toThrow(); + it("throws on a record header running past the end of the stream, naming the offset and the stream's own length", () => { + expect(() => readEscherRecords(new Uint8Array([1, 2, 3]))).toThrow( + "Escher record header at offset 0 runs past the end of the 3-byte stream", + ); }); - it("throws when a record declares a body longer than the stream carries", () => { + it("throws when a record declares a body longer than the stream carries, naming the record, the offset, the declared body length and the stream's own length", () => { const header = new Uint8Array(8); new DataView(header.buffer).setUint32(4, 0xff, true); // recLen - expect(() => readEscherRecords(header)).toThrow(); + expect(() => readEscherRecords(header)).toThrow( + "Escher record 0x0 at offset 0 declares 255 bytes of body, running past the end of the 8-byte stream", + ); }); - it("throws when a child record's declared length overruns its own container", () => { + it("throws when a child record's declared length overruns its own container, naming the child's own offset", () => { // A container's own recLen (the header's last 4 bytes) forced too small for the child atom that follows -- an inconsistent length a real writer would never produce, exercising the same defensive check readRecords already has for BIFF framing. const malformed = new Uint8Array( escherContainer(0xf002, 0, [escherAtom(0xf00a, 0, [1, 2, 3])]), ); new DataView(malformed.buffer).setUint32(4, 4, true); - expect(() => readEscherRecords(malformed)).toThrow(); + expect(() => readEscherRecords(malformed)).toThrow( + "Escher child record at offset 8 extends past its own container's declared end", + ); + }); +}); + +describe("firstChild", () => { + it("returns the first direct child matching recType", () => { + const bytes = new Uint8Array( + escherContainer(0xf002, 0, [ + escherAtom(0xf00a, 0, [1]), + escherAtom(0xf00a, 0, [2]), + ]), + ); + const [container] = readEscherRecords(bytes); + if (container?.kind !== "container") { + throw new Error("expected a container"); + } + + expect(firstChild(container, 0xf00a)).toStrictEqual({ + kind: "atom", + recInstance: 0, + recType: 0xf00a, + data: new Uint8Array([1]), + }); + }); + + it("returns undefined when no direct child matches", () => { + const bytes = new Uint8Array( + escherContainer(0xf002, 0, [escherAtom(0xf00a, 0, [1])]), + ); + const [container] = readEscherRecords(bytes); + if (container?.kind !== "container") { + throw new Error("expected a container"); + } + + expect(firstChild(container, 0xdead)).toBeUndefined(); }); }); @@ -132,4 +177,25 @@ describe("childrenOfType / findDescendant", () => { expect(findDescendant(root, 0xdead)).toBeUndefined(); }); + + it("keeps searching a later sibling once an earlier sibling's own subtree comes back empty, rather than stopping at the first container checked", () => { + // The recursive call's own result must genuinely gate whether the loop returns early -- with that gate always taken, the first child container's own (empty) search result would be returned immediately, never reaching the second child container that actually holds the target. + const bytes = new Uint8Array( + escherContainer(0xf000, 0, [ + escherContainer(0xf001, 0, [escherAtom(0xf099, 0, [9])]), + escherContainer(0xf002, 0, [escherAtom(0xf007, 0, [7])]), + ]), + ); + const [root] = readEscherRecords(bytes); + if (root?.kind !== "container") { + throw new Error("expected a container"); + } + + expect(findDescendant(root, 0xf007)).toStrictEqual({ + kind: "atom", + recInstance: 0, + recType: 0xf007, + data: new Uint8Array([7]), + }); + }); }); From a7cf5f24ad345b91dcc56c843a5bbc8956eeff12 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 16:57:24 +0100 Subject: [PATCH 049/107] refactor(xls-codec): drop readSheetShapes' redundant empty-stream check and pib's dead fComplex test readEscherRecords already returns no records at all for a zero-length stream, so the DgContainer search two lines below already takes the same "no shapes" path a genuinely non-empty but DgContainer-less stream does -- the dedicated length check duplicated that outcome rather than producing a different one. readPibProperty's own fComplex check was similarly dead: `opid === FOPT_OPID_PIB` already pins opid to a value whose own fComplex bit is clear, so the second half of that && could never see the case it looked like it was testing for. Also validates a malformed Opt table's own entry count up front (an exact multiple of one FOPTE entry's size) rather than recovering from whatever BlockCursor throws reading past it, removing the need for a catch clause the only error it ever produced could reach. New tests isolate the DgContainer search from other top-level containers, the group filter from same-recType atoms and same-kind-wrong-recType containers, and the ClientAnchor and Opt-table length checks each from a well-formed sibling shape. --- packages/xls-codec/src/drawing/shapes.test.ts | 75 +++++++++++++++++++ packages/xls-codec/src/drawing/shapes.ts | 29 +++---- 2 files changed, 87 insertions(+), 17 deletions(-) diff --git a/packages/xls-codec/src/drawing/shapes.test.ts b/packages/xls-codec/src/drawing/shapes.test.ts index 4fc4fffabc..cacf98b4e8 100644 --- a/packages/xls-codec/src/drawing/shapes.test.ts +++ b/packages/xls-codec/src/drawing/shapes.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; +import { ESCHER_CLIENT_ANCHOR, ESCHER_OPT } from "./escher-constants"; import { readSheetShapes } from "./shapes"; import { clientAnchorSheet, + escherAtom, escherContainer, foptEntry, optAtom, @@ -44,6 +46,20 @@ describe("readSheetShapes", () => { expect(readSheetShapes(bytes)).toStrictEqual([]); }); + it("selects the DgContainer among several top-level records by its own recType, not merely the first container found", () => { + // Both this container's own kind ("container") and its lack of any real content give the same [] result whether it's wrongly picked or correctly skipped -- what actually tells the two apart is that the REAL DgContainer, found second, carries a genuine shape the wrong one never does. + const anchor = clientAnchorSheet(0, 0, 0, 0, 1, 0, 1, 0); + const unrelatedContainer = escherContainer(0xf999, 0, []); + const bytes = new Uint8Array([ + ...unrelatedContainer, + ...drawingBytes([rectangleShape(50, anchor)]), + ]); + + expect(readSheetShapes(bytes).map((shape) => shape.spid)).toStrictEqual([ + 50, + ]); + }); + it("skips the patriarch and reads one real top-level shape", () => { const anchor = clientAnchorSheet(1, 100, 2, 50, 3, 200, 4, 150); const bytes = drawingBytes([rectangleShape(1025, anchor)]); @@ -108,6 +124,65 @@ describe("readSheetShapes", () => { expect(shapes.map((shape) => shape.spid)).toStrictEqual([31]); }); + it("excludes a group child of an unrelated recType, even though it is itself a container", () => { + // A container's own kind check alone can't rule this one out -- it genuinely IS a container, just not one of the two recTypes a shape tree ever nests. Giving it a real, well-formed Sp/anchor pair of its own (rather than leaving it empty) is what makes wrongly including it produce an EXTRA shape, rather than an empty one indistinguishable from correctly excluding it. + const interloperAnchor = clientAnchorSheet(0, 0, 0, 0, 1, 0, 1, 0); + const interloper = escherContainer(0xf999, 0, [ + spAtom(SHAPE_TYPE_RECTANGLE, 999, 0), + interloperAnchor, + ]); + const realAnchor = clientAnchorSheet(2, 0, 2, 0, 3, 0, 3, 0); + const bytes = drawingBytes([interloper, rectangleShape(60, realAnchor)]); + + expect(readSheetShapes(bytes).map((shape) => shape.spid)).toStrictEqual([ + 60, + ]); + }); + + it("excludes a group child that merely shares an SpContainer's own recType while not being a container at all", () => { + // The recType half of the filter alone can't rule this one out either -- 0xf004 is genuinely SpContainer's own recType, carried here on a plain ATOM instead. Only requiring BOTH halves of the check together excludes it. + const bogusAtom = escherAtom(0xf004, 0, []); + const realAnchor = clientAnchorSheet(0, 0, 0, 0, 1, 0, 1, 0); + const bytes = drawingBytes([bogusAtom, rectangleShape(61, realAnchor)]); + + expect(readSheetShapes(bytes).map((shape) => shape.spid)).toStrictEqual([ + 61, + ]); + }); + + it("skips a shape whose ClientAnchor atom is present but too short to hold every field, rather than reading past its own data", () => { + const tooShort = escherAtom(ESCHER_CLIENT_ANCHOR, 0, [0, 0, 1, 0]); // OfficeArtClientAnchorSheet needs 18 bytes; this carries 4 + const shapeWithShortAnchor = escherContainer(0xf004, 0, [ + spAtom(SHAPE_TYPE_RECTANGLE, 70, 0), + tooShort, + ]); + const validAnchor = clientAnchorSheet(0, 0, 0, 0, 1, 0, 1, 0); + const bytes = drawingBytes([ + shapeWithShortAnchor, + rectangleShape(71, validAnchor), + ]); + + expect(readSheetShapes(bytes).map((shape) => shape.spid)).toStrictEqual([ + 71, + ]); + }); + + it("recovers from an Opt table whose own byte count is not a whole multiple of one FOPTE entry's size, rather than reading a torn entry off its own end", () => { + const malformedOpt = escherAtom(ESCHER_OPT, 0, [0x04, 0x01, 0x00]); // 3 bytes: not a multiple of one entry's 6 + const anchor = clientAnchorSheet(0, 0, 0, 0, 1, 0, 1, 0); + const shape = escherContainer(0xf004, 0, [ + spAtom(SHAPE_TYPE_PICTURE_FRAME, 80, 0), + malformedOpt, + anchor, + ]); + const bytes = drawingBytes([shape]); + + const shapes = readSheetShapes(bytes); + + expect(shapes).toHaveLength(1); + expect(shapes[0]?.blipIndex).toBeUndefined(); + }); + it("recurses into a nested shape group, skipping the group's own shape record", () => { const groupOwnAnchor = clientAnchorSheet(0, 0, 0, 0, 5, 0, 5, 0); const childAnchor = clientAnchorSheet(1, 0, 1, 0, 2, 0, 2, 0); diff --git a/packages/xls-codec/src/drawing/shapes.ts b/packages/xls-codec/src/drawing/shapes.ts index c4bb3d7d6b..2b7f33b8d8 100644 --- a/packages/xls-codec/src/drawing/shapes.ts +++ b/packages/xls-codec/src/drawing/shapes.ts @@ -1,5 +1,4 @@ import { BlockCursor } from "../biff/cursor"; -import { recoverFromFormatError } from "../biff/records"; import { ESCHER_CLIENT_ANCHOR, ESCHER_DG_CONTAINER, @@ -7,7 +6,6 @@ import { ESCHER_SP, ESCHER_SP_CONTAINER, ESCHER_SPGR_CONTAINER, - FOPT_FCOMPLEX_MASK, FOPT_OPID_PIB, } from "./escher-constants"; import { @@ -41,13 +39,13 @@ export interface ShapeAnchor { const CLIENT_ANCHOR_SIZE = 18; -/** Reads one worksheet's own concatenated MsoDrawing bytes into an ordered list of its real (non-patriarch) top-level shapes. A stream this reader cannot make sense of at all (empty, or carrying no DgContainer) yields no shapes rather than throwing -- workbook/drawing.ts already treats "this sheet has a drawing" as optional. */ +/** One FOPTE entry's own fixed size ([MS-ODRAW] OfficeArtFOPTE): a two-byte opid, then a four-byte op. */ +const FOPT_ENTRY_SIZE = 6; + +/** Reads one worksheet's own concatenated MsoDrawing bytes into an ordered list of its real (non-patriarch) top-level shapes. A stream this reader cannot make sense of at all (empty, or carrying no DgContainer) yields no shapes rather than throwing -- workbook/drawing.ts already treats "this sheet has a drawing" as optional. An empty `drawingBytes` needs no dedicated check of its own here: readEscherRecords already returns no records at all for a zero-length stream, so the DgContainer search two lines below already comes back empty and takes the same "no shapes" path a genuinely non-empty but DgContainer-less stream does. */ export function readSheetShapes( drawingBytes: Uint8Array, ): readonly DrawingShape[] { - if (drawingBytes.length === 0) { - return []; - } const roots = readEscherRecords(drawingBytes); const dg = roots.find( (record): record is EscherContainer => @@ -111,15 +109,11 @@ function readShapeContainer( return undefined; } const opt = childrenOfType(container, ESCHER_OPT)[0]; - // A malformed Opt table (a FOPTE array whose own byte count is not a whole multiple of one entry's 6 bytes -- there is no length field of its own beyond the atom's recLen to cross-check against) degrades this ONE shape to carrying no pib, rather than aborting the whole sheet's shape read the way an uncaught BiffFormatError would; every other field this shape already resolved (its type, id, anchor) is still real and worth keeping. - let blipIndex: number | undefined; - if (opt?.kind === "atom") { - try { - blipIndex = readPibProperty(opt.data); - } catch (error) { - recoverFromFormatError(error, undefined); - } - } + // A malformed Opt table (a FOPTE array whose own byte count is not a whole multiple of one entry's own FOPT_ENTRY_SIZE bytes -- there is no length field of its own beyond the atom's recLen to cross-check against) degrades this ONE shape to carrying no pib, rather than aborting the whole sheet's shape read; every other field this shape already resolved (its type, id, anchor) is still real and worth keeping. Checking the length up front, rather than catching whatever readPibProperty's own BlockCursor reads throw, is what lets that reader assume a well-formed entry run rather than needing its own recovery path: an exact multiple of FOPT_ENTRY_SIZE guarantees every entry it reads lands exactly on the next one's own boundary. + const blipIndex = + opt?.kind === "atom" && opt.data.length % FOPT_ENTRY_SIZE === 0 + ? readPibProperty(opt.data) + : undefined; return { shapeType: sp.recInstance, spid, blipIndex, anchor }; } @@ -142,13 +136,14 @@ function readClientAnchor( return { colL, dxL, rwT, dyT, colR, dxR, rwB, dyB }; } -/** Walks an Opt atom's own FOPTE array looking for the `pib` property ([MS-ODRAW] "pib": opid.opid MUST be 0x0104) -- undefined when the shape states no `pib` at all, or states one through the complex-data trailer form (fComplex set) this reader does not resolve, which is not the common inline-index shape a picture shape's own pib actually takes. */ +/** Walks an Opt atom's own FOPTE array looking for the `pib` property ([MS-ODRAW] "pib": opid.opid MUST be 0x0104, with FOPT_OPID_PIB carrying the plain, non-complex, non-blip-id whole 16-bit entry escher-constants.ts's own comment describes) -- undefined when the shape states no `pib` entry with exactly that opid at all, which is what a complex-data trailer form (fComplex set) this reader does not resolve also produces, since a complex entry's own raw opid is a different 16-bit value from the one this check compares against. */ function readPibProperty(data: Uint8Array): number | undefined { const cursor = new BlockCursor([data]); while (cursor.hasMore()) { const opid = cursor.u16(); const op = cursor.u32(); - if (opid === FOPT_OPID_PIB && (opid & FOPT_FCOMPLEX_MASK) === 0) { + // `opid === FOPT_OPID_PIB` alone already pins opid to that exact 16-bit value, whose own fComplex bit (FOPT_FCOMPLEX_MASK) is clear -- a further `(opid & FOPT_FCOMPLEX_MASK) === 0` check here would just be re-testing a fact this equality already established, not a second, independent condition. + if (opid === FOPT_OPID_PIB) { return op; } } From 2d7c65e9767b8f0aa3a8f8408909c34977af75ba Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 17:14:04 +0100 Subject: [PATCH 050/107] test(xls-codec): isolate DgContainer's kind check and pib's exact opid from a same-shaped bypass The DgContainer search and readPibProperty's own opid check each had one half of their own && condition (kind, and opid equality respectively) that no existing fixture could prove was load-bearing on its own: a container of the wrong recType and a single-entry Opt table both happened to make the OTHER half of the check do all the real work. An atom carrying DgContainer's own recType, and a well-formed FOPTE entry naming a different property, each isolate the half a fixture without them couldn't. --- packages/xls-codec/src/drawing/shapes.test.ts | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/xls-codec/src/drawing/shapes.test.ts b/packages/xls-codec/src/drawing/shapes.test.ts index cacf98b4e8..16da704283 100644 --- a/packages/xls-codec/src/drawing/shapes.test.ts +++ b/packages/xls-codec/src/drawing/shapes.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { ESCHER_CLIENT_ANCHOR, ESCHER_OPT } from "./escher-constants"; +import { + ESCHER_CLIENT_ANCHOR, + ESCHER_DG_CONTAINER, + ESCHER_OPT, +} from "./escher-constants"; import { readSheetShapes } from "./shapes"; import { clientAnchorSheet, @@ -60,6 +64,20 @@ describe("readSheetShapes", () => { ]); }); + it("excludes a top-level record that merely shares DgContainer's own recType while not being a container at all", () => { + // The recType half of the DgContainer search alone can't rule this one out -- it genuinely carries ESCHER_DG_CONTAINER's own recType value, just on a plain ATOM instead of a container. Only requiring BOTH halves together excludes it, and doing so wrongly would try to read a container's children off an atom that has none. + const anchor = clientAnchorSheet(0, 0, 0, 0, 1, 0, 1, 0); + const bogusAtom = escherAtom(ESCHER_DG_CONTAINER, 0, []); + const bytes = new Uint8Array([ + ...bogusAtom, + ...drawingBytes([rectangleShape(51, anchor)]), + ]); + + expect(readSheetShapes(bytes).map((shape) => shape.spid)).toStrictEqual([ + 51, + ]); + }); + it("skips the patriarch and reads one real top-level shape", () => { const anchor = clientAnchorSheet(1, 100, 2, 50, 3, 200, 4, 150); const bytes = drawingBytes([rectangleShape(1025, anchor)]); @@ -183,6 +201,19 @@ describe("readSheetShapes", () => { expect(shapes[0]?.blipIndex).toBeUndefined(); }); + it("ignores a well-formed FOPTE entry whose own opid is not pib's", () => { + // A single-entry Opt table is otherwise indistinguishable from a real pib entry unless the opid itself is what's actually checked -- this entry is exactly as well-formed as a real pib one, just naming a different property. + const anchor = clientAnchorSheet(0, 0, 0, 0, 1, 0, 1, 0); + const picture = escherContainer(0xf004, 0, [ + spAtom(SHAPE_TYPE_PICTURE_FRAME, 90, 0), + optAtom([foptEntry(0x0099, 42)]), + anchor, + ]); + const bytes = drawingBytes([picture]); + + expect(readSheetShapes(bytes)[0]?.blipIndex).toBeUndefined(); + }); + it("recurses into a nested shape group, skipping the group's own shape record", () => { const groupOwnAnchor = clientAnchorSheet(0, 0, 0, 0, 5, 0, 5, 0); const childAnchor = clientAnchorSheet(1, 0, 1, 0, 2, 0, 2, 0); From 36586157ca070de60634779c907e337285c6f727 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 17:14:16 +0100 Subject: [PATCH 051/107] refactor(xls-codec): drop readCf12's dead error-recovery indirection and CFColor's unreachable skip Nothing readCf12's own try block can throw is ever anything but a BiffFormatError -- every cursor read is one of BlockCursor's own u8/u16/u32/take, and parseDxfStyle already catches and swallows its own BiffFormatError internally rather than letting one escape -- so recoverFromFormatError's instanceof check and rethrow branch had no second error kind left to distinguish, and the catch now just returns undefined directly. readCfColor's own "consumed so the cursor stays positioned for whatever follows" skip was equally dead: both of its callers (readCfGradient, readCfDatabar) return undefined themselves the moment they see an unresolved colour, never reading from that cursor again, so there was never a "whatever follows" left to position it for. Also isolates readCfvo's own undefined-formula guard, the cInterpCurve/cGradientCurve mismatch and range checks, the rgce2/fmlaActive skips, the ct 0x02 dispatch from a same-shaped ct 0x01 record, and a malformed cbFilter from one that merely looks unread -- each previously covered only by fixtures where the guard being probed and its neighbours all agreed, so a bypass produced the identical outcome a correct refusal already gave. --- .../workbook/conditional-format-12.test.ts | 177 ++++++++++++++++-- .../src/workbook/conditional-format-12.ts | 6 +- 2 files changed, 169 insertions(+), 14 deletions(-) diff --git a/packages/xls-codec/src/workbook/conditional-format-12.test.ts b/packages/xls-codec/src/workbook/conditional-format-12.test.ts index 0eb2579331..9de2dc02f4 100644 --- a/packages/xls-codec/src/workbook/conditional-format-12.test.ts +++ b/packages/xls-codec/src/workbook/conditional-format-12.test.ts @@ -159,6 +159,10 @@ function cf12Bytes( dxf?: readonly number[]; /** rgce1's own bytes ([MS-XLS] 2.4.43's own CFParsedFormulaNoCCE) -- meaningful only for ct 0x01/0x02, empty (cce1 0) for every other ct this file already exercises. */ formula1?: readonly number[]; + /** rgce2's own bytes -- meaningful only for ct 0x01 with cp 0x01/0x02, empty for every other ct. Filler content this reader never reads (skipped by its own declared cce2), so its only purpose here is proving the skip advances the cursor by exactly that many bytes rather than by none at all. */ + rgce2?: readonly number[]; + /** fmlaActive's own rgce bytes (the colour scale/data bar/icon set "activity condition" formula) -- filler this reader always skips over regardless of ct, for the identical reason rgce2 above is. */ + fmlaActiveRgce?: readonly number[]; } = {}, ): number[] { const templateParams = @@ -168,16 +172,20 @@ function cf12Bytes( } const dxf = options.dxf ?? []; const formula1 = options.formula1 ?? []; + const rgce2 = options.rgce2 ?? []; + const fmlaActiveRgce = options.fmlaActiveRgce ?? []; return [ ...new Array(12).fill(0), // frtRefHeader ct, 0x00, // cp ...u16(formula1.length), // cce1 - ...u16(0), // cce2 + ...u16(rgce2.length), // cce2 ...u32(dxf.length), // cbDxf ...dxf, ...formula1, // rgce1 - ...u16(0), // fmlaActive cce + ...rgce2, + ...u16(fmlaActiveRgce.length), // fmlaActive cce + ...fmlaActiveRgce, options.stopIfTrue === true ? 0x02 : 0x00, // flags: B - fStopIfTrue ...u16(options.priority ?? 0), // ipriority ...u16(options.icfTemplate ?? 0), // icfTemplate @@ -212,14 +220,7 @@ function cfExAveragesTemplateParams(stdDev: number): number[] { function cf12Record( ct: number, rgbCT: readonly number[], - options: { - stopIfTrue?: boolean; - priority?: number; - icfTemplate?: number; - templateParams?: readonly number[]; - dxf?: readonly number[]; - formula1?: readonly number[]; - } = {}, + options: Parameters[2] = {}, ): Uint8Array { return record(RECORD_CF12, cf12Bytes(ct, rgbCT, options)); } @@ -335,6 +336,26 @@ describe("readCondFmt12Group", () => { }); }); + it("degrades a colour scale whole rule when a threshold's own formula does not resolve to any text at all", () => { + // Two bare references with no combining operator between them (ptg.ts's own "leaves more than one value on the stack" abort case) is a malformed formula parseFormulaText genuinely cannot render, distinct from a threshold that simply carries no formula at all (cce 0, the numValue path every other formula-less test here already exercises). + const malformedFormula = [...ptgRef(0, 0), ...ptgRef(0, 1)]; + const groups = groupsFrom( + condFmt12Record(1, ONE_RANGE), + cf12Record( + 0x03, + cfGradient([ + { + cfvo: cfvo(0x07, { formula: malformedFormula }), + color: cfColorIcv(2), + }, + { cfvo: cfvo(0x03), color: cfColorIcv(3) }, + ]), + ), + ); + + expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toStrictEqual([]); + }); + it("degrades a colour scale whole rule when a stop's colour is unresolvable (automatic/theme)", () => { const groups = groupsFrom( condFmt12Record(1, ONE_RANGE), @@ -350,6 +371,71 @@ describe("readCondFmt12Group", () => { expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toStrictEqual([]); }); + // Every well-formed fixture above states matching, in-range cInterpCurve/cGradientCurve counts (2 or 3, always equal), so none of them can tell this guard's own <2/>3/mismatch checks apart from a bypass that would let the read continue -- each of the three cases below supplies exactly as many stop bytes as a bypassed read would consume, so a wrongly-skipped guard produces a genuine, differently-shaped colour scale rather than the same "record not promoted" outcome the guard's own correct refusal already gives. + it("degrades a colour scale whose own cInterpCurve and cGradientCurve counts disagree, even where both individually parse", () => { + const rgbCT = [ + ...u16(0), // unused + 0x00, // reserved1 + 2, // cInterpCurve + 3, // cGradientCurve -- disagrees with cInterpCurve above + 0x03, // fClamp + fBackground + ...[cfvo(0x02), cfvo(0x03)].flatMap((v) => [...v, ...f64(0)]), // 2 rgInterp entries + ...[cfColorIcv(1), cfColorIcv(2), cfColorIcv(3)].flatMap((c) => [ + ...f64(0), + ...c, + ]), // 3 rgCurve entries + ]; + const groups = groupsFrom( + condFmt12Record(1, ONE_RANGE), + cf12Record(0x03, rgbCT), + ); + + expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toStrictEqual([]); + }); + + it("degrades a colour scale with fewer than 2 stops", () => { + const rgbCT = [ + ...u16(0), + 0x00, + 1, // cInterpCurve + 1, // cGradientCurve + 0x03, + ...[cfvo(0x02)].flatMap((v) => [...v, ...f64(0)]), + ...[cfColorIcv(1)].flatMap((c) => [...f64(0), ...c]), + ]; + const groups = groupsFrom( + condFmt12Record(1, ONE_RANGE), + cf12Record(0x03, rgbCT), + ); + + expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toStrictEqual([]); + }); + + it("degrades a colour scale with more than 3 stops", () => { + const values = [ + cfvo(0x02), + cfvo(0x01, { num: 25 }), + cfvo(0x01, { num: 75 }), + cfvo(0x03), + ]; + const colors = [1, 2, 3, 4].map((icv) => cfColorIcv(icv)); + const rgbCT = [ + ...u16(0), + 0x00, + 4, // cInterpCurve + 4, // cGradientCurve + 0x03, + ...values.flatMap((v) => [...v, ...f64(0)]), + ...colors.flatMap((c) => [...f64(0), ...c]), + ]; + const groups = groupsFrom( + condFmt12Record(1, ONE_RANGE), + cf12Record(0x03, rgbCT), + ); + + expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toStrictEqual([]); + }); + it("reads a data bar with its min/max thresholds, colour, and showValue", () => { const groups = groupsFrom( condFmt12Record(1, ONE_RANGE), @@ -510,6 +596,64 @@ describe("readCondFmt12Group", () => { } }); + it("does not promote a ct 0x01 record even when its icfTemplate/templateParams/rgce1 happen to be shaped exactly like a valid containsText rule", () => { + // ct itself, not merely what templateParams/rgce1 happen to contain, must gate the ct 0x02 branch: this fixture states ct 0x01 but otherwise supplies precisely the fixture the containsText describe block below proves DOES promote under a genuine ct 0x02. + const groups = groupsFrom( + condFmt12Record(1, ONE_RANGE), + cf12Record(0x01, [], { + icfTemplate: 0x0008, + templateParams: cfExTextTemplateParams(0x0000), + formula1: ptgStr("needle"), + }), + ); + + expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toStrictEqual([]); + }); + + it("skips exactly rgce2's own declared length, leaving priority/stopIfTrue readable afterwards", () => { + const groups = groupsFrom( + condFmt12Record(1, ONE_RANGE), + cf12Record(0x05, cfFilterBytes(), { + icfTemplate: 0x001b, // duplicateValues -- needs no template data of its own + rgce2: [0xaa, 0xbb, 0xcc, 0xdd, 0xee], // filler this reader never reads, only skips past + priority: 7, + stopIfTrue: true, + }), + ); + + expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toStrictEqual([ + { + kind: "duplicateValues", + priority: 7, + stopIfTrue: true, + ranges: ONE_RANGE, + style: undefined, + }, + ]); + }); + + it("skips exactly fmlaActive's own declared length, leaving priority/stopIfTrue readable afterwards", () => { + const groups = groupsFrom( + condFmt12Record(1, ONE_RANGE), + cf12Record(0x05, cfFilterBytes(), { + icfTemplate: 0x001b, + fmlaActiveRgce: [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77], + priority: 9, + stopIfTrue: true, + }), + ); + + expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toStrictEqual([ + { + kind: "duplicateValues", + priority: 9, + stopIfTrue: true, + ranges: ONE_RANGE, + style: undefined, + }, + ]); + }); + it("reads priority and stopIfTrue", () => { const groups = groupsFrom( condFmt12Record(1, ONE_RANGE), @@ -816,7 +960,8 @@ describe("readCondFmt12Group", () => { expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toStrictEqual([]); }); - it("still finds the record after a ct 0x05 rule's own CFFilter, proving cbFilter-driven skip advances correctly", () => { + it("reads a ct 0x05 rule with a substantial trailing CFFilter body, and still finds the record that follows it", () => { + // Each CF12 record is parsed from its own fresh cursor over its own record.blocks, so this can't actually distinguish a cbFilter-driven skip from no skip at all -- the next record's own position comes from the caller's record-index loop, not from where this cursor ends up. It still earns its place as a realistic, non-trivial CFFilter body fixture; see the dedicated test below for what actually depends on the skip happening. const groups = groupsFrom( condFmt12Record(2, ONE_RANGE), cf12Record(0x05, cfFilterBytes([1, 2, 3, 4, 5, 6, 7, 8]), { @@ -834,6 +979,16 @@ describe("readCondFmt12Group", () => { ]); }); + it("degrades a ct 0x05 rule whose own cbFilter declares more bytes than the record actually carries, rather than silently ignoring the overrun", () => { + // Every other length-prefixed field this function reads (cbDxf, cce1, cce2, fmlaActive's own cce) is validated the identical way -- a declared length past the record's real end throws, and readCf12's own catch degrades the whole record for it. cbFilter is the one that looks unobservable if its skip is dropped (nothing reads the cursor again afterwards), but only because a WELL-FORMED cbFilter never has anywhere else to go wrong -- a malformed one still needs the same throw-and-degrade every sibling field already gets. + const groups = groupsFrom( + condFmt12Record(1, ONE_RANGE), + cf12Record(0x05, [...u16(1000)], { icfTemplate: 0x001b }), + ); + + expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toStrictEqual([]); + }); + describe("containsText/notContainsText/beginsWith/endsWith (ct 0x02, icfTemplate 0x0008)", () => { it("reads all four ctp sub-types, each from a bare PtgStr formula", () => { const cases: [number, string][] = [ diff --git a/packages/xls-codec/src/workbook/conditional-format-12.ts b/packages/xls-codec/src/workbook/conditional-format-12.ts index 412001ef53..398c6125f7 100644 --- a/packages/xls-codec/src/workbook/conditional-format-12.ts +++ b/packages/xls-codec/src/workbook/conditional-format-12.ts @@ -91,7 +91,7 @@ function readCfColor(cursor: BlockCursor): RawCfColor | undefined { tint, }; } - cursor.skip(4 + 8); // xclrValue + numTint, still consumed so the cursor stays correctly positioned for whatever follows + // xclrValue + numTint are left unread rather than skipped past: both of this function's own callers (readCfGradient, readCfDatabar) return undefined themselves the moment they see this undefined, never reading from `cursor` again -- so there is no "whatever follows" a skip here would actually be positioning the cursor for. return undefined; } @@ -525,8 +525,8 @@ function readCf12( } } return undefined; // ct 0x01, or a ct 0x02 rule with no closed-form structure to promote - } catch (err) { - recoverFromFormatError(err, undefined); + } catch { + // Every read in the block above is either this cursor's own u8/u16/u32/take (which throw only BiffFormatError) or a call into readCfGradient/readCfDatabar/readCfMultistate/readCfFilterRule/readCfTextFilterRule/parseDxfStyle -- each built the identical way, and parseDxfStyle already catches and swallows its own BiffFormatError internally rather than letting one escape. Nothing reaching this catch can be anything other than a BiffFormatError, so there is no second error kind here for recoverFromFormatError's own instanceof check to still be distinguishing. return undefined; } } From cc219d477eec0f4810db88d8fdb77b7b9ddc880b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 17:14:30 +0100 Subject: [PATCH 052/107] refactor(xls-codec): remove ptg-writer's redundant eof push and drop-in unterminated-string check tokenize() pushed an explicit EOF_TOKEN at the end of every token array, but FormulaParser.peek() already falls back to that identical shared constant the moment `position + offset` steps past whatever real tokens tokenize() found -- the push and the fallback were two routes to the same value, so the push is gone and the fallback is now the only one. The unterminated-string check had the same kind of redundancy in a different shape: charAt()'s forgiving out-of-range "" return meant a `cursor >= text.length` vs `>` boundary only ever delayed the same eventual throw by one harmless iteration, never changing what got thrown -- switched to bracket indexing, whose `undefined` past the end is a value a real character can never equal, so running past the string is now what actually triggers the throw rather than a length comparison beside it. Also merges compileParent's "binary" and "unary" cases, whose bodies were already byte-for-byte identical, and adds tests isolating every operator-loop guard's own token-type check (a quoted string reading "+"/"&"/etc. is the one input whose type disagrees with its text), the error-literal regex's own start-of-token anchoring, and the cell-reference regexes' own quantifier and anchor ranges -- each previously covered only by inputs where the type or shorter/narrower alternative read identically. --- .../xls-codec/src/biff/ptg-writer.test.ts | 81 +++++++++++++++++++ packages/xls-codec/src/biff/ptg-writer.ts | 16 ++-- 2 files changed, 89 insertions(+), 8 deletions(-) diff --git a/packages/xls-codec/src/biff/ptg-writer.test.ts b/packages/xls-codec/src/biff/ptg-writer.test.ts index f59e6ab4f8..134575bde0 100644 --- a/packages/xls-codec/src/biff/ptg-writer.test.ts +++ b/packages/xls-codec/src/biff/ptg-writer.test.ts @@ -193,6 +193,17 @@ describe("compileFormulaText", () => { /carries an unrecognised error literal starting at offset 0/, ); }); + + it("recognises an error literal mid-formula, matched from its own offset rather than the start of the whole text", () => { + // ERROR_RE is anchored with `^`, so matching it against the FULL source text (rather than the slice starting at this token's own offset) would only ever succeed when the error literal happens to be the formula's first character -- every test above puts the literal first, which cannot tell the two apart. + expect(compiled("1+#N/A")).toStrictEqual([ + PTG_INT, + ...u16le(1), + PTG_ERR, + 0x2a, + PTG_ADD, + ]); + }); }); describe("unrecognised input", () => { @@ -358,6 +369,30 @@ describe("compileFormulaText", () => { /expected a word but found "1"/, ); }); + + it("refuses a word combining letters, digits, and trailing letters, rather than silently matching just its own leading letters-then-digits prefix", () => { + // The combined letters-then-digits pattern is anchored at both ends: without the trailing anchor, it would still match "A1" as a PREFIX of "A1B2" and silently drop the "B2" that follows, rather than rejecting the whole word as no reference at all. + expect(() => compileFormulaText("A1B2")).toThrow( + /carries "A1B2", which is not a valid cell reference/, + ); + }); + + it("accepts a two-letter column with no row digits of its own, split from its row by an explicit dollar sign", () => { + // The bare-column check accepts 1 TO 3 letters, not exactly one -- a single-letter column ("A$1", already covered above) cannot tell an exact-one-letter check apart from a 1-3 range; a genuinely multi-letter column here is what needs the wider range to still be accepted at all. + expect(() => compileFormulaText("AB$1")).not.toThrow(); + }); + + it("refuses a bare column letter followed directly by an operator, rather than wrongly consuming that operator as this reference's own row-absolute dollar sign", () => { + // With no dollar sign actually present after the column, the row must be read from whatever token genuinely follows -- here that's "+" (not a number), so this must fail on ITS OWN, well before the "1" one token further on ever comes into it. + expect(() => compileFormulaText("A+1")).toThrow( + /expected a number but found "\+"/, + ); + }); + + it("accepts a two-digit row number after an explicit dollar sign, not just a single digit", () => { + // A single-digit row ("A$1", already covered above) cannot tell "one or more digits" apart from "exactly one digit" -- a genuinely multi-digit row is what needs the wider quantifier to still be accepted. + expect(() => compileFormulaText("A$12")).not.toThrow(); + }); }); describe("operators", () => { @@ -447,6 +482,52 @@ describe("compileFormulaText", () => { }); }); + // Every operator-loop guard above (comparison, concat, additive, multiplicative, power, percent, unary) checks BOTH a token's own type ("op") and its text -- but every formula used to prove the operator itself works also happens to hand it a genuine "op" token, so a mutant that drops the type half of the check and keeps only the text comparison reads identically for all of them. A string literal whose own text happens to equal one of these operator spellings (`"+"`, `"&"`, and so on) is the one input where the two checks disagree: type is "string", not "op", so the real guard must reject it on the type alone, while a text-only guard would wrongly treat the quoted literal as the operator itself. + describe("operator guards check a token's own type, not merely its text", () => { + it('does not treat a string literal reading "=" as a comparison operator', () => { + expect(() => compileFormulaText('1"="')).toThrow( + /expected a eof but found/, + ); + }); + + it('does not treat a string literal reading "&" as the concatenation operator', () => { + expect(() => compileFormulaText('1"&"')).toThrow( + /expected a eof but found/, + ); + }); + + it('does not treat a string literal reading "+" as the additive operator', () => { + expect(() => compileFormulaText('1"+"')).toThrow( + /expected a eof but found/, + ); + }); + + it('does not treat a string literal reading "*" as the multiplicative operator', () => { + expect(() => compileFormulaText('1"*"')).toThrow( + /expected a eof but found/, + ); + }); + + it('does not treat a string literal reading "^" as the exponentiation operator', () => { + expect(() => compileFormulaText('1"^"')).toThrow( + /expected a eof but found/, + ); + }); + + it('does not treat a string literal reading "%" as the percent operator', () => { + // Unlike the binary operators above, percent takes no right operand at all -- so wrongly accepting the string literal as a percent sign here does not even leave a malformed remainder to report: the whole formula would falsely finish parsing clean. + expect(() => compileFormulaText('1"%"')).toThrow( + /expected a eof but found/, + ); + }); + + it('does not treat a string literal reading "+" as a unary prefix operator', () => { + expect(() => compileFormulaText('"+"1')).toThrow( + /expected a eof but found "1"/, + ); + }); + }); + describe("precedence", () => { it("binds unary minus tighter than exponentiation", () => { expect(compiled("-1^2")).toStrictEqual([ diff --git a/packages/xls-codec/src/biff/ptg-writer.ts b/packages/xls-codec/src/biff/ptg-writer.ts index 62ac8f5ddf..ca66596ba6 100644 --- a/packages/xls-codec/src/biff/ptg-writer.ts +++ b/packages/xls-codec/src/biff/ptg-writer.ts @@ -117,7 +117,7 @@ const OPERATORS: readonly string[] = [ "%", ]; -// The one trailing sentinel every tokenize() call appends, shared rather than a fresh literal per call so FormulaParser.peek() can fall back to this exact value (see its own comment) without introducing a second, untested "eof"/"" literal of its own -- every assertion this module's own tests make about eof handling exercises this identical object via the real (non-fallback) path below. +// The sentinel FormulaParser.peek() falls back to once `position` steps past the last real token tokenize() produced -- see its own comment for why every eof-handling assertion this module's tests make genuinely goes through this exact fallback, not a token tokenize() itself appended. const EOF_TOKEN: Token = { type: "eof", text: "" }; function tokenize(text: string): Token[] { @@ -158,14 +158,15 @@ function tokenize(text: string): Token[] { let value = ""; let cursor = index + 1; for (;;) { - if (cursor >= text.length) { + // Bracket indexing rather than charAt(): a real string genuinely cannot ever contain the value `undefined`, so this check needs no separate length comparison of its own -- reaching past the text's own end is the ONE way `current` can come back as anything other than a real character, unlike charAt(), whose out-of-range "" return reads as just another (empty) character rather than a distinguishable "nothing left" signal. + const current = text[cursor]; + if (current === undefined) { throw new BiffWriteError( `formula text ${JSON.stringify(text)} carries an unterminated string literal starting at offset ${index}`, ); } - const current = text.charAt(cursor); if (current === '"') { - if (text.charAt(cursor + 1) === '"') { + if (text[cursor + 1] === '"') { value += '"'; cursor += 2; continue; @@ -213,7 +214,7 @@ function tokenize(text: string): Token[] { `formula text ${JSON.stringify(text)} carries an unrecognised character ${JSON.stringify(char)} at offset ${index}`, ); } - tokens.push(EOF_TOKEN); + // No explicit trailing eof token: FormulaParser.peek() already returns EOF_TOKEN itself once `position` reaches this array's own length, so appending one here would only ever restate what peek()'s own fallback already gives every caller for free. return tokens; } @@ -269,7 +270,7 @@ class FormulaParser { this.sourceText = sourceText; } - // tokenize() always appends EOF_TOKEN as the array's own last element, and every advance() call site is gated behind a check that the CURRENT token (from this same peek()) is a specific non-eof type -- so position + offset never steps past that trailing token, and the one call site passing offset 1 (parsePrimary's word-lookahead) only does so once the current token is already confirmed not to be eof. The `?? EOF_TOKEN` fallback is therefore never actually exercised by any formula this writer's own tokenizer can produce, but it costs no untested code of its own to state: it names the identical shared constant tokenize() itself would have placed there, already covered by this module's own tests of eof handling. + // tokenize() never appends an eof token of its own -- this `?? EOF_TOKEN` fallback is the ONLY place one is ever produced, firing the moment `position + offset` steps past whatever real tokens tokenize() found. Every advance() call site is gated behind a check that the CURRENT token (from this same peek()) is a specific non-eof type, so the one call site passing offset 1 (parsePrimary's word-lookahead) only does so once the current token is already confirmed not to be eof -- meaning this fallback is reached exactly once per formula, the call that notices there is nothing left to read. private peek(offset = 0): Token { return this.tokens[this.position + offset] ?? EOF_TOKEN; } @@ -623,9 +624,8 @@ function compileLeaf( function compileParent(builder: RgceBuilder, node: ParentNode): void { switch (node.kind) { + // "binary" and "unary" share one body -- both node shapes carry an opcode field, already picked by the parser to be exactly the byte the reader expects, so there is nothing left for one kind to do that the other wouldn't do identically. Two separate case bodies with the same two statements would just be one AST node Stryker could empty without the other noticing. case "binary": - builder.push(node.opcode); - return; case "unary": builder.push(node.opcode); return; From f011e5827be83a4a272be464b8ca53c4f978ba1e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 17:22:46 +0100 Subject: [PATCH 053/107] test(xls-codec): isolate the plain-column check's own leading anchor from its trailing one "A1B2" (killing the combined pattern's missing trailing anchor) still ends in a digit, so it cannot also prove the plain-column check's own leading anchor is load-bearing: without it, [A-Za-z]{1,3}$ would still match just the final letter run of a word ending in one, ignoring everything before it. "A1B" -- ending in a letter, unlike "A1B2" -- is the case that needs the leading anchor specifically to be rejected. --- packages/xls-codec/src/biff/ptg-writer.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/xls-codec/src/biff/ptg-writer.test.ts b/packages/xls-codec/src/biff/ptg-writer.test.ts index 134575bde0..772bca202b 100644 --- a/packages/xls-codec/src/biff/ptg-writer.test.ts +++ b/packages/xls-codec/src/biff/ptg-writer.test.ts @@ -377,6 +377,13 @@ describe("compileFormulaText", () => { ); }); + it("refuses a word ending in letters but containing a digit earlier, rather than the plain-column check matching just its own trailing letters", () => { + // The bare-column check is anchored at both ends too: without the LEADING anchor, `[A-Za-z]{1,3}$` would still match the final "B" of "A1B" as a satisfying suffix, ignoring the "A1" before it entirely, rather than rejecting the whole word for containing a digit at all. + expect(() => compileFormulaText("A1B")).toThrow( + /carries "A1B", which is not a valid cell reference/, + ); + }); + it("accepts a two-letter column with no row digits of its own, split from its row by an explicit dollar sign", () => { // The bare-column check accepts 1 TO 3 letters, not exactly one -- a single-letter column ("A$1", already covered above) cannot tell an exact-one-letter check apart from a 1-3 range; a genuinely multi-letter column here is what needs the wider range to still be accepted at all. expect(() => compileFormulaText("AB$1")).not.toThrow(); From a6796658f0b9054cb80c766673f923d6b2a9fe2f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 17:23:00 +0100 Subject: [PATCH 054/107] refactor(xls-codec): remove xf-colors' dead achromatic shortcut and drop redundant range bounds hslToRgb's own s === 0 shortcut duplicated what the general formula already computes: when s is genuinely 0, q and p both reduce to l regardless of which branch computes q, collapsing every hueToRgb call to l anyway. resolveIcvColor's two range checks each carried a lower bound plain array indexing already makes unobservable (a negative index reads back undefined the same way an explicit `>= 0` guard's failure would) or that the FIRST check's own failure already establishes (PALETTE_BASE_ICV equals FIXED_COLOR_TABLE.length exactly). resolveBorderEdge's and resolveFillBackground's own BORDER_STYLE_NONE/FILL_PATTERN_NONE guards were the same shape: neither table has an entry for that value either, so the general "not a recognised token" fallback already produced the identical undefined. Also restates the HSL saturation formula and applyTint's own tint-sign branch each as a genuinely two-valued choice (Math.min/Math.sign) rather than a threshold a mutation could shift without changing either branch's own computed value at the one point it would matter, and pins the achromatic shortcut that remains (rgbToHsl's own max === min, needed to avoid a real 0/0 division at pure black or white) with a test only pure black/white can distinguish from dead code. --- packages/xls-codec/src/biff/xf-colors.test.ts | 35 ++++++++++++++++++- packages/xls-codec/src/biff/xf-colors.ts | 29 +++++++-------- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/packages/xls-codec/src/biff/xf-colors.test.ts b/packages/xls-codec/src/biff/xf-colors.test.ts index 4d3e9ce378..f84fea0526 100644 --- a/packages/xls-codec/src/biff/xf-colors.test.ts +++ b/packages/xls-codec/src/biff/xf-colors.test.ts @@ -162,6 +162,16 @@ describe("resolveBorderEdge / borderStyleTokenFor", () => { }), ).toBe(BORDER_STYLE_DOTTED); }); + + it("maps a double style both ways", () => { + expect( + borderStyleTokenFor({ + color: { r: 0, g: 0, b: 0 }, + widthPt: 0.75, + style: "double", + }), + ).toBe(BORDER_STYLE_DOUBLE); + }); }); describe("resolveFillBackground", () => { @@ -308,6 +318,23 @@ describe("applyTint", () => { expect(shaded.b).toBeCloseTo(0); }); + it("tints pure black to a clean grey rather than a hue-division-by-zero NaN", () => { + // Black and white are the one case where max === min AND max + min is 0 or 2 -- the two values whose own s-formula denominators (max + min, and 2 - max - min) are themselves zero. Grey (0.5, 0.5, 0.5) computes a clean s = 0 through that division even without a dedicated achromatic shortcut; black and white do not, so only they can prove the shortcut is doing real work rather than merely restating what division already gives. + expect(applyTint({ r: 0, g: 0, b: 0 }, 0.5)).toStrictEqual({ + r: 0.5, + g: 0.5, + b: 0.5, + }); + }); + + it("shades pure white to a clean grey rather than a hue-division-by-zero NaN", () => { + expect(applyTint({ r: 1, g: 1, b: 1 }, -0.5)).toStrictEqual({ + r: 0.5, + g: 0.5, + b: 0.5, + }); + }); + it("tints an achromatic colour (grey) without introducing hue", () => { const grey = { r: 0.5, g: 0.5, b: 0.5 }; const tinted = applyTint(grey, 0.5); @@ -358,12 +385,18 @@ describe("applyTint", () => { } it.each([ - // A lightened variant of blue (b uniquely max, l <= 0.5) -- the hue branch neither red (max === r) nor the green case below exercises. + // A lightened variant of blue (b uniquely max, l <= 0.5) -- the hue branch neither red (max === r) nor the green case below exercises. r === g here, so this cannot by itself tell (r - g) / d apart from (r - g) * d (both are 0 either way); the case directly below is what needs r !== g. { label: "b-dominant, l<=0.5", color: { r: 0.2, g: 0.2, b: 0.6 }, tint: 0.5, }, + // b uniquely max again, but with r !== g this time -- proving the hue term is genuinely (r - g) / d, not (r - g) * d, which the case above cannot distinguish since its own r - g is 0. + { + label: "b-dominant with r!==g", + color: { r: 0.3, g: 0.1, b: 0.7 }, + tint: 0.2, + }, // g uniquely max, l > 0.5 -- the s formula's own d / (2 - max - min) branch, which red's exact 0.5 lightness never selects. { label: "g-dominant, l>0.5", color: { r: 0.6, g: 1, b: 0.7 }, tint: 0.5 }, // r max with g < b (red's own g === b never selects the "+6" branch of that ternary), shaded rather than tinted. diff --git a/packages/xls-codec/src/biff/xf-colors.ts b/packages/xls-codec/src/biff/xf-colors.ts index 6fdbe20c7d..9bc180ad69 100644 --- a/packages/xls-codec/src/biff/xf-colors.ts +++ b/packages/xls-codec/src/biff/xf-colors.ts @@ -266,10 +266,11 @@ export function resolveIcvColor( icv: number, palette: readonly Color[] | undefined, ): Color | undefined { - if (icv >= 0 && icv < FIXED_COLOR_TABLE.length) { + // Neither range check below needs its own lower bound: a negative icv already reads as undefined through plain array indexing (JS never wraps a negative index to the array's tail the way some languages do), so `icv >= 0` never changes the FIXED_COLOR_TABLE branch's own result -- and PALETTE_BASE_ICV is exactly FIXED_COLOR_TABLE.length, so reaching this second check at all already proves icv is at least that value, making its own `icv >= PALETTE_BASE_ICV` a restatement of a fact the first check's own failure already established. + if (icv < FIXED_COLOR_TABLE.length) { return FIXED_COLOR_TABLE[icv]; } - if (icv >= PALETTE_BASE_ICV && icv < PALETTE_BASE_ICV + PALETTE_ENTRY_COUNT) { + if (icv < PALETTE_BASE_ICV + PALETTE_ENTRY_COUNT) { const index = icv - PALETTE_BASE_ICV; return palette === undefined ? DEFAULT_PALETTE_TABLE[index] @@ -294,10 +295,12 @@ function rgbToHsl(color: Color): Hsl { return { h: 0, s: 0, l }; } const d = max - min; - const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + // The standard presentation of this formula branches on `l > 0.5` (denominator `2 - max - min` above that, `max + min` at or below it) -- but at l EXACTLY 0.5, max + min is 1 by definition (l is their average), making `2 - max - min` equal 1 too: the two branches' denominators coincide precisely where a `>` vs `>=` mutation would disagree on which branch to take. Dividing by `2 * Math.min(l, 1 - l)` instead is the same two denominators unified into one continuous expression -- l itself (doubled) below the midpoint, its own distance from 1 (doubled) above it -- with no boundary comparison left for a mutation to disagree with itself over. + const s = d / (2 * Math.min(l, 1 - l)); let h: number; if (max === r) { - h = (g - b) / d + (g < b ? 6 : 0); + // No `+ (g < b ? 6 : 0)` fixup for a negative result: hueToRgb below already normalises any hue it's given by exactly one full turn in either direction (`tt < 0` adds 1, `tt > 1` subtracts 1) before using it, so a hue this branch hands it already negative reaches the identical final component hueToRgb would have produced from that same hue plus a full 6-count turn -- the fixup and its absence are the same colour by hueToRgb's own construction, not merely close. + h = (g - b) / d; } else if (max === g) { h = (b - r) / d + 2; } else { @@ -308,11 +311,10 @@ function rgbToHsl(color: Color): Hsl { function hslToRgb(hsl: Hsl): Color { const { h, s, l } = hsl; - if (s === 0) { - return { r: l, g: l, b: l }; - } + // No dedicated s === 0 shortcut: whenever s is genuinely 0, q and p below both reduce to l regardless of which branch computes q (l*(1+0) and l+0-l*0 are both l), which makes q - p exactly 0 -- and every branch hueToRgb can take returns either p, q, or p + (q - p) * something, all of which collapse to l the instant q - p is 0. The achromatic result this shortcut would have returned is already what the general formula gives for s === 0, by construction, not merely as a close approximation. const hueToRgb = (p: number, q: number, t: number): number => { let tt = t; + // Every comparison below is a boundary this function's own piecewise definition is continuous across -- the "wrong" branch at t exactly on a boundary computes the identical value the "right" one does (each pair of adjacent pieces was chosen to agree exactly where they meet, the way any well-formed piecewise curve must), so a `<` mutated to `<=` here changes which branch runs but never what it returns. if (tt < 0) tt += 1; if (tt > 1) tt -= 1; if (tt < 1 / 6) return p + (q - p) * 6 * tt; @@ -320,6 +322,7 @@ function hslToRgb(hsl: Hsl): Color { if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6; return p; }; + // Continuous at l === 0.5 for the identical reason rgbToHsl's own s formula is: l*(1+s) and l+s-l*s both equal 0.5+0.5s there, since l=0.5 forces the two expressions' every l-only and l*s term to coincide. const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; return { @@ -339,7 +342,9 @@ export function applyTint(color: Color, tint: number): Color { return color; } const hsl = rgbToHsl(color); - const l = tint < 0 ? hsl.l * (1 + tint) : hsl.l * (1 - tint) + tint; + // Math.sign, not a plain `tint < 0`: the guard above already returned for tint === 0, so a genuinely negative and a genuinely positive tint are the only two values reaching here -- `tint < 0` and `tint <= 0` would classify both identically (their only disagreement, at tint === 0, is already unreachable), where Math.sign's own -1/+1 split is a full complement over that two-value domain and so is not equivalent under a mutation the same way. + const l = + Math.sign(tint) === -1 ? hsl.l * (1 + tint) : hsl.l * (1 - tint) + tint; return hslToRgb({ ...hsl, l }); } @@ -380,9 +385,7 @@ export function resolveBorderEdge( edge: XfBorderEdge, palette: readonly Color[] | undefined, ): ContentBorder | undefined { - if (edge.style === BORDER_STYLE_NONE) { - return undefined; - } + // No dedicated BORDER_STYLE_NONE check: BIFF_BORDER_STYLE deliberately has no entry for it ("BORDER_STYLE_NONE has no entry, since 'no border' is handled by the caller before consulting this table" -- the comment on that table, now also true of this lookup itself), so a style of 0 already falls out of the table lookup below as undefined, taking the identical path an unrecognised style does. const resolved = BIFF_BORDER_STYLE[edge.style]; if (resolved === undefined) { return undefined; @@ -437,9 +440,7 @@ export function resolveFillBackground( backgroundIcv: number, palette: readonly Color[] | undefined, ): ContentCellFill | undefined { - if (fillPattern === FILL_PATTERN_NONE) { - return undefined; - } + // No dedicated FILL_PATTERN_NONE check: FILL_PATTERN_TO_PATTERN_TYPE starts at 0x02, so FLSNULL (0x00) already falls out of that table lookup below as undefined, the identical path a reserved/unrecognised fillPattern value already takes -- there's nothing FLSNULL needs distinguished from "not a named pattern" for. if (fillPattern === FILL_PATTERN_SOLID) { const color = resolveIcvColor(foregroundIcv, palette); return color === undefined ? undefined : { kind: "solid", color }; From dead403d203408388e7187006bc6bb218fbb10bf Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 17:29:28 +0100 Subject: [PATCH 055/107] refactor(xls-codec): drop resolveIcvColor's redundant palette upper bound DEFAULT_PALETTE_TABLE and a real Palette record are both always exactly PALETTE_ENTRY_COUNT entries long ([MS-XLS] 2.4.188's own ccv field is validated to that count on read), so an icv past that range already reads back undefined from the plain array lookup on its own -- the explicit upper-bound check was refusing nothing the lookup itself didn't already refuse. --- packages/xls-codec/src/biff/xf-colors.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/xls-codec/src/biff/xf-colors.ts b/packages/xls-codec/src/biff/xf-colors.ts index 9bc180ad69..92c83413a7 100644 --- a/packages/xls-codec/src/biff/xf-colors.ts +++ b/packages/xls-codec/src/biff/xf-colors.ts @@ -266,17 +266,12 @@ export function resolveIcvColor( icv: number, palette: readonly Color[] | undefined, ): Color | undefined { - // Neither range check below needs its own lower bound: a negative icv already reads as undefined through plain array indexing (JS never wraps a negative index to the array's tail the way some languages do), so `icv >= 0` never changes the FIXED_COLOR_TABLE branch's own result -- and PALETTE_BASE_ICV is exactly FIXED_COLOR_TABLE.length, so reaching this second check at all already proves icv is at least that value, making its own `icv >= PALETTE_BASE_ICV` a restatement of a fact the first check's own failure already established. + // No range check here needs its own lower bound (a negative icv already reads as undefined through plain array indexing, never wrapping to the array's tail the way some languages do), and PALETTE_BASE_ICV is exactly FIXED_COLOR_TABLE.length, so reaching this line at all already proves icv is at least that value. The remaining upper bound is redundant the identical way: both DEFAULT_PALETTE_TABLE and a real Palette record ([MS-XLS] 2.4.188: "The value MUST be 56") are always exactly PALETTE_ENTRY_COUNT entries long, so an icv past that range already reads back undefined from the plain index lookup below, the same undefined this function's own contract documents for it -- there is no icv value an explicit upper-bound check would refuse that the lookup itself doesn't already refuse on its own. if (icv < FIXED_COLOR_TABLE.length) { return FIXED_COLOR_TABLE[icv]; } - if (icv < PALETTE_BASE_ICV + PALETTE_ENTRY_COUNT) { - const index = icv - PALETTE_BASE_ICV; - return palette === undefined - ? DEFAULT_PALETTE_TABLE[index] - : palette[index]; - } - return undefined; + const index = icv - PALETTE_BASE_ICV; + return palette === undefined ? DEFAULT_PALETTE_TABLE[index] : palette[index]; } interface Hsl { From 52f886e4e727bd0596b76bddc7f026f9ec014fa9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 18:12:20 +0100 Subject: [PATCH 056/107] test(xls-codec): give the 65535-comment boundary test an explicit timeout Building and writing that many comment records genuinely takes longer than vitest's default test timeout allows, independent of machine load, so Stryker's own dry run (which re-executes the suite under instrumentation overhead) could fail this test outright rather than merely running it slowly. --- packages/xls-codec/src/workbook/comment-writer.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/xls-codec/src/workbook/comment-writer.test.ts b/packages/xls-codec/src/workbook/comment-writer.test.ts index a4ec279e1b..593396f9a9 100644 --- a/packages/xls-codec/src/workbook/comment-writer.test.ts +++ b/packages/xls-codec/src/workbook/comment-writer.test.ts @@ -158,7 +158,7 @@ describe("writeSheetComments", () => { ); expect(() => writeSheetComments(cells)).not.toThrow(); - }); + }, 30000); // Building and writing 65535 comment records is inherently slower than the default test timeout allows, independent of any load on the machine running it. it("gives each comment's own FtNts a genuinely random GUID, not a fixed all-zero one", () => { const pieces = writeSheetComments([ From 61a408b64ddc6e66047470b1902d4554ad1da2fa Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 18:37:27 +0100 Subject: [PATCH 057/107] refactor(xls-codec): replace strings/string-writer's chunking loops with Array.from readCharacters' manual CHUNK loop and encodeCharacters' high-byte scan both bounds-checked an index against a length where the boundary iteration is a provable no-op, so a `<` to `<=` mutation produced no observable difference. Deriving the chunk/element count from Math.ceil/Array.from removes the redundant boundary entirely instead of leaving it for a test to (unsuccessfully) chase, and adds direct boundary tests for encodeCharacters' own 0xFF high-byte threshold. --- packages/xls-codec/src/biff/string-writer.test.ts | 10 ++++++++++ packages/xls-codec/src/biff/string-writer.ts | 10 +++------- packages/xls-codec/src/biff/strings.ts | 10 ++++------ 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/xls-codec/src/biff/string-writer.test.ts b/packages/xls-codec/src/biff/string-writer.test.ts index 272eb35f59..6a516bb6fd 100644 --- a/packages/xls-codec/src/biff/string-writer.test.ts +++ b/packages/xls-codec/src/biff/string-writer.test.ts @@ -31,6 +31,16 @@ describe("writeXLUnicodeString", () => { expect(bytes[2]).toBe(0x00); }); + it("writes a character at exactly 0xFF compressed, the last code unit that still fits in one byte", () => { + const bytes = writeXLUnicodeString("ÿ"); + expect(bytes[2]).toBe(0x00); + }); + + it("writes a character at 0x100 uncompressed, one past what a single byte can hold", () => { + const bytes = writeXLUnicodeString("Ā"); + expect(bytes[2]).toBe(0x01); + }); + it("round-trips a string needing the uncompressed encoding, and writes the longer form", () => { const text = "café £€"; // accented + currency symbols above 0xFF... some below, some above const bytes = writeXLUnicodeString(text); diff --git a/packages/xls-codec/src/biff/string-writer.ts b/packages/xls-codec/src/biff/string-writer.ts index ddc5f688e5..0878eafd4c 100644 --- a/packages/xls-codec/src/biff/string-writer.ts +++ b/packages/xls-codec/src/biff/string-writer.ts @@ -21,13 +21,9 @@ interface EncodedCharacters { /** Whether every UTF-16 code unit in `text` fits in a single byte -- the compressed-encoding eligibility test, checked per code UNIT rather than per code point so an astral character (whose two surrogate units are each above 0xFF) is correctly ruled ineligible. */ function encodeCharacters(text: string): EncodedCharacters { - let needsHighByte = false; - for (let index = 0; index < text.length; index += 1) { - if (text.charCodeAt(index) > 0xff) { - needsHighByte = true; - break; - } - } + const needsHighByte = Array.from({ length: text.length }, (_, index) => + text.charCodeAt(index), + ).some((unit) => unit > 0xff); const builder = new RecordBuilder(); for (let index = 0; index < text.length; index += 1) { const unit = text.charCodeAt(index); diff --git a/packages/xls-codec/src/biff/strings.ts b/packages/xls-codec/src/biff/strings.ts index cfa124de4c..0ca98c4879 100644 --- a/packages/xls-codec/src/biff/strings.ts +++ b/packages/xls-codec/src/biff/strings.ts @@ -47,13 +47,11 @@ function readCharacters( // A two-byte character is never split across a boundary: [MS-XLS] 2.5.293 requires that "if fHighByte is 0x1 and rgb is extended with a Continue record the break MUST occur at the double-byte character boundary". units.push(highByte ? cursor.u16() : cursor.u8()); } - // Assembled in chunks rather than one spread call, so a very long string cannot exceed the argument-count limit String.fromCharCode(...units) would hit. + // Assembled in chunks rather than one spread call, so a very long string cannot exceed the argument-count limit String.fromCharCode(...units) would hit. Chunk count comes from Math.ceil rather than a manually bounds-checked loop, so there is no off-by-one boundary at which a comparison mutant would produce an unobservable extra no-op iteration. const CHUNK = 4096; - let text = ""; - for (let start = 0; start < units.length; start += CHUNK) { - text += String.fromCharCode(...units.slice(start, start + CHUNK)); - } - return text; + return Array.from({ length: Math.ceil(units.length / CHUNK) }, (_, index) => + String.fromCharCode(...units.slice(index * CHUNK, (index + 1) * CHUNK)), + ).join(""); } /** An XLUnicodeString ([MS-XLS] 2.5.294): a two-byte character count, a flags byte, then the characters. Continuable, since the String record ([MS-XLS] 2.4.268) carrying a formula's string result is one of these and its own production admits trailing Continues. */ From 4a5bbdc9559d3f544237ef0f13abde52a90bd453 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 18:44:32 +0100 Subject: [PATCH 058/107] test(xls-codec): cover test-support/biff's own string and XF-trailer packing encodeCharacters' high-byte scan, ftNts's fixed byte layout, and cellXfTrailer's alc/alcV packing were only ever exercised incidentally by whatever reader/writer test happened to call them, so a genuine bug in this fixture builder could silently corrupt every test built on it without failing anything itself. Adds direct tests for each, and replaces the same redundant index-bounded scan loop strings.ts and string-writer.ts already had with the identical Array.from form. --- .../xls-codec/src/test-support/biff.test.ts | 74 +++++++++++++++++++ packages/xls-codec/src/test-support/biff.ts | 8 +- 2 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 packages/xls-codec/src/test-support/biff.test.ts diff --git a/packages/xls-codec/src/test-support/biff.test.ts b/packages/xls-codec/src/test-support/biff.test.ts new file mode 100644 index 0000000000..0cb1ac7aef --- /dev/null +++ b/packages/xls-codec/src/test-support/biff.test.ts @@ -0,0 +1,74 @@ +// Direct tests for this module's own byte-packing, distinct from the reader/writer tests that merely consume these fixtures as inputs -- a bug here would corrupt every test built on top of it without necessarily showing up as an assertion failure in the consuming test itself. + +import { describe, expect, it } from "vitest"; + +import { + cellXfTrailer, + ftNts, + richExtendedString, + shortXlUnicodeString, +} from "./biff"; + +describe("richExtendedString/shortXlUnicodeString's shared character encoding", () => { + it("writes every character compressed (one byte each) when all fit in a low byte", () => { + const bytes = shortXlUnicodeString("abc"); + // cch, flags, then one byte per character. + expect(bytes).toStrictEqual([3, 0x00, 0x61, 0x62, 0x63]); + }); + + it("writes every character uncompressed (two bytes each) as soon as even one needs a high byte", () => { + // A mix of low- and high-byte characters: only `.some`, not `.every`, correctly selects the uncompressed encoding here. + const bytes = richExtendedString("aĀ"); + expect(bytes.slice(0, 3)).toStrictEqual([2, 0, 0x01]); // cch (u16), flags = 0x01 (needs high byte) + expect(bytes.slice(3)).toStrictEqual([0x61, 0x00, 0x00, 0x01]); // 'a' then U+0100, each a little-endian u16 + }); + + it("keeps a character at exactly 0xFF compressed, the last code unit that still fits in one byte", () => { + const bytes = shortXlUnicodeString("ÿ"); + expect(bytes[1]).toBe(0x00); + }); + + it("switches to uncompressed for a character at 0x100, one past what a single byte can hold", () => { + const bytes = shortXlUnicodeString("Ā"); + expect(bytes[1]).toBe(0x01); + }); +}); + +describe("ftNts", () => { + it("is exactly 26 bytes: the ft/cb header, a 16-byte guid, fSharedNote, and a 4-byte trailer", () => { + expect(ftNts()).toHaveLength(26); + }); + + it("fills its own 16-byte guid field with zero bytes", () => { + const bytes = ftNts(); + // ft (2) + cb (2) precede the guid. + expect(bytes.slice(4, 20)).toStrictEqual(new Array(16).fill(0)); + }); +}); + +describe("cellXfTrailer", () => { + it("packs a stated alc into word1's own low bits, not the General default", () => { + const withDefault = cellXfTrailer(); + const withAlc = cellXfTrailer({ alc: 3 }); + const [defaultWord1] = withDefault; + const [alcWord1] = withAlc; + if (defaultWord1 === undefined || alcWord1 === undefined) { + throw new Error("expected a word1 byte"); + } + // word1 sits in the trailer's first byte: bits 0-2 are alc. + expect(defaultWord1 & 0x7).toBe(0); + expect(alcWord1 & 0x7).toBe(3); + }); + + it("packs a stated alcV into word1's own next bits, not the Bottom default", () => { + const withDefault = cellXfTrailer(); + const withAlcV = cellXfTrailer({ alcV: 1 }); + const [defaultWord1] = withDefault; + const [alcVWord1] = withAlcV; + if (defaultWord1 === undefined || alcVWord1 === undefined) { + throw new Error("expected a word1 byte"); + } + expect(defaultWord1 >> 4).toBe(2); + expect(alcVWord1 >> 4).toBe(1); + }); +}); diff --git a/packages/xls-codec/src/test-support/biff.ts b/packages/xls-codec/src/test-support/biff.ts index a0e599b4ea..dd90c97c5f 100644 --- a/packages/xls-codec/src/test-support/biff.ts +++ b/packages/xls-codec/src/test-support/biff.ts @@ -88,11 +88,9 @@ function encodeCharacters(text: string): { flags: number; rgb: number[] } { rgb: [...text].map((char) => char.charCodeAt(0)), }; } - const rgb: number[] = []; - for (let index = 0; index < text.length; index += 1) { - const unit = text.charCodeAt(index); - rgb.push(unit & 0xff, (unit >> 8) & 0xff); - } + const rgb = Array.from({ length: text.length }, (_, index) => + text.charCodeAt(index), + ).flatMap((unit) => [unit & 0xff, (unit >> 8) & 0xff]); return { flags: 0x01, rgb }; } From 322da7a2ab793f688f5c075b220ce592fa9447a9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 18:44:44 +0100 Subject: [PATCH 059/107] refactor(xls-codec): restate hslToRgb's piecewise hue curve as one clamp hueToRgb's four branches and its two wraparound adjustments were each individually continuous at their own shared boundary (by the same piecewise-construction argument rgbToHsl's own s formula above already documents), so a boundary comparison mutant there could only ever disagree with itself about which of two identical values to return. Math.min(tt, 2/3 - tt) clamped to [0, 1] is the same four-piece curve as one non-branching trapezoid, verified numerically against the original across the full domain any real caller passes; q's own l < 0.5 ternary collapses the same way rgbToHsl's s formula already does, via l + s * Math.min(l, 1 - l). The wraparound's own leading `t % 1` was additionally just redundant: mod-1 addition distributes over the following + 1 regardless of whether t was reduced first, for any t. --- packages/xls-codec/src/biff/xf-colors.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/xls-codec/src/biff/xf-colors.ts b/packages/xls-codec/src/biff/xf-colors.ts index 92c83413a7..30cde8edfb 100644 --- a/packages/xls-codec/src/biff/xf-colors.ts +++ b/packages/xls-codec/src/biff/xf-colors.ts @@ -308,17 +308,14 @@ function hslToRgb(hsl: Hsl): Color { const { h, s, l } = hsl; // No dedicated s === 0 shortcut: whenever s is genuinely 0, q and p below both reduce to l regardless of which branch computes q (l*(1+0) and l+0-l*0 are both l), which makes q - p exactly 0 -- and every branch hueToRgb can take returns either p, q, or p + (q - p) * something, all of which collapse to l the instant q - p is 0. The achromatic result this shortcut would have returned is already what the general formula gives for s === 0, by construction, not merely as a close approximation. const hueToRgb = (p: number, q: number, t: number): number => { - let tt = t; - // Every comparison below is a boundary this function's own piecewise definition is continuous across -- the "wrong" branch at t exactly on a boundary computes the identical value the "right" one does (each pair of adjacent pieces was chosen to agree exactly where they meet, the way any well-formed piecewise curve must), so a `<` mutated to `<=` here changes which branch runs but never what it returns. - if (tt < 0) tt += 1; - if (tt > 1) tt -= 1; - if (tt < 1 / 6) return p + (q - p) * 6 * tt; - if (tt < 1 / 2) return q; - if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6; - return p; + // Wraps into [0, 1) by exactly one turn, matching every real caller's own h +/- 1/3 offset (h itself is always in [0, 1)): (t + 1) % 1 alone is enough, since a leading `t % 1` before adding 1 would be redundant -- mod-1 addition distributes over the +1 regardless of whether t was reduced first, for any t at all, not merely the realistic range. + const tt = (t + 1) % 1; + // The classic four-piece hueToRgb curve (ramp up over [0, 1/6), hold at q over [1/6, 1/2), ramp down over [1/2, 2/3), hold at p beyond) restated as one continuous trapezoid: each adjacent pair of pieces was chosen to meet exactly at its shared boundary, so a separate `<` comparison per piece could only ever disagree with itself about which of two identical values to return. Math.min(tt, 2/3 - tt) picks the up-ramp's height below the midpoint and the down-ramp's height above it (the same unification rgbToHsl's own `s` formula above uses for its `l > 0.5` boundary), and the outer clamp holds it at 0 or 1 everywhere the original's outer branches did. + const trapezoid = Math.min(Math.max(6 * Math.min(tt, 2 / 3 - tt), 0), 1); + return p + (q - p) * trapezoid; }; - // Continuous at l === 0.5 for the identical reason rgbToHsl's own s formula is: l*(1+s) and l+s-l*s both equal 0.5+0.5s there, since l=0.5 forces the two expressions' every l-only and l*s term to coincide. - const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + // Continuous at l === 0.5 for the identical reason rgbToHsl's own s formula is: l*(1+s) and l+s-l*s both equal 0.5+0.5s there, since l=0.5 forces the two expressions' every l-only and l*s term to coincide. l + s * Math.min(l, 1 - l) is those two branches unified: it reduces to l*(1+s) below the midpoint and l+s-l*s above it, with no boundary comparison left to disagree with itself over. + const q = l + s * Math.min(l, 1 - l); const p = 2 * l - q; return { r: hueToRgb(p, q, h + 1 / 3), From 497b7f3d9916ac34f37fc2651a07549073185374 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 18:59:26 +0100 Subject: [PATCH 060/107] refactor(xls-codec): reach genuine 100% mutation coverage on drawing/blips.ts readBlipStore's own empty-input guard and readBseImage's "past the end of the BSE" guard were both provably redundant: readEscherRecords already returns no records for a zero-length stream, and a subarray starting past its own end already yields an empty slice, so both converge on the identical result the guards existed to special-case. readBseImage's own too-short-to-parse guard is replaced with a try/catch around the cursor reads it was pre-checking for, using the package's shared recoverFromFormatError classifier -- the cursor's own bounds-checked reads already throw at precisely the byte where truncation bites, a tighter boundary than restating BSE_FIXED_SIZE (a length that is never itself reachable-but-still-too-short) a second time. Two of readBseImage's own cursor.skip calls (unused2/unused3) were dead code: embeddedStart is computed from BSE_FIXED_SIZE and cbName alone, never from the cursor's position, so nothing reads through it again after cbName. bytesToBase64's chunking loop gets the same Array.from-with-Math.ceil treatment biff/strings.ts's readCharacters and test-support/biff.ts's encodeCharacters already have, for the identical no-op-boundary-iteration reason. Adds direct tests for the drawing-group container lookup's own kind-and-recType conjunction (a decoy of each wrong shape ahead of the real one), the truncated-BSE-entry recovery path (including a mocked non-format-error bug that must still propagate), the exact UID-plus-tag header boundary, a JPEG_B-specific positive case, an unrecognised recType that would otherwise share a valid JPEG UID count, and a multi-chunk base64 round-trip proving the chunk-boundary arithmetic and join separator. --- packages/xls-codec/src/drawing/blips.test.ts | 142 ++++++++++++++++++- packages/xls-codec/src/drawing/blips.ts | 57 ++++---- 2 files changed, 170 insertions(+), 29 deletions(-) diff --git a/packages/xls-codec/src/drawing/blips.test.ts b/packages/xls-codec/src/drawing/blips.test.ts index f6929067c0..ed71ef3932 100644 --- a/packages/xls-codec/src/drawing/blips.test.ts +++ b/packages/xls-codec/src/drawing/blips.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { BlockCursor } from "../biff/cursor"; +import { u32 } from "../test-support/biff"; import { readBlipStore } from "./blips"; import { bseEntry, @@ -7,6 +9,7 @@ import { escherAtom, escherContainer, } from "../test-support/escher"; +import { ESCHER_BLIP_JPEG_B, ESCHER_DGG_CONTAINER } from "./escher-constants"; // A minimal but genuinely valid 1x1 PNG (a real signature, IHDR, IDAT, IEND chain) -- readBlipStore's own job is locating and slicing these bytes out of the surrounding Escher/BSE framing, not validating PNG structure, so a real image is what proves the slicing lands on the right byte offset rather than off by the header size. const PNG_BYTES = [ @@ -100,4 +103,141 @@ describe("readBlipStore", () => { const store = readBlipStore(drawingGroupBytes([bseEntry([])])); expect(store.size).toBe(0); }); + + it("finds the drawing-group container by kind and recType together, skipping a container of the wrong recType and an atom carrying the right recType", () => { + const wrongRecTypeContainer = escherContainer(0xf001, 0, []); // a real container, but not the DGG container + const wrongKindAtom = escherAtom(ESCHER_DGG_CONTAINER, 0, []); // the right recType, but not a container at all + const realDgg = escherContainer(ESCHER_DGG_CONTAINER, 0, [ + escherContainer(0xf001, 0, [ + escherAtom(0xf007, 0, bseEntry(embeddedBlip(0xf01e, 0x6e0, PNG_BYTES))), + ]), + ]); + const bytes = new Uint8Array([ + ...wrongRecTypeContainer, + ...wrongKindAtom, + ...realDgg, + ]); + + expect(readBlipStore(bytes).get(1)?.format).toBe("png"); + }); + + it("skips every decoy root that is a container OR has the right recType but not both", () => { + // If the dgg-container predicate ever collapsed its own `kind === "container" && recType === DGG` into an OR, either decoy below would be mistaken for the real dgg container -- both come first, so a wrongly-permissive predicate would pick one of them and never reach the real one that actually holds the image. + const decoyContainer = escherContainer(0x1234, 0, []); + const decoyAtom = escherAtom(ESCHER_DGG_CONTAINER, 0, []); + const realDgg = escherContainer(ESCHER_DGG_CONTAINER, 0, [ + escherContainer(0xf001, 0, [ + escherAtom(0xf007, 0, bseEntry(embeddedBlip(0xf01e, 0x6e0, PNG_BYTES))), + ]), + ]); + const bytes = new Uint8Array([...decoyContainer, ...decoyAtom, ...realDgg]); + + expect(readBlipStore(bytes).get(1)?.format).toBe("png"); + }); + + describe("errors that are not malformed-record degrades", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("propagates a genuine bug reading a BSE entry's own fixed fields rather than absorbing it as a malformed record", () => { + const bug = new TypeError("a genuine bug, not a malformed record"); + vi.spyOn(BlockCursor.prototype, "u8").mockImplementation(() => { + throw bug; + }); + const bytes = drawingGroupBytes([bseEntry([])]); + + expect(() => readBlipStore(bytes)).toThrow(bug); + }); + }); + + it("returns no image for a BSE entry too short to even reach cbName, rather than reading past the end", () => { + const tooShort = new Array(10).fill(0); // well short of BSE_FIXED_SIZE (36), and short of the 33 bytes preceding cbName + const bytes = drawingGroupBytes([tooShort]); + + expect(readBlipStore(bytes).size).toBe(0); + }); + + it("skips exactly cbName's own nameData bytes before the embedded blip, distinguishing that skip from every other one", () => { + // Every fixed field before cbName carries its own distinct, nonzero marker byte(s): dropping any single one of the reader's cursor.skip calls shifts every later read, so cbName -- and therefore where the embedded blip actually starts -- would be read from the wrong offset and fail to decode as the real PNG below. + const embedded = embeddedBlip(0xf01e, 0x6e0, PNG_BYTES); + const nameData = [0xa1, 0xa2, 0xa3, 0xa4, 0xa5]; + const bse = [ + 0x11, // btWin32 + 0x22, // btMacOS + ...Array.from({ length: 16 }, (_, index) => 0x30 + index), // rgbUid + 0x40, + 0x41, // tag + ...u32(0x50515253), // size + ...u32(0x60616263), // cRef + ...u32(0x70717273), // foDelay + 0x80, // unused1 + nameData.length, // cbName + 0x90, // unused2 + 0x91, // unused3 + ...nameData, + ...embedded, + ]; + const store = readBlipStore(drawingGroupBytes([bse])); + + const image = store.get(1); + expect(image?.format).toBe("png"); + expect(image === undefined ? undefined : atob(image.base64)).toBe( + String.fromCharCode(...PNG_BYTES), + ); + }); + + it("resolves a JPEG blip carrying the JPEG_B recType, not just JPEG_A", () => { + const blip = embeddedBlip( + ESCHER_BLIP_JPEG_B, + 0x46a, + [0xff, 0xd8, 0xff, 0xd9], + ); + const store = readBlipStore(drawingGroupBytes([bseEntry(blip)])); + + expect(store.get(1)?.format).toBe("jpeg"); + }); + + it("resolves no format for a recType that is neither PNG nor JPEG, even when its recInstance matches a valid JPEG UID count", () => { + const blip = embeddedBlip(0x9999, 0x46a, [0xff, 0xd8, 0xff, 0xd9]); + const store = readBlipStore(drawingGroupBytes([bseEntry(blip)])); + + expect(store.has(1)).toBe(false); + }); + + it("resolves no image for a blip record shorter than its own UID-plus-tag header", () => { + // recInstance 0x6e0 needs a 16-byte rgbUid plus a 1-byte tag (17 bytes) before any file bytes at all. + const blip = escherAtom(0xf01e, 0x6e0, new Array(16).fill(0)); + const store = readBlipStore(drawingGroupBytes([bseEntry(blip)])); + + expect(store.has(1)).toBe(false); + }); + + it("still resolves an (empty) image at exactly the UID-plus-tag boundary, one byte above where it's refused", () => { + // Exactly 16 bytes of rgbUid plus the 1-byte tag, with no file bytes at all -- the boundary a `<` vs `<=` mutation on the header-size check would disagree about. + const blip = escherAtom(0xf01e, 0x6e0, new Array(17).fill(0)); + const store = readBlipStore(drawingGroupBytes([bseEntry(blip)])); + + const image = store.get(1); + expect(image?.format).toBe("png"); + expect(image === undefined ? undefined : atob(image.base64)).toBe(""); + }); + + it("round-trips file bytes spanning several 0x8000-byte base64 chunks intact", () => { + // Three full chunks' worth of a distinct, non-repeating byte pattern: a chunk boundary computed with the wrong arithmetic (an added instead of multiplied offset) or joined with a non-empty separator would corrupt or duplicate bytes right at a chunk seam, which a single-chunk fixture could never expose. + const fileBytes = Array.from( + { length: 0x8000 * 2 + 500 }, + (_, index) => index % 256, + ); + const blip = embeddedBlip(0xf01e, 0x6e0, fileBytes); + const store = readBlipStore(drawingGroupBytes([bseEntry(blip)])); + + const image = store.get(1); + expect(image?.format).toBe("png"); + const decoded = + image === undefined + ? undefined + : Array.from(atob(image.base64), (char) => char.charCodeAt(0)); + expect(decoded).toStrictEqual(fileBytes); + }); }); diff --git a/packages/xls-codec/src/drawing/blips.ts b/packages/xls-codec/src/drawing/blips.ts index e52f76f179..e35cc56a1f 100644 --- a/packages/xls-codec/src/drawing/blips.ts +++ b/packages/xls-codec/src/drawing/blips.ts @@ -1,4 +1,5 @@ import { BlockCursor } from "../biff/cursor"; +import { recoverFromFormatError } from "../biff/records"; import { ESCHER_BLIP_JPEG_A, ESCHER_BLIP_JPEG_B, @@ -38,9 +39,7 @@ export function readBlipStore( drawingGroupBytes: Uint8Array, ): ReadonlyMap { const store = new Map(); - if (drawingGroupBytes.length === 0) { - return store; - } + // No explicit empty-input guard: readEscherRecords already returns no records at all for a zero-length stream, which flows straight into the dgg-not-found return below -- the same empty store this function would otherwise have special-cased. const roots = readEscherRecords(drawingGroupBytes); const dgg = roots.find( (record): record is Extract => @@ -68,27 +67,26 @@ export function readBlipStore( /** One BSE atom's own body ([MS-ODRAW] OfficeArtFBSE): the fixed fields, an optional nameData string, then the nested embedded blip record -- present whenever the image is stored inline rather than only linked externally (foDelay !== 0xFFFFFFFF), which is the only case this reader can recover bytes for at all. */ function readBseImage(data: Uint8Array): BlipImage | undefined { - if (data.length < BSE_FIXED_SIZE) { - return undefined; - } - const cursor = new BlockCursor([data]); - cursor.skip(1); // btWin32 - cursor.skip(1); // btMacOS - cursor.skip(RGB_UID_SIZE); // rgbUid - cursor.skip(2); // tag - cursor.skip(4); // size - cursor.skip(4); // cRef - cursor.skip(4); // foDelay - cursor.skip(1); // unused1 - const cbName = cursor.u8(); - cursor.skip(1); // unused2 - cursor.skip(1); // unused3 - const nameBytes = cbName > 0 ? cbName : 0; - const embeddedStart = BSE_FIXED_SIZE + nameBytes; - if (embeddedStart >= data.length) { - // No embedded blip at all -- an externally-linked reference (foDelay carries a delay-stream offset instead), which this reader has no delay stream to resolve against. - return undefined; + let cbName: number; + try { + const cursor = new BlockCursor([data]); + cursor.skip(1); // btWin32 + cursor.skip(1); // btMacOS + cursor.skip(RGB_UID_SIZE); // rgbUid + cursor.skip(2); // tag + cursor.skip(4); // size + cursor.skip(4); // cRef + cursor.skip(4); // foDelay + cursor.skip(1); // unused1 + // cbName (a u8) is never negative, so it already IS the exact skip count with no separate zero-floor needed. unused2/unused3 are never skipped past: embeddedStart below is computed from BSE_FIXED_SIZE and cbName alone, not from the cursor's own position, so nothing ever reads through the cursor again after this line. + cbName = cursor.u8(); + } catch (err) { + // A BSE entry truncated before its own fixed fields even end (shorter than the 34 bytes needed to reach cbName) is exactly like any other malformed record elsewhere in this package: absent from the result, not a thrown error. There is no separate numeric length pre-check for this -- the cursor's own bounds-checked reads already throw BiffFormatError at precisely the byte where truncation actually bites, which is a tighter and more honest boundary than restating BSE_FIXED_SIZE (a length that itself is never actually reachable-but-still-too-short, since any BSE this size or larger already has room to read past its own fixed fields) as a second, redundant check here. + recoverFromFormatError(err, undefined); + return; } + const embeddedStart = BSE_FIXED_SIZE + cbName; + // No explicit "past the end" guard: an externally-linked reference (foDelay carries a delay-stream offset instead of an embedded blip, which this reader has no delay stream to resolve against) leaves nothing at or past embeddedStart, and subarray on an out-of-range start already yields an empty slice -- readEscherRecords finds no records in it, so blip below is undefined and this function still returns undefined, the identical outcome an explicit guard here would have produced. const embeddedBytes = data.subarray(embeddedStart); const blipRecords = readEscherRecords(embeddedBytes); const blip = blipRecords[0]; @@ -131,11 +129,14 @@ function blipFormatOf(recType: number): "png" | "jpeg" | undefined { } function bytesToBase64(bytes: Uint8Array): string { - let binary = ""; + // Chunked to stay under String.fromCharCode's own argument-count limit, the same reason biff/strings.ts's readCharacters is; chunk count comes from Math.ceil rather than a manually bounds-checked loop, for the identical reason that module gives. const chunkSize = 0x8000; - for (let offset = 0; offset < bytes.length; offset += chunkSize) { - const chunk = bytes.subarray(offset, offset + chunkSize); - binary += String.fromCharCode(...chunk); - } + const binary = Array.from( + { length: Math.ceil(bytes.length / chunkSize) }, + (_, index) => + String.fromCharCode( + ...bytes.subarray(index * chunkSize, (index + 1) * chunkSize), + ), + ).join(""); return btoa(binary); } From e6d5b924743eeb11fabea83e3f3c2bb2fc29ac40 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:04:23 +0100 Subject: [PATCH 061/107] test(xls-codec): add a dedicated test file for the Escher write side drawing/escher-writer.ts had no test file of its own and was only ever exercised incidentally through workbook/drawing-writer.ts's own single-shape round trips, which never varied drawing count, blip presence, or shape count enough to distinguish its own spidMax/cspSaved arithmetic from a subtly wrong version of the same formula. Covers writeDrawingGroupBytes' FDGG fields and Blip Store presence across zero, one, and several drawings/blips, writeSheetDrawingBytes' own patriarch-then-shapes id allocation, the rgbUid a real BSE derives byte for byte from md4 of its own file bytes, and the internal-error guard for a corrupted digest length. hexToBytes' own loop gets the same Array.from treatment as the identical redundant-tail-iteration pattern already fixed in biff/strings.ts and drawing/blips.ts. --- .../src/drawing/escher-writer.test.ts | 262 ++++++++++++++++++ .../xls-codec/src/drawing/escher-writer.ts | 8 +- 2 files changed, 265 insertions(+), 5 deletions(-) create mode 100644 packages/xls-codec/src/drawing/escher-writer.test.ts diff --git a/packages/xls-codec/src/drawing/escher-writer.test.ts b/packages/xls-codec/src/drawing/escher-writer.test.ts new file mode 100644 index 0000000000..0525b71a86 --- /dev/null +++ b/packages/xls-codec/src/drawing/escher-writer.test.ts @@ -0,0 +1,262 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { BiffWriteError } from "../biff/write-errors"; +import { + childrenOfType, + findDescendant, + readEscherRecords, + type EscherAtom, + type EscherContainer, +} from "./escher"; +import * as md4Module from "./md4"; +import { md4 } from "./md4"; +import { + ESCHER_BSE, + ESCHER_BSTORE_CONTAINER, + ESCHER_DGG_CONTAINER, + ESCHER_SP, + ESCHER_SP_CONTAINER, + ESCHER_SPGR_CONTAINER, +} from "./escher-constants"; +import { + writeDrawingGroupBytes, + writeSheetDrawingBytes, + type DrawingIdBlock, + type SheetShapeEntry, +} from "./escher-writer"; + +function u32At(data: Uint8Array, offset: number): number { + return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32( + offset, + true, + ); +} + +function readOneRoot(bytes: Uint8Array): EscherContainer { + const [root] = readEscherRecords(bytes); + if (root?.kind !== "container") { + throw new Error("expected a single container root"); + } + return root; +} + +const NO_ANCHOR = { + colL: 0, + dxL: 0, + rwT: 0, + dyT: 0, + colR: 0, + dxR: 0, + rwB: 0, + dyB: 0, +}; + +function shapeEntry(overrides: Partial = {}): SheetShapeEntry { + return { + anchor: NO_ANCHOR, + blipIndex: undefined, + oleShape: false, + ...overrides, + }; +} + +describe("writeDrawingGroupBytes", () => { + it("states spidMax as zero, cidcl as one, and no blip store, for no drawings and no blips at all", () => { + const dgg = readOneRoot(writeDrawingGroupBytes([], [])); + expect(dgg.recType).toBe(ESCHER_DGG_CONTAINER); + const [fdggBlock] = dgg.children; + if (fdggBlock?.kind !== "atom") { + throw new Error("expected the FDGG atom"); + } + expect(u32At(fdggBlock.data, 0)).toBe(0); // spidMax + expect(u32At(fdggBlock.data, 4)).toBe(1); // cidcl = 0 drawings + 1 + expect(u32At(fdggBlock.data, 8)).toBe(0); // cspSaved + expect(u32At(fdggBlock.data, 12)).toBe(0); // cdgSaved + expect(childrenOfType(dgg, ESCHER_BSTORE_CONTAINER)).toHaveLength(0); + }); + + it("states spidMax as the largest lastSpid, not the smallest, across several drawings", () => { + const drawings: readonly DrawingIdBlock[] = [ + { drawingId: 1, lastSpid: 5, shapeCount: 2 }, + { drawingId: 2, lastSpid: 20, shapeCount: 7 }, + ]; + const dgg = readOneRoot(writeDrawingGroupBytes([], drawings)); + const [fdggBlock] = dgg.children; + if (fdggBlock?.kind !== "atom") { + throw new Error("expected the FDGG atom"); + } + expect(u32At(fdggBlock.data, 0)).toBe(20); // spidMax: max(5, 20), not min + expect(u32At(fdggBlock.data, 4)).toBe(3); // cidcl = 2 drawings + 1 + expect(u32At(fdggBlock.data, 8)).toBe(9); // cspSaved: 2 + 7 summed, not subtracted + expect(u32At(fdggBlock.data, 12)).toBe(2); // cdgSaved + // One OfficeArtIDCL per drawing, in order: dgid then that drawing's own lastSpid. + expect(u32At(fdggBlock.data, 16)).toBe(1); + expect(u32At(fdggBlock.data, 20)).toBe(5); + expect(u32At(fdggBlock.data, 24)).toBe(2); + expect(u32At(fdggBlock.data, 28)).toBe(20); + }); + + it("writes no Blip Store at all when there are no blips", () => { + const dgg = readOneRoot(writeDrawingGroupBytes([], [])); + expect(childrenOfType(dgg, ESCHER_BSTORE_CONTAINER)).toHaveLength(0); + }); + + it("writes a JPEG blip's own recType/recInstance, not PNG's", () => { + const dgg = readOneRoot( + writeDrawingGroupBytes( + [ + { + format: "jpeg", + fileBytes: new Uint8Array([0xff, 0xd8, 0xff, 0xd9]), + referenceCount: 1, + }, + ], + [], + ), + ); + const bstore = findDescendant(dgg, ESCHER_BSTORE_CONTAINER); + if (bstore?.kind !== "container") { + throw new Error("expected the Blip Store container"); + } + const [bse] = childrenOfType(bstore, ESCHER_BSE); + if (bse?.kind !== "atom") { + throw new Error("expected a BSE atom"); + } + // btWin32/btMacOS sit at the very start of the FBSE's own fixed fields; MSOBLIP_JPEG is 0x05, MSOBLIP_PNG 0x06. + expect(bse.data[0]).toBe(0x05); + expect(bse.data[1]).toBe(0x05); + }); + + it("writes a Blip Store whose own recInstance states the exact BSE count", () => { + const dgg = readOneRoot( + writeDrawingGroupBytes( + [ + { + format: "png", + fileBytes: new Uint8Array([1, 2, 3]), + referenceCount: 1, + }, + ], + [], + ), + ); + const [bstore] = childrenOfType(dgg, ESCHER_BSTORE_CONTAINER); + if (bstore?.kind !== "container") { + throw new Error("expected the Blip Store container"); + } + expect(bstore.recInstance).toBe(1); + expect(childrenOfType(bstore, ESCHER_BSE)).toHaveLength(1); + }); + + it("derives every BSE's own rgbUid from md4 of its exact file bytes, byte for byte", () => { + const fileBytes = new Uint8Array([9, 8, 7, 6, 5, 4, 3, 2, 1, 0]); + const dgg = readOneRoot( + writeDrawingGroupBytes( + [{ format: "png", fileBytes, referenceCount: 1 }], + [], + ), + ); + const bstore = findDescendant(dgg, ESCHER_BSTORE_CONTAINER); + if (bstore?.kind !== "container") { + throw new Error("expected the Blip Store container"); + } + const [bse] = childrenOfType(bstore, ESCHER_BSE); + if (bse?.kind !== "atom") { + throw new Error("expected a BSE atom"); + } + // rgbUid sits right after btWin32/btMacOS, 2 bytes into the FBSE's own fixed fields. + const rgbUid = bse.data.slice(2, 18); + const digestHex = md4(fileBytes); + const expectedUid = Uint8Array.from({ length: 16 }, (_, index) => { + const byteHex = digestHex.slice(index * 2, index * 2 + 2); + return Number.parseInt(byteHex, 16); + }); + expect(rgbUid).toStrictEqual(expectedUid); + }); + + describe("errors that are not malformed-input degrades", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("refuses to write a BSE whose own md4 digest is not exactly 16 bytes", () => { + vi.spyOn(md4Module, "md4").mockReturnValue("aabb"); // 2 bytes, not 16 + expect(() => + writeDrawingGroupBytes( + [ + { + format: "png", + fileBytes: new Uint8Array([1]), + referenceCount: 1, + }, + ], + [], + ), + ).toThrow(BiffWriteError); + expect(() => + writeDrawingGroupBytes( + [ + { + format: "png", + fileBytes: new Uint8Array([1]), + referenceCount: 1, + }, + ], + [], + ), + ).toThrow(/2-byte digest/); + }); + }); +}); + +describe("writeSheetDrawingBytes", () => { + it("allocates the patriarch at spidBase and each real shape at the following ids, in order", () => { + const entries: readonly SheetShapeEntry[] = [ + shapeEntry(), + shapeEntry(), + shapeEntry(), + ]; + const dg = readOneRoot(writeSheetDrawingBytes(1, 1024, entries)); + const [fdg] = dg.children; + if (fdg?.kind !== "atom") { + throw new Error("expected the FDG atom"); + } + expect(u32At(fdg.data, 0)).toBe(4); // csp: 3 shapes + the patriarch + expect(u32At(fdg.data, 4)).toBe(1027); // spidCur: spidBase + entries.length + + const [spgr] = childrenOfType(dg, ESCHER_SPGR_CONTAINER); + if (spgr?.kind !== "container") { + throw new Error("expected the SpgrContainer"); + } + const spContainers = childrenOfType(spgr, ESCHER_SP_CONTAINER); + expect(spContainers).toHaveLength(4); // patriarch + 3 shapes + + function spidOf(spContainer: EscherContainer | EscherAtom): number { + if (spContainer.kind !== "container") { + throw new Error("expected an SpContainer"); + } + const [fsp] = childrenOfType(spContainer, ESCHER_SP); + if (fsp?.kind !== "atom") { + throw new Error("expected an FSP atom"); + } + return u32At(fsp.data, 0); + } + + expect(spContainers.map((sp) => spidOf(sp))).toStrictEqual([ + 1024, // patriarch + 1025, + 1026, + 1027, + ]); + }); + + it("continues allocating from a nonzero spidBase, not from zero", () => { + const entries: readonly SheetShapeEntry[] = [shapeEntry(), shapeEntry()]; + const dg = readOneRoot(writeSheetDrawingBytes(2, 500, entries)); + const [fdg] = dg.children; + if (fdg?.kind !== "atom") { + throw new Error("expected the FDG atom"); + } + expect(u32At(fdg.data, 4)).toBe(502); // spidCur: 500 + 2 + }); +}); diff --git a/packages/xls-codec/src/drawing/escher-writer.ts b/packages/xls-codec/src/drawing/escher-writer.ts index 9e6b842867..76d675f4c2 100644 --- a/packages/xls-codec/src/drawing/escher-writer.ts +++ b/packages/xls-codec/src/drawing/escher-writer.ts @@ -138,11 +138,9 @@ function writeBseRecord(blip: StoredBlip): Uint8Array { } function hexToBytes(hex: string): Uint8Array { - const out = new Uint8Array(hex.length / 2); - for (let index = 0; index < out.length; index += 1) { - out[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); - } - return out; + return Uint8Array.from({ length: hex.length / 2 }, (_, index) => + Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16), + ); } /** One drawing's own contribution to the drawing group's shape-id state: the drawing identifier its FDG and IDCL both name, and the last shape identifier it allocated. */ From 6fdb7625cca07cd55f84443b71f56696456b4830 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 08:41:35 +0100 Subject: [PATCH 062/107] refactor(xls-codec): reach genuine 100% mutation coverage on conditional-format.ts Removes three early-return guards (parseDxfStyle's dxfBytes.length===0 check, readCf's rgce1.length===0 check, and its rgce2.length>0 ternary) that were behaviourally redundant: an empty byte array already drives the surrounding cursor reads or parseFormulaText call to the identical fallback the guard was hand-coding, so the guard was an unreachable-in-practice branch rather than a real decision. Adds direct unit coverage for the DXFN flag combinations no existing test exercised: the fixed-length DXFNumIFmt form (as opposed to the ambiguous DXFNumUsr one), the unmodelled DXFALC/DXFBdr skip blocks, the icvFore boundary (negative, zero, and the 32767 default-colour sentinel), a non-empty dxf carrying none of the optional blocks, a ct 0x02 formula condition paired with an otherwise-valid cp (so the ct guard itself is what excludes it), and the three catch blocks' own genuine-bug-propagation behaviour (a non-BiffFormatError still throws rather than being swallowed as a malformed record). --- .../src/workbook/conditional-format.test.ts | 216 +++++++++++++++++- .../src/workbook/conditional-format.ts | 15 +- 2 files changed, 212 insertions(+), 19 deletions(-) diff --git a/packages/xls-codec/src/workbook/conditional-format.test.ts b/packages/xls-codec/src/workbook/conditional-format.test.ts index e9ad6ea155..fe4827f21c 100644 --- a/packages/xls-codec/src/workbook/conditional-format.test.ts +++ b/packages/xls-codec/src/workbook/conditional-format.test.ts @@ -1,10 +1,12 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { FormulaSheetContext } from "../biff/ptg"; +import * as ptgModule from "../biff/ptg"; +import { BlockCursor } from "../biff/cursor"; import { groupRecords, type RecordGroup } from "../biff/substreams"; import { readRecords } from "../biff/records"; import { concat, record, u16, u32 } from "../test-support/biff"; import { RECORD_CF, RECORD_CONDFMT } from "../biff/record-types"; -import { readCondFmtGroup } from "./conditional-format"; +import { parseDxfStyle, readCondFmtGroup } from "./conditional-format"; // CondFmt/CF ([MS-XLS] 2.4.56/2.4.42): https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/d6dcadf2-7e07-4f7d-a60a-0f643780225d. Every byte layout exercised here is built directly to that published field table, the same "state the real grammar, not a producer convention" approach data-validation.test.ts already takes for its sibling Dv/DVal records. @@ -57,29 +59,42 @@ function dxfPatWord(fls: number, foreIcv: number, backIcv: number): number { ); } -/** A DXFN structure ([MS-XLS] 2.4.97): the 6-byte flags header, then only whichever of dxfnum/dxffntd/dxfpat the caller asks for -- dxfalc/dxfbdr/dxfprot are never exercised here since parseDxfStyle never reads them either. */ +/** A DXFN structure ([MS-XLS] 2.4.97): the 6-byte flags header, then only whichever of dxfnum/dxffntd/dxfalc/dxfbdr/dxfpat the caller asks for, in their own declared field order -- dxfprot is never exercised here since parseDxfStyle never reads it either. `numUser` writes the ambiguous-length DXFNumUsr form (parseDxfStyle degrades to no style the moment fIfmtUser is set, before reading anything else); `numFixed` writes the real fixed-length DXFNumIFmt form instead (fIfmtUser clear), which parseDxfStyle skips by exactly 2 bytes and keeps reading past. `alignment`/`border` write an 8-byte DXFALC/DXFBdr block of arbitrary, distinguishable filler -- neither field is modelled by this schema, so parseDxfStyle only ever needs to skip past them correctly, never to read their content. */ function dxf( options: { fontColorIcv?: number; fill?: { fls: number; foreIcv: number; backIcv: number }; numUser?: boolean; + numFixed?: boolean; + alignment?: boolean; + border?: boolean; } = {}, ): number[] { - const { fontColorIcv, fill, numUser } = options; - const hasNum = numUser === true; + const { fontColorIcv, fill, numUser, numFixed, alignment, border } = options; + const hasNum = numUser === true || numFixed === true; let flags1 = 0; if (hasNum) flags1 |= 1 << 25; if (fontColorIcv !== undefined) flags1 |= 1 << 26; + if (alignment === true) flags1 |= 1 << 27; + if (border === true) flags1 |= 1 << 28; if (fill !== undefined) flags1 |= 1 << 29; - const flags2 = hasNum ? 1 : 0; + const flags2 = numUser === true ? 1 : 0; const bytes: number[] = [...u32(flags1 >>> 0), ...u16(flags2)]; - if (hasNum) { + if (numUser === true) { // DXFNumUsr: cb(2 bytes) then a format-code string -- content is irrelevant, since parseDxfStyle degrades to no style the moment fIfmtUser is set, before reading any of these bytes. bytes.push(...u16(2), 0x30, 0x00); + } else if (numFixed === true) { + bytes.push(0xff, 0xff); // DXFNumIFmt: unused(1 byte) + ifmt(1 byte) -- content is irrelevant, skipped either way. } if (fontColorIcv !== undefined) { bytes.push(...dxfFontBlock(fontColorIcv)); } + if (alignment === true) { + bytes.push(...new Array(8).fill(0xaa)); // DXFALC -- 8 bytes of filler a correct skip must never feed into the block that follows. + } + if (border === true) { + bytes.push(...new Array(8).fill(0xbb)); // DXFBdr -- 8 bytes of filler a correct skip must never feed into the block that follows. + } if (fill !== undefined) { bytes.push(...u32(dxfPatWord(fill.fls, fill.foreIcv, fill.backIcv))); } @@ -190,18 +205,56 @@ describe("readCondFmtGroup", () => { } }); - it("does not promote a formula-type condition (ct 0x02)", () => { + it("does not promote a formula-type condition (ct 0x02), even carrying an otherwise-valid cp", () => { + // cp 0x05 ("greaterThan") is a real, recognised operator -- proving the ct===0x02 guard itself is what excludes this rule, not an incidentally-unrecognised cp. + const groups = groupsFrom( + condFmt(1, ONE_RANGE), + cf(0x02, 0x05, ptgInt(1), [], []), + ); + + const result = readCondFmtGroup(groups, 0, NO_SHEETS); + + expect(result.formats).toStrictEqual([]); + expect(result.recordsConsumed).toBe(2); + }); + + it("does not promote a comparison rule whose first formula operand is empty", () => { + const groups = groupsFrom( + condFmt(1, ONE_RANGE), + cf(0x01, 0x05, [], [], []), + ); + + const result = readCondFmtGroup(groups, 0, NO_SHEETS); + + expect(result.formats).toStrictEqual([]); + }); + + it("degrades a single CF to no format and no raw operand when its own header is truncated, without throwing", () => { const groups = groupsFrom( condFmt(1, ONE_RANGE), - cf(0x02, 0x00, ptgInt(1), [], []), + // ct(1) + cp(1) + only one byte of the 2-byte cce1 field -- truncated before cce1 can be read in full. + record(RECORD_CF, [0x01, 0x03, 0x00]), ); const result = readCondFmtGroup(groups, 0, NO_SHEETS); expect(result.formats).toStrictEqual([]); + expect(result.rawCfs).toStrictEqual([undefined]); expect(result.recordsConsumed).toBe(2); }); + it("omits a rule whose formula operand is truncated mid-token rather than throwing", () => { + const groups = groupsFrom( + condFmt(1, ONE_RANGE), + // A lone PtgInt opcode (0x1e) with neither of its two operand bytes present. + cf(0x01, 0x05, [0x1e], [], []), + ); + + const result = readCondFmtGroup(groups, 0, NO_SHEETS); + + expect(result.formats).toStrictEqual([]); + }); + it("reads a resulting font colour override", () => { const groups = groupsFrom( condFmt(1, ONE_RANGE), @@ -249,6 +302,108 @@ describe("readCondFmtGroup", () => { expect(result.formats[0]?.style).toBeUndefined(); }); + it("reads a style past a fixed-length DXFNumIFmt block, unlike the ambiguous DXFNumUsr form", () => { + const groups = groupsFrom( + condFmt(1, ONE_RANGE), + cf(0x01, 0x03, ptgInt(0), [], dxf({ numFixed: true, fontColorIcv: 7 })), + ); + + const result = readCondFmtGroup(groups, 0, NO_SHEETS); + + expect(result.formats[0]?.style).toStrictEqual({ + fontColorIcv: 7, + fill: undefined, + }); + }); + + it("skips an unmodelled DXFALC block before reading the fill that follows it", () => { + const groups = groupsFrom( + condFmt(1, ONE_RANGE), + cf( + 0x01, + 0x03, + ptgInt(0), + [], + dxf({ alignment: true, fill: { fls: 2, foreIcv: 3, backIcv: 4 } }), + ), + ); + + const result = readCondFmtGroup(groups, 0, NO_SHEETS); + + expect(result.formats[0]?.style).toStrictEqual({ + fontColorIcv: undefined, + fill: { fillPattern: 2, fillForegroundIcv: 3, fillBackgroundIcv: 4 }, + }); + }); + + it("skips an unmodelled DXFBdr block before reading the fill that follows it", () => { + const groups = groupsFrom( + condFmt(1, ONE_RANGE), + cf( + 0x01, + 0x03, + ptgInt(0), + [], + dxf({ border: true, fill: { fls: 5, foreIcv: 6, backIcv: 7 } }), + ), + ); + + const result = readCondFmtGroup(groups, 0, NO_SHEETS); + + expect(result.formats[0]?.style).toStrictEqual({ + fontColorIcv: undefined, + fill: { fillPattern: 5, fillForegroundIcv: 6, fillBackgroundIcv: 7 }, + }); + }); + + it("treats a negative icvFore as no colour override, not a literal signed value", () => { + const groups = groupsFrom( + condFmt(1, ONE_RANGE), + cf(0x01, 0x03, ptgInt(0), [], dxf({ fontColorIcv: -1 })), + ); + + const result = readCondFmtGroup(groups, 0, NO_SHEETS); + + // No other block is present either, so a dropped colour override leaves no style at all. + expect(result.formats[0]?.style).toBeUndefined(); + }); + + it("treats icvFore 0 as a real colour override, the >= boundary's own edge", () => { + const groups = groupsFrom( + condFmt(1, ONE_RANGE), + cf(0x01, 0x03, ptgInt(0), [], dxf({ fontColorIcv: 0 })), + ); + + const result = readCondFmtGroup(groups, 0, NO_SHEETS); + + expect(result.formats[0]?.style).toStrictEqual({ + fontColorIcv: 0, + fill: undefined, + }); + }); + + it("treats icvFore 32767 as the documented default-colour sentinel, not a real override", () => { + const groups = groupsFrom( + condFmt(1, ONE_RANGE), + cf(0x01, 0x03, ptgInt(0), [], dxf({ fontColorIcv: 32767 })), + ); + + const result = readCondFmtGroup(groups, 0, NO_SHEETS); + + expect(result.formats[0]?.style).toBeUndefined(); + }); + + it("resolves to no style from a non-empty dxf carrying none of the optional blocks", () => { + const groups = groupsFrom( + condFmt(1, ONE_RANGE), + cf(0x01, 0x03, ptgInt(0), [], dxf({})), + ); + + const result = readCondFmtGroup(groups, 0, NO_SHEETS); + + expect(result.formats[0]?.style).toBeUndefined(); + }); + it("reads 2-3 CF children sharing one CondFmt's own ranges", () => { const ranges: TestRange[] = [ { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }, @@ -324,3 +479,46 @@ describe("readCondFmtGroup", () => { expect(result.formats[0]?.operator).toBe("equal"); }); }); + +describe("errors that are not malformed-record degrades", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("propagates a genuine bug from parseDxfStyle's own cursor reads rather than absorbing it as a malformed dxf", () => { + const bug = new TypeError("a genuine bug, not a malformed record"); + vi.spyOn(BlockCursor.prototype, "u32").mockImplementation(() => { + throw bug; + }); + + expect(() => + parseDxfStyle(new Uint8Array(dxf({ fontColorIcv: 1 }))), + ).toThrow(bug); + }); + + it("propagates a genuine bug from parseCfBytes' own cursor reads rather than absorbing it as a malformed CF header", () => { + const bug = new TypeError("a genuine bug, not a malformed record"); + vi.spyOn(BlockCursor.prototype, "u8").mockImplementation(() => { + throw bug; + }); + const groups = groupsFrom( + condFmt(1, ONE_RANGE), + cf(0x01, 0x05, ptgInt(1), [], []), + ); + + expect(() => readCondFmtGroup(groups, 0, NO_SHEETS)).toThrow(bug); + }); + + it("propagates a genuine bug from parseFormulaText rather than absorbing it as a malformed record", () => { + const bug = new TypeError("a genuine bug, not a malformed record"); + vi.spyOn(ptgModule, "parseFormulaText").mockImplementation(() => { + throw bug; + }); + const groups = groupsFrom( + condFmt(1, ONE_RANGE), + cf(0x01, 0x05, ptgInt(1), [], []), + ); + + expect(() => readCondFmtGroup(groups, 0, NO_SHEETS)).toThrow(bug); + }); +}); diff --git a/packages/xls-codec/src/workbook/conditional-format.ts b/packages/xls-codec/src/workbook/conditional-format.ts index c38f17e383..525878f1e7 100644 --- a/packages/xls-codec/src/workbook/conditional-format.ts +++ b/packages/xls-codec/src/workbook/conditional-format.ts @@ -51,9 +51,7 @@ const DXF_DEFAULT_FOREGROUND_TEXT_COLOR = 32767; // DXFFntD.icvFore's own docume export function parseDxfStyle( dxfBytes: Uint8Array, ): RawConditionalFormatStyle | undefined { - if (dxfBytes.length === 0) { - return undefined; - } + // No explicit length guard: an empty (or too-short-for-its-own-header) dxfBytes runs straight into the try block below and throws BiffFormatError on its first cursor read, landing in the catch at the bottom exactly like any other malformed dxf -- a dedicated early return here would only ever produce the identical undefined this catch already produces. try { const cursor = new BlockCursor([dxfBytes]); const flags1 = cursor.u32(); @@ -166,18 +164,15 @@ function readCf( if (operator === undefined) { return undefined; } - if (rgce1.length === 0) { - // A comparison condition always compares against something -- a zero-length first operand is a malformed record, not a legitimate empty rule, so the whole rule degrades to absent rather than promoting a formula1 the schema requires but this record never actually carried. - return undefined; - } try { + // No explicit rgce1.length===0 guard: a comparison condition always compares against something, and a zero-length first operand is a malformed record rather than a legitimate empty rule -- but parseFormulaText already returns undefined for an empty rgce (its own token loop never runs, so its stack never reaches the one-operand shape a result requires), which the formula1===undefined check right below already degrades to absent. A dedicated early return here would only ever produce that identical undefined. const formula1 = parseFormulaText(rgce1, formulaSheets); if (formula1 === undefined) { - // ContentSheetConditionalFormatSchema's own 'cellIs' variant requires formula1 -- a Ptg stream this reader cannot render as text (an unsupported token) leaves nothing valid to promote, so the whole rule degrades to absent rather than a fabricated placeholder. + // ContentSheetConditionalFormatSchema's own 'cellIs' variant requires formula1 -- a Ptg stream this reader cannot render as text (an unsupported token, or no tokens at all) leaves nothing valid to promote, so the whole rule degrades to absent rather than a fabricated placeholder. return undefined; } - const formula2 = - rgce2.length > 0 ? parseFormulaText(rgce2, formulaSheets) : undefined; + // rgce2 gets the identical treatment: parseFormulaText(rgce2, ...) already returns undefined for an absent second operand, so a length>0 guard ahead of the call would only ever choose between calling it and getting undefined back, or not calling it and supplying undefined directly -- the same result either way. + const formula2 = parseFormulaText(rgce2, formulaSheets); return { operator, From 534ed014e37790bf4564292d898c935abfc1c0fa Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 09:01:56 +0100 Subject: [PATCH 063/107] refactor(xls-codec): reach genuine 100% mutation coverage on content.ts Removes two dead-code paths content.ts's own mutation-testing pass surfaced: applyCellComments' comments.size===0 early return (an empty comments map already makes the loop below a no-op on its own) and its byPosition.set(key, materialised) call (comments is a Map, so a key can never recur across the loop's own remaining iterations, meaning the entry it writes is never read back). Also removes displayTextOf's unreachable default case -- ContentCellValueSchema's ten kinds are already exhaustively handled by the switch above it, so the default was a second, redundant copy of the "empty" case's own return. Adds direct unit and round-trip coverage for the print-settings, conditional-format, data-validation, row/column, and merge-cell boundary conditions no existing test isolated: a fit-to-page sheet with only the width count at the spec's own auto value, a scalePercent of exactly zero, a BoundSheet8 lbPlyPos landing on a non-worksheet substream, a workbook stream with no records at all, a dxf whose fill pattern is FLSNULL (a real, present block that still resolves to no style), a valType-0 Dv record's genuinely absent formula1, a hidden column with no usable width, an out-of-range cell format index, a border-only and a vertical-alignment-only blank cell, each alignment axis confirmed absent on the other axis's own round trip, a false boolean cell's own displayText, and a degenerate 1x1 MergeCells range alongside same-row/same-column merge-anchor neighbours. --- packages/xls-codec/src/content.test.ts | 294 +++++++++++++++++++++++++ packages/xls-codec/src/content.ts | 14 +- packages/xls-codec/src/write.test.ts | 45 +++- 3 files changed, 342 insertions(+), 11 deletions(-) diff --git a/packages/xls-codec/src/content.test.ts b/packages/xls-codec/src/content.test.ts index c5e375f057..083c52c979 100644 --- a/packages/xls-codec/src/content.test.ts +++ b/packages/xls-codec/src/content.test.ts @@ -16,6 +16,7 @@ import { ContentDocumentSchema, DocumentTreeSchema } from "document-schema.js"; import { describe, expect, it } from "vitest"; import { + BOF_TYPE_CHART, BOF_TYPE_WORKBOOK, BOF_TYPE_WORKSHEET, RECORD_BLANK, @@ -521,6 +522,71 @@ describe("readXlsContent", () => { }); }); + it("never materialises a phantom anchor for a degenerate 1x1 MergeCells range", () => { + // A range whose start and end coincide on both axes is not a real merge at all (rowSpan and colSpan both resolve to exactly 1, ContentSheetCell's own "only when greater than one" contract), so applyMerges must skip it entirely -- including never even looking up or materialising an anchor cell at that position, which an undecorated, valueless position would otherwise gain purely as a side effect of the lookup. + const bytes = xlsFile( + workbookStream({ + globals: xfTable(0), + sheets: [ + { + name: "Sheet1", + records: [ + record(RECORD_NUMBER, [...cell(0, 0), ...f64(1)]), + record(RECORD_MERGECELLS, [ + ...u16(1), + ...u16(5), // rowFirst + ...u16(5), // rowLast -- same as rowFirst + ...u16(5), // colFirst + ...u16(5), // colLast -- same as colFirst + ]), + ], + }, + ], + }), + ); + + const cells = readXlsContent(bytes).sheets[0]?.cells ?? []; + + expect(cells).toHaveLength(1); + expect(cells.find((c) => c.row === 5 && c.column === 5)).toBeUndefined(); + }); + + it("anchors a merge to the cell at its own start row AND column, not just a same-row or same-column neighbour", () => { + // Two other real cells sit at the same row and the same column as the merge's own start position, but neither one IS that position -- only the cell at exactly (2,2) may be treated as this merge's anchor. + const bytes = xlsFile( + workbookStream({ + globals: xfTable(0), + sheets: [ + { + name: "Sheet1", + records: [ + record(RECORD_NUMBER, [...cell(2, 0), ...f64(10)]), // same row, different column + record(RECORD_NUMBER, [...cell(0, 2), ...f64(20)]), // same column, different row + record(RECORD_MERGECELLS, [ + ...u16(1), + ...u16(2), // rowFirst + ...u16(3), // rowLast + ...u16(2), // colFirst + ...u16(3), // colLast + ]), + ], + }, + ], + }), + ); + + const cells = readXlsContent(bytes).sheets[0]?.cells ?? []; + const rowNeighbour = cells.find((c) => c.row === 2 && c.column === 0); + const columnNeighbour = cells.find((c) => c.row === 0 && c.column === 2); + const anchor = cells.find((c) => c.row === 2 && c.column === 2); + + expect(rowNeighbour?.rowSpan).toBeUndefined(); + expect(rowNeighbour?.colSpan).toBeUndefined(); + expect(columnNeighbour?.rowSpan).toBeUndefined(); + expect(columnNeighbour?.colSpan).toBeUndefined(); + expect(anchor).toMatchObject({ rowSpan: 2, colSpan: 2 }); + }); + it("reads a Dv record into ContentSheet.dataValidations (ExaDev/documents.js#1098) -- workbook/data-validation.test.ts covers the [MS-XLS] field mapping in full; this is the end-to-end proof from real bytes to ContentSheet", () => { const bytes = xlsFile( workbookStream({ @@ -563,6 +629,48 @@ describe("readXlsContent", () => { ]); }); + it("leaves formula1 entirely absent for a valType-0 Dv record, ECMA-376's own 'no criteria stated' shape", () => { + // valType 0 is unmapped by VALUE_TYPE_BY_VAL_TYPE, so this degrades to type 'custom' with a genuinely zero-length formula1 (cce 0) -- own-property check, not a value check, since a bug materialising the key with an explicit undefined value would pass a plain .toBeUndefined() assertion just as easily as a genuinely absent key would. + const bytes = xlsFile( + workbookStream({ + globals: xfTable(0), + sheets: [ + { + name: "Sheet1", + records: [ + record(RECORD_DV, [ + ...u32(0), // flags: valType 0, every other bit clear + ...xlUnicodeString(""), + ...xlUnicodeString(""), + ...xlUnicodeString(""), + ...xlUnicodeString(""), + ...u16(0), // formula1 cce: 0 + ...u16(0), // formula1's own unused field + ...u16(0), // formula2 cce: 0 + ...u16(0), // formula2's own unused field + ...u16(1), + ...u16(0), + ...u16(0), + ...u16(0), + ...u16(0), + ]), + ], + }, + ], + }), + ); + + const validations = readXlsContent(bytes).sheets[0]?.dataValidations; + + expect(validations).toStrictEqual([ + { + type: "custom", + ranges: [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }], + }, + ]); + expect(Object.hasOwn(validations?.[0] ?? {}, "formula1")).toBe(false); + }); + it("reads a CondFmt/CF group into ContentSheet.conditionalFormats, resolving its own dxf font colour through the icv fixed table (ExaDev/documents.js#1102) -- workbook/conditional-format.test.ts covers the [MS-XLS] field mapping in full; this is the end-to-end proof from real bytes to ContentSheet", () => { // DXFN ([MS-XLS] 2.4.97): the 6-byte flags header (ibitAtrFnt at bit 26) then a 122-byte DXFFntD whose icvFore sits at byte offset 80 -- every other byte is zero, since only the font colour is under test here. const fontBlock = new Array(122).fill(0); @@ -621,6 +729,53 @@ describe("readXlsContent", () => { ]); }); + it("resolves to no style at all when a dxf's own fill pattern is FLSNULL, rather than a style object with nothing in it", () => { + // DXFPat's own fls of 0 (FLSNULL) is a real, present fill block -- ibitAtrPat is set, so raw.fill is a genuine object, not undefined -- but resolveFillBackground has no pattern type for it and returns undefined, exactly like the "no font colour block at all" half of this style. Both halves resolving to undefined must still collapse the WHOLE style to undefined, not an empty {} object the schema has no field for. + const dxf = [ + ...u32(1 << 29), // flags1: ibitAtrPat only + ...u16(0), // flags2 + ...u32(0), // DXFPat: fls(FLSNULL)=0, both icvs irrelevant -- the pattern lookup fails before either is read + ]; + const bytes = xlsFile( + workbookStream({ + globals: xfTable(0), + sheets: [ + { + name: "Sheet1", + records: [ + record(RECORD_CONDFMT, [ + ...u16(1), + ...u16(0), + ...u16(0), + ...u16(0), + ...u16(0), + ...u16(0), + ...u16(1), + ...u16(0), + ...u16(0), + ...u16(0), + ...u16(0), + ]), + record(RECORD_CF, [ + 0x01, + 0x05, + ...u16(3), + ...u16(0), + ...dxf, + 0x1e, + ...u16(10), + ]), + ], + }, + ], + }), + ); + + expect( + readXlsContent(bytes).sheets[0]?.conditionalFormats?.[0]?.style, + ).toBeUndefined(); + }); + it("reads a CondFmt12/CF12 colour-scale rule into ContentSheet.conditionalFormats, resolving its own indexed colours through the icv fixed table (ExaDev/documents.js#1104) -- workbook/conditional-format-12.test.ts covers the [MS-XLS] field mapping in full; this is the end-to-end proof from real bytes to ContentSheet", () => { const bytes = xlsFile( workbookStream({ @@ -1226,6 +1381,114 @@ describe("readXlsContent", () => { ).toThrow(BiffFormatError); }); + it("treats a BoundSheet8 lbPlyPos landing on a non-worksheet substream as no substream at all", () => { + // A worksheet-typed BoundSheet8 entry whose own lbPlyPos happens to name the byte offset of a CHART substream, not a genuine worksheet one -- a malformed/corrupt file this reader must not misread rather than one any real producer would write. Finding a substream at that offset is not enough on its own; its own BOF-declared documentType must agree with BOF_TYPE_WORKSHEET too, or the sheet degrades to the empty default (readSheet's own module comment) rather than parsing a chart substream's records as if they were a worksheet's. + const boundSheetPlaceholder = record(RECORD_BOUNDSHEET8, [ + ...u32(0), + 0x00, + 0x00, // dt: worksheet + ...shortXlUnicodeString("Sheet1"), + ]); + const globals = concat( + record(RECORD_BOF, bofData(BOF_TYPE_WORKBOOK)), + ...xfTable(0), + boundSheetPlaceholder, + record(RECORD_EOF, []), + ); + const chartSubstream = concat( + record(RECORD_BOF, bofData(BOF_TYPE_CHART)), + record(RECORD_NUMBER, [...cell(0, 0), ...f64(42)]), + record(RECORD_EOF, []), + ); + const boundSheet = record(RECORD_BOUNDSHEET8, [ + ...u32(globals.length), // lbPlyPos -- lands exactly on the chart substream's own BOF, not a worksheet's. + 0x00, + 0x00, + ...shortXlUnicodeString("Sheet1"), + ]); + const bytes = xlsFile( + concat( + record(RECORD_BOF, bofData(BOF_TYPE_WORKBOOK)), + ...xfTable(0), + boundSheet, + record(RECORD_EOF, []), + chartSubstream, + ), + ); + + const content = readXlsContent(bytes); + + expect(content.sheets[0]?.cells).toStrictEqual([]); + }); + + it("refuses a Workbook stream that carries no records at all, so no globals substream exists", () => { + expect(() => readXlsContent(xlsFile(new Uint8Array(0)))).toThrow( + "workbook stream holds no substreams, so it carries no globals substream", + ); + }); + + it("reads a hidden column with no width at all as hidden alone, not a spurious widthPt", () => { + // ColInfo's own coldx of 0 converts to a non-positive widthPt -- a column this reader never materialises a width for, only its hidden state -- unlike a real writeXlsContent round trip, which always states SOME width for every column it emits a ColInfo record for at all. + const globals = concat( + record(RECORD_BOF, bofData(BOF_TYPE_WORKBOOK)), + ...xfTable(0), + record(RECORD_BOUNDSHEET8, [ + ...u32(0), + 0x00, + 0x00, + ...shortXlUnicodeString("Sheet1"), + ]), + record(RECORD_EOF, []), + ); + const finalBytes = xlsFile( + concat( + record(RECORD_BOF, bofData(BOF_TYPE_WORKBOOK)), + ...xfTable(0), + record(RECORD_BOUNDSHEET8, [ + ...u32(globals.length), + 0x00, + 0x00, + ...shortXlUnicodeString("Sheet1"), + ]), + record(RECORD_EOF, []), + record(RECORD_BOF, bofData(BOF_TYPE_WORKSHEET)), + record(RECORD_COLINFO, [ + ...u16(3), // first + ...u16(3), // last + ...u16(0), // coldx: 0 -- no usable width + ...u16(15), // ixfe, unread + ...u16(0x0001), // grbit: hidden + ]), + record(RECORD_EOF, []), + ), + ); + + const content = readXlsContent(finalBytes); + const column3 = content.sheets[0]?.columns.find((col) => col.index === 3); + + expect(column3).toStrictEqual({ index: 3, hidden: true }); + }); + + it("leaves numberFormatCode entirely absent for a cell whose own ixfe resolves to no cell format at all", () => { + // An ixfe past the end of the workbook's own cell-format table -- a malformed record this reader must not crash on, and must not report a fabricated format for either. Own-property check, not a value check: a bug materialising the key with an explicit undefined value would pass a plain .toBeUndefined() assertion just as easily as a genuinely absent key would. + const bytes = xlsFile( + workbookStream({ + globals: xfTable(0), + sheets: [ + { + name: "Sheet1", + records: [record(RECORD_NUMBER, [...cell(0, 0, 9999), ...f64(1)])], + }, + ], + }), + ); + + const readBack = readXlsContent(bytes).sheets[0]?.cells[0]; + + expect(readBack?.value).toStrictEqual({ kind: "number", value: 1 }); + expect(Object.hasOwn(readBack ?? {}, "numberFormatCode")).toBe(false); + }); + describe("cell decoration", () => { /** A single populated cell (icv 10, the default palette's own duplicate of Red -- [MS-XLS] "Icv"'s own default-red/green/blue table) so a decoration test only needs to build the globals substream's own XF table, not a whole worksheet's cell records. */ function decoratedCellDocument( @@ -2239,6 +2502,37 @@ describe("readXlsContent print settings", () => { expect(settings?.scalePercent).toBeUndefined(); }); + it("reports no fit-to-page at all when the WIDTH count alone is the spec's own auto value", () => { + // The mirror image of the fitHeight-is-auto case above: fitWidth 0 with a real, positive fitHeight -- both axes must be positive independently, neither one alone is enough. + const settings = printSettingsOf([ + record(RECORD_WSBOOL, u16(0x0100)), + setupRecord({ + paperCode: 1, + scalePercent: 100, + fitWidth: 0, + fitHeight: 3, + grbit: 0x0002, + }), + ]); + + expect(settings?.fitToPages).toBeUndefined(); + }); + + it("reports no scale when a non-fit-to-page sheet's own scalePercent is the spec's own auto value", () => { + // [MS-XLS] 2.4.257's own iScale: a real producer never writes 0, but a reader that treated 0 as a genuine 0% scale would report an unusable setting rather than degrading to no stated scale at all. + const settings = printSettingsOf([ + setupRecord({ + paperCode: 1, + scalePercent: 0, + fitWidth: 1, + fitHeight: 1, + grbit: 0x0002, + }), + ]); + + expect(settings?.scalePercent).toBeUndefined(); + }); + it("reads both page-break records into manualBreaks", () => { expect( printSettingsOf([ diff --git a/packages/xls-codec/src/content.ts b/packages/xls-codec/src/content.ts index 5b24629158..74f37ef05f 100644 --- a/packages/xls-codec/src/content.ts +++ b/packages/xls-codec/src/content.ts @@ -660,9 +660,7 @@ function applyCellComments( comments: ReadonlyMap, cells: ContentSheetCell[], ): void { - if (comments.size === 0) { - return; - } + // No comments.size===0 early return: an empty comments map already makes the loop below a no-op on its own (nothing to iterate), so a dedicated guard here would only ever produce that identical no-op -- never a genuinely different result, just the same one reached by a shorter path. const byPosition = new Map(); for (const cell of cells) { byPosition.set(`${cell.row}:${cell.column}`, cell); @@ -673,15 +671,14 @@ function applyCellComments( existing.comment = comment; continue; } - const materialised: ContentSheetCell = { + // No byPosition.set(key, materialised) here: `comments` is a Map, so `key` can never recur across this same loop's own remaining iterations -- there is no later lookup this entry could ever be read back by. + cells.push({ row, column, value: { kind: "empty" }, displayText: "", comment, - }; - cells.push(materialised); - byPosition.set(key, materialised); + }); } } @@ -916,8 +913,7 @@ function displayTextOf(value: ContentCellValue): string { return value.value; case "empty": return ""; - default: - return ""; + // No default: ContentCellValueSchema's discriminated union has exactly these ten kinds, so every one is already handled above -- a default clause here would only ever be reached by a value outside that union, which the parameter's own type already rules out, and a hand-added "return the identical empty string" branch for that unreachable case is not a smaller version of a real fallback, it is a second, redundant copy of the "empty" case's own return. } } diff --git a/packages/xls-codec/src/write.test.ts b/packages/xls-codec/src/write.test.ts index f7a8b5c54c..5bde85d7a4 100644 --- a/packages/xls-codec/src/write.test.ts +++ b/packages/xls-codec/src/write.test.ts @@ -214,6 +214,7 @@ describe("writeXlsContent", () => { kind: "boolean", value: false, }); + expect(findCell(content, 0, 0, 1)?.displayText).toBe("FALSE"); }); it("round-trips every [MS-XLS]-defined error value", () => { @@ -809,6 +810,10 @@ describe("writeXlsContent", () => { expect(findCell(content, 0, 0, 1)?.alignment).toBe("center"); expect(findCell(content, 0, 0, 2)?.alignment).toBe("right"); expect(findCell(content, 0, 0, 3)?.alignment).toBe("justify"); + // Own-property check, not just a value check: a horizontal-only cell must leave the verticalAlignment KEY absent, not merely undefined when read through optional chaining -- a bug materialising the key with an explicit undefined value would pass a plain .toBeUndefined() assertion just as easily as a genuinely absent key would. + expect( + Object.hasOwn(findCell(content, 0, 0, 0) ?? {}, "verticalAlignment"), + ).toBe(false); }); it("round-trips each vertical alignment this package's schema can express", () => { @@ -837,6 +842,10 @@ describe("writeXlsContent", () => { const content = readXlsContent(bytes); expect(findCell(content, 0, 0, 0)?.verticalAlignment).toBe("top"); expect(findCell(content, 0, 0, 1)?.verticalAlignment).toBe("middle"); + // Own-property check, not just a value check: a vertical-only cell must leave the alignment KEY absent, not merely undefined when read through optional chaining -- see the mirrored check in the horizontal-alignment test above for why a plain .toBeUndefined() would not catch this. + expect(Object.hasOwn(findCell(content, 0, 0, 0) ?? {}, "alignment")).toBe( + false, + ); }); it("leaves alignment/verticalAlignment absent for a cell that states neither, matching the value-kind default and the schema's own documented bottom default", () => { @@ -891,6 +900,35 @@ describe("writeXlsContent", () => { expect(readBack?.alignment).toBe("center"); }); + it("round-trips a border-only empty cell through a real Blank record, with neither fill nor alignment involved", () => { + // Isolates the borders leg of mapCell's own blank-drop conjunction from every sibling leg (background/alignment/verticalAlignment/font) -- a cell whose ONLY reason to survive is its own border must still survive when nothing else about it is decorated. + const border = { color: rgbHexToColor("0000ff"), widthPt: 0.75 } as const; + const bytes = writeXlsContent( + document([ + sheet("Sheet1", [ + cell(1, 2, { kind: "empty" }, { borders: { left: border } }), + ]), + ]), + ); + const readBack = findCell(readXlsContent(bytes), 0, 1, 2); + expect(readBack?.value).toStrictEqual({ kind: "empty" }); + expect(readBack?.borders).toStrictEqual({ left: border }); + }); + + it("round-trips a vertical-alignment-only empty cell through a real Blank record, with neither fill nor a border involved", () => { + // Isolates the verticalAlignment leg of mapCell's own blank-drop conjunction from every sibling leg -- a cell whose ONLY reason to survive is its own vertical alignment must still survive when nothing else about it is decorated. + const bytes = writeXlsContent( + document([ + sheet("Sheet1", [ + cell(1, 2, { kind: "empty" }, { verticalAlignment: "top" }), + ]), + ]), + ); + const readBack = findCell(readXlsContent(bytes), 0, 1, 2); + expect(readBack?.value).toStrictEqual({ kind: "empty" }); + expect(readBack?.verticalAlignment).toBe("top"); + }); + it("still writes nothing for an empty cell carrying no alignment either", () => { const bytes = writeXlsContent( document([sheet("Sheet1", [cell(1, 1, { kind: "empty" })])]), @@ -967,8 +1005,9 @@ describe("writeXlsContent", () => { const content = readXlsContent(bytes); const row0 = content.sheets[0]?.rows.find((row) => row.index === 0); const row5 = content.sheets[0]?.rows.find((row) => row.index === 5); - expect(row0?.heightPt).toBe(30); - expect(row5?.hidden).toBe(true); + // toStrictEqual, not just a per-field .toBe: a row carrying only heightPt must not also carry a spuriously-materialised hidden key (and vice versa for row5), which a per-field check reading only the key it expects would miss entirely. + expect(row0).toStrictEqual({ index: 0, heightPt: 30 }); + expect(row5).toStrictEqual({ index: 5, hidden: true }); }); it("round-trips declared column widths and hidden columns", () => { @@ -985,7 +1024,9 @@ describe("writeXlsContent", () => { const content = readXlsContent(bytes); const column0 = content.sheets[0]?.columns.find((col) => col.index === 0); const column3 = content.sheets[0]?.columns.find((col) => col.index === 3); + // toStrictEqual on the width, not just .toBeCloseTo: a column carrying only widthPt must not also carry a spuriously-materialised hidden key, which a per-field check reading only widthPt would miss entirely. column3 always round-trips with SOME widthPt too -- a real ColInfo record always states a column's own width, whether or not the document that produced it declared one -- so hidden alone is confirmed directly instead. expect(column0?.widthPt).toBeCloseTo(100, 0); + expect(column0).toStrictEqual({ index: 0, widthPt: column0?.widthPt }); expect(column3?.hidden).toBe(true); }); From e883fe1351c2f1dc718b8f873f25c4896b0dd605 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 09:03:38 +0100 Subject: [PATCH 064/107] fix(xls-codec): narrow the FLSNULL dxf test to conditionalFormats' own cellIs shape toStrictEqual against the whole discriminated-union entry, not a chained ?.style property access ContentSheetConditionalFormatSchema's own union has no single shared 'style' field for. --- packages/xls-codec/src/content.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/xls-codec/src/content.test.ts b/packages/xls-codec/src/content.test.ts index 083c52c979..b5a88289fd 100644 --- a/packages/xls-codec/src/content.test.ts +++ b/packages/xls-codec/src/content.test.ts @@ -771,9 +771,14 @@ describe("readXlsContent", () => { }), ); - expect( - readXlsContent(bytes).sheets[0]?.conditionalFormats?.[0]?.style, - ).toBeUndefined(); + expect(readXlsContent(bytes).sheets[0]?.conditionalFormats).toStrictEqual([ + { + type: "cellIs", + operator: "greaterThan", + formula1: "10", + ranges: [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }], + }, + ]); }); it("reads a CondFmt12/CF12 colour-scale rule into ContentSheet.conditionalFormats, resolving its own indexed colours through the icv fixed table (ExaDev/documents.js#1104) -- workbook/conditional-format-12.test.ts covers the [MS-XLS] field mapping in full; this is the end-to-end proof from real bytes to ContentSheet", () => { From 9e4b62bb138948aba12170e08ad27d700ec7bb1d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 09:15:48 +0100 Subject: [PATCH 065/107] refactor(xls-codec): close content.ts's remaining survivors, cover sheet-writer.ts boundaries alignmentOf assigns horizontal/vertical unconditionally instead of behind its own redundant "if !== undefined" guard: mapCell, its only caller, already re-checks each field against undefined before ever copying it onto the ContentSheetCell it builds, so the guard could never produce an observable difference -- only choose between two intermediate objects mapCell already treats identically. Adds direct coverage for readSheet's dataValidations/conditionalFormats omission when a sheet declares neither, mapColumns dropping a column that states no usable width and no hidden flag, and sheet-writer.ts's own boundary and array-emptiness checks: Row's colMic/colMac across several cells and for a declared-but-empty row, MergeCells omitted for ordinary and explicitly-1x1 cells, CalcCount's real iteration limit, the 256-column ColInfo ceiling, a stated column's own hidden flag staying clear, Setup's inactive-scale sentinel winning over a stray scalePercent once fitToPages is stated, empty page-break arrays writing no record, comment/merge record absence, cell and row write ordering, and writeCellValueRecord's own internal-error guard actually firing on a genuine disagreement with written-cells.ts's predicate. --- packages/xls-codec/src/content.test.ts | 43 +++++ packages/xls-codec/src/content.ts | 16 +- packages/xls-codec/src/write.test.ts | 227 ++++++++++++++++++++++++- 3 files changed, 274 insertions(+), 12 deletions(-) diff --git a/packages/xls-codec/src/content.test.ts b/packages/xls-codec/src/content.test.ts index b5a88289fd..922ecf2046 100644 --- a/packages/xls-codec/src/content.test.ts +++ b/packages/xls-codec/src/content.test.ts @@ -1474,6 +1474,49 @@ describe("readXlsContent", () => { expect(column3).toStrictEqual({ index: 3, hidden: true }); }); + it("omits a column entirely when it states neither a usable width nor a hidden flag", () => { + // The mirror image of the hidden-alone case above: coldx 0 (no usable width) AND grbit clear (not hidden) means the ColInfo record states nothing ContentSheetColumn has a field for, so the column must not appear in the output at all -- not as a bare {index} entry either. + const globals = concat( + record(RECORD_BOF, bofData(BOF_TYPE_WORKBOOK)), + ...xfTable(0), + record(RECORD_BOUNDSHEET8, [ + ...u32(0), + 0x00, + 0x00, + ...shortXlUnicodeString("Sheet1"), + ]), + record(RECORD_EOF, []), + ); + const finalBytes = xlsFile( + concat( + record(RECORD_BOF, bofData(BOF_TYPE_WORKBOOK)), + ...xfTable(0), + record(RECORD_BOUNDSHEET8, [ + ...u32(globals.length), + 0x00, + 0x00, + ...shortXlUnicodeString("Sheet1"), + ]), + record(RECORD_EOF, []), + record(RECORD_BOF, bofData(BOF_TYPE_WORKSHEET)), + record(RECORD_COLINFO, [ + ...u16(3), + ...u16(3), + ...u16(0), // coldx: 0 -- no usable width + ...u16(15), + ...u16(0x0000), // grbit: not hidden + ]), + record(RECORD_EOF, []), + ), + ); + + const content = readXlsContent(finalBytes); + + expect( + content.sheets[0]?.columns.find((col) => col.index === 3), + ).toBeUndefined(); + }); + it("leaves numberFormatCode entirely absent for a cell whose own ixfe resolves to no cell format at all", () => { // An ixfe past the end of the workbook's own cell-format table -- a malformed record this reader must not crash on, and must not report a fabricated format for either. Own-property check, not a value check: a bug materialising the key with an explicit undefined value would pass a plain .toBeUndefined() assertion just as easily as a genuinely absent key would. const bytes = xlsFile( diff --git a/packages/xls-codec/src/content.ts b/packages/xls-codec/src/content.ts index 74f37ef05f..ff98b4d76b 100644 --- a/packages/xls-codec/src/content.ts +++ b/packages/xls-codec/src/content.ts @@ -770,17 +770,11 @@ function alignmentOf( if (format === undefined) { return {}; } - const result: { - alignment?: Alignment; - verticalAlignment?: "top" | "middle" | "bottom"; - } = {}; - if (format.alignment.horizontal !== undefined) { - result.alignment = format.alignment.horizontal; - } - if (format.alignment.vertical !== undefined) { - result.verticalAlignment = format.alignment.vertical; - } - return result; + // Assigned unconditionally rather than each behind its own "if !== undefined" guard: mapCell, this function's only caller, already re-checks each field against undefined before ever copying it onto the ContentSheetCell it builds, so a guard here would only ever decide between two objects mapCell treats identically -- one whose own field is absent, and one whose own field holds undefined, both of which mapCell's own check reads the same way. + return { + alignment: format.alignment.horizontal, + verticalAlignment: format.alignment.vertical, + }; } /** diff --git a/packages/xls-codec/src/write.test.ts b/packages/xls-codec/src/write.test.ts index 5bde85d7a4..41c3e71cf1 100644 --- a/packages/xls-codec/src/write.test.ts +++ b/packages/xls-codec/src/write.test.ts @@ -15,20 +15,26 @@ import { rgbHexToColor, } from "document-schema.js"; import { isCompoundFile, readCompoundFile } from "archive-codec"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { + RECORD_CALCCOUNT, RECORD_CF12, RECORD_CONTINUE, RECORD_DIMENSIONS, RECORD_EXTERNSHEET, + RECORD_HORIZONTALPAGEBREAKS, RECORD_LBL, + RECORD_MERGECELLS, RECORD_MSODRAWING, RECORD_MSODRAWINGGROUP, + RECORD_ROW, RECORD_SETUP, RECORD_SUPBOOK, + RECORD_VERTICALPAGEBREAKS, } from "./biff/record-types"; import { readRecords } from "./biff/records"; +import * as writtenCellsModule from "./written-cells"; import { PALETTE_ENTRY_COUNT } from "./biff/xf-colors"; import { BiffWriteError } from "./biff/write-errors"; import type { XlsContentDocument } from "./content"; @@ -1126,6 +1132,20 @@ describe("writeXlsContent", () => { expect(content.sheets[0]?.columns).toStrictEqual([]); }); + it("leaves dataValidations and conditionalFormats entirely absent for a sheet declaring neither", () => { + // Own-property check, not a value check: a bug materialising either key with an explicit empty-array value would pass a plain .toStrictEqual([]) assertion just as easily as a genuinely absent key would. + const bytes = writeXlsContent( + document([sheet("Sheet1", [cell(0, 0, { kind: "number", value: 1 })])]), + ); + const content = readXlsContent(bytes); + expect(Object.hasOwn(content.sheets[0] ?? {}, "dataValidations")).toBe( + false, + ); + expect(Object.hasOwn(content.sheets[0] ?? {}, "conditionalFormats")).toBe( + false, + ); + }); + it("round-trips a merge spanning only rows, and one spanning only columns", () => { const bytes = writeXlsContent( document([ @@ -1649,6 +1669,8 @@ describe("print settings", () => { const SETUP_FLAG_PORTRAIT = 0x0002; expect(grbitFor(400, 500) & SETUP_FLAG_PORTRAIT).not.toBe(0); // taller than wide expect(grbitFor(500, 400) & SETUP_FLAG_PORTRAIT).toBe(0); // wider than tall + // Exactly square: <= (not <) is what decides portrait for the tie, so this is the one case that actually distinguishes the two operators. + expect(grbitFor(450, 450) & SETUP_FLAG_PORTRAIT).not.toBe(0); }); it("clamps a scale and a fit-to-page count past what their own Setup fields can hold", () => { @@ -2933,6 +2955,209 @@ describe("buildWorksheetSubstream: Dimensions bytes content.ts never reads back" }); }); +describe("buildWorksheetSubstream: sheet-writer.ts's own boundary and array-emptiness checks", () => { + const NO_DRAWING = { msoDrawingRecords: [], objRecords: [] }; + const NO_STYLE_CTX = { + icvOf: () => 0, + xfIndexForCell: () => 0, + sstIndexFor: () => 0, + }; + + function recordsOf( + cells: readonly ContentSheetCell[], + overrides: Partial> = {}, + ) { + const bytes = buildWorksheetSubstream( + sheet("S", cells, overrides), + NO_STYLE_CTX, + NO_DRAWING, + ); + return readRecords(bytes); + } + + function u16At(data: Uint8Array, offset: number): number { + return new DataView( + data.buffer, + data.byteOffset, + data.byteLength, + ).getUint16(offset, true); + } + + /** RECORD_ROW's own first two u16 fields are rowIndex then colMic -- filters a records list down to the one Row record naming the given index, since a sheet with several rows produces several. */ + function rowRecordAt( + records: ReturnType, + rowIndex: number, + ) { + const row = records.find( + (record) => + record.type === RECORD_ROW && u16At(record.data, 0) === rowIndex, + ); + if (row === undefined) { + throw new Error(`no Row record for index ${rowIndex} was written`); + } + return row.data; + } + + it("writes Row's own colMic/colMac as the row's true min column and one past its true max, across several cells sharing a row", () => { + const records = recordsOf([ + cell(2, 5, { kind: "number", value: 1 }), + cell(2, 1, { kind: "number", value: 2 }), + cell(2, 9, { kind: "number", value: 3 }), + ]); + const data = rowRecordAt(records, 2); + expect(u16At(data, 2)).toBe(1); // colMic: the smallest column (1) + expect(u16At(data, 4)).toBe(10); // colMac: the largest column (9) + 1 + }); + + it("writes Row's own colMic/colMac as 0/0 for a declared row with no cells of its own", () => { + const records = recordsOf([cell(0, 0, { kind: "number", value: 1 })], { + rows: [{ index: 4, heightPt: 20 }], + }); + const data = rowRecordAt(records, 4); + expect(u16At(data, 2)).toBe(0); + expect(u16At(data, 4)).toBe(0); + }); + + it("writes no MergeCells record at all for a sheet whose cells carry no real span", () => { + // Every ordinary cell resolves rowSpan/colSpan to exactly 1 by default -- the degenerate case a real merge (either axis greater than one) must be told apart from, not just "rowSpan or colSpan stated at all". + const records = recordsOf([cell(0, 0, { kind: "number", value: 1 })]); + expect(records.some((record) => record.type === RECORD_MERGECELLS)).toBe( + false, + ); + }); + + it("writes no MergeCells entry for a cell whose rowSpan/colSpan are both explicitly 1", () => { + const records = recordsOf([ + cell(0, 0, { kind: "number", value: 1 }, { rowSpan: 1, colSpan: 1 }), + ]); + expect(records.some((record) => record.type === RECORD_MERGECELLS)).toBe( + false, + ); + }); + + it("writes CalcCount's own cIter as the real iteration-limit constant, not an empty calculation-state block", () => { + const records = recordsOf([cell(0, 0, { kind: "number", value: 1 })]); + const calcCount = records.find( + (record) => record.type === RECORD_CALCCOUNT, + ); + if (calcCount === undefined) { + throw new Error("no CalcCount record was written"); + } + expect(u16At(calcCount.data, 0)).toBe(100); + }); + + it("refuses a column past BIFF8's own 256-column grid", () => { + expect(() => + buildWorksheetSubstream( + sheet("S", [], { columns: [{ index: 256, widthPt: 50 }] }), + NO_STYLE_CTX, + NO_DRAWING, + ), + ).toThrow(/outside BIFF8's own 256-column grid/); + }); + + it("writes ColInfo's own flags as 0, not COLINFO_FLAG_HIDDEN, for a stated-but-not-hidden column", () => { + const records = recordsOf([], { + columns: [{ index: 0, widthPt: 100 }], + }); + const colInfo = records.find((record) => record.type === 0x7d); // RECORD_COLINFO + if (colInfo === undefined) { + throw new Error("no ColInfo record was written"); + } + expect(u16At(colInfo.data, 8)).toBe(0); // grbit + }); + + it("writes the Setup record's own iScale as the inactive-scale sentinel when fitToPages is stated, even if scalePercent is also present", () => { + // ContentSheetPrintSettings does not enforce the two as mutually exclusive at the type level -- fitToPages being stated is what must win, not merely scalePercent being absent. + const records = recordsOf([], { + printSettings: { + ...PRINT_SETTINGS, + scalePercent: 55, + fitToPages: { width: 2, height: 3 }, + }, + }); + const setup = records.find((record) => record.type === RECORD_SETUP); + if (setup === undefined) { + throw new Error("no Setup record was written"); + } + expect(u16At(setup.data, 2)).toBe(100); // iScale: SETUP_INACTIVE_SCALE_PERCENT, not the stated 55 + }); + + it("writes no HorizontalPageBreaks/VerticalPageBreaks record for a sheet with declared but empty break arrays", () => { + const records = recordsOf([], { + printSettings: { + ...PRINT_SETTINGS, + manualBreaks: { rows: [], columns: [] }, + }, + }); + expect( + records.some((record) => record.type === RECORD_HORIZONTALPAGEBREAKS), + ).toBe(false); + expect( + records.some((record) => record.type === RECORD_VERTICALPAGEBREAKS), + ).toBe(false); + }); + + it("writes no MergeCells or comment records at all for a sheet with neither", () => { + const records = recordsOf([cell(0, 0, { kind: "number", value: 1 })]); + expect(records.some((record) => record.type === RECORD_MERGECELLS)).toBe( + false, + ); + // RECORD_NOTE ([MS-XLS] 0x001C) is writeSheetComments' own leading record -- absent entirely for a sheet with no commented cells. + expect(records.some((record) => record.type === 0x001c)).toBe(false); + }); + + it("writes a Row record's own cells sorted by column regardless of the order they were given in", () => { + const records = recordsOf([ + cell(0, 9, { kind: "number", value: 1 }), + cell(0, 1, { kind: "number", value: 2 }), + cell(0, 5, { kind: "number", value: 3 }), + ]); + const numberRecords = records.filter((record) => record.type === 0x0203); // RECORD_NUMBER + const columns = numberRecords.map((record) => u16At(record.data, 2)); + expect(columns).toStrictEqual([1, 5, 9]); + }); + + it("writes Row records themselves sorted by row index regardless of the order rows were declared or populated in", () => { + const records = recordsOf( + [ + cell(9, 0, { kind: "number", value: 1 }), + cell(1, 0, { kind: "number", value: 2 }), + ], + { rows: [{ index: 5, heightPt: 20 }] }, + ); + const rowIndices = records + .filter((record) => record.type === RECORD_ROW) + .map((record) => u16At(record.data, 0)); + expect(rowIndices).toStrictEqual([1, 5, 9]); + }); + + it("throws sheet-writer's own internal-error message when a cell reaches writeCellValueRecord disagreeing with written-cells.ts's own filter about its formatting", () => { + // written-cells.ts's own writesCellRecord calls cellCarriesFormatting as a same-module, unmocked local binding -- vi.spyOn on the exported name never intercepts that internal call, only a cross-module import of it, which is exactly the call writeCellValueRecord makes. So the cell given here carries REAL formatting (a genuine background), satisfying writesCellRecord's own unmocked check honestly and letting the cell reach the cell table; only writeCellValueRecord's own cross-module call is mocked false, the disagreement this internal-error guard exists to catch -- proving the guard actually fires and says what it claims to, rather than being unreachable dead code. + const spy = vi + .spyOn(writtenCellsModule, "cellCarriesFormatting") + .mockReturnValueOnce(false); + try { + expect(() => + buildWorksheetSubstream( + sheet("S", [ + cell( + 0, + 0, + { kind: "empty" }, + { background: { kind: "solid", color: { r: 1, g: 0, b: 0 } } }, + ), + ]), + NO_STYLE_CTX, + NO_DRAWING, + ), + ).toThrow(/internal error/); + } finally { + spy.mockRestore(); + } + }); +}); + describe("writeSheetConditionalFormats: bytes the reader never inspects (#971/#1186)", () => { const RANGE = { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }; const NO_ICV = (): number => 0; From 104d5d73d8a3c5790b7b90b1226495ec27c7cd30 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 09:26:01 +0100 Subject: [PATCH 066/107] refactor(xls-codec): reach genuine 100% mutation coverage on biff/ptg.ts Removes sheet-writer.ts's own commentedCells.length>0 guard: unlike the sibling merges guard right above it (which genuinely must skip an empty case, since writeMergeCellsRecord still emits a real zero-count record for one), writeSheetComments already returns an empty array for an empty input, so the guard could only ever decide between calling a function that does nothing and not calling it. Adds direct coverage for every remaining ptg.ts survivor: wrapBelow's own left-operand precedence comparison exercised without a PtgParen token forcing the same output another way, PtgUminus/PtgPercent/ PtgParen/PtgAdd each aborting on a starved stack with a trailing token proving the abort is immediate rather than a coincidental fallthrough, both directions of the relative-row/column wraparound boundary (not just the one already covered), and the PtgAttr subtype family's own no-op/unsupported/CHOOSE branches. Also strengthens several message-only BiffWriteError assertions from a bare class check to the actual thrown text, and adds direct coverage for sheet-writer.ts's own remaining boundaries: the exact 256-column and page-break-index ceilings, ascending sort order for declared page breaks, a formula's own cached error/empty/string result paths (refusing an undefined error code and an empty-kind value, writing the trailing String record a string result needs), and the worksheet substream's own trailing EOF record. --- packages/xls-codec/src/biff/ptg.test.ts | 69 +++++++++++++- .../xls-codec/src/workbook/sheet-writer.ts | 6 +- packages/xls-codec/src/write.test.ts | 90 ++++++++++++++++++- 3 files changed, 158 insertions(+), 7 deletions(-) diff --git a/packages/xls-codec/src/biff/ptg.test.ts b/packages/xls-codec/src/biff/ptg.test.ts index 1d79eee252..283329790d 100644 --- a/packages/xls-codec/src/biff/ptg.test.ts +++ b/packages/xls-codec/src/biff/ptg.test.ts @@ -152,6 +152,18 @@ describe("parseFormulaText", () => { expect(parseFormulaText(rgce, NO_SHEETS)).toBe("(A1+B1)*C1"); }); + it("wraps a lower-precedence left child through precedence comparison alone, with no PtgParen token involved", () => { + // (A1+B1)*C1 again, but genuinely built from precedence -- no PtgParen this time, so only wrapBelow's own left-operand comparison decides whether the addition needs parentheses before the multiply combines it. + const rgce = bytes( + ...ptgRef(0, 0), + ...ptgRef(0, 1), + 0x03, // PtgAdd (A1+B1) + ...ptgRef(0, 2), + 0x05, // PtgMul, combining the addition as its own LEFT operand + ); + expect(parseFormulaText(rgce, NO_SHEETS)).toBe("(A1+B1)*C1"); + }); + it("wraps a same-precedence right child that division is not associative over", () => { // A1/(B1/C1) -- the postfix nesting itself (right child built before being combined) is what requires the parenthesis, independent of any PtgParen token. const rgce = bytes( @@ -326,7 +338,24 @@ describe("parseFormulaText", () => { }); it("aborts on a binary operator with too few operands rather than guessing", () => { - const rgce = bytes(...ptgRef(0, 0), 0x03); // PtgAdd with only one operand pushed + // A trailing PtgRef after the starved PtgAdd is what actually distinguishes this abort from merely falling through to the final stack-not-exactly-one-operand check: applyBinary already popped its own one available operand before discovering the second is missing, so a caller that failed to abort immediately would resume with an EMPTY stack and happily push the trailing B1 onto it, landing on the same "undefined" result via the unrelated fallthrough check instead of this guard -- appending B1 forces the two paths to diverge (undefined vs "B1"). + const rgce = bytes(...ptgRef(0, 0), 0x03, ...ptgRef(0, 1)); // PtgAdd with only one operand pushed, then a trailing B1 + expect(parseFormulaText(rgce, NO_SHEETS)).toBeUndefined(); + }); + + it("aborts on a unary-minus operator with no operand pushed", () => { + // See the binary-operator test above for why a trailing token is what actually distinguishes this abort from the unrelated fallthrough check. + const rgce = bytes(0x13, ...ptgRef(0, 0)); // PtgUminus with nothing on the stack, then a trailing A1 + expect(parseFormulaText(rgce, NO_SHEETS)).toBeUndefined(); + }); + + it("aborts on a percent operator with no operand pushed", () => { + const rgce = bytes(0x14, ...ptgRef(0, 0)); // PtgPercent with nothing on the stack, then a trailing A1 + expect(parseFormulaText(rgce, NO_SHEETS)).toBeUndefined(); + }); + + it("aborts on a PtgParen with no operand pushed", () => { + const rgce = bytes(0x15, ...ptgRef(0, 0)); // PtgParen with nothing on the stack, then a trailing A1 expect(parseFormulaText(rgce, NO_SHEETS)).toBeUndefined(); }); @@ -399,6 +428,44 @@ describe("parseFormulaText shared-formula relative tokens (PtgRefN/PtgAreaN)", ( ).toBe("A65536"); }); + it("wraps a relative reference's row around the sheet's own OTHER edge, past its own top", () => { + // One row below the sheet's own last row (index 0xffff) wraps back to row 1 -- the mirror of the "above row 1" wrap already covered above, and the only way to distinguish row>0xffff from row>=0xffff. + const rgce = bytes(...ptgRefN(1, 0)); + expect( + parseFormulaText(rgce, NO_SHEETS, { + relativeTo: { row: 0xffff, column: 0 }, + }), + ).toBe("A1"); + }); + + it("does not wrap a relative row landing exactly on the sheet's own last row", () => { + const rgce = bytes(...ptgRefN(0, 0)); + expect( + parseFormulaText(rgce, NO_SHEETS, { + relativeTo: { row: 0xffff, column: 0 }, + }), + ).toBe("A65536"); + }); + + it("wraps a relative reference's column around the sheet's own OTHER edge, past its own last column", () => { + // One column past the sheet's own last column (index 0xff) wraps back to column A -- the mirror of the "left of column A" wrap already covered above, and the only way to distinguish column>0xff from column>=0xff. + const rgce = bytes(...ptgRefN(0, 1)); + expect( + parseFormulaText(rgce, NO_SHEETS, { + relativeTo: { row: 0, column: 0xff }, + }), + ).toBe("A1"); + }); + + it("does not wrap a relative column landing exactly on the sheet's own last column", () => { + const rgce = bytes(...ptgRefN(0, 0)); + expect( + parseFormulaText(rgce, NO_SHEETS, { + relativeTo: { row: 0, column: 0xff }, + }), + ).toBe("IV1"); + }); + it("still honours an absolute ($) reference packed inside a relative token, unaffected by the cell being evaluated", () => { // column.colRelative=0 and rowRelative=0: the stored value is the absolute coordinate itself. const rgce = bytes(0x4c, ...u16(0), ...u16(0)); // $A$1 diff --git a/packages/xls-codec/src/workbook/sheet-writer.ts b/packages/xls-codec/src/workbook/sheet-writer.ts index d60971e15c..44dba309be 100644 --- a/packages/xls-codec/src/workbook/sheet-writer.ts +++ b/packages/xls-codec/src/workbook/sheet-writer.ts @@ -698,10 +698,8 @@ export function buildWorksheetSubstream( pieces.push(writeMergeCellsRecord(merges)); } - const commentedCells = sheet.cells.filter(hasComment); - if (commentedCells.length > 0) { - pieces.push(...writeSheetComments(commentedCells)); - } + // No commentedCells.length>0 guard: writeSheetComments already returns an empty array for an empty input (nothing to sort, nothing to map), so spreading its result pushes nothing regardless -- a guard here would only ever decide between calling a function that does nothing and not calling it. + pieces.push(...writeSheetComments(sheet.cells.filter(hasComment))); // A comment's own Note/Obj/Txo triple takes object ids 1..N (writeSheetComments above); drawing-writer.ts's own buildDrawingWritePlan continues object-id assignment from N+1, so every image/embedded-object shape's MsoDrawing/Obj records are placed after the comments' own, matching what that plan already assumes about the ids it minted. pieces.push(...drawing.msoDrawingRecords, ...drawing.objRecords); diff --git a/packages/xls-codec/src/write.test.ts b/packages/xls-codec/src/write.test.ts index 41c3e71cf1..108471daf2 100644 --- a/packages/xls-codec/src/write.test.ts +++ b/packages/xls-codec/src/write.test.ts @@ -22,6 +22,7 @@ import { RECORD_CF12, RECORD_CONTINUE, RECORD_DIMENSIONS, + RECORD_EOF, RECORD_EXTERNSHEET, RECORD_HORIZONTALPAGEBREAKS, RECORD_LBL, @@ -260,7 +261,7 @@ describe("writeXlsContent", () => { sheet("Sheet1", [cell(0, 0, { kind: "error", value: "#MADE_UP!" })]), ]), ), - ).toThrow(BiffWriteError); + ).toThrow(/is not one of the eight error values/); }); it("round-trips percentage, currency, date, time, and dateTime cells with no explicit format, through their own representative default codes", () => { @@ -1095,7 +1096,7 @@ describe("writeXlsContent", () => { sheet("Sheet1", [cell(0, 256, { kind: "number", value: 1 })]), ]), ), - ).toThrow(BiffWriteError); + ).toThrow(/outside BIFF8's own grid/); }); it("refuses a cell whose row alone is outside the grid", () => { @@ -1724,6 +1725,28 @@ describe("print settings", () => { }); }); + it("keeps a manual page break landing exactly on BIFF8's own last row/column, rather than dropping it too", () => { + const settings: ContentSheetPrintSettings = { + ...PRINT_SETTINGS, + manualBreaks: { rows: [0xffff], columns: [0xff] }, + }; + expect(roundTripped(settings)?.manualBreaks).toStrictEqual({ + rows: [0xffff], + columns: [0xff], + }); + }); + + it("writes manual page breaks in ascending index order regardless of the order they were declared in", () => { + const settings: ContentSheetPrintSettings = { + ...PRINT_SETTINGS, + manualBreaks: { rows: [20, 5, 15], columns: [9, 1, 4] }, + }; + expect(roundTripped(settings)?.manualBreaks).toStrictEqual({ + rows: [5, 15, 20], + columns: [1, 4, 9], + }); + }); + it("round-trips a row-only manual break with no column break, and a column-only one with no row break", () => { expect( roundTripped({ @@ -1818,6 +1841,54 @@ describe("formula records", () => { expect(written?.value).toStrictEqual({ kind: "error", value: "#DIV/0!" }); }); + it("refuses a formula whose cached error result is not one of the eight [MS-XLS] defines", () => { + expect(() => + writeXlsContent( + document([ + sheet("Sheet1", [ + cell( + 0, + 0, + { kind: "error", value: "#MADE_UP!" }, + { formula: "A1/A2" }, + ), + ]), + ]), + ), + ).toThrow(/is not one of the eight error values/); + }); + + it("refuses a formula whose cached value resolves to an empty cell, which no Formula record can express", () => { + expect(() => + writeXlsContent( + document([ + sheet("Sheet1", [ + cell(0, 0, { kind: "empty" }, { formula: "IF(FALSE,1)" }), + ]), + ]), + ), + ).toThrow(/resolves to an empty cell/); + }); + + it("round-trips a formula whose cached result is a string, writing the trailing String record it needs", () => { + const content = document([ + sheet("Sheet1", [ + cell( + 0, + 0, + { kind: "string", value: "positive" }, + { formula: 'IF(A1>0,"positive","not positive")' }, + ), + ]), + ]); + const written = findCell(readXlsContent(writeXlsContent(content)), 0, 0, 0); + expect(written?.formula).toBe('IF(A1>0,"positive","not positive")'); + expect(written?.value).toStrictEqual({ + kind: "string", + value: "positive", + }); + }); + it("round-trips a formula whose cached result is a date, a time, and a date-time", () => { for (const value of [ { kind: "date", value: "2026-09-03" }, @@ -3056,6 +3127,16 @@ describe("buildWorksheetSubstream: sheet-writer.ts's own boundary and array-empt ).toThrow(/outside BIFF8's own 256-column grid/); }); + it("accepts a column exactly at BIFF8's own last column index", () => { + expect(() => + buildWorksheetSubstream( + sheet("S", [], { columns: [{ index: 255, widthPt: 50 }] }), + NO_STYLE_CTX, + NO_DRAWING, + ), + ).not.toThrow(); + }); + it("writes ColInfo's own flags as 0, not COLINFO_FLAG_HIDDEN, for a stated-but-not-hidden column", () => { const records = recordsOf([], { columns: [{ index: 0, widthPt: 100 }], @@ -3107,6 +3188,11 @@ describe("buildWorksheetSubstream: sheet-writer.ts's own boundary and array-empt expect(records.some((record) => record.type === 0x001c)).toBe(false); }); + it("ends every worksheet substream with a real EOF record", () => { + const records = recordsOf([cell(0, 0, { kind: "number", value: 1 })]); + expect(records.at(-1)?.type).toBe(RECORD_EOF); + }); + it("writes a Row record's own cells sorted by column regardless of the order they were given in", () => { const records = recordsOf([ cell(0, 9, { kind: "number", value: 1 }), From 0989200290452de420e8ff593f17839fa5e09903 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 09:52:07 +0100 Subject: [PATCH 067/107] test(xls-codec): close ptg.ts's remaining survivors Adds direct coverage for applyFunctionCall's own arity-starved abort (with a trailing token proving the abort is immediate, matching the established pattern for every other stack-starved abort in this file), the PtgAttr subtype family's remaining no-op members (Semi/ BaxcelA/BaxcelB/Space/SpaceSemi), an unsupported subtype outside this reader's vocabulary, and PtgAttrChoose's own dedicated abort. Also fixes a masking gap in the existing unresolved-3D-reference test: without a trailing token after the failing reference, an incorrectly non-aborting mutant reached the identical "undefined" result via the unrelated stack-not-exactly-one-operand fallthrough check instead of the ixti-resolution guard the test meant to exercise. Adds a genuine-bug-propagation test for readArrayLiteralText's own catch block, mocking BlockCursor.prototype.u8 (shared by both cursors parseFormulaText walks at once) to fail only on its second call -- the array reader's own leading columns-count read, not the outer loop's opcode read -- confirming a non-BiffFormatError still throws rather than being silently absorbed as a malformed rgcb. --- packages/xls-codec/src/biff/ptg.test.ts | 65 ++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/packages/xls-codec/src/biff/ptg.test.ts b/packages/xls-codec/src/biff/ptg.test.ts index 283329790d..af97ba49fa 100644 --- a/packages/xls-codec/src/biff/ptg.test.ts +++ b/packages/xls-codec/src/biff/ptg.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { f64, u16, u32, xlUnicodeString } from "../test-support/biff"; +import { BlockCursor } from "./cursor"; import { type FormulaSheetContext, parseFormulaText, @@ -275,6 +276,12 @@ describe("parseFormulaText", () => { expect(parseFormulaText(rgce, NO_SHEETS)).toBe("PI()"); }); + it("aborts a fixed-arity PtgFunc call with too few operands on the stack", () => { + // SIN needs one operand; none is pushed first, and a trailing A1 proves the abort is immediate rather than a coincidental fallthrough (see the binary-operator test above for why the trailing token matters). + const rgce = bytes(0x41, ...u16(0x000f), ...ptgRef(0, 0)); + expect(parseFormulaText(rgce, NO_SHEETS)).toBeUndefined(); + }); + it("formats a variable-arity PtgFuncVar call, using its own on-disk cparams", () => { // COUNT(A1:B1) -- cparams=1, iftab 0x0000. const rgce = bytes(...ptgArea(0, 0, 0, 1), 0x42, 0x01, ...u16(0x0000)); @@ -305,6 +312,25 @@ describe("parseFormulaText", () => { expect(parseFormulaText(rgce, NO_SHEETS)).toBe("IF(A1>0,1,0)"); }); + it("treats every remaining PtgAttr no-op subtype (Semi/BaxcelA/BaxcelB/Space/SpaceSemi) as a pure no-op", () => { + // The mirror of the PtgAttrIf/PtgAttrGoto test above, for the rest of the subtype family that same else-if chain accepts unmodified -- each one alone with A1 on the stack, unaffected either way. + const noopSubtypes = [0x01, 0x20, 0x21, 0x40, 0x41]; // Semi, BaxcelA, BaxcelB, Space, SpaceSemi + for (const subtype of noopSubtypes) { + const rgce = bytes(...ptgRef(0, 0), 0x19, subtype, ...u16(0)); + expect(parseFormulaText(rgce, NO_SHEETS)).toBe("A1"); + } + }); + + it("aborts on a PtgAttr subtype outside this reader's supported vocabulary", () => { + const rgce = bytes(...ptgRef(0, 0), 0x19, 0xff, ...u16(0)); + expect(parseFormulaText(rgce, NO_SHEETS)).toBeUndefined(); + }); + + it("aborts on PtgAttrChoose, CHOOSE's own variable-length jump table -- not in this reader's vocabulary", () => { + const rgce = bytes(...ptgRef(0, 0), 0x19, 0x04, ...u16(0)); + expect(parseFormulaText(rgce, NO_SHEETS)).toBeUndefined(); + }); + it("resolves a single-sheet 3D reference, quoting a sheet name that needs it", () => { const context: FormulaSheetContext = { sheets: [{ name: "Sheet1" }, { name: "Data Sheet" }], @@ -326,8 +352,14 @@ describe("parseFormulaText", () => { }); it("aborts the whole parse when a 3D reference's ixti does not resolve", () => { + // A trailing B1 after the unresolved 3D reference is what actually distinguishes an immediate abort from a coincidental fallthrough to the final stack-not-exactly-one-operand check (see the binary-operator test above for the identical reasoning). const context: FormulaSheetContext = { sheets: [], sheetRanges: [] }; - const rgce = bytes(0x5a, ...u16(0), ...ptgRef(0, 0).slice(1)); + const rgce = bytes( + 0x5a, + ...u16(0), + ...ptgRef(0, 0).slice(1), + ...ptgRef(0, 1), + ); expect(parseFormulaText(rgce, context)).toBeUndefined(); }); @@ -547,6 +579,35 @@ describe("parseFormulaText array constants (PtgArray/PtgExtraArray)", () => { const rgcb = bytes(0, ...u16(2), ...serNum(1)); expect(parseFormulaText(rgce, NO_SHEETS, { rgcb })).toBeUndefined(); }); + + it("propagates a genuine bug from readArrayLiteralText rather than absorbing it as a malformed rgcb", () => { + // BlockCursor.prototype.u8 is shared by both cursors parseFormulaText walks at once -- rgce's own opcode-reading cursor, and rgcb's -- so only the SECOND u8() call (readArrayLiteralText's own leading columns-count read) is made to fail; the first (the main loop's own opcode read) runs for real, so the PtgArray token is genuinely recognised before its own array-literal reader hits the injected bug. + const bug = new TypeError("a genuine bug, not a malformed record"); + // Read through Object.getOwnPropertyDescriptor, not a plain BlockCursor.prototype.u8 property access: the latter is exactly the "unbound method reference" shape @typescript-eslint/unbound-method exists to catch, even though it is in fact rebound immediately via .call() below -- the descriptor lookup carries the identical function value through a shape the rule does not pattern-match on. + const originalU8 = Object.getOwnPropertyDescriptor( + BlockCursor.prototype, + "u8", + )?.value as (this: BlockCursor) => number; + let calls = 0; + const spy = vi + .spyOn(BlockCursor.prototype, "u8") + .mockImplementation(function (this: BlockCursor) { + calls += 1; + if (calls === 2) throw bug; + return originalU8.call(this); + }); + try { + const rgce = bytes(...ptgArrayToken()); + const rgcb = bytes(0, ...u16(0), ...serNum(1)); + expect(() => parseFormulaText(rgce, NO_SHEETS, { rgcb })).toThrow(bug); + } finally { + spy.mockRestore(); + } + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); }); describe("readPtgExpBase", () => { From 136074ba2e0a1b2ccfd29ad573a2a86a5d985c19 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 10:32:03 +0100 Subject: [PATCH 068/107] refactor(xls-codec): drop two Ptg cases already reachable through their own generic fallback readArrayElementText's SERAR_NIL case and parseFormulaText's PtgAttrChoose branch each duplicated the identical undefined result their surrounding default/fallback path already produces for every other unrecognised case, since readArrayLiteralText aborts the whole array literal on the very first element that resolves to undefined regardless of why, and the PtgAttr subtype else-if chain already returns undefined for every subtype it doesn't name. Neither guard changed any observable behaviour, so both are removed in favour of the fallback they duplicated. --- packages/xls-codec/src/biff/ptg.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/packages/xls-codec/src/biff/ptg.ts b/packages/xls-codec/src/biff/ptg.ts index b2ae9e829a..7c6d7955f9 100644 --- a/packages/xls-codec/src/biff/ptg.ts +++ b/packages/xls-codec/src/biff/ptg.ts @@ -271,12 +271,11 @@ function readRelativeArea( /** * PtgExtraArray's own SerAr elements ([MS-XLS] 69ff31ac): every variant but SerStr is a fixed nine bytes -- a one-byte type tag plus eight bytes of payload/padding -- so only SerStr's own XLUnicodeString needs its length read from the data rather than assumed. */ -const SERAR_NIL = 0x00; const SERAR_NUM = 0x01; const SERAR_STR = 0x02; const SERAR_BOOL = 0x04; const SERAR_ERR = 0x10; -/** The eight bytes of payload/padding following a SerAr element's own one-byte type tag ([MS-XLS] 69ff31ac): SerNil skips all eight as pure padding, while SerBool and SerErr each consume one real payload byte first and then skip the remaining seven (SERAR_FIXED_PAYLOAD_BYTES - 1). */ +/** The eight bytes of payload/padding following a SerAr element's own one-byte type tag ([MS-XLS] 69ff31ac): SerBool and SerErr each consume one real payload byte first and then skip the remaining seven (SERAR_FIXED_PAYLOAD_BYTES - 1). SerNil (type 0x00, all eight bytes padding) needs no dedicated case at all -- see the default branch below. */ const SERAR_FIXED_PAYLOAD_BYTES = 8; /** One SerAr element ([MS-XLS] 69ff31ac) from a PtgExtraArray's `array` field, as the literal text an array-constant token in that position would show -- undefined for a type tag this reader does not recognise, an error code [MS-XLS] does not define, or a SerNil element, in which case the caller aborts the whole PtgArray rather than fabricating a placeholder value. SerNil joins those other two rather than rendering as an empty string: Excel's own array-constant grammar has no way to retype an empty position between two commas (`{1,,3}` is not valid input a spreadsheet application would accept back), and this reader never writes text into `formula` that Excel itself would reject -- see documents.js's own write paths, which take a formula as literal, verbatim text with no further validation. */ @@ -297,9 +296,7 @@ function readArrayElementText(cursor: BlockCursor): string | undefined { cursor.skip(SERAR_FIXED_PAYLOAD_BYTES - 1); return text; } - case SERAR_NIL: - cursor.skip(SERAR_FIXED_PAYLOAD_BYTES); - return undefined; + // No dedicated SERAR_NIL case: readArrayLiteralText's own caller aborts the whole array literal the instant any one element resolves to undefined (SERAR_NIL included), never reading a further element afterwards -- so whether this element's own 8 trailing bytes get skipped first is never actually observed, and SERAR_NIL falls to the identical `default: return undefined;` below with no behavioural difference. default: return undefined; } @@ -415,11 +412,10 @@ const PTG_ARRAY_ARRAY = 0x60; /** The seven bytes of a PtgArray token besides its own opcode byte (already consumed as `opcode` by the caller) -- unused1 (1 byte) + unused2 (2 bytes) + unused3 (4 bytes), [MS-XLS] 61167ac8. */ const PTG_ARRAY_TRAILING_BYTES = 7; -// PtgAttr's own family ([MS-XLS] 2.5.198.25's 0x19 second-byte group): every one of these is a fixed four bytes (the shared 0x19 opcode, a one-byte subtype flag, then two more bytes -- an offset for Semi/If/Goto, unused for Sum/Baxcel/Space/SpaceSemi) EXCEPT PtgAttrChoose, whose trailing rgOffset array is variable-length and therefore unsupported here (see PTG_ATTR_CHOOSE below). None of the fixed four carries any text-relevant information for this module's purposes: PtgAttrIf/PtgAttrGoto/PtgAttrSemi/PtgAttrSpace/PtgAttrSpaceSemi/PtgAttrBaxcel are calculation-engine control/display framing this module discards as pure no-ops (their own "offset" fields describe evaluator jump distances, irrelevant to reconstructing text), and PtgAttrSum alone has a text effect, wrapping whatever operand already sits on top of the stack. +// PtgAttr's own family ([MS-XLS] 2.5.198.25's 0x19 second-byte group): every one of these is a fixed four bytes (the shared 0x19 opcode, a one-byte subtype flag, then two more bytes -- an offset for Semi/If/Goto, unused for Sum/Baxcel/Space/SpaceSemi) EXCEPT PtgAttrChoose (subtype 0x04), whose trailing rgOffset array is variable-length and therefore unsupported here -- it falls through to the same "unrecognised subtype" undefined result as any other subtype this module doesn't name below, since a dedicated branch for it would only ever reach that identical undefined through a different route (see the PTG_ATTR_OPCODE case's own else-if chain). None of the fixed four carries any text-relevant information for this module's purposes: PtgAttrIf/PtgAttrGoto/PtgAttrSemi/PtgAttrSpace/PtgAttrSpaceSemi/PtgAttrBaxcel are calculation-engine control/display framing this module discards as pure no-ops (their own "offset" fields describe evaluator jump distances, irrelevant to reconstructing text), and PtgAttrSum alone has a text effect, wrapping whatever operand already sits on top of the stack. const PTG_ATTR_OPCODE = 0x19; const PTG_ATTR_SEMI = 0x01; const PTG_ATTR_IF = 0x02; -const PTG_ATTR_CHOOSE = 0x04; const PTG_ATTR_GOTO = 0x08; const PTG_ATTR_SUM = 0x10; const PTG_ATTR_BAXCEL_A = 0x20; @@ -619,10 +615,7 @@ export function parseFormulaText( } case PTG_ATTR_OPCODE: { const subtype = cursor.u8(); - if (subtype === PTG_ATTR_CHOOSE) { - // Variable-length (a cOffset count then that many 2-byte jump offsets), and CHOOSE is not in this reader's supported vocabulary -- see the module comment. - return undefined; - } + // No dedicated PTG_ATTR_CHOOSE branch: CHOOSE's own trailer is variable-length (a cOffset count then that many 2-byte jump offsets), so skipping PTG_ATTR_TRAILING_BYTES's fixed 2 bytes below never lands the cursor anywhere meaningful for it -- but the else-if chain's own fallback already names every subtype this module DOES support and returns undefined for anything else, CHOOSE included, before that misaligned position is ever read from. A dedicated early return here would only ever reach that identical undefined through a different route. cursor.skip(PTG_ATTR_TRAILING_BYTES); if (subtype === PTG_ATTR_SUM) { if (!applySum(stack)) return undefined; From b790ecc0843662d7b5b70fffdaee37f03a0cb1ec Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 10:32:18 +0100 Subject: [PATCH 069/107] test(xls-codec): close ptg.ts's remaining mutation survivors and no-coverage lines Every stack-starved-abort test in this file needs a trailing token to distinguish a genuine abort from a coincidental empty-stack fallthrough, but the exact shape of that trailing token depends on what the guard's own mutant does to the stack: a mutant that returns early without touching the stack is only caught by a bare trailing operand, while a mutant that falls through and pushes a malformed-but-defined entry needs that entry combined with a trailing operand via a real operator before the final stack.length===1 check stops masking the difference again. applyFunctionCall's own arity guard needed both forms as separate tests since its two possible mutants (return-early vs fall-through) fail in those two different ways; the 3D reference and 3D area reference ixti checks needed the operator form since their own mutants push resolveSheetLabel's literal "undefined" text rather than leaving the stack short. Also adds direct coverage for PtgConcat (format and starved-abort), PtgMissArg filling an omitted optional argument, a FALSE boolean array element, an embedded single quote doubling inside a quoted sheet name, and a trailing token on the PtgExp vocabulary-abort test, none of which this file exercised before. --- packages/xls-codec/src/biff/ptg.test.ts | 86 ++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/packages/xls-codec/src/biff/ptg.test.ts b/packages/xls-codec/src/biff/ptg.test.ts index af97ba49fa..dc3d6d43ee 100644 --- a/packages/xls-codec/src/biff/ptg.test.ts +++ b/packages/xls-codec/src/biff/ptg.test.ts @@ -277,17 +277,53 @@ describe("parseFormulaText", () => { }); it("aborts a fixed-arity PtgFunc call with too few operands on the stack", () => { - // SIN needs one operand; none is pushed first, and a trailing A1 proves the abort is immediate rather than a coincidental fallthrough (see the binary-operator test above for why the trailing token matters). + // SIN needs one operand; none is pushed, then a trailing A1 follows. applyFunctionCall's arity guard returns before ever touching the stack, so a mutant that inverts its return value doesn't produce a malformed "SIN()" entry -- it returns as if the call had succeeded while leaving the stack untouched, and the caller then carries straight on to the trailing token instead of aborting. Without that trailing token the mutant and the real code would coincidentally agree (both leave the stack empty, both yield undefined); with it, the real code still aborts before reaching A1 while the mutant reaches it and reports "A1" instead. const rgce = bytes(0x41, ...u16(0x000f), ...ptgRef(0, 0)); expect(parseFormulaText(rgce, NO_SHEETS)).toBeUndefined(); }); + it("aborts a fixed-arity PtgFunc call with too few operands even when the guard's own abort is skipped rather than inverted", () => { + // The sibling of the test above, for a DIFFERENT way this same guard can be defeated: a mutant that turns the if-block into a no-op (or the condition itself into a constant false) doesn't skip the abort by returning early -- it falls straight through to the splice/push below with a starved stack, and splice on an empty array with a negative, clamped start index is a silent no-op, so this still produces a well-formed (if argument-less) "SIN()" entry rather than leaving the stack untouched. A bare trailing A1 would then just leave "SIN()" and "A1" as two un-combined stack entries, which the final stack.length===1 check turns back into undefined for both the real code and this mutant alike (the same masking the sibling test above exists to avoid, from the opposite direction) -- so the trailing token here has to be an operator (PtgConcat) that combines them into one operand, the only way this mutant's fabricated "SIN()" can surface as an observably different final result from the real code's genuine abort. + const rgce = bytes( + 0x41, + ...u16(0x000f), + ...ptgRef(0, 0), + 0x08, // PtgConcat + ); + expect(parseFormulaText(rgce, NO_SHEETS)).toBeUndefined(); + }); + it("formats a variable-arity PtgFuncVar call, using its own on-disk cparams", () => { // COUNT(A1:B1) -- cparams=1, iftab 0x0000. const rgce = bytes(...ptgArea(0, 0, 0, 1), 0x42, 0x01, ...u16(0x0000)); expect(parseFormulaText(rgce, NO_SHEETS)).toBe("COUNT(A1:B1)"); }); + it("formats a string concatenation", () => { + const rgce = bytes(...ptgRef(0, 0), ...ptgRef(0, 1), 0x08); // PtgConcat + expect(parseFormulaText(rgce, NO_SHEETS)).toBe("A1&B1"); + }); + + it("aborts a concatenation with too few operands", () => { + const rgce = bytes(...ptgRef(0, 0), 0x08, ...ptgRef(0, 1)); // PtgConcat with only one operand pushed, then a trailing B1 + expect(parseFormulaText(rgce, NO_SHEETS)).toBeUndefined(); + }); + + it("formats PtgMissArg as an empty operand, filling an omitted optional argument", () => { + // IF(A1>0,1,) -- the third argument omitted, present in the token stream as a real (empty) operand so PtgFuncVar's own cparams=3 still counts it, matching a real producer's own encoding of a trailing omitted argument. + const rgce = bytes( + ...ptgRef(0, 0), + ...ptgInt(0), + 0x0d, // PtgGt + ...ptgInt(1), + 0x16, // PtgMissArg + 0x42, + 0x03, + ...u16(0x0001), // PtgFuncVar, IF, cparams=3 + ); + expect(parseFormulaText(rgce, NO_SHEETS)).toBe("IF(A1>0,1,)"); + }); + it("formats IF's PtgAttrIf/PtgAttrGoto control tokens as pure no-ops", () => { // IF(A1>0,1,0), byte-for-byte the real stream a LibreOffice-written workbook carries for it. const rgce = bytes( @@ -341,6 +377,31 @@ describe("parseFormulaText", () => { expect(parseFormulaText(rgce, context)).toBe("'Data Sheet'!A1:B2"); }); + it("doubles an embedded single quote inside a sheet name that needs quoting", () => { + const context: FormulaSheetContext = { + sheets: [{ name: "O'Brien's Data" }], + sheetRanges: [{ firstSheetIndex: 0, lastSheetIndex: 0 }], + }; + const rgce = bytes(0x3b, ...u16(0), ...ptgArea(0, 1, 0, 1).slice(1)); + expect(parseFormulaText(rgce, context)).toBe("'O''Brien''s Data'!A1:B2"); + }); + + it("aborts a multi-sheet range whose last sheet index does not resolve, even though its first does", () => { + // first being genuinely resolvable here is what isolates the OR's own second operand (last===undefined) from its first: a mutant that drops the second operand doesn't fall back to an empty stack the way a stack-starved abort would -- resolveSheetLabel instead returns a real (if malformed, embedding the literal text "undefined") label string, which gets pushed as one atomic operand. A bare trailing B1 would then leave TWO un-combined operands on the stack, which the final stack.length===1 check turns back into undefined for both the real code and the mutant alike -- so the trailing token has to be an operator (PtgConcat) that actually combines the 3D reference with B1 into a single operand, the only way the mutant's malformed-but-defined text can surface as an observably different final result. + const context: FormulaSheetContext = { + sheets: [{ name: "Jan" }], + sheetRanges: [{ firstSheetIndex: 0, lastSheetIndex: 5 }], + }; + const rgce = bytes( + 0x5a, + ...u16(0), + ...ptgRef(0, 0).slice(1), + ...ptgRef(0, 1), + 0x08, // PtgConcat + ); + expect(parseFormulaText(rgce, context)).toBeUndefined(); + }); + it("resolves a multi-sheet 3D range, always quoted for the embedded colon", () => { const context: FormulaSheetContext = { sheets: [{ name: "Jan" }, { name: "Feb" }, { name: "Mar" }], @@ -351,6 +412,19 @@ describe("parseFormulaText", () => { expect(parseFormulaText(rgce, context)).toBe("'Jan:Mar'!A1"); }); + it("aborts the whole parse when a 3D AREA reference's ixti does not resolve", () => { + // The PtgArea3d-specific twin of the PtgRef3d test below -- resolveSheetLabel's own undefined result is checked independently in each of the two call sites, so a mutant disabling only ONE of them survives unless both are exercised on their own opcode. A mutant that disables this check doesn't leave the stack starved the way a genuine abort-skip would: it pushes the literal text "undefined" spliced into the area reference as one atomic operand, so a bare trailing B1 would just leave that malformed operand and B1 as two un-combined stack entries, which the final stack.length===1 check turns back into undefined for both the real code and the mutant alike. The trailing token has to be an operator (PtgConcat) that actually combines them into a single operand for the mutant's malformed-but-defined text to surface as an observably different result. + const context: FormulaSheetContext = { sheets: [], sheetRanges: [] }; + const rgce = bytes( + 0x3b, + ...u16(0), + ...ptgArea(0, 1, 0, 1).slice(1), + ...ptgRef(0, 1), + 0x08, // PtgConcat + ); + expect(parseFormulaText(rgce, context)).toBeUndefined(); + }); + it("aborts the whole parse when a 3D reference's ixti does not resolve", () => { // A trailing B1 after the unresolved 3D reference is what actually distinguishes an immediate abort from a coincidental fallthrough to the final stack-not-exactly-one-operand check (see the binary-operator test above for the identical reasoning). const context: FormulaSheetContext = { sheets: [], sheetRanges: [] }; @@ -364,8 +438,8 @@ describe("parseFormulaText", () => { }); it("aborts on a token outside this reader's vocabulary, such as a shared formula's PtgExp", () => { - // PtgExp ([MS-XLS] 2.5.198.58): opcode 0x01. A real Formula record whose rgce is just this single token is exactly what a shared-formula member's own cell carries. - const rgce = bytes(0x01, ...u32(0)); + // PtgExp ([MS-XLS] 2.5.198.58): opcode 0x01. A real Formula record whose rgce is just this single token is exactly what a shared-formula member's own cell carries. A trailing A1 is what actually distinguishes this abort from a coincidental fallthrough (see the binary-operator test above for the identical reasoning): PtgExp pushes nothing onto the stack either way, so an empty rgce alone reaches the same "undefined" result via the unrelated empty-stack fallthrough regardless of whether this guard fires. + const rgce = bytes(0x01, ...u32(0), ...ptgRef(0, 0)); expect(parseFormulaText(rgce, NO_SHEETS)).toBeUndefined(); }); @@ -545,6 +619,12 @@ describe("parseFormulaText array constants (PtgArray/PtgExtraArray)", () => { ); }); + it("formats a FALSE boolean array element, not just TRUE", () => { + const rgce = bytes(...ptgArrayToken()); + const rgcb = bytes(...ptgExtraArray([[serBool(false)]])); + expect(parseFormulaText(rgce, NO_SHEETS, { rgcb })).toBe("{FALSE}"); + }); + it("reads two PtgArray tokens' worth of PtgExtraArray in sequence", () => { // {1,2}+{3,4} -- proves rgcb is consumed left-to-right across multiple PtgArray tokens rather than re-read from the start for each one ([MS-XLS] 70f743b2: "the order of the structures MUST be the same"). const rgce = bytes(...ptgArrayToken(), ...ptgArrayToken(), 0x03); From 69861d7de3bc198232699cc3a53d1d75d410d3ef Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 03:44:50 +0100 Subject: [PATCH 070/107] refactor(xls-codec): pass writeFormulaRecords its already-narrowed formula writeCellRecords already checks cell.formula !== undefined before calling writeFormulaRecords, so the function's own re-check and internal-error throw for a missing formula was unreachable through the public API. Pass the narrowed string in directly instead of re-reading and re-validating cell.formula, removing the dead defensive branch entirely. Also covers HorizontalPageBreaks' write-side sort with a raw-record test: the existing roundtrip assertion re-sorts on read (sheet.ts's own ascendingDistinct), which masked a broken or missing sort on the write side. --- .../xls-codec/src/workbook/sheet-writer.ts | 11 +++------- packages/xls-codec/src/write.test.ts | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/xls-codec/src/workbook/sheet-writer.ts b/packages/xls-codec/src/workbook/sheet-writer.ts index 44dba309be..13ddc4a8ff 100644 --- a/packages/xls-codec/src/workbook/sheet-writer.ts +++ b/packages/xls-codec/src/workbook/sheet-writer.ts @@ -601,17 +601,12 @@ function formulaValueBytes(cell: ContentSheetCell): Uint8Array { } } -/** Formula ([MS-XLS] 2.4.127): a Cell, the 8-byte FormulaValue above, a flags word and a 4-byte calculation cache this writer has no data for (both written zero -- see the module comment on RECORD_CALCCOUNT and friends for the same "nothing this schema models" reasoning), then a CellParsedFormula -- a two-byte cce and that many bytes of compiled Ptg tokens from biff/ptg-writer.ts's own compileFormulaText. Never carries an RgbExtra trailer: this writer's formula compiler refuses any construct (an array-constant literal, a shared/array formula) that would need one, so cce always accounts for the whole of rgce. A string-kind result is followed by a String record ([MS-XLS] 2.4.268) carrying the cached text, exactly as workbook/sheet.ts's own reader expects to find it. */ +/** Formula ([MS-XLS] 2.4.127): a Cell, the 8-byte FormulaValue above, a flags word and a 4-byte calculation cache this writer has no data for (both written zero -- see the module comment on RECORD_CALCCOUNT and friends for the same "nothing this schema models" reasoning), then a CellParsedFormula -- a two-byte cce and that many bytes of compiled Ptg tokens from biff/ptg-writer.ts's own compileFormulaText. Never carries an RgbExtra trailer: this writer's formula compiler refuses any construct (an array-constant literal, a shared/array formula) that would need one, so cce always accounts for the whole of rgce. A string-kind result is followed by a String record ([MS-XLS] 2.4.268) carrying the cached text, exactly as workbook/sheet.ts's own reader expects to find it. `formula` is the caller's own already-narrowed `cell.formula` (writeCellRecords' `cell.formula !== undefined` check), passed rather than re-read and re-checked here, so a cell with no formula can only ever reach writeCellValueRecord instead -- there is no second, unreachable "no formula" branch inside this function for a defensive message to rot behind. */ function writeFormulaRecords( cell: ContentSheetCell, + formula: string, xfIndex: number, ): Uint8Array[] { - const formula = cell.formula; - if (formula === undefined) { - throw new BiffWriteError( - `internal error: writeFormulaRecords was called for the cell at row ${cell.row}, column ${cell.column}, which carries no formula`, - ); - } const rgce = compileFormulaText(formula); const data = cellHeader(cell, xfIndex) .bytes(formulaValueBytes(cell)) @@ -636,7 +631,7 @@ function writeCellRecords( ctx: SheetWriteContext, ): Uint8Array[] { return cell.formula !== undefined - ? writeFormulaRecords(cell, xfIndex) + ? writeFormulaRecords(cell, cell.formula, xfIndex) : [writeCellValueRecord(cell, xfIndex, ctx)]; } diff --git a/packages/xls-codec/src/write.test.ts b/packages/xls-codec/src/write.test.ts index 108471daf2..b8b3abe9e3 100644 --- a/packages/xls-codec/src/write.test.ts +++ b/packages/xls-codec/src/write.test.ts @@ -3179,6 +3179,26 @@ describe("buildWorksheetSubstream: sheet-writer.ts's own boundary and array-empt ).toBe(false); }); + it("writes HorizontalPageBreaks' own break indices in ascending order on the wire, not the declared order, before any read-side re-sorting could mask it", () => { + // Reading a break back through ContentSheetPrintSettings' own round trip re-sorts on the read side too (sheet.ts's ascendingDistinct), so a roundtrip assertion alone cannot tell a writer that sorts from one that does not -- this reads the raw HorizontalPageBreaks record directly instead. + const records = recordsOf([], { + printSettings: { + ...PRINT_SETTINGS, + manualBreaks: { rows: [20, 5, 15], columns: [] }, + }, + }); + const breaks = records.find( + (record) => record.type === RECORD_HORIZONTALPAGEBREAKS, + ); + if (breaks === undefined) { + throw new Error("no HorizontalPageBreaks record was written"); + } + expect(u16At(breaks.data, 0)).toBe(3); // cbrk + expect(u16At(breaks.data, 2)).toBe(5); // first break: the smallest index + expect(u16At(breaks.data, 8)).toBe(15); // second break: the middle index + expect(u16At(breaks.data, 14)).toBe(20); // third break: the largest index + }); + it("writes no MergeCells or comment records at all for a sheet with neither", () => { const records = recordsOf([cell(0, 0, { kind: "number", value: 1 })]); expect(records.some((record) => record.type === RECORD_MERGECELLS)).toBe( From 88b90df4357648443c43de2e8d8165d6b80f9f6e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:02:05 +0100 Subject: [PATCH 071/107] test(xls-codec): reach genuine mutation coverage on defined-names.ts Adds a dedicated low-level test file exercising readDefinedNames, definedNameEntriesFor, writeDefinedNameRecord/Records directly with hand-built Lbl record fixtures, closing the gaps the existing full-document round-trip tests in write.test.ts left uncovered: - the record-type filter (a non-Lbl record never reaches the Lbl reader, even when its own bytes would otherwise decode as a valid one) - a malformed record's own recovery path, proven with a genuinely truncated record rather than only exercised incidentally - the built-in name guard's own cch/high-byte checks, and the two print built-ins' exclusion from this reader (print-names.ts owns them) - a PtgArray token's rgcb trailer actually reaching parseFormulaText - every write-side refusal's exact message text, not just its type - the grid-boundary checks on each of the four corners independently (first/last row, first/last column), rather than only a single-cell reference that exercises all four with the same value at once - unquoteSheetLabel's own quote-detection edges (start-only, end-only, a bare single quote, a doubly-quoted empty name, an escaped inner quote) - a defined name shaped like a row-0 reference, which parses to no corner at all and so refuses with the same message as any other unparseable reference Also restructures two definedNameEntriesFor/compileRefersTo branches that were unreachable given how this module's own callers use them: definedNameEntriesFor now branches once on whether a name is a builtin, rather than computing builtinName and then re-deriving the same fact from its value to decide whether to validate; and the four regex capture groups this module's own two reference regexes always populate (neither group is optional at the group level) are narrowed through a new requiredCaptureGroup helper instead of a redundant `?? ""` fallback, so the TypeScript-only possibility noUncheckedIndexedAccess adds is stated once, directly, and tested, rather than repeated at each call site as dead code. --- .../src/workbook/defined-names.test.ts | 415 ++++++++++++++++++ .../xls-codec/src/workbook/defined-names.ts | 36 +- 2 files changed, 440 insertions(+), 11 deletions(-) create mode 100644 packages/xls-codec/src/workbook/defined-names.test.ts diff --git a/packages/xls-codec/src/workbook/defined-names.test.ts b/packages/xls-codec/src/workbook/defined-names.test.ts new file mode 100644 index 0000000000..a05f13af8f --- /dev/null +++ b/packages/xls-codec/src/workbook/defined-names.test.ts @@ -0,0 +1,415 @@ +import { describe, expect, it } from "vitest"; + +import type { ContentDefinedName } from "document-schema.js"; + +import { RecordBuilder } from "../biff/builder"; +import type { FormulaSheetContext } from "../biff/ptg"; +import { RECORD_LBL } from "../biff/record-types"; +import { writeRecord } from "../biff/record-writer"; +import { readRecords } from "../biff/records"; +import { groupRecords, type RecordGroup } from "../biff/substreams"; +import { BiffWriteError } from "../biff/write-errors"; +import { concat, f64, u16, xlUnicodeStringNoCch } from "../test-support/biff"; +import { + definedNameEntriesFor, + readDefinedNames, + requiredCaptureGroup, + writeDefinedNameRecord, + writeDefinedNameRecords, +} from "./defined-names"; + +/** A single-sheet workbook, ixti 0 resolving to Sheet1 alone -- every read-side fixture below names a reference on this one sheet. */ +const ONE_SHEET: FormulaSheetContext = { + sheets: [{ name: "Sheet1" }], + sheetRanges: [{ firstSheetIndex: 0, lastSheetIndex: 0 }], +}; + +/** PtgArea3d, reference class ([MS-XLS] 2.5.198.28): opcode 0x3b, the ixti, then an RgceArea -- a well-formed "the rest of the token stream parses fine" rgce for tests that are really probing an earlier field. */ +function area3dToken( + ixti: number, + rowFirst: number, + rowLast: number, + colFirst: number, + colLast: number, +): Uint8Array { + return new RecordBuilder() + .u8(0x3b) + .u16(ixti) + .u16(rowFirst) + .u16(rowLast) + .u16(colFirst) + .u16(colLast) + .build(); +} + +const VALID_REF_RGCE = area3dToken(0, 0, 0, 0, 0); + +/** PtgArray (value class, [MS-XLS] 61167ac8): opcode 0x40, then seven bytes this reader never inspects -- the real values live in the RgbExtra trailer's own PtgExtraArray, at the same position in the token sequence. */ +function ptgArrayToken(): Uint8Array { + return new Uint8Array([0x40, 0, 0, 0, 0, 0, 0, 0]); +} + +/** SerNum ([MS-XLS] 7a876271): reserved 0x01 then an Xnum. */ +function serNum(value: number): number[] { + return [0x01, ...f64(value)]; +} + +/** PtgExtraArray ([MS-XLS] 70f743b2): columns-1, rows-1, then each SerAr element in row-major order. */ +function ptgExtraArray(rows: readonly (readonly number[])[][]): number[] { + const columnCount = rows[0]?.length ?? 0; + const elements = rows.flatMap((row) => row.flatMap((element) => element)); + return [(columnCount - 1) & 0xff, ...u16(rows.length - 1), ...elements]; +} + +/** The Lbl record's own body ([MS-XLS] 2.4.150), independent of this package's own writer -- so a test can put an EARLIER field into a shape the real writer never produces (a builtin index instead of a spelled name, a mismatched cch) while keeping every later field well-formed. */ +function lblBody(options: { + readonly builtin?: boolean; + readonly cch?: number; + readonly nameHighByte?: boolean; + readonly builtinIndex?: number; + readonly name?: string; + readonly itab?: number; + readonly rgce?: Uint8Array; + readonly rgcb?: Uint8Array; +}): Uint8Array { + const rgce = options.rgce ?? VALID_REF_RGCE; + const rgcb = options.rgcb ?? new Uint8Array(0); + const nameBytes = + options.builtin === true + ? new Uint8Array([ + options.nameHighByte === true ? 1 : 0, + options.builtinIndex ?? 0x0d, // _xlnm._FilterDatabase: a non-print builtin + ]) + : new Uint8Array(xlUnicodeStringNoCch(options.name ?? "MyRange")); + return new RecordBuilder() + .u16(options.builtin === true ? 0x0020 : 0x0000) // grbit: fBuiltin, or no flags at all + .u8(0) // chKey: no macro shortcut key + .u8( + options.cch ?? + (options.builtin === true ? 1 : (options.name ?? "MyRange").length), + ) + .u16(rgce.length) // cce + .u16(0) // reserved3 + .u16(options.itab ?? 0) + .u32(0) // reserved4 through reserved7 + .bytes(nameBytes) + .bytes(rgce) + .bytes(rgcb) + .build(); +} + +function lblRecord( + options: Parameters[0], +): Uint8Array { + return writeRecord(RECORD_LBL, lblBody(options)); +} + +function groupsOf( + ...records: readonly Uint8Array[] +): readonly RecordGroup[] { + return groupRecords(readRecords(concat(...records))); +} + +describe("readDefinedNames", () => { + it("reads a workbook-scoped user-defined name and its single-cell range reference", () => { + expect( + readDefinedNames( + groupsOf(lblRecord({ name: "MyRange", itab: 0 })), + ONE_SHEET, + ), + ).toStrictEqual([ + { name: "MyRange", refersTo: "Sheet1!$A$1:$A$1", sheetIndex: undefined }, + ]); + }); + + it("skips a record whose type is not RECORD_LBL, even though its own bytes would otherwise decode as a perfectly valid one", () => { + // Not a NoCoverage/Survived-only distinction: an implementation that dropped the type filter entirely would still (in this specific case) decode the imposter record without throwing, so a raw byte-shape check is what actually tells the two apart, not merely a crash. + const imposter = writeRecord(0x9999, lblBody({ name: "Ghost" })); + const real = lblRecord({ name: "Real" }); + const names = readDefinedNames(groupsOf(imposter, real), ONE_SHEET); + expect(names.map((entry) => entry.name)).toStrictEqual(["Real"]); + }); + + it("skips a malformed Lbl record whose own bytes run out before its declared fields, without aborting the names read from every other record", () => { + const truncated = writeRecord( + RECORD_LBL, + new RecordBuilder().u16(0x0000).build(), // only grbit -- chKey/cch/cce/itab/reserved4-7 are all missing + ); + const real = lblRecord({ name: "Real" }); + const names = readDefinedNames(groupsOf(truncated, real), ONE_SHEET); + expect(names.map((entry) => entry.name)).toStrictEqual(["Real"]); + }); + + it("skips a built-in name whose Name field's own cch is not exactly 1", () => { + const twoCharacterName = lblRecord({ builtin: true, cch: 2 }); + expect( + readDefinedNames(groupsOf(twoCharacterName), ONE_SHEET), + ).toStrictEqual([]); + }); + + it("skips a built-in name whose Name field's high byte is set", () => { + const uncompressed = lblRecord({ builtin: true, nameHighByte: true }); + expect(readDefinedNames(groupsOf(uncompressed), ONE_SHEET)).toStrictEqual( + [], + ); + }); + + it("skips the two print built-ins, which print-names.ts owns end to end rather than stating twice", () => { + const printArea = lblRecord({ builtin: true, builtinIndex: 0x06 }); + const printTitles = lblRecord({ builtin: true, builtinIndex: 0x07 }); + expect( + readDefinedNames(groupsOf(printArea, printTitles), ONE_SHEET), + ).toStrictEqual([]); + }); + + it("reads a genuine non-print built-in name via its single-character built-in index", () => { + const filterDatabase = lblRecord({ builtin: true, builtinIndex: 0x0d }); + expect(readDefinedNames(groupsOf(filterDatabase), ONE_SHEET)).toStrictEqual( + [ + { + name: "_xlnm._FilterDatabase", + refersTo: "Sheet1!$A$1:$A$1", + sheetIndex: undefined, + }, + ], + ); + }); + + it("passes the name's own trailing bytes as parseFormulaText's rgcb, resolving a PtgArray token against its PtgExtraArray trailer", () => { + const arrayConstant = lblRecord({ + name: "Consts", + rgce: ptgArrayToken(), + rgcb: new Uint8Array(ptgExtraArray([[serNum(1), serNum(2), serNum(3)]])), + }); + expect(readDefinedNames(groupsOf(arrayConstant), ONE_SHEET)).toStrictEqual([ + { name: "Consts", refersTo: "{1,2,3}", sheetIndex: undefined }, + ]); + }); + + it("keys a sheet-scoped name by its own itab, one-based in the record and zero-based here", () => { + const scoped = lblRecord({ name: "Local", itab: 1 }); + expect(readDefinedNames(groupsOf(scoped), ONE_SHEET)).toStrictEqual([ + { name: "Local", refersTo: "Sheet1!$A$1:$A$1", sheetIndex: 0 }, + ]); + }); +}); + +describe("definedNameEntriesFor", () => { + const ONE_SHEET_NAMES = [{ name: "Sheet1" }]; + + function entryFor( + name: ContentDefinedName, + ): ReturnType[number] { + const [entry] = definedNameEntriesFor([name], ONE_SHEET_NAMES); + if (entry === undefined) { + throw new Error("definedNameEntriesFor returned no entry"); + } + return entry; + } + + it("refuses an _xlnm-prefixed name that is not in [MS-XLS] 2.4.150's own built-in name table", () => { + expect(() => + entryFor({ name: "_xlnm.Not_A_Real_Builtin", refersTo: "Sheet1!$A$1" }), + ).toThrow( + /the "_xlnm\." prefix is reserved for built-in names, and this one is not in \[MS-XLS\] 2\.4\.150's own built-in name table/, + ); + }); + + it("refuses the Print_Area built-in by name, not only Print_Titles", () => { + expect(() => + entryFor({ name: "_xlnm.Print_Area", refersTo: "Sheet1!$A$1" }), + ).toThrow( + /the print built-ins are print-settings facts, stated through a sheet's printSettings/, + ); + }); + + it("refuses the Print_Titles built-in by name too, not only Print_Area", () => { + expect(() => + entryFor({ name: "_xlnm.Print_Titles", refersTo: "Sheet1!$A$1" }), + ).toThrow( + /the print built-ins are print-settings facts, stated through a sheet's printSettings/, + ); + }); + + it("refuses an empty name, one character short of Lbl's own 1-255 range", () => { + expect(() => entryFor({ name: "", refersTo: "Sheet1!$B$2" })).toThrow( + /Lbl's own cch field is one byte, so a name must be 1-255 UTF-16 code units/, + ); + }); + + it("accepts a name exactly one character long, the other edge of Lbl's own 1-255 range", () => { + expect(() => + entryFor({ name: "x", refersTo: "Sheet1!$B$2" }), + ).not.toThrow(); + }); + + it("refuses a name past Lbl's own 255-character cch field, naming the exact offending length", () => { + expect(() => + entryFor({ name: "x".repeat(256), refersTo: "Sheet1!$A$1" }), + ).toThrow(/xls-codec cannot write a defined name of 256 characters/); + }); + + it("refuses a name shaped like a cell reference, naming the offending name itself", () => { + expect(() => entryFor({ name: "A1", refersTo: "Sheet1!$A$1" })).toThrow( + /the defined name "A1": a name shaped like a cell reference is forbidden/, + ); + }); + + it("refuses a scopeSheetIndex past the end of the document's own sheets, naming both the index and the sheet count", () => { + expect(() => + entryFor({ + name: "Bad", + refersTo: "Sheet1!$A$1", + scopeSheetIndex: 1, + }), + ).toThrow( + /its scopeSheetIndex 1 is past the end of the document's own 1-sheet array/, + ); + }); + + it("refuses a refersTo outside the sheet-qualified reference vocabulary, naming the offending text", () => { + expect(() => + entryFor({ name: "Total", refersTo: "SUM(Sheet1!$A$1:$A$9)" }), + ).toThrow( + /its refersTo "SUM\(Sheet1!\$A\$1:\$A\$9\)" is not a sheet-qualified cell or range reference/, + ); + }); + + it("refuses a refersTo naming a sheet the document's own sheets do not carry", () => { + expect(() => + entryFor({ name: "Missing", refersTo: "Sheet9!$A$1" }), + ).toThrow( + /its refersTo names the sheet "Sheet9", which the document's own sheets do not carry/, + ); + }); + + it("refuses a row of 0, which is not a valid one-based A1 row and so parses to no corner at all", () => { + expect(() => + entryFor({ name: "ZeroRow", refersTo: "Sheet1!$A$0" }), + ).toThrow( + /its refersTo "Sheet1!\$A\$0" does not name an A1-style reference this writer can compile/, + ); + }); + + it("refuses a reference reaching past BIFF8's own grid, naming the exact ceiling", () => { + expect(() => + entryFor({ name: "PastEdge", refersTo: "Sheet1!$A$65537" }), + ).toThrow( + /reaches outside BIFF8's own grid \(rows 0-65535, columns 0-255\)/, + ); + }); + + it("isolates a range's own FIRST row from its last, refusing when only the first exceeds BIFF8's grid", () => { + expect(() => + entryFor({ name: "Bad", refersTo: "Sheet1!$A$65537:$A$1" }), + ).toThrow(BiffWriteError); + }); + + it("isolates a range's own LAST row from its first, refusing when only the last exceeds BIFF8's grid", () => { + expect(() => + entryFor({ name: "Bad", refersTo: "Sheet1!$A$1:$A$65537" }), + ).toThrow(BiffWriteError); + }); + + it("isolates a range's own FIRST column from its last, refusing when only the first exceeds BIFF8's grid", () => { + expect(() => + entryFor({ name: "Bad", refersTo: "Sheet1!$IW$1:$A$1" }), + ).toThrow(BiffWriteError); + }); + + it("isolates a range's own LAST column from its first, refusing when only the last exceeds BIFF8's grid", () => { + expect(() => + entryFor({ name: "Bad", refersTo: "Sheet1!$A$1:$IW$1" }), + ).toThrow(BiffWriteError); + }); + + it("accepts a reference exactly at BIFF8's own last column, IV, not just its last row", () => { + expect(() => + entryFor({ name: "AtEdge", refersTo: "Sheet1!$IV$1:$IV$1" }), + ).not.toThrow(); + }); + + it("takes an unquoted sheet-name prefix verbatim, resolving it against the document's own sheets", () => { + expect(() => + entryFor({ name: "N", refersTo: "Sheet1!$A$1" }), + ).not.toThrow(); + }); + + it("unwraps a quoted sheet-name prefix that both starts and ends with a single quote", () => { + const [entry] = definedNameEntriesFor( + [{ name: "N", refersTo: "'My Sheet'!$A$1" }], + [{ name: "My Sheet" }], + ); + expect(entry?.name).toBe("N"); + }); + + it("leaves a sheet-name prefix that starts with a quote but does not end with one, rather than mis-slicing it as if it were properly quoted", () => { + const [entry] = definedNameEntriesFor( + [{ name: "N", refersTo: "'Bad!$A$1" }], + [{ name: "'Bad" }], + ); + expect(entry?.name).toBe("N"); + }); + + it("leaves a sheet-name prefix that ends with a quote but does not start with one, rather than mis-slicing it as if it were properly quoted", () => { + const [entry] = definedNameEntriesFor( + [{ name: "N", refersTo: "Bad'!$A$1" }], + [{ name: "Bad'" }], + ); + expect(entry?.name).toBe("N"); + }); + + it("leaves a single stray quote character as-is, one character short of the two quotes unwrapping requires", () => { + const [entry] = definedNameEntriesFor( + [{ name: "N", refersTo: "'!$A$1" }], + [{ name: "'" }], + ); + expect(entry?.name).toBe("N"); + }); + + it("unwraps a doubly-quoted empty sheet name to the empty string, the two-character edge unwrapping requires", () => { + const [entry] = definedNameEntriesFor( + [{ name: "N", refersTo: "''!$A$1" }], + [{ name: "" }], + ); + expect(entry?.name).toBe("N"); + }); + + it("unescapes a doubled single quote inside a quoted sheet name back to one literal quote", () => { + const [entry] = definedNameEntriesFor( + [{ name: "N", refersTo: "'It''s Mine'!$A$1" }], + [{ name: "It's Mine" }], + ); + expect(entry?.name).toBe("N"); + }); +}); + +describe("requiredCaptureGroup", () => { + it("returns the group unchanged when it is present", () => { + expect(requiredCaptureGroup("A1")).toBe("A1"); + }); + + it("throws for an undefined group -- the one case this module's own regexes never actually produce, verified directly since none of its real callers can construct it", () => { + expect(() => requiredCaptureGroup(undefined)).toThrow(BiffWriteError); + }); +}); + +describe("writeDefinedNameRecord / writeDefinedNameRecords", () => { + it("writes every planned entry as its own Lbl record, in the order given", () => { + const entries = definedNameEntriesFor( + [ + { name: "First", refersTo: "Sheet1!$A$1" }, + { name: "Second", refersTo: "Sheet1!$B$2" }, + ], + [{ name: "Sheet1" }], + ); + const records = writeDefinedNameRecords(entries); + expect(records).toHaveLength(2); + const [firstEntry, secondEntry] = entries; + if (firstEntry === undefined || secondEntry === undefined) { + throw new Error("definedNameEntriesFor returned fewer than 2 entries"); + } + expect(records[0]).toStrictEqual(writeDefinedNameRecord(firstEntry)); + expect(records[1]).toStrictEqual(writeDefinedNameRecord(secondEntry)); + }); +}); diff --git a/packages/xls-codec/src/workbook/defined-names.ts b/packages/xls-codec/src/workbook/defined-names.ts index a714994c4f..4944b1a5dc 100644 --- a/packages/xls-codec/src/workbook/defined-names.ts +++ b/packages/xls-codec/src/workbook/defined-names.ts @@ -163,21 +163,33 @@ const SHEET_QUALIFIED_REFERENCE_RE = const CELL_REFERENCE_NAME_RE = /^\$?[A-Za-z]{1,3}\$?[0-9]{1,5}(:\$?[A-Za-z]{1,3}\$?[0-9]{1,5})?$/; +/** + * Narrows a regex capture group's `string | undefined` type -- every array index this project's own `noUncheckedIndexedAccess` sees this way, capturing groups included -- to the plain `string` it always genuinely holds once this module's own callers reach it. Both regexes below capture every one of their own groups unconditionally: none is wrapped in a group-level `?` (only the `$` markers' own inner content is optional, matching an empty string rather than leaving the surrounding group unmatched), and `String.prototype.split` always returns at least one element even for the empty string. So this function's own `undefined` branch is never reachable from any of this module's actual call sites -- exported so that fact is directly testable rather than trusted to a comment alone. + */ +export function requiredCaptureGroup(group: string | undefined): string { + if (group === undefined) { + throw new BiffWriteError( + "internal error: a regex capture group this module's own callers already proved present was undefined", + ); + } + return group; +} + function parseCorner(text: string): ReferenceCorner | undefined { const match = REFERENCE_CORNER_RE.exec(text); if (match === null) { return undefined; } - const column = columnLettersToIndex(match[2] ?? ""); - const row = Number.parseInt(match[4] ?? "", 10) - 1; + const column = columnLettersToIndex(requiredCaptureGroup(match[2])); + const row = Number.parseInt(requiredCaptureGroup(match[4]), 10) - 1; if (column === undefined || row < 0) { return undefined; } return { row, column, - columnAbsolute: (match[1] ?? "") === "$", - rowAbsolute: (match[3] ?? "") === "$", + columnAbsolute: match[1] === "$", + rowAbsolute: match[3] === "$", }; } @@ -227,11 +239,13 @@ export function definedNameEntriesFor( sheets: readonly { readonly name: string }[], ): DefinedNamePlanEntry[] { return names.map((defined) => { - const builtinName = defined.name.startsWith(XLNM_PREFIX) - ? builtinIndexOf(defined.name) - : undefined; - if (builtinName === undefined) { + // A name resolves to exactly one of the two branches below, not a builtin lookup followed by a separate "was it a builtin" re-check: every BUILTIN_NAME_SPELLINGS entry is itself already exempt from validateUserName's own length/cell-reference-shape rules by construction, so re-deriving "is this a builtin" from builtinName's own value would only restate what the branch already knows. + let builtinName: number | undefined; + if (defined.name.startsWith(XLNM_PREFIX)) { + builtinName = builtinIndexOf(defined.name); + } else { validateUserName(defined.name); + builtinName = undefined; } return { name: defined.name, @@ -303,16 +317,16 @@ function compileRefersTo( ); } // The sheet-name prefix ends at the LAST "!": Excel sheet names cannot contain "!" (a reserved formula character), so this split is unambiguous without parsing the quoting, the same rule ooxml.js's own stripSheetPrefix applies. - const sheetName = unquoteSheetLabel(match[1] ?? ""); + const sheetName = unquoteSheetLabel(requiredCaptureGroup(match[1])); const sheetIndex = sheets.findIndex((sheet) => sheet.name === sheetName); if (sheetIndex === -1) { throw new BiffWriteError( `xls-codec cannot write the defined name ${JSON.stringify(defined.name)}: its refersTo names the sheet ${JSON.stringify(sheetName)}, which the document's own sheets do not carry`, ); } - const referenceText = match[2] ?? ""; + const referenceText = requiredCaptureGroup(match[2]); const [firstText, lastText] = referenceText.split(":"); - const first = parseCorner(firstText ?? ""); + const first = parseCorner(requiredCaptureGroup(firstText)); const last = lastText === undefined ? first : parseCorner(lastText); if (first === undefined || last === undefined) { throw new BiffWriteError( From cc76a418168f486a10a5b9d64916a846216b4550 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:04:19 +0100 Subject: [PATCH 072/107] test(xls-codec): close defined-names.ts's last two mutation survivors recoverFromFormatError's own call was covered by the truncated-record test but not killed: readLblRecord's catch block ignores the call's return value either way, so the only observable effect of actually calling it is that a non-BiffFormatError exception gets re-thrown rather than silently absorbed. A spy forcing BlockCursor's first read to throw a plain bug now proves readDefinedNames propagates it instead of swallowing it as just another malformed record. requiredCaptureGroup's own test asserted only the error's type, not its message, so a mutant emptying the message text survived unnoticed. --- .../src/workbook/defined-names.test.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/xls-codec/src/workbook/defined-names.test.ts b/packages/xls-codec/src/workbook/defined-names.test.ts index a05f13af8f..47a2e2e54a 100644 --- a/packages/xls-codec/src/workbook/defined-names.test.ts +++ b/packages/xls-codec/src/workbook/defined-names.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { ContentDefinedName } from "document-schema.js"; import { RecordBuilder } from "../biff/builder"; +import { BlockCursor } from "../biff/cursor"; import type { FormulaSheetContext } from "../biff/ptg"; import { RECORD_LBL } from "../biff/record-types"; import { writeRecord } from "../biff/record-writer"; @@ -140,6 +141,23 @@ describe("readDefinedNames", () => { expect(names.map((entry) => entry.name)).toStrictEqual(["Real"]); }); + it("propagates a genuine bug rather than absorbing it as just another malformed record", () => { + // BlockCursor.prototype.u16 is what readLblRecord's very first field read (grbit) calls, so failing its first call fails before any real malformed-record condition could apply -- proving readDefinedNames' own catch only recovers from a genuine BiffFormatError (recoverFromFormatError's own re-throw for anything else), not silently swallowing every exception a malformed record's own reader could throw. + const bug = new TypeError("a genuine bug, not a malformed record"); + const spy = vi + .spyOn(BlockCursor.prototype, "u16") + .mockImplementationOnce(() => { + throw bug; + }); + try { + expect(() => + readDefinedNames(groupsOf(lblRecord({ name: "Real" })), ONE_SHEET), + ).toThrow(bug); + } finally { + spy.mockRestore(); + } + }); + it("skips a built-in name whose Name field's own cch is not exactly 1", () => { const twoCharacterName = lblRecord({ builtin: true, cch: 2 }); expect( @@ -390,7 +408,9 @@ describe("requiredCaptureGroup", () => { }); it("throws for an undefined group -- the one case this module's own regexes never actually produce, verified directly since none of its real callers can construct it", () => { - expect(() => requiredCaptureGroup(undefined)).toThrow(BiffWriteError); + expect(() => requiredCaptureGroup(undefined)).toThrow( + "internal error: a regex capture group this module's own callers already proved present was undefined", + ); }); }); From 148256bf16ca1fc4d89d3991b6303b6657fed175 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:22:29 +0100 Subject: [PATCH 073/107] test(xls-codec): reach mutation coverage on write.ts's workbook-wide plans Exports write.ts's per-workbook plan builders (buildFormatPlan, buildPalettePlan, buildFontPlan, buildCellXfPlan, buildSstPlan, buildWorkbookStream, builtinCode) so their own dedup, overflow, and "was this ever registered" guards can be tested directly against a crafted plan input rather than only reachable through a full document round trip, closing gaps a whole-document write never happens to exercise (a custom-format table actually overflowing its 219-identifier range, a lookup for a code/colour/string/signature the scan never saw, sequential custom-format-id assignment, an empty-but-present shared-string table). Two of the three writesCellRecord filters guarding the palette and font scans turned out to be redundant once traced against cellCarriesFormatting: every field either scan reads (a background, a differing font's own colour, a present border side) is also one of cellCarriesFormatting's own checks, so a cell either scan would register anything new from is already a cell writesCellRecord counts as written -- filtering first can never change what either scan sees. Both now iterate every cell directly, with a comment recording why. The format scan's own filter stays: a numberFormatCode is independent of cellCarriesFormatting entirely, so an empty, unformatted, formula-free cell's own stated format code genuinely must never mint a customFormats entry no written record could reference, now covered by a dedicated test. buildWorkbookStream's own defensive copy of globals.bytes before patching its BoundSheet8 offsets in place was dead for the identical reason: concatRecords (buildWorkbookGlobals's own final step) always allocates a fresh Uint8Array regardless of how many pieces it concatenates, so globals.bytes was never a reference anything else could observe, and nothing reads it again after this point either way. --- packages/xls-codec/src/write.test.ts | 351 ++++++++++++++++++++++++++- packages/xls-codec/src/write.ts | 42 ++-- 2 files changed, 370 insertions(+), 23 deletions(-) diff --git a/packages/xls-codec/src/write.test.ts b/packages/xls-codec/src/write.test.ts index b8b3abe9e3..06624efca8 100644 --- a/packages/xls-codec/src/write.test.ts +++ b/packages/xls-codec/src/write.test.ts @@ -41,10 +41,22 @@ import { BiffWriteError } from "./biff/write-errors"; import type { XlsContentDocument } from "./content"; import { readXls, readXlsContent } from "./content"; import { isXlsFile } from "./container"; -import { writeXls, writeXlsContent } from "./write"; +import { + buildCellXfPlan, + buildFontPlan, + buildFormatPlan, + buildPalettePlan, + buildSstPlan, + buildWorkbookStream, + builtinCode, + writeXls, + writeXlsContent, +} from "./write"; import { writeSheetConditionalFormats } from "./workbook/conditional-format-write"; import { writeSheetDataValidations } from "./workbook/data-validation-write"; import { buildWorksheetSubstream } from "./workbook/sheet-writer"; +import * as drawingWriterModule from "./workbook/drawing-writer"; +import { GENERAL_CELL_XF_INDEX } from "./workbook/globals-writer"; // Genuine .xls bytes -- a real [MS-CFB] compound file holding a real BIFF8 Workbook stream -- built by this package's own writer and read back through its own reader, the "primary verification method" this session's writers use throughout (the CFB writer, rtf-codec, wpd-codec). Every test here is a round trip: build a ContentDocument, write it, read it back, and check the read result reflects what was written -- exercising the writer against a reader whose own correctness is independently pinned by content.test.ts's hand-built byte sequences. @@ -1506,13 +1518,15 @@ describe("writeXls", () => { expect(readTree.kind).toBe("spreadsheet"); }); - it("refuses a non-spreadsheet DocumentTree", () => { + it("refuses a non-spreadsheet DocumentTree, naming the offending kind", () => { const wordTree: ReturnType = { kind: "wordprocessing", metadata: {}, children: [], }; - expect(() => writeXls(wordTree)).toThrow(BiffWriteError); + expect(() => writeXls(wordTree)).toThrow( + "writeXls was given a DocumentTree of kind 'wordprocessing', but a .xls workbook can only be written from a 'spreadsheet' document", + ); }); }); @@ -3368,3 +3382,334 @@ describe("writeSheetConditionalFormats: bytes the reader never inspects (#971/#1 expect(interpFractionAt(data, 1)).toBe(0.5); }); }); + +describe("builtinCode", () => { + it("returns the real BUILTIN_NUMBER_FORMATS string for a genuine built-in id", () => { + expect(builtinCode(0)).toBe("General"); + }); + + it("throws for an id BUILTIN_NUMBER_FORMATS has no entry for", () => { + expect(() => builtinCode(-1)).toThrow( + "internal error: BUILTIN_NUMBER_FORMATS has no entry for id -1", + ); + }); +}); + +describe("buildFormatPlan", () => { + function planFor(cells: readonly ContentSheetCell[]) { + return buildFormatPlan([sheet("S", cells)]); + } + + it("reuses one customFormats entry for two cells sharing an identical custom format code", () => { + const plan = planFor([ + cell(0, 0, { kind: "number", value: 1 }, { numberFormatCode: "0.0000" }), + cell(0, 1, { kind: "number", value: 2 }, { numberFormatCode: "0.0000" }), + ]); + expect(plan.customFormats).toHaveLength(1); + }); + + it("mints sequential custom format ids starting at FIRST_CUSTOM_FORMAT_ID (164)", () => { + const plan = planFor([ + cell( + 0, + 0, + { kind: "number", value: 1 }, + { numberFormatCode: "CUSTOM_A" }, + ), + cell( + 0, + 1, + { kind: "number", value: 2 }, + { numberFormatCode: "CUSTOM_B" }, + ), + ]); + expect(plan.customFormats.map((format) => format.id)).toStrictEqual([ + 164, 165, + ]); + }); + + it("throws once a workbook needs more than [MS-XLS] 2.4.126's own 164-382 custom-identifier range allows", () => { + // 220 distinct custom codes: the range holds exactly 219 (164 through 382 inclusive), so the 220th distinct code is the one that overflows it. + const cells = Array.from({ length: 220 }, (_, index) => + cell( + 0, + index, + { kind: "number", value: index }, + { + numberFormatCode: `CUSTOM_${index}`, + }, + ), + ); + expect(() => planFor(cells)).toThrow( + "workbook needs more than 219 distinct custom number formats, more than [MS-XLS] 2.4.126's own 164-382 custom-identifier range allows", + ); + }); + + it("never registers the number-format code of a cell writesCellRecord would drop, so an unused custom format is never minted for it", () => { + // An empty, unformatted, formula-free cell writes no record at all (written-cells.ts's own writesCellRecord), so a numberFormatCode stated on it alone must never mint a customFormats entry no written cell record could ever reference. + const droppedCell = cell( + 0, + 0, + { kind: "empty" }, + { numberFormatCode: "NEVER_WRITTEN" }, + ); + expect(planFor([droppedCell]).customFormats).toStrictEqual([]); + }); + + it("refuses to look up a format code the workbook-wide scan never registered", () => { + const plan = planFor([cell(0, 0, { kind: "number", value: 1 })]); + expect(() => plan.formatIdOf("never scanned")).toThrow( + 'internal error: number-format code "never scanned" was not registered during the workbook-wide format scan', + ); + }); +}); + +describe("buildPalettePlan", () => { + it("refuses to resolve any colour at all when the workbook's cells use none", () => { + const plan = buildPalettePlan([ + sheet("S", [cell(0, 0, { kind: "number", value: 1 })]), + ]); + expect(() => plan.icvOf(rgbHexToColor("ff0000"))).toThrow( + "internal error: colour ff0000 was not registered during the workbook-wide palette scan", + ); + }); + + it("needs no Palette record when every distinct colour already matches the fixed default table", () => { + const black = rgbHexToColor("000000"); // DEFAULT_PALETTE_TABLE's own entry 0 + const plan = buildPalettePlan([ + sheet("S", [ + cell( + 0, + 0, + { kind: "number", value: 1 }, + { + background: { kind: "solid", color: black }, + }, + ), + ]), + ]); + expect(plan.paletteColors).toBeUndefined(); + expect(() => plan.icvOf(black)).not.toThrow(); + }); + + it("builds a real Palette record once at least one colour is not in the fixed default table, including every distinct colour the workbook uses, not just the non-default one", () => { + const black = rgbHexToColor("000000"); // already in the default table + const custom = rgbHexToColor("123456"); // not in the default table + const plan = buildPalettePlan([ + sheet("S", [ + cell( + 0, + 0, + { kind: "number", value: 1 }, + { + background: { kind: "solid", color: black }, + }, + ), + cell( + 0, + 1, + { kind: "number", value: 2 }, + { + background: { kind: "solid", color: custom }, + }, + ), + ]), + ]); + expect(plan.paletteColors).toHaveLength(PALETTE_ENTRY_COUNT); + expect(() => plan.icvOf(black)).not.toThrow(); + expect(() => plan.icvOf(custom)).not.toThrow(); + }); + + it("refuses a workbook needing more distinct decoration colours than a Palette record can hold, naming the exact count and ceiling", () => { + const cells = Array.from({ length: PALETTE_ENTRY_COUNT + 1 }, (_, index) => + cell( + 0, + index, + { kind: "number", value: index }, + { + background: { + kind: "solid", + color: rgbHexToColor(index.toString(16).padStart(6, "0")), + }, + }, + ), + ); + expect(() => buildPalettePlan([sheet("S", cells)])).toThrow( + `workbook needs ${PALETTE_ENTRY_COUNT + 1} distinct decoration colours, more than the ${PALETTE_ENTRY_COUNT} entries [MS-XLS] 2.4.188's own Palette record can hold`, + ); + }); +}); + +describe("buildFontPlan", () => { + it("resolves a cell stating no font at all to font-table index 0, minting no second entry for it", () => { + const plan = buildFontPlan( + [sheet("S", [cell(0, 0, { kind: "number", value: 1 })])], + buildPalettePlan([]), + ); + expect(plan.fontEntries).toHaveLength(1); + expect( + plan.fontIndexForCell(cell(0, 0, { kind: "number", value: 1 })), + ).toBe(0); + }); + + it("refuses to resolve a cell whose own font the workbook-wide font scan never saw", () => { + const plan = buildFontPlan( + [sheet("S", [cell(0, 0, { kind: "number", value: 1 })])], + buildPalettePlan([]), + ); + const neverScanned = cell( + 0, + 0, + { kind: "number", value: 1 }, + { + font: { bold: true }, + }, + ); + expect(() => plan.fontIndexForCell(neverScanned)).toThrow( + /resolves to a font the workbook-wide font scan never saw/, + ); + }); +}); + +describe("buildCellXfPlan", () => { + function planFor(cells: readonly ContentSheetCell[]) { + const sheets = [sheet("S", cells)]; + const formatPlan = buildFormatPlan(sheets); + const palettePlan = buildPalettePlan(sheets); + const fontPlan = buildFontPlan(sheets, palettePlan); + return buildCellXfPlan(sheets, formatPlan, palettePlan, fontPlan); + } + + it("resolves a plain, undecorated cell to the implicit General cell XF, minting no entry for it", () => { + const plan = planFor([cell(0, 0, { kind: "number", value: 1 })]); + expect(plan.cellXfEntries).toStrictEqual([]); + expect(plan.xfIndexForCell(cell(0, 0, { kind: "number", value: 1 }))).toBe( + GENERAL_CELL_XF_INDEX, + ); + }); + + it("reuses one cell-XF entry for two cells sharing an identical format/font/alignment/decoration combination", () => { + const decorated = (row: number) => + cell( + row, + 0, + { kind: "number", value: 1 }, + { + alignment: "center", + background: { kind: "solid", color: rgbHexToColor("ff0000") }, + }, + ); + const plan = planFor([decorated(0), decorated(1)]); + expect(plan.cellXfEntries).toHaveLength(1); + }); + + it("gives two cells differing only in alignment two distinct cell-XF entries, not one shared entry", () => { + const centered = cell( + 0, + 0, + { kind: "number", value: 1 }, + { + alignment: "center", + }, + ); + const rightAligned = cell( + 0, + 1, + { kind: "number", value: 1 }, + { + alignment: "right", + }, + ); + const plan = planFor([centered, rightAligned]); + expect(plan.cellXfEntries).toHaveLength(2); + }); + + it("mints a real decoration for a cell that carries formatting, rather than treating every cell as undecorated", () => { + const decorated = cell( + 0, + 0, + { kind: "number", value: 1 }, + { + background: { kind: "solid", color: rgbHexToColor("ff0000") }, + }, + ); + const plan = planFor([decorated]); + expect(plan.cellXfEntries).toHaveLength(1); + expect(plan.cellXfEntries[0]?.decoration).not.toBeUndefined(); + }); + + it("refuses to resolve a cell whose own cell-Xf signature the workbook-wide scan never saw", () => { + const plan = planFor([cell(0, 0, { kind: "number", value: 1 })]); + const neverScanned = cell( + 0, + 0, + { kind: "number", value: 1 }, + { + alignment: "center", + }, + ); + expect(() => plan.xfIndexForCell(neverScanned)).toThrow( + /which the workbook-wide cell-format scan never saw/, + ); + }); +}); + +describe("buildSstPlan", () => { + it("is empty for a workbook with no string-kind cells at all", () => { + const plan = buildSstPlan([ + sheet("S", [cell(0, 0, { kind: "number", value: 1 })]), + ]); + expect(plan.strings).toStrictEqual([]); + expect(plan.totalCount).toBe(0); + }); + + it("counts every string-kind cell towards totalCount, even repeats of the identical value that share one strings-table slot", () => { + const plan = buildSstPlan([ + sheet("S", [ + cell(0, 0, { kind: "string", value: "Repeat" }), + cell(0, 1, { kind: "string", value: "Repeat" }), + ]), + ]); + expect(plan.strings).toStrictEqual(["Repeat"]); + expect(plan.totalCount).toBe(2); + }); + + it("refuses to look up a string the workbook-wide shared-string scan never registered", () => { + const plan = buildSstPlan([ + sheet("S", [cell(0, 0, { kind: "string", value: "Known" })]), + ]); + expect(() => plan.indexOf("Unknown")).toThrow( + 'internal error: string "Unknown" was not registered during the workbook-wide shared-string scan', + ); + }); +}); + +describe("buildWorkbookStream", () => { + it("refuses a document with no sheets at all", () => { + expect(() => buildWorkbookStream(document([]))).toThrow( + "a .xls workbook must contain at least one sheet ([MS-XLS] 2.1.7.20.3's own BUNDLESHEET production requires 1*BoundSheet8), but the document being written has none", + ); + }); + + it("refuses a workbook whose own drawing plan produced fewer sheet-drawing entries than the document has sheets", () => { + // buildDrawingWritePlan's own contract guarantees one sheetDrawings entry per sheet, so this can only be reached by a genuine disagreement between the two -- proven here by making the real function lie about it, rather than by a document this writer could ever produce on its own. + const spy = vi + .spyOn(drawingWriterModule, "buildDrawingWritePlan") + .mockReturnValue({ + drawingGroupBytes: undefined, + sheetDrawings: [], + embeddingStreams: [], + }); + try { + expect(() => + buildWorkbookStream( + document([sheet("S", [cell(0, 0, { kind: "number", value: 1 })])]), + ), + ).toThrow( + "internal error: sheet 0 has no drawing plan entry -- buildDrawingWritePlan produced fewer entries than there are sheets", + ); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/packages/xls-codec/src/write.ts b/packages/xls-codec/src/write.ts index eb9673a048..0bd5f471c3 100644 --- a/packages/xls-codec/src/write.ts +++ b/packages/xls-codec/src/write.ts @@ -84,7 +84,7 @@ const BUILTIN_FORMAT_TIME = 21; // "h:mm:ss" const BUILTIN_FORMAT_DATE_TIME = 22; // "m/d/yy h:mm" const GENERAL_FORMAT_ID = 0; -function builtinCode(id: number): string { +export function builtinCode(id: number): string { const code = BUILTIN_NUMBER_FORMATS.get(id); if (code === undefined) { throw new BiffWriteError( @@ -136,7 +136,7 @@ function formatCodeForCell(cell: ContentSheetCell): string { ); } -interface FormatPlan { +export interface FormatPlan { readonly customFormats: readonly { readonly id: number; readonly code: string; @@ -145,7 +145,7 @@ interface FormatPlan { } /** Scans every sheet's cells once, assigning each distinct number-format code a formatId: reusing a built-in id for a code matching one of excel-number-format's own BUILTIN_NUMBER_FORMATS strings exactly, minting a new custom id from FIRST_CUSTOM_FORMAT_ID otherwise. Cell XF index assignment is a separate, later pass (buildCellXfPlan below) -- a formatId alone no longer determines a cell's XF index once decoration exists, since two cells sharing a format but differing in background/borders need two distinct XFs. */ -function buildFormatPlan(sheets: readonly ContentSheet[]): FormatPlan { +export function buildFormatPlan(sheets: readonly ContentSheet[]): FormatPlan { const codeToFormatId = new Map(); const builtinIdByCode = new Map( Array.from(BUILTIN_NUMBER_FORMATS, ([id, code]) => [code, id]), @@ -198,7 +198,7 @@ function buildFormatPlan(sheets: readonly ContentSheet[]): FormatPlan { // --- Cell decoration: the workbook-wide colour table, and the (format, decoration) -> XF-index interning that carries it --- -interface PalettePlan { +export interface PalettePlan { /** The workbook's own custom colour table (56 entries, icv 8 first), or undefined when every distinct decoration colour the workbook's cells use already matches the fixed default table -- in which case no Palette record is needed at all, and icvOf resolves every colour straight through that default table. */ readonly paletteColors: readonly Color[] | undefined; /** The icv (7-bit colour-table index) a decoration colour resolves to -- into `paletteColors` when defined, into the fixed default table otherwise. Every colour this is called with must already have been registered during the workbook-wide colour scan below. */ @@ -206,7 +206,7 @@ interface PalettePlan { } /** Scans every sheet's cells once for the distinct fill/border colours the workbook actually uses (background, and each present border side's own colour), then decides whether they all already have a home in the fixed default table (no Palette record needed) or whether at least one genuinely custom colour forces a real one -- in which case every distinct colour, not just the non-default ones, is allocated its own dedicated slot, so the whole 56-entry table is self-consistent and every reference resolves through it rather than a mix of "the file's own table" and "the implicit default". */ -function buildPalettePlan(sheets: readonly ContentSheet[]): PalettePlan { +export function buildPalettePlan(sheets: readonly ContentSheet[]): PalettePlan { const colorByHex = new Map(); const record = (color: Color | undefined): void => { if (color === undefined) { @@ -230,8 +230,8 @@ function buildPalettePlan(sheets: readonly ContentSheet[]): PalettePlan { }; for (const sheet of sheets) { - // Only cells that actually become records, so the scan can never allocate a palette slot to a colour the XF pass below then never writes -- see written-cells.ts on why every pass shares one predicate. - for (const cell of sheet.cells.filter(writesCellRecord)) { + // No writesCellRecord filter here, unlike the format/font scans below: every field this loop reads (background, a differing font's own colour, a present border side's colour) is also one of cellCarriesFormatting's own checks, so a cell this loop would register a colour from is already a cell writesCellRecord counts as formatted and therefore written -- filtering first can never change which colours this scan sees, only cost an extra pass to compute the identical answer. + for (const cell of sheet.cells) { recordFill(cell.background); record(cell.font?.color); record(cell.borders?.left?.color); @@ -407,7 +407,7 @@ function signatureOfCellXf( return signature; } -interface FontPlan { +export interface FontPlan { /** The workbook's font table in write order: entry 0 is the Normal font, every later entry one distinct cell font, exactly as globals-writer.ts writes the records. */ readonly fontEntries: readonly XfFontFields[]; /** The font-table index a cell's own font resolves to -- 0 (the Normal font) for a cell stating none, so the index this returns and the font-entry interning above can never disagree about what "no font" means. */ @@ -426,7 +426,7 @@ function signatureOfFont(fields: XfFontFields): string { /** * Scans every sheet's cells once, interning each distinct cell font into its own font-table entry: the Normal font is always entry 0 (every style XF and the implicit General cell XF reference it, whether or not any cell states a font of its own), and each distinct ContentFont the workbook's cells resolve to mints one further entry the first time it is seen. A ContentFont that normalises back to the Normal font's own fields -- absent, empty, or restating only default values -- resolves to entry 0 and mints nothing, the write-side mirror of the reader's own diff against entry 0. */ -function buildFontPlan( +export function buildFontPlan( sheets: readonly ContentSheet[], palettePlan: PalettePlan, ): FontPlan { @@ -438,8 +438,8 @@ function buildFontPlan( xfFontFieldsOf(cell.font, palettePlan.icvOf); for (const sheet of sheets) { - // The same predicate every other workbook-wide pass applies, so a font is never interned for a cell that then writes no record naming it. - for (const cell of sheet.cells.filter(writesCellRecord)) { + // No writesCellRecord filter here: cellFontDiffersFromNormal (cellCarriesFormatting's own font check) diffs a font against exactly the same fields xfFontFieldsOf resolves, so a font this loop would ever intern as a NEW entry already makes its own cell one writesCellRecord counts as formatted and therefore written. A font that resolves to NORMAL_FONT_FIELDS' own signature (an absent font, or one restating only default values) is already registered at index 0 before the loop starts, so scanning a filtered-out cell's font mints nothing new either way. + for (const cell of sheet.cells) { const fields = fieldsOf(cell); const signature = signatureOfFont(fields); if (indexBySignature.has(signature)) { @@ -464,7 +464,7 @@ function buildFontPlan( }; } -interface CellXfPlan { +export interface CellXfPlan { readonly cellXfEntries: readonly CellXfPlanEntry[]; readonly xfIndexForCell: (cell: ContentSheetCell) => number; } @@ -474,7 +474,7 @@ interface CellXfPlan { * * The returned xfIndexForCell only ever LOOKS UP -- it cannot mint an entry, and refuses a signature this scan never saw. buildWorkbookGlobals is handed cellXfEntries before any sheet's records are built, so an entry minted later than this scan would be one no XF record was written for, and the cell record naming its index would point past the end of the workbook's XF table. Nothing about the resulting bytes says so: a reader resolves that index to whatever XF happens to sit there, or to none, and the cell's format is silently wrong either way. Refusing the lookup is the only place that divergence can still be caught. */ -function buildCellXfPlan( +export function buildCellXfPlan( sheets: readonly ContentSheet[], formatPlan: FormatPlan, palettePlan: PalettePlan, @@ -543,14 +543,14 @@ function buildCellXfPlan( }; } -interface SstPlan { +export interface SstPlan { readonly strings: readonly string[]; readonly totalCount: number; readonly indexOf: (text: string) => number; } /** Scans every sheet's string-kind cells once, in sheet then cell order, assigning each distinct value the shared string table index every LabelSst cell referencing it uses. */ -function buildSstPlan(sheets: readonly ContentSheet[]): SstPlan { +export function buildSstPlan(sheets: readonly ContentSheet[]): SstPlan { const indexOf = new Map(); const strings: string[] = []; let totalCount = 0; @@ -623,7 +623,7 @@ function concatBytes( return out; } -interface WorkbookStreamBuild { +export interface WorkbookStreamBuild { readonly bytes: Uint8Array; /** The Embedding Storage streams (drawing-writer.ts's own MBD-named Package streams, [MS-XLS] 2.1.7) an embedded OLE object needs beside the Workbook stream in the outer compound file -- empty when the workbook carries none. */ readonly embeddingStreams: readonly { @@ -633,7 +633,9 @@ interface WorkbookStreamBuild { } /** Builds the [MS-XLS] Workbook stream: the globals substream followed by one worksheet substream per sheet, with every BoundSheet8's own lbPlyPos patched to the real byte offset its sheet's substream landed at. */ -function buildWorkbookStream(content: XlsContentDocument): WorkbookStreamBuild { +export function buildWorkbookStream( + content: XlsContentDocument, +): WorkbookStreamBuild { if (content.sheets.length === 0) { throw new BiffWriteError( "a .xls workbook must contain at least one sheet ([MS-XLS] 2.1.7.20.3's own BUNDLESHEET production requires 1*BoundSheet8), but the document being written has none", @@ -689,11 +691,11 @@ function buildWorkbookStream(content: XlsContentDocument): WorkbookStreamBuild { offset += stream.length; } - const globalsBytes = globals.bytes.slice(); - patchBoundSheetOffsets(globalsBytes, globals.lbPlyPosOffsets, sheetOffsets); + // No defensive copy before patching in place: concatRecords (buildWorkbookGlobals's own final step) always allocates a fresh Uint8Array regardless of piece count, so globals.bytes is never a reference to any other array this module -- or globals-writer.ts's own caller -- could observe, and nothing reads globals.bytes again after this point. + patchBoundSheetOffsets(globals.bytes, globals.lbPlyPosOffsets, sheetOffsets); return { - bytes: concatBytes([globalsBytes, ...sheetStreams]), + bytes: concatBytes([globals.bytes, ...sheetStreams]), embeddingStreams: drawingPlan.embeddingStreams, }; } From 7ee956782f30dcab6a1421d7b4863bc71ff9bc83 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:30:39 +0100 Subject: [PATCH 074/107] test(xls-codec): close write.ts's remaining mutation survivors buildPalettePlan's own dedup guard and empty-scan early return both turned out to be dead: Map.set on an existing key neither moves it in iteration order nor changes what colorToRgbHex would later produce for it (two Color values sharing one hex serialise identically), and an empty colorByHex already falls out of the fast-path default-table loop with the identical paletteColors-undefined, everything-unregistered behaviour the dedicated empty branch restated. Both removed. signatureOfCellXf's own alignment/verticalAlignment fields moved from a `?? ""` fallback to appending the segment only when stated: no real Alignment value can ever equal an arbitrary placeholder string, so no mutation of that placeholder could ever produce an observable difference either way -- omitting the segment entirely instead makes two cells that both leave a field unstated share its absence, and one that states it never does, the same distinction expressed through the segment's presence rather than trusted to an unobservable constant. New tests cover: the custom-format range's own exact 219-identifier boundary (382 accepted, not just 220 refused); each of the four border sides contributing its own genuinely distinct segment to a cell-Xf signature, proven against an otherwise-identical background-only baseline rather than only cross-checking four already-different cells against each other; two fonts differing only in colour minting two distinct font-table entries; and a cell fill of a kind ContentCellFillSchema's own discriminated union does not define. --- packages/xls-codec/src/write.test.ts | 93 ++++++++++++++++++++++++++++ packages/xls-codec/src/write.ts | 24 ++++--- 2 files changed, 104 insertions(+), 13 deletions(-) diff --git a/packages/xls-codec/src/write.test.ts b/packages/xls-codec/src/write.test.ts index 06624efca8..6bf8b5213f 100644 --- a/packages/xls-codec/src/write.test.ts +++ b/packages/xls-codec/src/write.test.ts @@ -3445,6 +3445,22 @@ describe("buildFormatPlan", () => { ); }); + it("accepts exactly 219 distinct custom codes, filling the 164-382 range without overflowing it", () => { + const cells = Array.from({ length: 219 }, (_, index) => + cell( + 0, + index, + { kind: "number", value: index }, + { + numberFormatCode: `CUSTOM_${index}`, + }, + ), + ); + const plan = planFor(cells); + expect(plan.customFormats).toHaveLength(219); + expect(plan.customFormats.at(-1)?.id).toBe(382); + }); + it("never registers the number-format code of a cell writesCellRecord would drop, so an unused custom format is never minted for it", () => { // An empty, unformatted, formula-free cell writes no record at all (written-cells.ts's own writesCellRecord), so a numberFormatCode stated on it alone must never mint a customFormats entry no written cell record could ever reference. const droppedCell = cell( @@ -3569,6 +3585,32 @@ describe("buildFontPlan", () => { /resolves to a font the workbook-wide font scan never saw/, ); }); + + it("mints two distinct font entries for fonts differing only in colour, not just name/size/weight/style", () => { + const sheets = [ + sheet("S", [ + cell( + 0, + 0, + { kind: "number", value: 1 }, + { + font: { color: rgbHexToColor("ff0000") }, + }, + ), + cell( + 0, + 1, + { kind: "number", value: 2 }, + { + font: { color: rgbHexToColor("0000ff") }, + }, + ), + ]), + ]; + const plan = buildFontPlan(sheets, buildPalettePlan(sheets)); + // Entry 0 is Normal; the two colours must each mint their own entry rather than collapsing onto one. + expect(plan.fontEntries).toHaveLength(3); + }); }); describe("buildCellXfPlan", () => { @@ -3624,6 +3666,57 @@ describe("buildCellXfPlan", () => { expect(plan.cellXfEntries).toHaveLength(2); }); + it("gives each of the four border sides its own distinct cell-XF entry, against an otherwise-identical undecorated baseline", () => { + // Every cell here shares the identical background, so the only thing that could tell two of their cell-Xf signatures apart is which single border side (if any) each one states -- proving each side's own segment of the signature genuinely carries the side's identity, not just its style/colour. + const backgroundOnly = { + kind: "solid", + color: rgbHexToColor("00ff00"), + } as const; + const borderEdge = { color: rgbHexToColor("ff0000"), widthPt: 0.75 }; + const withBorder = ( + column: number, + side: "left" | "right" | "top" | "bottom", + ) => + cell( + 0, + column, + { kind: "number", value: 1 }, + { + background: backgroundOnly, + borders: { [side]: borderEdge }, + }, + ); + const baseline = cell( + 0, + 0, + { kind: "number", value: 1 }, + { + background: backgroundOnly, + }, + ); + const plan = planFor([ + baseline, + withBorder(1, "left"), + withBorder(2, "right"), + withBorder(3, "top"), + withBorder(4, "bottom"), + ]); + expect(plan.cellXfEntries).toHaveLength(5); + }); + + it("refuses a cell fill of a kind ContentCellFillSchema's own discriminated union does not define", () => { + const bogusFill = { + kind: "bogus", + } as unknown as ContentSheetCell["background"]; + expect(() => + planFor([ + cell(0, 0, { kind: "number", value: 1 }, { background: bogusFill }), + ]), + ).toThrow( + "xls-codec cannot write a cell fill with kind 'bogus': ContentCellFillSchema's discriminated union only defines 'solid' and 'pattern'", + ); + }); + it("mints a real decoration for a cell that carries formatting, rather than treating every cell as undecorated", () => { const decorated = cell( 0, diff --git a/packages/xls-codec/src/write.ts b/packages/xls-codec/src/write.ts index 0bd5f471c3..76344c9b77 100644 --- a/packages/xls-codec/src/write.ts +++ b/packages/xls-codec/src/write.ts @@ -212,10 +212,8 @@ export function buildPalettePlan(sheets: readonly ContentSheet[]): PalettePlan { if (color === undefined) { return; } - const hex = colorToRgbHex(color); - if (!colorByHex.has(hex)) { - colorByHex.set(hex, color); - } + // Unconditional: Map.set on a key already present neither moves it in iteration order (only a genuinely new key is appended) nor changes what colorToRgbHex would produce for it later (two Color values sharing one hex are equal in every byte this writer ever serialises), so a has() guard first would only spend a lookup to reach the identical map every time. + colorByHex.set(colorToRgbHex(color), color); }; const recordFill = (fill: ContentCellFill | undefined): void => { if (fill === undefined) { @@ -255,14 +253,7 @@ export function buildPalettePlan(sheets: readonly ContentSheet[]): PalettePlan { ); }; - if (colorByHex.size === 0) { - return { - paletteColors: undefined, - icvOf: (color) => missing(colorToRgbHex(color)), - }; - } - - // Fast path: does every distinct colour already match the fixed default table exactly? If so, no Palette record is needed at all. + // No separate empty-map return: an empty colorByHex has no hex failing the default-table lookup below (there is nothing to iterate), so it already falls out of the fast path exactly as the dedicated empty case would -- paletteColors undefined, icvOf refusing every colour as unregistered, since none ever was. Fast path: does every distinct colour already match the fixed default table exactly? If so, no Palette record is needed at all. const defaultIcvByHex = new Map(); let needsCustomPalette = false; for (const hex of colorByHex.keys()) { @@ -394,7 +385,14 @@ function signatureOfCellXf( verticalAlignment: "top" | "middle" | "bottom" | undefined, decoration: XfDecorationFields | undefined, ): string { - let signature = `f${formatId}|n${fontIndex}|a${alignment ?? ""}|v${verticalAlignment ?? ""}`; + // alignment/verticalAlignment are appended only when stated, rather than through a `?? ""` fallback: the fallback's own placeholder value is never observable either way (no real Alignment/verticalAlignment value can ever equal it, so it can never falsely collide with or falsely distinguish a real one), so a StringLiteral mutant swapping the placeholder for a different one is undetectable no matter what string is chosen there. Omitting the segment entirely instead means two cells that both leave alignment unstated share the identical (absent) segment, and one that states it never does -- the same distinction, expressed as a difference the segment's own presence carries rather than one it is trusted to encode into an arbitrary constant. + let signature = `f${formatId}|n${fontIndex}`; + if (alignment !== undefined) { + signature += `|a${alignment}`; + } + if (verticalAlignment !== undefined) { + signature += `|v${verticalAlignment}`; + } if (decoration === undefined) { return signature; } From d1c2372a48577ba06e919094e98f78bc103afadd Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:34:48 +0100 Subject: [PATCH 075/107] refactor(xls-codec): build a cell-Xf signature via JSON.stringify The hand-assembled template version's own conditional appends for alignment/verticalAlignment ("only append this segment when the field is stated") introduced exactly the equivalent-mutant problem they were meant to fix: forcing either guard permanently true still produces a signature every affected cell shares consistently with every other cell in the identical state, so no test could ever observe the difference. JSON.stringify sidesteps this by construction: it already drops an undefined-valued property from its own object argument entirely, so "every cell leaving a field unstated shares one signature, a cell stating it never does" falls out of a builtin rather than a hand-written branch, and there is no per-field placeholder value or guard left for a mutation to target at all. --- packages/xls-codec/src/write.ts | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/packages/xls-codec/src/write.ts b/packages/xls-codec/src/write.ts index 76344c9b77..01cb8614e3 100644 --- a/packages/xls-codec/src/write.ts +++ b/packages/xls-codec/src/write.ts @@ -377,7 +377,11 @@ function resolveDecorationForCell( }; } -/** A deterministic signature for one cell XF's own (formatId, fontIndex, alignment, verticalAlignment, decoration) tuple, so two cells sharing all five share one XF record -- the interning key buildCellXfPlan below dedupes on, mirroring how CellFormatTable in ooxml.js's typed/xlsx/styles.ts dedupes an on (number format, decoration) together rather than on format alone, widened here by the cell's own font and alignment. */ +/** + * A deterministic signature for one cell XF's own (formatId, fontIndex, alignment, verticalAlignment, decoration) tuple, so two cells sharing all five share one XF record -- the interning key buildCellXfPlan below dedupes on, mirroring how CellFormatTable in ooxml.js's typed/xlsx/styles.ts dedupes an on (number format, decoration) together rather than on format alone, widened here by the cell's own font and alignment. + * + * JSON.stringify rather than hand-assembled template segments: a per-field placeholder for "this field was left unstated" (a `?? ""` fallback, an `if (field !== undefined)` guard before appending a segment) is either unobservable -- no real Alignment/verticalAlignment/decoration value can ever equal an arbitrary placeholder string, so no mutation of it changes any test's outcome -- or, worse, itself wrong: `JSON.stringify` already drops an `undefined`-valued property from its own object-literal argument entirely (`JSON.stringify({a: undefined})` is `"{}"`, identical to an object that never had the key), which is exactly "unstated fields collapse to one shared signature, stated ones do not" with no hand-written branch to get subtly wrong or leave untested. + */ function signatureOfCellXf( formatId: number, fontIndex: number, @@ -385,24 +389,13 @@ function signatureOfCellXf( verticalAlignment: "top" | "middle" | "bottom" | undefined, decoration: XfDecorationFields | undefined, ): string { - // alignment/verticalAlignment are appended only when stated, rather than through a `?? ""` fallback: the fallback's own placeholder value is never observable either way (no real Alignment/verticalAlignment value can ever equal it, so it can never falsely collide with or falsely distinguish a real one), so a StringLiteral mutant swapping the placeholder for a different one is undetectable no matter what string is chosen there. Omitting the segment entirely instead means two cells that both leave alignment unstated share the identical (absent) segment, and one that states it never does -- the same distinction, expressed as a difference the segment's own presence carries rather than one it is trusted to encode into an arbitrary constant. - let signature = `f${formatId}|n${fontIndex}`; - if (alignment !== undefined) { - signature += `|a${alignment}`; - } - if (verticalAlignment !== undefined) { - signature += `|v${verticalAlignment}`; - } - if (decoration === undefined) { - return signature; - } - signature += - `|p${decoration.fillPattern}:${decoration.fillForegroundIcv}:${decoration.fillBackgroundIcv}` + - `|l${decoration.left.style}:${decoration.left.icv}` + - `|r${decoration.right.style}:${decoration.right.icv}` + - `|t${decoration.top.style}:${decoration.top.icv}` + - `|b${decoration.bottom.style}:${decoration.bottom.icv}`; - return signature; + return JSON.stringify({ + formatId, + fontIndex, + alignment, + verticalAlignment, + decoration, + }); } export interface FontPlan { From 50601b3458e57294e2695c9477e0a7450e09cdbe Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:42:18 +0100 Subject: [PATCH 076/107] refactor(xls-codec): drop two redundant loop-bound checks in sheet.ts collectFormulaGroups and stringResultAfter each looped with an indexed for-loop whose only reason for an `undefined` guard on the current element was noUncheckedIndexedAccess's own blanket typing, not any sparseness records itself can actually have: reaching one index past the real content, then continuing or returning on that guard, is exactly the outcome a for-of over records.entries() (or a records.slice() for the offset search) already produces on its own once the real elements run out, with no separate length comparison for a mutation to silently neutralise. readSheetRecords' own main loop keeps its indexed form: it genuinely mutates its own index mid-iteration to skip past a CondFmt/CondFmt12 group's already-consumed child records, which a for-of over entries() cannot express. --- packages/xls-codec/src/workbook/sheet.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/xls-codec/src/workbook/sheet.ts b/packages/xls-codec/src/workbook/sheet.ts index 7b740ed071..04b973fc13 100644 --- a/packages/xls-codec/src/workbook/sheet.ts +++ b/packages/xls-codec/src/workbook/sheet.ts @@ -213,10 +213,10 @@ function collectFormulaGroups( records: readonly RecordGroup[], ): ReadonlyMap { const groups = new Map(); - for (let index = 0; index < records.length; index += 1) { - const record = records[index]; + // records.entries() rather than an indexed for-loop: it types `record` as a genuine RecordGroup with no undefined case to guard for the loop's own sake (noUncheckedIndexedAccess only has an opinion about arr[i], not about-of iteration), leaving `next = records[index + 1]` -- genuinely capable of running past the array's own end -- as the one undefined check this loop actually needs. + for (const [index, record] of records.entries()) { const next = records[index + 1]; - if (record === undefined || next === undefined) { + if (next === undefined) { continue; } if (record.type !== RECORD_FORMULA) { @@ -536,11 +536,8 @@ function stringResultAfter( records: readonly RecordGroup[], formulaIndex: number, ): RecordGroup | undefined { - for (let index = formulaIndex + 1; index < records.length; index += 1) { - const candidate = records[index]; - if (candidate === undefined) { - return undefined; - } + // A slice, not an indexed for-loop bounded by records.length: reaching the end of the slice ends the search with the identical "no String found" outcome the explicit undefined check below stated separately, so a real array plus for-of leaves nothing here for that check to do. + for (const candidate of records.slice(formulaIndex + 1)) { if (candidate.type === RECORD_STRING) { return candidate; } From 6b59886e5510d03f4e293895c9dd49651c782745 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:50:51 +0100 Subject: [PATCH 077/107] test(xls-codec): reach mutation coverage on sheet.ts's record dispatch Adds targeted low-level tests for readSheetRecords/collectFormulaGroups gaps the existing round-trip tests left uncovered: - groupKey distinguishing two genuinely separate shared-formula groups on the same sheet, rather than only two cells sharing one group - an array-formula group's own rgcb trailer, both a real PtgArray resolving against it and the exact-zero-length boundary when rgce carries no PtgArray at all - stringResultAfter skipping an Array or Table record (not just ShrFmla) between a Formula and its cached String, and stopping the search at anything else - Dimensions' rwMac/colMac check as a genuine OR, not an AND - a declared row height of exactly zero twips still omitting the field - a CFEx record actually reaching readCfEx through readSheetRecords' own dispatch, not just readCfEx and readCondFmtGroup in isolation - fromFormula: false on every non-formula cell-record reader, and fromFormula: true on a cell whose formula text actually resolved, not just the cached-value-only case already asserted - MulRk's own byte-count error message, and its genuinely negative (too-short-for-even-the-fixed-fields) payload case, not just a positive-but-non-multiple one - usedRange's own key presence, not just its value, on both the present and absent sides --- packages/xls-codec/src/workbook/sheet.test.ts | 344 +++++++++++++++++- 1 file changed, 341 insertions(+), 3 deletions(-) diff --git a/packages/xls-codec/src/workbook/sheet.test.ts b/packages/xls-codec/src/workbook/sheet.test.ts index 248c49a109..10c580ae7f 100644 --- a/packages/xls-codec/src/workbook/sheet.test.ts +++ b/packages/xls-codec/src/workbook/sheet.test.ts @@ -7,6 +7,7 @@ import { RECORD_BOTTOMMARGIN, RECORD_CF, RECORD_CF12, + RECORD_CFEX, RECORD_COLINFO, RECORD_CONDFMT, RECORD_CONDFMT12, @@ -29,11 +30,12 @@ import { RECORD_SETUP, RECORD_SHRFMLA, RECORD_STRING, + RECORD_TABLE, RECORD_TOPMARGIN, RECORD_VERTICALPAGEBREAKS, RECORD_WSBOOL, } from "../biff/record-types"; -import { BiffFormatError, readRecords } from "../biff/records"; +import { readRecords } from "../biff/records"; import { groupRecords, type RecordGroup } from "../biff/substreams"; import { cell, @@ -42,6 +44,7 @@ import { record, rkDouble, rkInteger, + shortXlUnicodeString, u16, u32, xlUnicodeString, @@ -132,12 +135,22 @@ describe("readSheetRecords cell records", () => { expect(cells.map((entry) => entry.xfIndex)).toStrictEqual([15, 16]); }); - it("rejects a MulRk record whose length holds no whole number of entries", () => { + it("rejects a MulRk record whose length holds no whole number of entries, naming the exact byte counts", () => { expect(() => readCells( record(RECORD_MULRK, [...u16(0), ...u16(0), 0x01, 0x02, ...u16(1)]), ), - ).toThrow(BiffFormatError); + ).toThrow( + "multiple-cell record of 8 bytes does not hold a whole number of 6-byte entries", + ); + }); + + it("rejects a MulRk record shorter than its own fixed rw/colFirst/colLast fields, a genuinely negative payload rather than merely a non-multiple one", () => { + expect(() => + readCells(record(RECORD_MULRK, [...u16(0), ...u16(0)])), + ).toThrow( + "multiple-cell record of 4 bytes does not hold a whole number of 6-byte entries", + ); }); it("reads a Blank cell", () => { @@ -225,6 +238,27 @@ describe("readSheetRecords cell records", () => { expect(cells[0]?.value).toStrictEqual({ kind: "string", value: "Inline" }); }); + + it("marks every non-formula cell record's own reading as fromFormula: false, not just Number's own", () => { + const cells = readCells( + record(RECORD_BLANK, cell(0, 0)), + record(RECORD_MULBLANK, [...u16(1), ...u16(0), ...u16(15), ...u16(1)]), + record(RECORD_RK, [...cell(2, 0), ...rkInteger(1)]), + record(RECORD_MULRK, [ + ...u16(3), + ...u16(0), + ...u16(15), + ...rkInteger(1), + ...u16(1), + ]), + record(RECORD_BOOLERR, [...cell(4, 0), 0x01, 0x00]), + record(RECORD_BOOLERR, [...cell(5, 0), 0x07, 0x01]), + record(RECORD_LABELSST, [...cell(6, 0), ...u32(0)]), + record(RECORD_LABEL, [...cell(7, 0), ...xlUnicodeString("x")]), + ); + expect(cells).toHaveLength(8); + expect(cells.every((entry) => !entry.fromFormula)).toBe(true); + }); }); describe("readSheetRecords formula cells", () => { @@ -302,6 +336,78 @@ describe("readSheetRecords formula cells", () => { expect(cells[0]?.value).toStrictEqual({ kind: "string", value: "Result" }); }); + it("finds a string cached result past an Array record sitting between the Formula and its String, an array formula's own FORMULA production shape", () => { + const arrayFiller = record(RECORD_ARRAY, [ + ...u16(0), + ...u16(0), + 0, + 0, // ref + ...u16(0), // flags + ...u32(0), // unused + ...u16(0), // cce + ]); + const cells = readCells( + record(RECORD_FORMULA, [ + ...cell(0, 0), + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0xff, + 0xff, + ...formulaTail, + ]), + arrayFiller, + record(RECORD_STRING, xlUnicodeString("Result")), + ); + + expect(cells[0]?.value).toStrictEqual({ kind: "string", value: "Result" }); + }); + + it("finds a string cached result past a Table record sitting between the Formula and its String, a data table's own FORMULA production shape", () => { + const cells = readCells( + record(RECORD_FORMULA, [ + ...cell(0, 0), + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0xff, + 0xff, + ...formulaTail, + ]), + record(RECORD_TABLE, [...u16(0), ...u16(0)]), + record(RECORD_STRING, xlUnicodeString("Result")), + ); + + expect(cells[0]?.value).toStrictEqual({ kind: "string", value: "Result" }); + }); + + it("stops the search and leaves the cached string empty when the record after the Formula is none of String/Array/Table/ShrFmla", () => { + const cells = readCells( + record(RECORD_FORMULA, [ + ...cell(0, 0), + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0xff, + 0xff, + ...formulaTail, + ]), + record(RECORD_NUMBER, [...cell(9, 9), ...f64(1)]), + record(RECORD_STRING, xlUnicodeString("Result")), + ); + + expect(cells[0]?.value).toStrictEqual({ kind: "string", value: "" }); + }); + it("finds a string cached result past the ShrFmla record of a shared formula", () => { // The FORMULA production of [MS-XLS] 2.1.7.20.6 is `[Uncalced] Formula [Array / Table / ShrFmla / SUB] [String *Continue]`, so a member of a shared-formula run puts a record between the Formula and its String -- checking only the immediately following record would read the result as empty. const cells = readCells( @@ -397,6 +503,7 @@ describe("readSheetRecords formula cells", () => { ); expect(cells[0]?.formula).toBe("A1+B1"); + expect(cells[0]?.fromFormula).toBe(true); }); it("leaves formula absent for a token this reader does not resolve", () => { @@ -480,6 +587,66 @@ describe("readSheetRecords formula cells", () => { expect(cells[1]?.formula).toBe("A2"); }); + it("keys two distinct shared-formula groups by their own separate base cells, never resolving one cell's PtgExp against the other group", () => { + // Two independent shared-formula runs on the same sheet -- base (0,1) filled with the literal 100, base (5,7) with the literal 200 -- each referenced by its own cell via a PtgExp pointing back at its own base. If groupKey ever collapsed two different (row, column) pairs onto the same map key, the second group recorded would silently overwrite the first, and the cell referencing the first base would wrongly resolve to the second group's own text instead. + const ptgInt = (value: number) => [0x1e, ...u16(value)]; + const ptgExpTo = (row: number, column: number) => [ + 0x01, + ...u16(row), + ...u16(column), + ]; + const shrFmlaOf = (rwFirst: number, colFirst: number, rgce: number[]) => + record(RECORD_SHRFMLA, [ + ...u16(rwFirst), + ...u16(rwFirst), + colFirst, + colFirst, + 0, + 2, + ...u16(rgce.length), + ...rgce, + ]); + const cells = readCells( + record(RECORD_FORMULA, [ + ...cell(0, 1), + ...f64(100), + ...u16(0), + ...u32(0), + ...u16(ptgExpTo(0, 1).length), + ...ptgExpTo(0, 1), + ]), + shrFmlaOf(0, 1, ptgInt(100)), + record(RECORD_FORMULA, [ + ...cell(5, 7), + ...f64(200), + ...u16(0), + ...u32(0), + ...u16(ptgExpTo(5, 7).length), + ...ptgExpTo(5, 7), + ]), + shrFmlaOf(5, 7, ptgInt(200)), + record(RECORD_FORMULA, [ + ...cell(2, 2), + ...f64(100), + ...u16(0), + ...u32(0), + ...u16(ptgExpTo(0, 1).length), + ...ptgExpTo(0, 1), + ]), + record(RECORD_FORMULA, [ + ...cell(8, 9), + ...f64(200), + ...u16(0), + ...u32(0), + ...u16(ptgExpTo(5, 7).length), + ...ptgExpTo(5, 7), + ]), + ); + + expect(cells[2]?.formula).toBe("100"); + expect(cells[3]?.formula).toBe("200"); + }); + it("expands a shared formula mixing an absolute PtgRef with a relative PtgRefN, a real on-disk shape per [MS-XLS]", () => { // "=$A$1+A" filled down: the absolute half never changes with the referencing cell, only the relative half does. SharedParsedFormula's own grammar permits ordinary (non-N) Ptg tokens alongside PtgRefN/PtgAreaN in the same rgce -- only the relative ones expand per cell. const shrFmlaRgce = [ @@ -728,6 +895,69 @@ describe("readSheetRecords formula cells", () => { expect(cells[1]?.value).toStrictEqual({ kind: "number", value: 99 }); }); + it("resolves an array-formula group's own PtgArray token against its Array record's real rgcb trailer", () => { + // The Array record's rgce is a bare PtgArray (needing an rgcb to resolve at all), and rgcb -- inferred from the record's own remaining byte length, never declared directly -- carries exactly the PtgExtraArray for a single-element array constant. If the byte arithmetic deriving rgcbLength were wrong, this either reads the wrong bytes as rgcb (a corrupted array constant) or fails to see any rgcb at all (formula absent), rather than resolving to the real "{5}" text. + const ptgArrayToken = [0x40, 0, 0, 0, 0, 0, 0, 0]; + const ptgExtraArraySingleElement = [ + 0, // columns - 1 = 0 + ...u16(0), // rows - 1 = 0 + 0x01, + ...f64(5), // SerNum 5 + ]; + const ptgExpToBase = [0x01, ...u16(3), ...u16(3)]; + const cells = readCells( + record(RECORD_FORMULA, [ + ...cell(3, 3), + ...f64(5), + ...u16(0), + ...u32(0), + ...u16(ptgExpToBase.length), + ...ptgExpToBase, + ]), + record(RECORD_ARRAY, [ + ...u16(3), + ...u16(3), + 3, + 3, // ref: rwFirst=rwLast=colFirst=colLast=3 + ...u16(0), // flags word + ...u32(0), // unused + ...u16(ptgArrayToken.length), + ...ptgArrayToken, + ...ptgExtraArraySingleElement, + ]), + ); + + expect(cells[0]?.formula).toBe("{5}"); + }); + + it("resolves an array-formula group whose own rgce carries no PtgArray at all, needing no rgcb trailer -- the record ends exactly at rgce's own end, rgcbLength genuinely zero rather than negative or overrun", () => { + const rgce = [0x1e, ...u16(42)]; // PtgInt 42 + const ptgExpToBase = [0x01, ...u16(4), ...u16(4)]; + const cells = readCells( + record(RECORD_FORMULA, [ + ...cell(4, 4), + ...f64(42), + ...u16(0), + ...u32(0), + ...u16(ptgExpToBase.length), + ...ptgExpToBase, + ]), + record(RECORD_ARRAY, [ + ...u16(4), + ...u16(4), + 4, + 4, + ...u16(0), + ...u32(0), + ...u16(rgce.length), + ...rgce, + // no trailing bytes at all: rgcbLength is exactly 0, not merely small + ]), + ); + + expect(cells[0]?.formula).toBe("42"); + }); + it("does not abort the whole sheet read when a shared group's own rgce carries a token with a lying embedded length", () => { // The ShrFmla record itself is perfectly well-formed here -- its own cce (4) correctly bounds the 4 bytes of rgce that follow, so collectFormulaGroup's cursor reads all succeed and a group IS recovered for this base cell. The malformed part is inside that already-correctly-bounded rgce: a PtgStr token (0x17) whose own ShortXLUnicodeString cch claims 200 characters when only one byte of character data actually follows. Joining this group against the base cell's PtgExp runs parseFormulaText's cursor past the end of that 4-byte buffer for reasons that are pure file-controlled malformed input (a lying token-internal length), not a bug in this reader's own token walking -- so it must degrade this one cell's formula to absent, not abort the whole sheet the way an uncaught BiffFormatError would. const shrFmlaRgce = [0x17, 200, 0, 0x41]; @@ -871,6 +1101,38 @@ describe("readSheetRecords grid geometry", () => { ); expect(sheet.usedRange).toBeUndefined(); + // Not merely undefined-valued but genuinely absent: readSheetRecords omits the key entirely rather than including it set to undefined, so a caller spreading the result (content.ts's own fallbacks) sees "this file states no used range" rather than a present-but-empty one. + expect("usedRange" in sheet).toBe(false); + }); + + it("includes a genuine usedRange key, not merely a truthy value, once a real Dimensions record is present", () => { + const sheet = readSheetRecords( + groupsOf( + record(RECORD_DIMENSIONS, [...u32(0), ...u32(5), ...u16(0), ...u16(3)]), + ), + [], + ); + expect("usedRange" in sheet).toBe(true); + }); + + it("treats a zero rwMac alone, with a genuinely non-zero colMac, as no used range -- the OR is not an AND", () => { + const sheet = readSheetRecords( + groupsOf( + record(RECORD_DIMENSIONS, [...u32(0), ...u32(0), ...u16(0), ...u16(3)]), + ), + [], + ); + expect("usedRange" in sheet).toBe(false); + }); + + it("treats a zero colMac alone, with a genuinely non-zero rwMac, as no used range too", () => { + const sheet = readSheetRecords( + groupsOf( + record(RECORD_DIMENSIONS, [...u32(0), ...u32(5), ...u16(0), ...u16(0)]), + ), + [], + ); + expect("usedRange" in sheet).toBe(false); }); it("reads a row's manually set height as points", () => { @@ -919,6 +1181,27 @@ describe("readSheetRecords grid geometry", () => { expect(sheet.rows[0]).toStrictEqual({ index: 0, hidden: false }); }); + it("omits a height of exactly zero twips even when the producer did mark it declared", () => { + const sheet = readSheetRecords( + groupsOf( + record(RECORD_ROW, [ + ...u16(0), + ...u16(0), + ...u16(1), + ...u16(0), // miyRw: zero twips + ...u16(0), + ...u16(0), + 0x40, // fUnsynced: declared + 0x01, + ...u16(0), + ]), + ), + [], + ); + + expect(sheet.rows[0]).toStrictEqual({ index: 0, hidden: false }); + }); + it("reads a hidden row", () => { const sheet = readSheetRecords( groupsOf( @@ -1177,6 +1460,61 @@ describe("readSheetRecords grid geometry", () => { }); }); + it("resolves a CFEx record into conditionalFormats12, extending the CondFmt group it names by nID (conditional-format-ex.test.ts covers readCfEx/readCondFmtGroup's own composition in full; this proves the record dispatch actually reaches readCfEx at all)", () => { + const nID = 7; + const sheet = readSheetRecords( + groupsOf( + record(RECORD_CONDFMT, [ + ...u16(1), // ccf -- one CF record follows + ...u16(nID << 1), // A-fToughRecalc(0) + nID + ...u16(0), + ...u16(0), + ...u16(0), + ...u16(0), // refBound (Ref8U), unused + ...u16(1), // one range + ...u16(0), + ...u16(0), + ...u16(0), + ...u16(0), + ]), + record(RECORD_CF, [ + 0x02, // ct: formula + 0x00, // cp + ...u16(9), // cce1 + ...u16(0), // cce2 + 0x17, + ...shortXlUnicodeString("needle"), // PtgStr "needle" + ]), + record(RECORD_CFEX, [ + ...new Array(12).fill(0), // frtRefHeaderU + ...u32(0), // fIsCF12: 0, this is the legacy-CF-extending shape + ...u16(nID), + ...u16(0), // icf + 0x00, // cp + 0x08, // icfTemplate: containsText + ...u16(0), // ipriority + 0x01, // flags: A-fActive set, B-fStopIfTrue clear + 0x00, // fHasDXF: no DXF trailer + 16, // cbTemplateParm + ...u16(0), // ctp + ...new Array(14).fill(0), // reserved + ]), + ), + [], + ); + + expect(sheet.conditionalFormats12).toStrictEqual([ + { + kind: "containsText", + text: "needle", + priority: 0, + stopIfTrue: false, + ranges: [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }], + style: undefined, + }, + ]); + }); + it("ignores records it has no use for", () => { const sheet = readSheetRecords( groupsOf( From 57c43fdedc954ebb8aefc62d745bbcf692c92645 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:55:38 +0100 Subject: [PATCH 078/107] refactor(xls-codec): remove two unobservable sheet.ts mutation surfaces A FormulaValue's own tagged-vs-numeric check compared a getUint16 read against 0xffff, whose two bytes are identical -- so the read's own littleEndian argument could never change the comparison's outcome no matter which byte order was asked for, an unobservable boolean literal in permanent disguise. Reading each byte directly removes the argument, and the constant it existed to name, entirely. readFormula's own two-branch return (with or without a resolved formula) existed only to decide whether the formula field's key was present at all; every real reader of it (content.ts's own `cell.formula !== undefined`) checks by value, never by key, so the branch could never produce an observable difference either way. Spreading the field in unconditionally states the same fact -- present, value undefined when nothing resolved -- without a branch for a mutation to survive undetected in. New tests close the remaining gaps: a false (not just true) boolean cached result, and a Formula record's own inline PtgArray with genuinely zero trailing bytes rather than only a too-short or well-supplied one. --- packages/xls-codec/src/workbook/sheet.test.ts | 37 +++++++++++++++++++ packages/xls-codec/src/workbook/sheet.ts | 12 +++--- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/xls-codec/src/workbook/sheet.test.ts b/packages/xls-codec/src/workbook/sheet.test.ts index 10c580ae7f..26b2924f21 100644 --- a/packages/xls-codec/src/workbook/sheet.test.ts +++ b/packages/xls-codec/src/workbook/sheet.test.ts @@ -297,6 +297,25 @@ describe("readSheetRecords formula cells", () => { expect(cells[0]?.value).toStrictEqual({ kind: "boolean", value: true }); }); + it("reads a false boolean cached result from its tag byte, not just true", () => { + const cells = readCells( + record(RECORD_FORMULA, [ + ...cell(0, 0), + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0xff, + 0xff, + ...formulaTail, + ]), + ); + + expect(cells[0]?.value).toStrictEqual({ kind: "boolean", value: false }); + }); + it("reads an error cached result from its tag byte", () => { const cells = readCells( record(RECORD_FORMULA, [ @@ -776,6 +795,24 @@ describe("readSheetRecords formula cells", () => { expect(cells[0]?.formula).toBe("SUM({1;2;3})"); }); + it("leaves formula absent for a PtgArray with genuinely zero trailing bytes, the record ending exactly at rgce's own end", () => { + const rgce = [0x40, 0, 0, 0, 0, 0, 0, 0]; // PtgArray, needing an rgcb this record carries none of + const cells = readCells( + record(RECORD_FORMULA, [ + ...cell(0, 0), + ...f64(6), + ...u16(0), + ...u32(0), + ...u16(rgce.length), + ...rgce, + // no trailing bytes at all: rgcbLength is exactly 0 + ]), + ); + + expect(cells[0]?.formula).toBeUndefined(); + expect(cells[0]?.value).toStrictEqual({ kind: "number", value: 6 }); + }); + it("keeps the cell's cached value when its own rgcb trailer is too short for the PtgExtraArray it claims to hold", () => { // rgcb's own byte length is never declared anywhere in the file -- this reader infers it by subtraction from the record's total length -- so a PtgExtraArray whose row/column counts overrun what's actually there is a real malformation risk, not a hypothetical one. This must degrade to an absent formula for this one cell, exactly like any other unresolved construct, rather than throwing and losing every other cell's read along with it. const rgce = [ diff --git a/packages/xls-codec/src/workbook/sheet.ts b/packages/xls-codec/src/workbook/sheet.ts index 04b973fc13..320357c473 100644 --- a/packages/xls-codec/src/workbook/sheet.ts +++ b/packages/xls-codec/src/workbook/sheet.ts @@ -165,9 +165,7 @@ const ROW_FLAG_UNSYNCED = 0x40; /** ColInfo flag bits ([MS-XLS] 2.4.53). */ const COLINFO_FLAG_HIDDEN = 0x0001; -/** A FormulaValue whose fExprO field is this is not an Xnum but a tagged non-numeric value ([MS-XLS] 2.5.133). */ -const FORMULA_VALUE_TAGGED = 0xffff; -/** The tag byte's own vocabulary in that case. */ +/** A FormulaValue whose last two bytes are both 0xff means it is not an Xnum but a tagged non-numeric value ([MS-XLS] 2.5.133); the tag byte's own vocabulary in that case follows. */ const FORMULA_VALUE_STRING = 0x00; const FORMULA_VALUE_BOOLEAN = 0x01; const FORMULA_VALUE_ERROR = 0x02; @@ -782,7 +780,8 @@ function readFormula( const header = readCellHeader(cursor); const bytes = cursor.take(8); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - const tagged = view.getUint16(6, true) === FORMULA_VALUE_TAGGED; + // A byte-for-byte comparison, not a getUint16 read against 0xffff: 0xffff's own two bytes are identical (0xff, 0xff), so which byte order getUint16 is asked to use can never change this specific comparison's outcome -- checking each byte directly removes the endianness argument's own unobservable boolean literal instead of leaving it in as dead configuration. + const tagged = view.getUint8(6) === 0xff && view.getUint8(7) === 0xff; const value = tagged ? taggedFormulaValue(view, next) : { kind: "number" as const, value: view.getFloat64(0, true) }; @@ -801,13 +800,12 @@ function readFormula( rgce = undefined; rgcb = undefined; } + // formula is spread in unconditionally, its own value undefined when nothing resolved: every real consumer (content.ts's own `cell.formula !== undefined` check) reads it by value, never by key presence, so a present-but-undefined field and an absent one are indistinguishable to anything that actually looks at this object -- an "is it undefined" branch deciding whether to include the key at all would be true by construction, never a fact a test could observe either way. const formula = rgce === undefined ? undefined : resolveFormulaText(rgce, rgcb, header, formulaSheets, formulaGroups); - return formula === undefined - ? { ...header, value, fromFormula: true } - : { ...header, value, fromFormula: true, formula }; + return { ...header, value, fromFormula: true, formula }; } /** From 7cd6be32b09cae68581016a28e8b56b88bd79256 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:03:19 +0100 Subject: [PATCH 079/107] refactor(xls-codec): drop readFormula's own unreachable rgcb branch rgcbLength can never be negative by the time this line runs: cursor.take(cce) just above already proved that many bytes genuinely present, so recordByteLength(record) is provably at least FORMULA_HEADER_BYTES + cce already. Branching on rgcbLength > 0 to decide between cursor.take(rgcbLength) and undefined was therefore never observable at the zero boundary either -- an empty-but-defined rgcb makes ptg.ts's own rgcbCursor real rather than undefined, but a cursor with zero bytes left fails on its own first read exactly as an absent one already does, so a formula needing one resolves to undefined either way. Always taking removes the branch outright. The same reasoning empties the catch beneath it: reaching it is only possible through cursor.take(cce) itself throwing, before rgce or rgcb is ever assigned, so both are already sitting at their un-reassigned undefined from their own declarations -- the explicit resets restated a fact already true rather than changing anything a mutation could survive undetected in. Two new tests inject a genuine (non-BiffFormatError) bug via a mock, one into readFormula's own rgce/rgcb read and one into resolveFormulaText's call to parseFormulaText, proving each surrounding catch still re-throws anything that isn't the malformed-record error it exists to recover from, rather than silently absorbing it as just another malformed record. --- packages/xls-codec/src/workbook/sheet.test.ts | 63 ++++++++++++++++++- packages/xls-codec/src/workbook/sheet.ts | 6 +- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/xls-codec/src/workbook/sheet.test.ts b/packages/xls-codec/src/workbook/sheet.test.ts index 26b2924f21..d6a4db6c54 100644 --- a/packages/xls-codec/src/workbook/sheet.test.ts +++ b/packages/xls-codec/src/workbook/sheet.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { BlockCursor } from "../biff/cursor"; +import * as ptgModule from "../biff/ptg"; import { RECORD_ARRAY, RECORD_BLANK, @@ -1096,6 +1098,65 @@ describe("readSheetRecords formula cells", () => { expect(cells[0]?.value).toStrictEqual({ kind: "number", value: 1 }); expect(cells[1]?.value).toStrictEqual({ kind: "number", value: 42 }); }); + + it("propagates a genuine bug out of readFormula's own rgce/rgcb read rather than absorbing it as just another malformed record", () => { + // BlockCursor.prototype.take is shared by every take() call this cursor makes -- readCellHeader's own fields use u16/u32 rather than take, so the FormulaValue's own take(8) is the first call, and rgce's own take(cce) inside readFormula's try block is the second -- forcing that second call specifically to throw a plain bug proves the surrounding catch only recovers from a genuine BiffFormatError (recoverFromFormatError's own re-throw for anything else), not silently swallowing every exception a malformed record's own reader could throw. + // Read through Object.getOwnPropertyDescriptor, not a plain BlockCursor.prototype.take property access: the latter is exactly the "unbound method reference" shape @typescript-eslint/unbound-method exists to catch, even though it is in fact rebound immediately via .call() below -- the descriptor lookup carries the identical function value through a shape the rule does not pattern-match on. + const originalTake = Object.getOwnPropertyDescriptor( + BlockCursor.prototype, + "take", + )?.value as (this: BlockCursor, count: number) => Uint8Array; + const bug = new TypeError("a genuine bug, not a malformed record"); + let calls = 0; + const spy = vi + .spyOn(BlockCursor.prototype, "take") + .mockImplementation(function (this: BlockCursor, count: number) { + calls += 1; + if (calls === 2) throw bug; + return originalTake.call(this, count); + }); + try { + expect(() => + readCells( + record(RECORD_FORMULA, [ + ...cell(0, 0), + ...f64(1), + ...u16(0), + ...u32(0), + ...u16(0), + ]), + ), + ).toThrow(bug); + } finally { + spy.mockRestore(); + } + }); + + it("propagates a genuine bug out of resolveFormulaText rather than absorbing it as just another malformed token", () => { + // The rgce here is perfectly well-formed -- the injected bug is a plain Error a spy forces parseFormulaText itself to throw, not anything a file could ever produce, proving resolveFormulaText's own catch only recovers from a genuine BiffFormatError (recoverFromFormatError's own re-throw for anything else), not silently swallowing every exception parseFormulaText could throw. + const bug = new TypeError("a genuine bug, not a malformed record"); + const spy = vi + .spyOn(ptgModule, "parseFormulaText") + .mockImplementation(() => { + throw bug; + }); + try { + expect(() => + readCells( + record(RECORD_FORMULA, [ + ...cell(0, 0), + ...f64(1), + ...u16(0), + ...u32(0), + ...u16(1), + 0x1e, // an opcode readPtgExpBase does not recognise as a PtgExp, so resolveFormulaText's own parseFormulaText branch is the one reached -- only 1 byte of a 3-byte PtgInt, but the bug fires before that would ever matter + ]), + ), + ).toThrow(bug); + } finally { + spy.mockRestore(); + } + }); }); describe("readSheetRecords grid geometry", () => { diff --git a/packages/xls-codec/src/workbook/sheet.ts b/packages/xls-codec/src/workbook/sheet.ts index 320357c473..d445a00b88 100644 --- a/packages/xls-codec/src/workbook/sheet.ts +++ b/packages/xls-codec/src/workbook/sheet.ts @@ -793,12 +793,12 @@ function readFormula( let rgcb: Uint8Array | undefined; try { rgce = cursor.take(cce); + // Never negative here, unlike readArrayGroup's identical-looking subtraction: cce is only reached this line if the cursor.take(cce) just above already proved that many bytes genuinely present, so recordByteLength(record) is provably >= FORMULA_HEADER_BYTES + cce already. Always taking it (rather than branching on rgcbLength > 0) still hands parseFormulaText the exact same "no PtgArray trailer" fact for a genuinely empty result: an empty-but-defined rgcb makes ptg.ts's own rgcbCursor real rather than undefined, but a real cursor with zero bytes left fails on its own very first read exactly as an absent one already does, so a formula needing one still resolves to undefined either way, and one that needs none never consults rgcb at all. const rgcbLength = recordByteLength(record) - (FORMULA_HEADER_BYTES + cce); - rgcb = rgcbLength > 0 ? cursor.take(rgcbLength) : undefined; + rgcb = cursor.take(rgcbLength); } catch (error) { + // rgce and rgcb are only ever reached here through cursor.take(cce) itself throwing (rgcb's own take, immediately after, is provably given exactly its own remaining byte count and so cannot overrun in turn) -- both variables are still sitting at their un-reassigned `undefined` from the declarations above, so there is nothing left to reset. recoverFromFormatError(error, undefined); - rgce = undefined; - rgcb = undefined; } // formula is spread in unconditionally, its own value undefined when nothing resolved: every real consumer (content.ts's own `cell.formula !== undefined` check) reads it by value, never by key presence, so a present-but-undefined field and an absent one are indistinguishable to anything that actually looks at this object -- an "is it undefined" branch deciding whether to include the key at all would be true by construction, never a fact a test could observe either way. const formula = From b0f1b87f5f9691f9c578c5e9933ad6617f49de70 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:08:50 +0100 Subject: [PATCH 080/107] refactor(xls-codec): drop readArrayGroup's unreachable rgcb branch and a redundant break readArrayGroup's own rgcbLength<=0 special case was the identical unobservable branch readFormula's own rgcb read already was: never negative (cursor.take(cce) just above already proved that many bytes present), and an empty-but-defined rgcb behaves exactly like an absent one to any consumer (ptg.ts's own rgcbCursor fails on its first read either way). Always taking it removes the branch. readSheetRecords' own switch drops its default case's break, which had nothing to skip past: default already sits last, so control leaves the switch there regardless of whether a break statement says so. New tests close the remaining gaps: a FormulaValue's own tagged check with one byte genuinely not 0xff (proving both bytes matter, not just their disjunction), and MulBlank's own negative-payload guard isolated from its neighbouring modulo check -- a payload of exactly zero (a legitimate empty run) and a negative payload that is nonetheless an exact multiple of the record's own entry width, which the modulo clause alone would have accepted. --- packages/xls-codec/src/workbook/sheet.test.ts | 54 +++++++++++++++++++ packages/xls-codec/src/workbook/sheet.ts | 8 +-- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/packages/xls-codec/src/workbook/sheet.test.ts b/packages/xls-codec/src/workbook/sheet.test.ts index d6a4db6c54..c0d58f6e32 100644 --- a/packages/xls-codec/src/workbook/sheet.test.ts +++ b/packages/xls-codec/src/workbook/sheet.test.ts @@ -155,6 +155,22 @@ describe("readSheetRecords cell records", () => { ); }); + it("rejects a MulBlank record whose negative payload is nonetheless an exact multiple of its own entry width, proving the negative check is not just standing in for the modulo one", () => { + // MulBlank's own entry width is 2 bytes, and a 4-byte record (row + colFirst only, no colLast, no entries) gives a payload of 4 - 6 = -2 -- negative, but -2 % 2 is 0 in JS's own signed modulo, so only a genuine `payload < 0` check catches this; the modulo clause alone would wrongly accept it. + expect(() => + readCells(record(RECORD_MULBLANK, [...u16(0), ...u16(0)])), + ).toThrow( + "multiple-cell record of 4 bytes does not hold a whole number of 2-byte entries", + ); + }); + + it("accepts a MulBlank record whose payload is exactly zero, a legitimate empty run rather than a negative one", () => { + const cells = readCells( + record(RECORD_MULBLANK, [...u16(0), ...u16(0), ...u16(0)]), + ); + expect(cells).toStrictEqual([]); + }); + it("reads a Blank cell", () => { const cells = readCells(record(RECORD_BLANK, cell(1, 1))); @@ -318,6 +334,44 @@ describe("readSheetRecords formula cells", () => { expect(cells[0]?.value).toStrictEqual({ kind: "boolean", value: false }); }); + it("treats byte 6 alone being 0xff, with byte 7 genuinely not, as an untagged numeric FormulaValue -- both bytes must be 0xff, not just one", () => { + const cells = readCells( + record(RECORD_FORMULA, [ + ...cell(0, 0), + 0x01, // would read as a boolean tag if this FormulaValue were actually tagged + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0xff, + 0x00, // byte 7 is genuinely not 0xff + ...formulaTail, + ]), + ); + + expect(cells[0]?.value.kind).toBe("number"); + }); + + it("treats byte 7 alone being 0xff, with byte 6 genuinely not, as an untagged numeric FormulaValue too", () => { + const cells = readCells( + record(RECORD_FORMULA, [ + ...cell(0, 0), + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, // byte 6 is genuinely not 0xff + 0xff, + ...formulaTail, + ]), + ); + + expect(cells[0]?.value.kind).toBe("number"); + }); + it("reads an error cached result from its tag byte", () => { const cells = readCells( record(RECORD_FORMULA, [ diff --git a/packages/xls-codec/src/workbook/sheet.ts b/packages/xls-codec/src/workbook/sheet.ts index d445a00b88..a7bfe9eac6 100644 --- a/packages/xls-codec/src/workbook/sheet.ts +++ b/packages/xls-codec/src/workbook/sheet.ts @@ -264,11 +264,8 @@ function readArrayGroup(record: RecordGroup): ArrayFormulaGroup { cursor.skip(ARRAY_HEADER_BYTES); const cce = cursor.u16(); const rgce = cursor.take(cce); + // Never negative: cursor.take(cce) just above already proved that many bytes genuinely present, so recordByteLength(record) is provably >= ARRAY_HEADER_BYTES + 2 + cce already. Always taking it (rather than special-casing a non-positive length as undefined) still hands parseFormulaText the exact same "no PtgArray trailer" fact when it is genuinely zero: an empty-but-defined rgcb makes ptg.ts's own rgcbCursor real rather than undefined, but a real cursor with zero bytes left fails on its own very first read exactly as an absent one already does, so a formula needing one resolves to undefined either way, and one that needs none never consults rgcb at all. Reading a length larger than what the record actually holds (a genuine overrun) still throws BiffFormatError, caught below for the same reason as before: a malformed trailer should degrade only this one array formula's group, not abort any other cell's read. const rgcbLength = recordByteLength(record) - (ARRAY_HEADER_BYTES + 2 + cce); - // A non-positive length means the record's own declared byte total does not even cover its header and rgce -- malformed, and genuinely undefined rather than a fake empty buffer: an empty Uint8Array would claim "this record legitimately carries zero bytes of rgcb," which is a real, valid state (an array formula whose rgce has no PtgArray at all) that this distinguishes from. Reading a rgcbLength byte count larger than what the record actually holds (an overrun, as opposed to this too-short case) throws BiffFormatError instead, caught the same way for the same reason: both are this one Array record's own malformed trailer, and neither should stop any OTHER cell's formula from resolving. - if (rgcbLength <= 0) { - return { kind: "array", rgce, rgcb: undefined }; - } try { return { kind: "array", rgce, rgcb: cursor.take(rgcbLength) }; } catch (error) { @@ -432,8 +429,7 @@ export function readSheetRecords( columnBreaks.push(...readPageBreaks(record)); break; default: - // Every other record a worksheet substream carries -- the window settings, the drawing objects, the row-block index -- is not read yet. - break; + // Every other record a worksheet substream carries -- the window settings, the drawing objects, the row-block index -- is not read yet. No break: this is the switch's own last case, so control already leaves it here regardless. } } From 6142af8720c71ae30e75b458a2f8f71387cc5766 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:14:07 +0100 Subject: [PATCH 081/107] refactor(xls-codec): drop readSheetRecords' own redundant loop bound Its main per-record loop needed a mutable index (the CondFmt/CondFmt12 skip below advances it by more than one), so it could not move to a records.entries() for-of the way collectFormulaGroups and stringResultAfter already did. But records itself has no genuine holes, so records[index] === undefined already means, and means only, that index has run past the last record -- an explicit index < records.length loop condition only ever restated that same fact one iteration earlier, for a mutation to silently swap the comparison without changing anything the loop actually does. Looping unconditionally and breaking on the existing undefined check removes the restated bound entirely. New tests close the remaining collectFormulaGroups gaps: a Number record's own base cell happens to parse through readCellHeader exactly like a Formula record's would, so only the record.type check tells the two apart; a Table record can be shaped byte-for-byte like a well-formed Array group, so only next.type says whether it actually is one; and a genuine (non-BiffFormatError) bug injected into a base cell's own read propagates out of collectFormulaGroup rather than being absorbed as just another malformed one. --- packages/xls-codec/src/workbook/sheet.test.ts | 95 +++++++++++++++++++ packages/xls-codec/src/workbook/sheet.ts | 5 +- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/packages/xls-codec/src/workbook/sheet.test.ts b/packages/xls-codec/src/workbook/sheet.test.ts index c0d58f6e32..4f6e533440 100644 --- a/packages/xls-codec/src/workbook/sheet.test.ts +++ b/packages/xls-codec/src/workbook/sheet.test.ts @@ -722,6 +722,101 @@ describe("readSheetRecords formula cells", () => { expect(cells[3]?.formula).toBe("200"); }); + it("never forms a shared-formula group from a non-Formula record immediately followed by a ShrFmla, even though the two share the identical leading Cell-header layout", () => { + // A Number record's own base cell (3, 3) happens to parse through readCellHeader exactly like a Formula record's would -- collectFormulaGroups' own record.type check is the only thing distinguishing "this is a real base cell" from "this happens to precede a ShrFmla by coincidence." + const ptgInt = (value: number) => [0x1e, ...u16(value)]; + const ptgExpTo = (row: number, column: number) => [ + 0x01, + ...u16(row), + ...u16(column), + ]; + const cells = readCells( + record(RECORD_NUMBER, [...cell(3, 3), ...f64(9)]), + record(RECORD_SHRFMLA, [ + ...u16(3), + ...u16(3), + 3, + 3, + 0, + 2, + ...u16(ptgInt(999).length), + ...ptgInt(999), + ]), + record(RECORD_FORMULA, [ + ...cell(0, 0), + ...f64(1), + ...u16(0), + ...u32(0), + ...u16(ptgExpTo(3, 3).length), + ...ptgExpTo(3, 3), + ]), + ); + + expect(cells[1]?.formula).toBeUndefined(); + }); + + it("never forms an array-formula group from a Formula record followed by anything other than ShrFmla or Array, even a record shaped just like a well-formed Array group", () => { + // Table shares the identical layout an Array record's own group-reading would expect (12-byte header, then a two-byte cce and that many rgce bytes) -- only next.type distinguishes "this really is this Formula's own Array companion" from "the next record just happens to be shaped the same way." + const ptgInt = (value: number) => [0x1e, ...u16(value)]; + const ptgExpTo = (row: number, column: number) => [ + 0x01, + ...u16(row), + ...u16(column), + ]; + const arrayShapedRgce = ptgInt(77); + const cells = readCells( + record(RECORD_FORMULA, [ + ...cell(2, 2), + ...f64(1), + ...u16(0), + ...u32(0), + ...u16(ptgExpTo(2, 2).length), + ...ptgExpTo(2, 2), + ]), + record(RECORD_TABLE, [ + ...new Array(12).fill(0), // the identical 12-byte header ARRAY_HEADER_BYTES skips + ...u16(arrayShapedRgce.length), + ...arrayShapedRgce, + ]), + record(RECORD_FORMULA, [ + ...cell(5, 5), + ...f64(1), + ...u16(0), + ...u32(0), + ...u16(ptgExpTo(2, 2).length), + ...ptgExpTo(2, 2), + ]), + ); + + expect(cells[1]?.formula).toBeUndefined(); + }); + + it("propagates a genuine bug out of collectFormulaGroup rather than absorbing it as just another malformed base cell", () => { + // A well-formed Formula+ShrFmla pair, the very first thing readSheetRecords touches -- the injected bug is a plain Error a spy forces the base cell's own very first field read to throw, not anything a file could ever produce, proving collectFormulaGroup's own catch only recovers from a genuine BiffFormatError (recoverFromFormatError's own re-throw for anything else), not silently swallowing every exception reading a base cell or its group could throw. + const bug = new TypeError("a genuine bug, not a malformed record"); + const spy = vi + .spyOn(BlockCursor.prototype, "u16") + .mockImplementationOnce(() => { + throw bug; + }); + try { + expect(() => + readCells( + record(RECORD_FORMULA, [ + ...cell(0, 1), + ...f64(1), + ...u16(0), + ...u32(0), + ...u16(0), + ]), + record(RECORD_SHRFMLA, [...u16(0), ...u16(0), 1, 1, 0, 2, ...u16(0)]), + ), + ).toThrow(bug); + } finally { + spy.mockRestore(); + } + }); + it("expands a shared formula mixing an absolute PtgRef with a relative PtgRefN, a real on-disk shape per [MS-XLS]", () => { // "=$A$1+A" filled down: the absolute half never changes with the referencing cell, only the relative half does. SharedParsedFormula's own grammar permits ordinary (non-N) Ptg tokens alongside PtgRefN/PtgAreaN in the same rgce -- only the relative ones expand per cell. const shrFmlaRgce = [ diff --git a/packages/xls-codec/src/workbook/sheet.ts b/packages/xls-codec/src/workbook/sheet.ts index a7bfe9eac6..c43c70d03e 100644 --- a/packages/xls-codec/src/workbook/sheet.ts +++ b/packages/xls-codec/src/workbook/sheet.ts @@ -307,10 +307,11 @@ export function readSheetRecords( let printHeaders: boolean | undefined; let fitToPage: boolean | undefined; - for (let index = 0; index < records.length; index += 1) { + // No `index < records.length` bound: index can jump past the end of a genuinely dense array (the CondFmt/CondFmt12 skip below advances it by more than one), and records itself has no real holes, so records[index] === undefined already means, and means only, "index has run past the last record" -- a second, separate length comparison would only ever restate that same fact, one iteration later, for a mutation to silently swap without changing anything this loop actually does. + for (let index = 0; ; index += 1) { const record = records[index]; if (record === undefined) { - continue; + break; } switch (record.type) { case RECORD_DIMENSIONS: From 73a7604207813f91d7256635f2da24bc973618f9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:24:47 +0100 Subject: [PATCH 082/107] test(xls-codec): reach mutation coverage on globals.ts's SupBook/XTI resolution Exports readSupBook, resolveXti, and fileNameFromVirtPath for direct testing against crafted inputs, rather than only reachable through the full readWorkbookGlobals pipeline. New tests cover: the virtPath length guard's own exact 1-255 boundary (0 and 256 refused, 1 and 255 accepted); the same-sheet and unused-link single-character sentinels read from their own exact virtPath; itabFirst/itabLast's -2 workbook-level and -1 not-found sentinels isolated from each other rather than only tested together; an external-workbook XTI's own first/last sheet-name lookup failing independently on either index; the EXTERNAL placeholder used only when the workbook's own name was not recovered, with the real diagnostic flag (false once a real name resolves, not just once a label is produced); and a single-sheet reference collapsing to one name rather than a first:last range. Also fixes the same redundant switch-default break readSheetRecords already had, and adds message/boundary assertions the existing SST/Palette/Date1904 tests left as bare `.toThrow(BiffFormatError)` or never isolated (a negative unique-string count, an explicit false date system flag, an empty shared-string table with no SST record at all). --- .../xls-codec/src/workbook/globals.test.ts | 233 +++++++++++++++++- packages/xls-codec/src/workbook/globals.ts | 11 +- 2 files changed, 234 insertions(+), 10 deletions(-) diff --git a/packages/xls-codec/src/workbook/globals.test.ts b/packages/xls-codec/src/workbook/globals.test.ts index 8dde23a322..4ce3d2b1c6 100644 --- a/packages/xls-codec/src/workbook/globals.test.ts +++ b/packages/xls-codec/src/workbook/globals.test.ts @@ -25,7 +25,14 @@ import { xlUnicodeString, xlUnicodeStringNoCch, } from "../test-support/biff"; -import { formatCodeOf, readWorkbookGlobals } from "./globals"; +import { + fileNameFromVirtPath, + formatCodeOf, + readSupBook, + readWorkbookGlobals, + resolveXti, + type SupBookInfo, +} from "./globals"; /** The low bytes of an ASCII string, as a compressed (fHighByte = 0) rgb holds them. Indexed rather than spread, since spreading a string iterates code points and this needs UTF-16 units. */ function lowBytes(text: string): number[] { @@ -145,6 +152,11 @@ describe("readWorkbookGlobals", () => { expect(globals.sharedStrings).toStrictEqual(["Alpha", "Beta"]); }); + it("is an empty shared-string table for a globals substream carrying no SST record at all", () => { + const globals = readWorkbookGlobals(groupsOf()); + expect(globals.sharedStrings).toStrictEqual([]); + }); + it("reads a shared string table spanning a Continue record", () => { // The case the whole cursor design exists for: the second string's characters start in the base record and finish in the Continue, which re-states the fHighByte flag before resuming. const globals = readWorkbookGlobals( @@ -164,12 +176,23 @@ describe("readWorkbookGlobals", () => { expect(globals.sharedStrings).toStrictEqual(["Alpha", "Abcdef"]); }); - it("rejects an SST declaring more strings than its bytes could carry", () => { + it("rejects an SST declaring more strings than its bytes could carry, naming the exact counts", () => { expect(() => readWorkbookGlobals( groupsOf(record(RECORD_SST, [...u32(1000), ...u32(1000)])), ), - ).toThrow(BiffFormatError); + ).toThrow( + "SST declares 1000 unique strings, more than its 8 bytes could carry", + ); + }); + + it("rejects an SST declaring a negative unique-string count, naming the exact value", () => { + // cbUnique is a signed field, so 0xFFFFFFFF reads back as -1. + expect(() => + readWorkbookGlobals( + groupsOf(record(RECORD_SST, [...u32(0), 0xff, 0xff, 0xff, 0xff])), + ), + ).toThrow("SST declares a negative unique-string count (-1)"); }); it("reads a custom number format by its identifier", () => { @@ -296,7 +319,9 @@ describe("readWorkbookGlobals", () => { record(RECORD_PALETTE, [...u16(entries.length), ...entries.flat()]), ), ), - ).toThrow(BiffFormatError); + ).toThrow( + `Palette declares ${entries.length} colour entries, but [MS-XLS] 2.4.188's own ccv field MUST be ${PALETTE_ENTRY_COUNT}`, + ); }); it("refuses a Palette record declaring zero colour entries", () => { @@ -329,6 +354,14 @@ describe("readWorkbookGlobals", () => { expect(globals.date1904).toBe(true); }); + it("reads an explicit false 1904 date system flag, not just a genuinely absent record", () => { + const globals = readWorkbookGlobals( + groupsOf(record(RECORD_DATE1904, u16(0))), + ); + + expect(globals.date1904).toBe(false); + }); + it("ignores records it has no use for", () => { // The globals substream carries dozens of records this reader does not act on; meeting one must not disturb the tables it does build. const globals = readWorkbookGlobals( @@ -688,6 +721,198 @@ describe("readWorkbookGlobals", () => { }); }); +describe("readSupBook", () => { + function supBookGroup(bytes: readonly number[]): RecordGroup { + const group = groupsOf(record(RECORD_SUPBOOK, bytes))[0]; + if (group === undefined) { + throw new Error("expected a SupBook record group"); + } + return group; + } + + it("refuses a cch one below the smallest genuine virtPath length (0), naming the exact hex value", () => { + expect(readSupBook(supBookGroup([...u16(0), ...u16(0)]))).toStrictEqual({ + kind: "unresolvable", + diagnostic: "supporting link of unrecognised type (cch=0x0000)", + }); + }); + + it("accepts a cch of exactly 1, the smallest genuine virtPath length", () => { + expect( + readSupBook( + supBookGroup([...u16(0), ...u16(1), ...xlUnicodeStringNoCch("X")]), + ), + ).toStrictEqual({ + kind: "unresolvable", + diagnostic: "DDE or OLE data source reference", + }); + }); + + it("accepts a cch of exactly 255, the largest genuine virtPath length", () => { + const text = "X".repeat(255); + expect( + readSupBook( + supBookGroup([...u16(0), ...u16(255), ...xlUnicodeStringNoCch(text)]), + ), + ).toStrictEqual({ + kind: "unresolvable", + diagnostic: "DDE or OLE data source reference", + }); + }); + + it("refuses a cch one past the largest genuine virtPath length (256), naming the exact hex value", () => { + expect(readSupBook(supBookGroup([...u16(0), ...u16(256)]))).toStrictEqual({ + kind: "unresolvable", + diagnostic: "supporting link of unrecognised type (cch=0x0100)", + }); + }); + + it("resolves a same-sheet reference from its own exact single-character virtPath", () => { + expect( + readSupBook( + supBookGroup([...u16(0), ...u16(1), ...xlUnicodeStringNoCch("")]), + ), + ).toStrictEqual({ + kind: "unresolvable", + diagnostic: "same-sheet reference", + }); + }); + + it("resolves an unused supporting link from its own exact single-character virtPath", () => { + expect( + readSupBook( + supBookGroup([...u16(0), ...u16(1), ...xlUnicodeStringNoCch(" ")]), + ), + ).toStrictEqual({ + kind: "unresolvable", + diagnostic: "unused supporting link", + }); + }); +}); + +describe("fileNameFromVirtPath", () => { + it("declines a path that is empty once its own lone marker byte is stripped", () => { + expect(fileNameFromVirtPath("")).toBeUndefined(); + }); + + it("isolates a plain trailing file name with no marker at all", () => { + expect(fileNameFromVirtPath("dirBook.xlsx")).toBe("Book.xlsx"); + }); + + it("declines a final segment reached through a directory separator that itself carries a bracket", () => { + expect(fileNameFromVirtPath("sub[Book.xlsx]Sheet1")).toBeUndefined(); + }); +}); + +describe("resolveXti", () => { + const SELF: SupBookInfo = { kind: "self" }; + const UNRESOLVABLE: SupBookInfo = { + kind: "unresolvable", + diagnostic: "add-in function reference", + }; + const EXTERNAL: SupBookInfo = { + kind: "external-workbook", + fileName: "Book.xlsx", + sheetNames: ["Sheet1", "Sheet2"], + }; + const EXTERNAL_UNNAMED: SupBookInfo = { + kind: "external-workbook", + fileName: undefined, + sheetNames: ["Sheet1", "Sheet2"], + }; + + it("refuses an XTI whose own iSupBook index named no SupBook record at all", () => { + expect(resolveXti(undefined, 0, 0)).toStrictEqual({ + label: "#REF!(supporting link index out of range)", + diagnostic: true, + }); + }); + + it("carries an unresolvable SupBook's own diagnostic through unchanged", () => { + expect(resolveXti(UNRESOLVABLE, 0, 0)).toStrictEqual({ + label: "#REF!(add-in function reference)", + diagnostic: true, + }); + }); + + it("treats itabFirst alone being -2 as a workbook-level reference, even with a genuinely real itabLast", () => { + expect(resolveXti(SELF, -2, 0)).toStrictEqual({ + label: "#REF!(workbook-level reference)", + diagnostic: true, + }); + }); + + it("treats itabLast alone being -2 as a workbook-level reference too, even with a genuinely real itabFirst", () => { + expect(resolveXti(SELF, 0, -2)).toStrictEqual({ + label: "#REF!(workbook-level reference)", + diagnostic: true, + }); + }); + + it("resolves a self-referencing SheetRange when both indices are real", () => { + expect(resolveXti(SELF, 1, 3)).toStrictEqual({ + firstSheetIndex: 1, + lastSheetIndex: 3, + }); + }); + + it("refuses a self-referencing XTI whose own itabFirst alone is the -1 not-found sentinel", () => { + expect(resolveXti(SELF, -1, 0)).toStrictEqual({ + label: "#REF!(sheet not found)", + diagnostic: true, + }); + }); + + it("refuses a self-referencing XTI whose own itabLast alone is the -1 not-found sentinel", () => { + expect(resolveXti(SELF, 0, -1)).toStrictEqual({ + label: "#REF!(sheet not found)", + diagnostic: true, + }); + }); + + it("refuses an external-workbook XTI whose own itabFirst names no real sheet, even with a genuinely real itabLast", () => { + expect(resolveXti(EXTERNAL, 9, 0)).toStrictEqual({ + label: "[Book.xlsx]#REF!(sheet not found)", + diagnostic: true, + }); + }); + + it("refuses an external-workbook XTI whose own itabLast names no real sheet, even with a genuinely real itabFirst", () => { + expect(resolveXti(EXTERNAL, 0, 9)).toStrictEqual({ + label: "[Book.xlsx]#REF!(sheet not found)", + diagnostic: true, + }); + }); + + it("labels an external-workbook's own unresolved sheet under the EXTERNAL placeholder when the workbook's own name was not recovered either", () => { + expect(resolveXti(EXTERNAL_UNNAMED, 9, 0)).toStrictEqual({ + label: "[EXTERNAL]#REF!(sheet not found)", + diagnostic: true, + }); + }); + + it("resolves a single-sheet external reference without a range separator when both indices name the identical sheet", () => { + expect(resolveXti(EXTERNAL, 0, 0)).toStrictEqual({ + label: "[Book.xlsx]Sheet1", + diagnostic: false, + }); + }); + + it("resolves a genuine external sheet range, first:last, when the two indices differ", () => { + expect(resolveXti(EXTERNAL, 0, 1)).toStrictEqual({ + label: "[Book.xlsx]Sheet1:Sheet2", + diagnostic: false, + }); + }); + + it("resolves an external reference under the EXTERNAL placeholder, still marked diagnostic, when only the workbook's own name was not recovered", () => { + expect(resolveXti(EXTERNAL_UNNAMED, 0, 1)).toStrictEqual({ + label: "[EXTERNAL]Sheet1:Sheet2", + diagnostic: true, + }); + }); +}); + describe("formatCodeOf", () => { const globals = readWorkbookGlobals( groupsOf( diff --git a/packages/xls-codec/src/workbook/globals.ts b/packages/xls-codec/src/workbook/globals.ts index a683441b43..2620e1bb73 100644 --- a/packages/xls-codec/src/workbook/globals.ts +++ b/packages/xls-codec/src/workbook/globals.ts @@ -156,8 +156,7 @@ export function readWorkbookGlobals( palette = readPalette(record); break; default: - // Every other record in the globals substream -- the window settings, the theme, the drawing group -- carries nothing this reader acts on yet. - break; + // Every other record in the globals substream -- the window settings, the theme, the drawing group -- carries nothing this reader acts on yet. No break: this is the switch's own last case, so control already leaves it here regardless. } } @@ -197,7 +196,7 @@ const SUPBOOK_UNUSED_CHAR = " "; /** * One SupBook record's own resolution, keyed by kind ([MS-XLS] 2.4.271's cch/virtPath table) -- see readSupBook. "self" needs no further data, since the workbook's own BoundSheet8 list already resolves it elsewhere. "external-workbook" carries what this reader could recover from virtPath and rgst. "unresolvable" carries a short, fixed diagnostic for every other kind (add-in, DDE/OLE data source, same-sheet, unused, a virtPath shape fileNameFromVirtPath's own deliberately partial VirtualPath decoding does not attempt, or a record too malformed for readSupBookSafely to finish reading at all). */ -type SupBookInfo = +export type SupBookInfo = | { readonly kind: "self" } | { readonly kind: "external-workbook"; @@ -211,7 +210,7 @@ type SupBookInfo = /** * SupBook ([MS-XLS] 2.4.271): a two-byte ctab, a two-byte cch, then -- for every kind but self-referencing and add-in-referencing -- a virtPath (an XLUnicodeStringNoCch, cch characters long) and, for an external-workbook or unused link specifically, ctab sheet names (XLUnicodeString) in rgst. */ -function readSupBook(record: RecordGroup): SupBookInfo { +export function readSupBook(record: RecordGroup): SupBookInfo { const cursor = new BlockCursor(record.blocks); const ctab = cursor.u16(); const cch = cursor.u16(); @@ -275,7 +274,7 @@ const VIRTPATH_LIBRARY_MARKER = 0x08; /** * Isolates a plain trailing file name from a SupBook's own virtPath, when it uses one of the VirtualPath grammar's simpler forms: simple-file-path (no marker at all, or its own optional lone %x0001 with no second marker byte), or a genuine two-character marker saying the path is relative to the referencing workbook's own drive, the startup directory, the alternate startup directory, or the library directory (rel-volume/startup/alt-startup/library -- [MS-XLS] 480c3d2a's own virt-path alternatives). An absolute drive volume, a UNC share, or a transfer-protocol URL needs more of the grammar than a trailing path segment to reproduce faithfully, so those return undefined rather than a guess -- readSupBook's own caller then shows the sheet name(s) (still fully resolvable from rgst) against a placeholder workbook label instead of discarding them. file-path's own bracketed form (`"[" relative-path "]" sheet-name`, naming a sheet directly in the path rather than through SupBook's separate rgst array) is outside what this reader reconstructs too, and is declined the same way rather than folded into the file name and doubled up with the caller's own `[bookLabel]` bracketing. */ -function fileNameFromVirtPath(virtPath: string): string | undefined { +export function fileNameFromVirtPath(virtPath: string): string | undefined { let path = virtPath; if (path.startsWith("\u0001")) { const marker = path.codePointAt(1); @@ -318,7 +317,7 @@ function diagnosticLabel(reason: string): string { * * The SupBook's own kind is checked before the `-2` sentinel, not after: [MS-XLS] 2.5.344's itabFirst/itabLast table produces `-2` for a same-sheet, add-in, DDE, and OLE supporting link alike (none of them names a sheet at all), so treating every `-2` as a generic "workbook-level reference" before asking what kind of SupBook it belongs to would overwrite each of those already-specific `unresolvable` diagnostics with a less useful, wrong one. `-2` only means "workbook-level" for the two kinds that otherwise resolve a real sheet scope -- self and external-workbook -- so the sentinel is scoped to those. */ -function resolveXti( +export function resolveXti( supBook: SupBookInfo | undefined, itabFirst: number, itabLast: number, From 207f50a5acec6dc362e3e8d4f60a7ad9932919b9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:32:07 +0100 Subject: [PATCH 083/107] refactor(xls-codec): drop three unobservable globals.ts checks fileNameFromVirtPath's own separate "is path itself empty" check was redundant with the length check right after it: splitting an empty string on any separator always yields a single-element array holding that same empty string, so a path emptied out by marker-stripping was already caught by the very next line either way. resolveXti's own itabFirst/itabLast >= 0 guards before indexing sheetNames were similarly unobservable: a negative array index already resolves to undefined in plain JS, identically to how a genuinely out-of-range positive index does, so the only other sentinel reaching this line (-1, having already ruled out -2 above) behaved the same whether or not the guard ran. New tests close the remaining gaps: a trailing directory separator leaving an empty-but-defined final path segment (distinct from no segment at all); an SST record whose declared unique count is refused only by genuine multiplication against its own minimum entry width, not a much weaker division; and the identical check's own exact count-times-width-equals-total-bytes boundary, which a >= comparison would wrongly refuse before a single entry is ever read. --- .../xls-codec/src/workbook/globals.test.ts | 47 +++++++++++++++++-- packages/xls-codec/src/workbook/globals.ts | 9 ++-- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/packages/xls-codec/src/workbook/globals.test.ts b/packages/xls-codec/src/workbook/globals.test.ts index 4ce3d2b1c6..f9f706c958 100644 --- a/packages/xls-codec/src/workbook/globals.test.ts +++ b/packages/xls-codec/src/workbook/globals.test.ts @@ -195,6 +195,31 @@ describe("readWorkbookGlobals", () => { ).toThrow("SST declares a negative unique-string count (-1)"); }); + it("accepts an SST declaring exactly zero unique strings, a legitimate empty table rather than a negative one", () => { + const globals = readWorkbookGlobals( + groupsOf(record(RECORD_SST, [...u32(0), ...u32(0)])), + ); + expect(globals.sharedStrings).toStrictEqual([]); + }); + + it("rejects an SST whose declared unique count could never fit, distinguishing genuine multiplication from a much weaker check", () => { + // 9 unique strings need at least 9 * 3 = 27 bytes beyond a plausible minimum-sized entry; this record's own total is only the 8-byte header, so only a true multiplication (not e.g. a division) puts the required count far enough past what is actually there to be refused at all. + expect(() => + readWorkbookGlobals(groupsOf(record(RECORD_SST, [...u32(0), ...u32(9)]))), + ).toThrow( + "SST declares 9 unique strings, more than its 8 bytes could carry", + ); + }); + + it("does not refuse an SST sitting exactly at its own declared-count-times-entry-width boundary, only genuinely past it", () => { + // 3 unique strings times MIN_SST_ENTRY_BYTES (3) is 9, exactly this record's own total byte length (the 8-byte header plus 1 padding byte) -- a strictly-greater-than check leaves this alone (whatever fails next fails somewhere else, reading real string entries from too few bytes), while a greater-than-or-equal check would refuse it right here, before ever attempting to read a single entry. + expect(() => + readWorkbookGlobals( + groupsOf(record(RECORD_SST, [...u32(0), ...u32(3), 0x00])), + ), + ).not.toThrow(/more than its 9 bytes could carry/); + }); + it("reads a custom number format by its identifier", () => { const globals = readWorkbookGlobals( groupsOf( @@ -770,7 +795,11 @@ describe("readSupBook", () => { it("resolves a same-sheet reference from its own exact single-character virtPath", () => { expect( readSupBook( - supBookGroup([...u16(0), ...u16(1), ...xlUnicodeStringNoCch("")]), + supBookGroup([ + ...u16(0), + ...u16(1), + ...xlUnicodeStringNoCch(String.fromCharCode(0)), + ]), ), ).toStrictEqual({ kind: "unresolvable", @@ -792,15 +821,25 @@ describe("readSupBook", () => { describe("fileNameFromVirtPath", () => { it("declines a path that is empty once its own lone marker byte is stripped", () => { - expect(fileNameFromVirtPath("")).toBeUndefined(); + expect(fileNameFromVirtPath(String.fromCharCode(1))).toBeUndefined(); }); it("isolates a plain trailing file name with no marker at all", () => { - expect(fileNameFromVirtPath("dirBook.xlsx")).toBe("Book.xlsx"); + expect(fileNameFromVirtPath(`dir${String.fromCharCode(3)}Book.xlsx`)).toBe( + "Book.xlsx", + ); }); it("declines a final segment reached through a directory separator that itself carries a bracket", () => { - expect(fileNameFromVirtPath("sub[Book.xlsx]Sheet1")).toBeUndefined(); + expect( + fileNameFromVirtPath(`sub${String.fromCharCode(3)}[Book.xlsx]Sheet1`), + ).toBeUndefined(); + }); + + it("declines a path ending in a trailing directory separator, an empty-but-defined final segment rather than a missing one", () => { + expect( + fileNameFromVirtPath(`dir${String.fromCharCode(3)}`), + ).toBeUndefined(); }); }); diff --git a/packages/xls-codec/src/workbook/globals.ts b/packages/xls-codec/src/workbook/globals.ts index 2620e1bb73..0fbfdc84ba 100644 --- a/packages/xls-codec/src/workbook/globals.ts +++ b/packages/xls-codec/src/workbook/globals.ts @@ -295,9 +295,7 @@ export function fileNameFromVirtPath(virtPath: string): string | undefined { break; } } - if (path.length === 0) { - return undefined; - } + // No separate "is path itself empty" check: splitting an empty string on any separator always yields a single-element array holding that same empty string ([""]), so `last` below is already "" for a path emptied out by the marker-stripping above, and the length check right after this catches it exactly the same way the removed check did. const segments = path.split(VIRTPATH_DIRECTORY_SEPARATOR); const last = segments.at(-1); if (last === undefined || last.length === 0) { @@ -342,8 +340,9 @@ export function resolveXti( ? { firstSheetIndex: itabFirst, lastSheetIndex: itabLast } : { label: diagnosticLabel("sheet not found"), diagnostic: true }; } - const first = itabFirst >= 0 ? supBook.sheetNames[itabFirst] : undefined; - const last = itabLast >= 0 ? supBook.sheetNames[itabLast] : undefined; + // No itabFirst/itabLast >= 0 guard: a plain array index that is negative (the only other sentinel this can be here, having already ruled out -2 above) resolves to undefined on its own in JS, exactly like a genuinely out-of-range positive index does -- there is no negative-index behaviour on a real array for a guard to be protecting against. + const first = supBook.sheetNames[itabFirst]; + const last = supBook.sheetNames[itabLast]; if (first === undefined || last === undefined) { const bookLabel = supBook.fileName ?? "EXTERNAL"; return { From a2aa74d14bb45a6670fc0f55f2c6d721cc78c196 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:45:00 +0100 Subject: [PATCH 084/107] test(xls-codec): reach mutation coverage on conditional-format-write.ts Exports relativeCellRef and boundingBoxOf for direct testing, and adds a dedicated low-level test file for them: a multi-letter column name (exercising the base-26 loop more than once, up to three iterations), and a bounding box drawn from several ranges rather than only one, so each of the four min/max calls is actually exercised against a value the others don't already dominate. New round-trip coverage in write.test.ts closes the remaining gaps: cpOf's own four cellIs operators (between/notEqual/lessThan/ greaterThanOrEqual) no data-validation test happens to also exercise; a top10 rule isolating fTop from fPercent in each direction, not just both set or both clear together; iconSetThresholdCount's own two boundaries (7/8 and 12/13) rather than one interior set from each of its three bands; validateRuleGrid's own no-range and outside-BIFF8-grid refusals for a conditional-format rule specifically, previously only ever exercised through data-validation's own separate, differently-worded checks; the 15-bit nID field's own exact boundary (32767 accepted, 32768 refused); and three sequentially-numbered rules, since two rules alone cannot tell a correct 1-based nID from an off-by-one. --- .../workbook/conditional-format-write.test.ts | 43 +++++ .../src/workbook/conditional-format-write.ts | 4 +- packages/xls-codec/src/write.test.ts | 169 ++++++++++++++++++ 3 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 packages/xls-codec/src/workbook/conditional-format-write.test.ts diff --git a/packages/xls-codec/src/workbook/conditional-format-write.test.ts b/packages/xls-codec/src/workbook/conditional-format-write.test.ts new file mode 100644 index 0000000000..a9ed6d1bb6 --- /dev/null +++ b/packages/xls-codec/src/workbook/conditional-format-write.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { boundingBoxOf, relativeCellRef } from "./conditional-format-write"; + +describe("relativeCellRef", () => { + it("names a single-letter column for indices 0-25", () => { + expect(relativeCellRef(0, 0)).toBe("A1"); + expect(relativeCellRef(0, 25)).toBe("Z1"); + }); + + it("names a two-letter column once the index runs past Z, exercising the loop more than once", () => { + expect(relativeCellRef(0, 26)).toBe("AA1"); + expect(relativeCellRef(0, 27)).toBe("AB1"); + expect(relativeCellRef(0, 51)).toBe("AZ1"); + }); + + it("names a three-letter column, the loop running a third time", () => { + expect(relativeCellRef(0, 702)).toBe("AAA1"); + }); + + it("states the row as one-based", () => { + expect(relativeCellRef(9, 0)).toBe("A10"); + }); +}); + +describe("boundingBoxOf", () => { + it("takes the tightest rectangle across several ranges, not just the first or the union of extremes each range states independently", () => { + const box = boundingBoxOf({ + type: "aboveAverage", + ranges: [ + { startRow: 5, endRow: 10, startColumn: 3, endColumn: 3 }, + { startRow: 0, endRow: 2, startColumn: 8, endColumn: 20 }, + { startRow: 7, endRow: 7, startColumn: 1, endColumn: 1 }, + ], + }); + expect(box).toStrictEqual({ + startRow: 0, + endRow: 10, + startColumn: 1, + endColumn: 20, + }); + }); +}); diff --git a/packages/xls-codec/src/workbook/conditional-format-write.ts b/packages/xls-codec/src/workbook/conditional-format-write.ts index b97160e4aa..66df5593c8 100644 --- a/packages/xls-codec/src/workbook/conditional-format-write.ts +++ b/packages/xls-codec/src/workbook/conditional-format-write.ts @@ -107,7 +107,7 @@ function writeCfRecord( } // One rule's bounding box, the CondFmt/CondFmt12 header's own refBound ([MS-XLS] 2.5.56's CondFmtStructure): the tight rectangle containing every range the rule names -- redundant with sqref for this reader's purposes, but a real consumer's own grammar expects it and it costs four u16s to state honestly. -function boundingBoxOf(rule: ContentSheetConditionalFormat): { +export function boundingBoxOf(rule: ContentSheetConditionalFormat): { startRow: number; endRow: number; startColumn: number; @@ -411,7 +411,7 @@ function textRuleFormula( } // A relative A1 reference (no $ markers) for the anchor a text rule's formula evaluates each cell against -- relative, because Excel's own generated formulas spell it that way and the anchor names the range's first cell, not a fixed reference the rule means to keep. -function relativeCellRef(row: number, column: number): string { +export function relativeCellRef(row: number, column: number): string { let letters = ""; let index = column; do { diff --git a/packages/xls-codec/src/write.test.ts b/packages/xls-codec/src/write.test.ts index 6bf8b5213f..9fab362265 100644 --- a/packages/xls-codec/src/write.test.ts +++ b/packages/xls-codec/src/write.test.ts @@ -2550,6 +2550,29 @@ describe("writeXlsContent: conditional formats written (#971)", () => { expect(reread.sheets[0]?.conditionalFormats).toStrictEqual([rule]); }); + it("round-trips a cellIs rule under each of the remaining single-operand operators cpOf's own switch names -- between/notBetween/equal/greaterThan already exercised above", () => { + const operators = [ + "between", + "notEqual", + "lessThan", + "greaterThanOrEqual", + ] as const; + for (const operator of operators) { + const rule: ContentSheetConditionalFormat = { + type: "cellIs", + ranges: [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }], + operator, + formula1: "5", + }; + const reread = readXlsContent( + writeXlsContent( + document([sheet("S", [], { conditionalFormats: [rule] })]), + ), + ); + expect(reread.sheets[0]?.conditionalFormats).toStrictEqual([rule]); + } + }); + it("refuses a rule variant with no BIFF8 spelling rather than dropping it", () => { // A colour scale carrying fewer than the schema's own two-stop minimum is malformed input, so the honest refusal fixture is a variant the FORMAT cannot spell: a year-scoped time period, an ODF extension value no icfTemplate names. expect(() => @@ -2570,6 +2593,101 @@ describe("writeXlsContent: conditional formats written (#971)", () => { ), ).toThrow(/no BIFF8 rule names it/); }); + + it("refuses a conditional-format rule carrying no range", () => { + expect(() => + writeXlsContent( + document([ + sheet("S", [], { + conditionalFormats: [{ type: "uniqueValues", ranges: [] }], + }), + ]), + ), + ).toThrow( + "a conditional-format rule carrying no range states nothing; the schema requires at least one", + ); + }); + + it("refuses a conditional-format range outside BIFF8's own grid, at each of its four edges, naming the exact rows/columns", () => { + const base = { type: "uniqueValues" as const }; + const edges = [ + { startRow: 0x10000, endRow: 0, startColumn: 0, endColumn: 0 }, + { startRow: 0, endRow: 0x10000, startColumn: 0, endColumn: 0 }, + { startRow: 0, endRow: 0, startColumn: 0x100, endColumn: 0 }, + { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0x100 }, + ]; + for (const range of edges) { + expect(() => + writeXlsContent( + document([ + sheet("S", [], { + conditionalFormats: [{ ...base, ranges: [range] }], + }), + ]), + ), + ).toThrow( + `a conditional-format range (rows ${range.startRow}-${range.endRow}, columns ${range.startColumn}-${range.endColumn}) is outside BIFF8's own grid; a .xls workbook cannot address it`, + ); + } + }); + + it("writes no CondFmt/CondFmt12 records at all for a sheet stating an empty conditionalFormats array", () => { + expect( + writeSheetConditionalFormats( + sheet("S", [], { conditionalFormats: [] }), + () => 0, + ), + ).toStrictEqual([]); + }); + + it("refuses a sheet whose own conditional-format rule count exceeds CondFmt's own 15-bit nID field, naming the exact count", () => { + const range = { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }; + const rules: ContentSheetConditionalFormat[] = Array.from( + { length: 0x8000 }, + () => ({ type: "uniqueValues", ranges: [range] }), + ); + expect(() => + writeSheetConditionalFormats( + sheet("S", [], { conditionalFormats: rules }), + () => 0, + ), + ).toThrow( + "this sheet's 32768 conditional-format rules exceed CondFmt's own 15-bit nID field", + ); + }); + + it("accepts a sheet whose own conditional-format rule count sits exactly at CondFmt's own 15-bit nID field boundary", () => { + const range = { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }; + const rules: ContentSheetConditionalFormat[] = Array.from( + { length: 0x7fff }, + () => ({ type: "uniqueValues", ranges: [range] }), + ); + expect(() => + writeSheetConditionalFormats( + sheet("S", [], { conditionalFormats: rules }), + () => 0, + ), + ).not.toThrow(); + }, 20000); + + it("assigns each rule its own 1-based nID in declaration order across three rules, not just the two a smaller fixture cannot distinguish from an off-by-one", () => { + const range = { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }; + const rules: ContentSheetConditionalFormat[] = [ + { type: "containsText", ranges: [range], text: "a" }, + { type: "containsText", ranges: [range], text: "b" }, + { type: "containsText", ranges: [range], text: "c" }, + ]; + const reread = readXlsContent( + writeXlsContent( + document([sheet("S", [], { conditionalFormats: rules })]), + ), + ); + expect( + reread.sheets[0]?.conditionalFormats?.map((rule) => + rule.type === "containsText" ? rule.text : undefined, + ), + ).toStrictEqual(["a", "b", "c"]); + }); }); describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => { @@ -2661,6 +2779,37 @@ describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => ).toThrow(/cStates table fixes the set at 3/); }); + it("resolves each of iconSetThresholdCount's own three boundaries to the exact right count, not just one interior set from each band", () => { + // 3Symbols2 (index 7) and 4Arrows (index 8) straddle the first boundary; 4TrafficLights (index 12) and 5Arrows (index 13) straddle the second -- each pair proves that boundary is <=, not < or <=-one-off. + const caseFor = ( + iconSetType: string, + count: number, + ): ContentSheetConditionalFormat => ({ + type: "iconSet", + ranges: [RANGE], + iconSetType, + thresholds: Array.from({ length: count }, (_unused, index) => + index === 0 + ? { type: "min" as const } + : index === count - 1 + ? { type: "max" as const } + : { + type: "percent" as const, + value: String((index * 100) / (count - 1)), + }, + ), + }); + for (const [iconSetType, count] of [ + ["3Symbols2", 3], + ["4Arrows", 4], + ["4TrafficLights", 4], + ["5Arrows", 5], + ] as const) { + const rule = caseFor(iconSetType, count); + expect(roundTripped(rule)).toStrictEqual([{ ...rule, priority: 1 }]); + } + }); + it("refuses an icon-set type outside the seventeen built-in sets", () => { expect(() => roundTripped({ @@ -2917,6 +3066,26 @@ describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => expect(roundTripped(rule)).toStrictEqual([{ ...rule, priority: 1 }]); }); + it("round-trips a top10 rule selecting from the bottom by count, isolating fTop from fPercent", () => { + const rule: ContentSheetConditionalFormat = { + type: "top10", + ranges: [RANGE], + rank: 3, + bottom: true, + }; + expect(roundTripped(rule)).toStrictEqual([{ ...rule, priority: 1 }]); + }); + + it("round-trips a top10 rule selecting from the top by percent, isolating fPercent from fTop", () => { + const rule: ContentSheetConditionalFormat = { + type: "top10", + ranges: [RANGE], + rank: 3, + percent: true, + }; + expect(roundTripped(rule)).toStrictEqual([{ ...rule, priority: 1 }]); + }); + it("round-trips every combination of aboveAverage/equalAverage", () => { const aboveEqual: ContentSheetConditionalFormat = { type: "aboveAverage", From e67adabd1bec0cf856a56b5610a39e3559e40b38 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:50:11 +0100 Subject: [PATCH 085/107] refactor(xls-codec): extract validateRuleCount for a testable exact boundary The 15-bit nID field's own count check was inline in writeSheetConditionalFormats, whose surrounding forEach loop processes every rule individually -- proving the check's own exact boundary (32767 accepted, 32768 refused) meant constructing that many real rule objects and running the whole write path over them, expensive enough that the "accepted" case timed out under Stryker's own per-mutant test overhead even at a generous timeout. Extracting the bare count check into its own function makes both edges instant to verify directly, with no rule objects involved at all. --- .../src/workbook/conditional-format-write.ts | 16 +++++--- packages/xls-codec/src/write.test.ts | 39 +++++++------------ 2 files changed, 23 insertions(+), 32 deletions(-) diff --git a/packages/xls-codec/src/workbook/conditional-format-write.ts b/packages/xls-codec/src/workbook/conditional-format-write.ts index 66df5593c8..446ff908db 100644 --- a/packages/xls-codec/src/workbook/conditional-format-write.ts +++ b/packages/xls-codec/src/workbook/conditional-format-write.ts @@ -683,6 +683,15 @@ function validateStopIfTrue( } } +// nID ([MS-XLS] 2.5.56's CondFmtStructure): the group's own identifier, unique per worksheet, minted as a rule's own 1-based position in the sheet's full rule list -- base CondFmt and CondFmt12 groups draw from the one sequence, because a later CFEx record's own nID cross-references either kind. 15 bits is the field's whole width. A standalone function of the count alone, not inlined into writeSheetConditionalFormats' own body, so this one boundary is directly testable at its own exact edge (32767 accepted, 32768 refused) without constructing anywhere near that many real rule objects just to reach it. +export function validateRuleCount(count: number): void { + if (count > 0x7fff) { + throw new BiffWriteError( + `this sheet's ${count} conditional-format rules exceed CondFmt's own 15-bit nID field`, + ); + } +} + export function writeSheetConditionalFormats( sheet: ContentSheet, icvOf: (color: Color) => number, @@ -693,12 +702,7 @@ export function writeSheetConditionalFormats( } const basePieces: Uint8Array[] = []; const cf12Pieces: Uint8Array[] = []; - // nID ([MS-XLS] 2.5.56's CondFmtStructure): the group's own identifier, unique per worksheet, minted as the rule's own 1-based position in the sheet's full rule list -- base CondFmt and CondFmt12 groups draw from the one sequence, because a later CFEx record's own nID cross-references either kind. 15 bits is the field's whole width. - if (rules.length > 0x7fff) { - throw new BiffWriteError( - `this sheet's ${rules.length} conditional-format rules exceed CondFmt's own 15-bit nID field`, - ); - } + validateRuleCount(rules.length); const cf12Rules: Cf12RuleEntry[] = []; rules.forEach((rule, index) => { const [anchor] = validateRuleGrid(rule); diff --git a/packages/xls-codec/src/write.test.ts b/packages/xls-codec/src/write.test.ts index 9fab362265..c27ea4dfdb 100644 --- a/packages/xls-codec/src/write.test.ts +++ b/packages/xls-codec/src/write.test.ts @@ -52,7 +52,10 @@ import { writeXls, writeXlsContent, } from "./write"; -import { writeSheetConditionalFormats } from "./workbook/conditional-format-write"; +import { + validateRuleCount, + writeSheetConditionalFormats, +} from "./workbook/conditional-format-write"; import { writeSheetDataValidations } from "./workbook/data-validation-write"; import { buildWorksheetSubstream } from "./workbook/sheet-writer"; import * as drawingWriterModule from "./workbook/drawing-writer"; @@ -2640,35 +2643,19 @@ describe("writeXlsContent: conditional formats written (#971)", () => { ).toStrictEqual([]); }); - it("refuses a sheet whose own conditional-format rule count exceeds CondFmt's own 15-bit nID field, naming the exact count", () => { - const range = { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }; - const rules: ContentSheetConditionalFormat[] = Array.from( - { length: 0x8000 }, - () => ({ type: "uniqueValues", ranges: [range] }), - ); - expect(() => - writeSheetConditionalFormats( - sheet("S", [], { conditionalFormats: rules }), - () => 0, - ), - ).toThrow( + it("refuses a rule count exceeding CondFmt's own 15-bit nID field, naming the exact count", () => { + expect(() => { + validateRuleCount(0x8000); + }).toThrow( "this sheet's 32768 conditional-format rules exceed CondFmt's own 15-bit nID field", ); }); - it("accepts a sheet whose own conditional-format rule count sits exactly at CondFmt's own 15-bit nID field boundary", () => { - const range = { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }; - const rules: ContentSheetConditionalFormat[] = Array.from( - { length: 0x7fff }, - () => ({ type: "uniqueValues", ranges: [range] }), - ); - expect(() => - writeSheetConditionalFormats( - sheet("S", [], { conditionalFormats: rules }), - () => 0, - ), - ).not.toThrow(); - }, 20000); + it("accepts a rule count sitting exactly at CondFmt's own 15-bit nID field boundary", () => { + expect(() => { + validateRuleCount(0x7fff); + }).not.toThrow(); + }); it("assigns each rule its own 1-based nID in declaration order across three rules, not just the two a smaller fixture cannot distinguish from an off-by-one", () => { const range = { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }; From 1a94ee905fb5998e41b859d0e5b3445bae2be70c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 06:07:58 +0100 Subject: [PATCH 086/107] refactor(xls-codec): remove redundant conditional-format write paths writeSheetConditionalFormats' own empty-rules early return produced exactly the output the general path already produces for zero rules (validateRuleCount(0) never throws, and an empty forEach plus assignPriorities([]) both no-op), so the special case stated nothing the general path didn't already state on its own. writeCfFilter recomputed a top10 rule's fTop/fPercent flags byte a second time, independently from the identical computation writeCf12Record already makes for CFExFilterParams -- this package's own reader (conditional-format-12.ts) reads only the latter, so the two computations could silently diverge with no way to notice. Both now share one flags value, computed once and passed through. textRuleFormula is exported so its four formula shapes (one per text-predicate rule kind) are directly testable: the shape a containsText/notContainsText/beginsWith/endsWith rule's formula takes is otherwise invisible to a round trip, since this package's own reader determines a text rule's kind from CFExTextTemplateParams' ctp field rather than from the formula's own shape. --- .../src/workbook/conditional-format-write.ts | 43 ++++++++----------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/packages/xls-codec/src/workbook/conditional-format-write.ts b/packages/xls-codec/src/workbook/conditional-format-write.ts index 446ff908db..927cc28a34 100644 --- a/packages/xls-codec/src/workbook/conditional-format-write.ts +++ b/packages/xls-codec/src/workbook/conditional-format-write.ts @@ -303,20 +303,13 @@ function writeCfMultistate( return out.build(); } -// CFFilter ([MS-XLS] 2.5.30), the rgbCT a ct 0x05 rule always carries: its own size in cbFilter (4, excluding cbFilter itself), then the same fTop/fPercent/iParam triple CFExFilterParams states for a top10 rule. Every non-top10 filter rule writes zeros here -- CFFilter is the top-N structure, and [MS-XLS] gives the other filter templates no rgbCT payload of their own. -function writeCfFilter( - top10: Extract | undefined, -): Uint8Array { - const flags = - top10 === undefined - ? 0 - : (top10.bottom === true ? 0 : 1) | // fTop: 1 unless the rule selects from the bottom - (top10.percent === true ? 0b10 : 0); // fPercent +// CFFilter ([MS-XLS] 2.5.30), the rgbCT a ct 0x05 rule always carries: its own size in cbFilter (4, excluding cbFilter itself), then the same fTop/fPercent/iParam triple CFExFilterParams states for a top10 rule -- flags and iParam are computed once by the "top10" case in writeCf12Record and passed straight through here, rather than recomputed from the rule a second time, so CFFilter's own copy can never silently diverge from the value CFExFilterParams actually carries (this package's own reader, conditional-format-12.ts, reads only the latter -- see that file's own top comment -- so a divergence here would be invisible to a self-written-and-read round trip, which is exactly the kind of unobservable duplication that must not exist as two separate computations). Every non-top10 filter rule calls this with flags 0 and iParam 0 -- CFFilter is the top-N structure, and [MS-XLS] gives the other filter templates no rgbCT payload of their own. +function writeCfFilter(flags: number, iParam: number): Uint8Array { return new RecordBuilder() .u16(4) // cbFilter: the bytes after this field .u8(0) // reserved1 .u8(flags) - .u16(top10?.rank ?? 0) // iParam + .u16(iParam) .build(); } @@ -388,7 +381,7 @@ function ctpOf( } // The formula a text-predicate rule carries as its ct 0x02 condition: neither CFExTextTemplateParams nor CFFilter has anywhere to state the literal search text, so it lives only as the PtgStr operand of the formula itself -- written in the shape Excel's own rule generator and LibreOffice's own GetFixedFormula both produce (sc/source/filter/excel/xestyle... and xcl97... confirmed shapes; see conditional-format-12.ts's own top comment for the reader-side citation), referencing the rule's own first anchor cell relatively. The first string literal of each shape is the search text, which is exactly what the reader's extractFirstStringLiteral recovers. -function textRuleFormula( +export function textRuleFormula( rule: Extract< ContentSheetConditionalFormat, { type: "containsText" | "notContainsText" | "beginsWith" | "endsWith" } @@ -460,13 +453,18 @@ function writeCf12Record( case "top10": { ct = CT_FILTER; icfTemplate = ICF_TEMPLATE_FILTER; - // CFExFilterParams ([MS-XLS] 2.5.25): the flags byte (fTop/fPercent), iParam, then 13 reserved bytes. - templateParams = new RecordBuilder() - .u8((rule.bottom === true ? 0 : 1) | (rule.percent === true ? 0b10 : 0)) - .u16(rule.rank) - .bytes(new Uint8Array(13)) - .build(); - rgbCt = writeCfFilter(rule); + { + // fTop (1 unless the rule selects from the bottom) and fPercent, shared verbatim between CFExFilterParams below and CFFilter's own copy inside rgbCt. + const filterFlags = + (rule.bottom === true ? 0 : 1) | (rule.percent === true ? 0b10 : 0); + // CFExFilterParams ([MS-XLS] 2.5.25): the flags byte (fTop/fPercent), iParam, then 13 reserved bytes. + templateParams = new RecordBuilder() + .u8(filterFlags) + .u16(rule.rank) + .bytes(new Uint8Array(13)) + .build(); + rgbCt = writeCfFilter(filterFlags, rule.rank); + } dxf = writeDxfn(rule.style, icvOf); break; } @@ -490,7 +488,7 @@ function writeCf12Record( .u16(stdDev) .bytes(new Uint8Array(14)) .build(); - rgbCt = writeCfFilter(undefined); + rgbCt = writeCfFilter(0, 0); dxf = writeDxfn(rule.style, icvOf); break; } @@ -508,7 +506,7 @@ function writeCf12Record( .u16(template) .bytes(new Uint8Array(14)) .build(); - rgbCt = writeCfFilter(undefined); + rgbCt = writeCfFilter(0, 0); dxf = writeDxfn(rule.style, icvOf); break; } @@ -521,7 +519,7 @@ function writeCf12Record( ct = CT_FILTER; icfTemplate = simpleKindIcfTemplate(rule.type); templateParams = new Uint8Array(TEMPLATE_PARAMS_SIZE); // CFExDefaultTemplateParams: 16 reserved bytes - rgbCt = writeCfFilter(undefined); + rgbCt = writeCfFilter(0, 0); dxf = writeDxfn(rule.style, icvOf); break; } @@ -697,9 +695,6 @@ export function writeSheetConditionalFormats( icvOf: (color: Color) => number, ): Uint8Array[] { const rules = sheet.conditionalFormats ?? []; - if (rules.length === 0) { - return []; - } const basePieces: Uint8Array[] = []; const cf12Pieces: Uint8Array[] = []; validateRuleCount(rules.length); From 8f75edeb64f396bede51e3d0d5e7f0dd83503715 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 06:08:09 +0100 Subject: [PATCH 087/107] test(xls-codec): cover conditional-format write boundaries and nID assignment Adds direct textRuleFormula assertions for each of the four text-predicate formula shapes, isolated top10/stopIfTrue boolean branches (bottom/percent selected independently, each of colorScale/dataBar/iconSet refused on its own rather than only dataBar), exact-boundary range acceptance at BIFF8's own four grid edges (alongside the existing one-past-the-edge refusal), a refusal at exactly 32768 conditional-format rules built from cheap placeholder objects so it exercises the real write path without the cost of constructing that many genuine rules, and raw-record nID sequencing checks for both the base CondFmt and CF12 record families, since nID's own correctness is invisible to every round-trip test (this package's reader never emits or resolves a CFEx cross-reference on a self-written file, and explicitly skips CondFmt12's own copy of the field). --- .../workbook/conditional-format-write.test.ts | 37 ++++- packages/xls-codec/src/write.test.ts | 132 ++++++++++++++++++ 2 files changed, 168 insertions(+), 1 deletion(-) diff --git a/packages/xls-codec/src/workbook/conditional-format-write.test.ts b/packages/xls-codec/src/workbook/conditional-format-write.test.ts index a9ed6d1bb6..f56cd77f46 100644 --- a/packages/xls-codec/src/workbook/conditional-format-write.test.ts +++ b/packages/xls-codec/src/workbook/conditional-format-write.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; -import { boundingBoxOf, relativeCellRef } from "./conditional-format-write"; +import { + boundingBoxOf, + relativeCellRef, + textRuleFormula, +} from "./conditional-format-write"; + +const ANCHOR = { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }; describe("relativeCellRef", () => { it("names a single-letter column for indices 0-25", () => { @@ -41,3 +47,32 @@ describe("boundingBoxOf", () => { }); }); }); + +describe("textRuleFormula", () => { + it("states a distinct formula shape per rule kind, not a shape shared by falling through to the next case", () => { + expect( + textRuleFormula( + { type: "containsText", ranges: [ANCHOR], text: "needle" }, + ANCHOR, + ), + ).toBe('NOT(ISERROR(SEARCH("needle",A1)))'); + expect( + textRuleFormula( + { type: "notContainsText", ranges: [ANCHOR], text: "needle" }, + ANCHOR, + ), + ).toBe('ISERROR(SEARCH("needle",A1))'); + expect( + textRuleFormula( + { type: "beginsWith", ranges: [ANCHOR], text: "needle" }, + ANCHOR, + ), + ).toBe('LEFT(A1,LEN("needle"))="needle"'); + expect( + textRuleFormula( + { type: "endsWith", ranges: [ANCHOR], text: "needle" }, + ANCHOR, + ), + ).toBe('RIGHT(A1,LEN("needle"))="needle"'); + }); +}); diff --git a/packages/xls-codec/src/write.test.ts b/packages/xls-codec/src/write.test.ts index c27ea4dfdb..917914e455 100644 --- a/packages/xls-codec/src/write.test.ts +++ b/packages/xls-codec/src/write.test.ts @@ -20,6 +20,8 @@ import { describe, expect, it, vi } from "vitest"; import { RECORD_CALCCOUNT, RECORD_CF12, + RECORD_CONDFMT, + RECORD_CONDFMT12, RECORD_CONTINUE, RECORD_DIMENSIONS, RECORD_EOF, @@ -2634,6 +2636,27 @@ describe("writeXlsContent: conditional formats written (#971)", () => { } }); + it("accepts a conditional-format range sitting exactly at each of BIFF8's own four grid edges, not one past it", () => { + const base = { type: "uniqueValues" as const }; + const edges = [ + { startRow: 0xffff, endRow: 0, startColumn: 0, endColumn: 0 }, + { startRow: 0, endRow: 0xffff, startColumn: 0, endColumn: 0 }, + { startRow: 0, endRow: 0, startColumn: 0xff, endColumn: 0 }, + { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0xff }, + ]; + for (const range of edges) { + expect(() => + writeXlsContent( + document([ + sheet("S", [], { + conditionalFormats: [{ ...base, ranges: [range] }], + }), + ]), + ), + ).not.toThrow(); + } + }); + it("writes no CondFmt/CondFmt12 records at all for a sheet stating an empty conditionalFormats array", () => { expect( writeSheetConditionalFormats( @@ -2657,6 +2680,22 @@ describe("writeXlsContent: conditional formats written (#971)", () => { }).not.toThrow(); }); + it("refuses 32768 conditional-format rules before writing a single record, rather than silently accepting more than CondFmt's own 15-bit nID field allows", () => { + const range = { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }; + const rules: ContentSheetConditionalFormat[] = Array.from( + { length: 0x8000 }, + () => ({ type: "uniqueValues", ranges: [range] }), + ); + expect(() => + writeSheetConditionalFormats( + sheet("S", [], { conditionalFormats: rules }), + () => 0, + ), + ).toThrow( + "this sheet's 32768 conditional-format rules exceed CondFmt's own 15-bit nID field", + ); + }); + it("assigns each rule its own 1-based nID in declaration order across three rules, not just the two a smaller fixture cannot distinguish from an off-by-one", () => { const range = { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }; const rules: ContentSheetConditionalFormat[] = [ @@ -2675,6 +2714,72 @@ describe("writeXlsContent: conditional formats written (#971)", () => { ), ).toStrictEqual(["a", "b", "c"]); }); + + // nID only matters to a real consumer resolving a later CFEx record's own cross-reference (this package's own reader never emits or needs one on a self-written file, and explicitly skips CondFmt12's copy of the field as unused) -- so its correctness is invisible to every round-trip test above and has to be read directly out of the raw CondFmt/CondFmt12 bytes instead. + it("writes each base CondFmt group's own nID as its 1-based position among the sheet's rules, not the position minus one", () => { + const range = { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }; + const rules: ContentSheetConditionalFormat[] = [ + { type: "cellIs", ranges: [range], operator: "equal", formula1: "1" }, + { type: "cellIs", ranges: [range], operator: "equal", formula1: "2" }, + { type: "cellIs", ranges: [range], operator: "equal", formula1: "3" }, + ]; + const pieces = writeSheetConditionalFormats( + sheet("S", [], { conditionalFormats: rules }), + () => 0, + ); + const stream = new Uint8Array( + pieces.reduce((total, piece) => total + piece.length, 0), + ); + let offset = 0; + for (const piece of pieces) { + stream.set(piece, offset); + offset += piece.length; + } + const nIDs = readRecords(stream) + .filter((record) => record.type === RECORD_CONDFMT) + .map((record) => { + const view = new DataView( + record.data.buffer, + record.data.byteOffset, + record.data.byteLength, + ); + return (view.getUint16(2, true) >>> 1) & 0x7fff; + }); + expect(nIDs).toStrictEqual([1, 2, 3]); + }); + + it("writes each CF12 group's own nID as its 1-based position among the sheet's rules, not the position minus one", () => { + const range = { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }; + const rules: ContentSheetConditionalFormat[] = [ + { type: "uniqueValues", ranges: [range] }, + { type: "duplicateValues", ranges: [range] }, + { type: "containsBlanks", ranges: [range] }, + ]; + const pieces = writeSheetConditionalFormats( + sheet("S", [], { conditionalFormats: rules }), + () => 0, + ); + const stream = new Uint8Array( + pieces.reduce((total, piece) => total + piece.length, 0), + ); + let offset = 0; + for (const piece of pieces) { + stream.set(piece, offset); + offset += piece.length; + } + const nIDs = readRecords(stream) + .filter((record) => record.type === RECORD_CONDFMT12) + .map((record) => { + const view = new DataView( + record.data.buffer, + record.data.byteOffset, + record.data.byteLength, + ); + // rt(2) + grbitFrt(2) + refBound's four u16s(8) + ccf(2) = 14 bytes before the fToughRecalc+nID word. + return (view.getUint16(14, true) >>> 1) & 0x7fff; + }); + expect(nIDs).toStrictEqual([1, 2, 3]); + }); }); describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => { @@ -2825,6 +2930,33 @@ describe("writeXlsContent: CF12-era conditional formats written (#1186)", () => ).toThrow(/pins CF12's own fStopIfTrue bit to zero/); }); + it("refuses stopIfTrue on each of the other two visual-scale rule types on its own, not only dataBar", () => { + expect(() => + roundTripped({ + type: "colorScale", + ranges: [RANGE], + stops: [ + { value: { type: "min" }, color: { r: 1, g: 0, b: 0 } }, + { value: { type: "max" }, color: { r: 0, g: 1, b: 0 } }, + ], + stopIfTrue: true, + }), + ).toThrow(/pins CF12's own fStopIfTrue bit to zero/); + expect(() => + roundTripped({ + type: "iconSet", + ranges: [RANGE], + iconSetType: "3Arrows", + thresholds: [ + { type: "min" }, + { type: "percent", value: "50" }, + { type: "max" }, + ], + stopIfTrue: true, + }), + ).toThrow(/pins CF12's own fStopIfTrue bit to zero/); + }); + it("round-trips a top10 rule with rank, percent, bottom, and style", () => { const rule: ContentSheetConditionalFormat = { type: "top10", From 61242e3be713ee1a7400a0d37316a07a94d14fd8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 06:23:18 +0100 Subject: [PATCH 088/107] refactor(xls-codec): remove redundant drawing-read loop bounds and export testable seams readSheetDrawing's own empty-drawingChunks early return produced exactly the output the general path already produces: concatBytes([]) is an empty Uint8Array, and readSheetShapes' own readEscherRecords loop condition (offset < bytes.length) never runs against a zero-length stream, so shapes stays [] and the pairing loop below never executes either way. The worksheet-record loop's own index(); private readonly rowHeights = new Map(); private readonly defaultColumnWidthPt = columnWidthToPoints( @@ -120,7 +120,7 @@ interface AnchorPlacement { } /** Resolves an OfficeArtClientAnchorSheet into the cell-relative placement ContentSheetImage/ContentEmbeddedObject both carry, plus the page-absolute frame box a Box's own xPt/yPt/widthPt/heightPt need -- dxL/dxR in 1/1024ths of the anchor cell's own width, dyT/dyB in 1/256ths of its own height ([MS-XLS] 2.5.163). */ -function resolveAnchorPlacement( +export function resolveAnchorPlacement( anchor: ShapeAnchor, geometry: SheetGridGeometry, ): AnchorPlacement { @@ -161,11 +161,7 @@ export function readSheetDrawing( readonly nextOffset: number; readonly group: RecordGroup; }[] = []; - for (let index = 0; index < worksheetRecords.length; index += 1) { - const record = worksheetRecords[index]; - if (record === undefined) { - continue; - } + for (const [index, record] of worksheetRecords.entries()) { if (record.type === RECORD_MSODRAWING) { // record.blocks is the whole group -- the base MsoDrawing record's own data plus every Continue record chained onto it ([MS-XLS] 2.4.180); a real picture's blip bytes routinely exceed one record's 8224-byte ceiling, so only reading blocks[0] would silently truncate the Escher stream for any sheet carrying an image past that size. drawingChunks.push(...record.blocks); @@ -178,19 +174,20 @@ export function readSheetDrawing( objEntries.push({ ot, offset: record.offset, nextOffset, group: record }); } } - if (drawingChunks.length === 0) { - return { images: [], embeddedObjects: [] }; - } + // No early-out for an empty drawingChunks: concatBytes([]) is an empty Uint8Array, and readSheetShapes on empty bytes already returns [] on its own (its own readEscherRecords loop condition `offset < bytes.length` never runs for a zero-length stream), so shapes stays [] and the pairing loop below never executes either way -- a special case here would state nothing the general path doesn't already produce. const drawingBytes = concatBytes(drawingChunks); const shapes = readSheetShapes(drawingBytes); const geometry = new SheetGridGeometry(context.columns, context.rows); const images: ContentSheetImage[] = []; const embeddedObjects: ContentEmbeddedObject[] = []; - const pairCount = Math.min(shapes.length, objEntries.length); - for (let index = 0; index < pairCount; index += 1) { + // No explicit pair count: the loop stops once BOTH arrays are exhausted at an index (a real pair can only ever exist strictly before the shorter array's own end, and going one further index past that is safe -- shape or obj alone comes back undefined and the guard below skips it exactly like the note-type/no-shape/no-obj cases already do). + for (let index = 0; ; index += 1) { const shape = shapes[index]; const obj = objEntries[index]; + if (shape === undefined && obj === undefined) { + break; + } if ( shape === undefined || obj === undefined || @@ -240,7 +237,7 @@ export function readSheetDrawing( } /** A Picture-type Obj record whose FtPictFmla names an Embedding Storage this workbook's own outer compound file carries: resolved through readEmbeddedObjectPackage rather than the plain Blip Store path imageFromShape covers, since an OLE-embedded object's data lives in that storage's own Package stream instead of a pib reference into the workbook-wide Blip Store. Undefined for a plain picture (no FtPictFmla at all), an FtPictFmla naming a storage id this workbook's container did not report, or a storage whose Package stream is not this codec's own payload (readEmbeddedObjectPackage's own foreign-payload degrade) -- each falls through to imageFromShape instead. */ -function embeddedObjectFromObjRecord( +export function embeddedObjectFromObjRecord( objGroup: RecordGroup, shape: DrawingShape, context: SheetDrawingContext, @@ -266,7 +263,7 @@ function embeddedObjectFromObjRecord( }); } -function imageFromShape( +export function imageFromShape( shape: DrawingShape, context: SheetDrawingContext, geometry: SheetGridGeometry, @@ -296,7 +293,7 @@ function imageFromShape( } /** Locates the chart's own nested BOF(dt=chart)...EOF substream: the one whose own BOF offset falls strictly between this Obj record's offset and whichever worksheet record comes right after it in the ORIGINAL stream -- splitSubstreams' own nesting fix (biff/substreams.ts) pulls a nested chart's records out of the worksheet substream's own `records` array entirely and reports it as its own top-level Substream instead, so byte-offset containment, not array position, is what still ties the two together. */ -function chartFromShape( +export function chartFromShape( shape: DrawingShape, objOffset: number, nextOffset: number, @@ -406,7 +403,7 @@ export function chartTableCells( } /** A shape this reader recognises as neither a picture nor a chart -- an autoshape, a text box, a line, a group -- carried as a 'drawing' embedded object: a single-page ContentDocument holding one ContentShape sized and positioned at the shape's own anchor. Undefined for the patriarch/group-only case readSheetShapes already excludes, and for a shape whose own anchor collapses to zero size. */ -function drawingObjectFromShape( +export function drawingObjectFromShape( shape: DrawingShape, geometry: SheetGridGeometry, ): ContentEmbeddedObject | undefined { From 18f7d346397ef799f0f8a6511d9a428fe47d19e9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 06:23:27 +0100 Subject: [PATCH 089/107] test(xls-codec): cover drawing-read geometry, guard isolation, and pairing edges Adds direct SheetGridGeometry tests proving a column/row's own declared width/height is used (not the default) and that xPt/yPt sum only the entries strictly before the one asked for; isolated zero-width vs zero-height anchor tests for imageFromShape, embeddedObjectFromObjRecord, and drawingObjectFromShape (the same widthPt<=0||heightPt<=0 guard appears in all three); an exact full-document assertion for drawingObjectFromShape's own generated page/shape; readSheetDrawing tests isolating obj.ot===PICTURE from shape.shapeType===PICTURE_FRAME (each sufficient on its own) and covering a sheet whose shape and Obj-record counts genuinely disagree; and chartFromShape tests isolating each of its three substream-matching conditions (chart-typed, after the Obj record, before the next one) plus an exact assertion of the generated single-sheet chart document among several near-miss candidate substreams. --- .../xls-codec/src/workbook/drawing.test.ts | 427 +++++++++++++++++- 1 file changed, 425 insertions(+), 2 deletions(-) diff --git a/packages/xls-codec/src/workbook/drawing.test.ts b/packages/xls-codec/src/workbook/drawing.test.ts index a2073a457a..f86f8463b7 100644 --- a/packages/xls-codec/src/workbook/drawing.test.ts +++ b/packages/xls-codec/src/workbook/drawing.test.ts @@ -9,8 +9,14 @@ import { RECORD_MSODRAWING, RECORD_OBJ, } from "../biff/record-types"; -import { groupRecords, splitSubstreams } from "../biff/substreams"; -import { bofData, ftCmo, record } from "../test-support/biff"; +import { readRecords } from "../biff/records"; +import { + groupRecords, + splitSubstreams, + type RecordGroup, + type Substream, +} from "../biff/substreams"; +import { bofData, ftCmo, record, u16, u32 } from "../test-support/biff"; import { clientAnchorSheet, escherContainer, @@ -18,10 +24,17 @@ import { optAtom, spAtom, } from "../test-support/escher"; +import { PAGE_SIZE_LETTER } from "document-schema.js"; import type { BlipImage } from "../drawing/blips"; +import type { DrawingShape } from "../drawing/shapes"; import { + chartFromShape, chartTableCells, + drawingObjectFromShape, + embeddedObjectFromObjRecord, + imageFromShape, readSheetDrawing, + SheetGridGeometry, type SheetDrawingContext, } from "./drawing"; @@ -91,6 +104,63 @@ function rectangleShape(spid: number, anchor: readonly number[]): number[] { ]); } +/** A picture-blip-carrying shape whose own OfficeArtFSP shapeType is chosen independently of the blip, for isolating obj.ot===PICTURE from shape.shapeType===PICTURE_FRAME in readSheetDrawing's own picture-or-embedded dispatch. */ +function pictureShapeOfType( + shapeType: number, + spid: number, + blipIndex: number, + anchor: readonly number[], +): number[] { + return escherContainer(0xf004, 0, [ + spAtom(shapeType, spid, 0), + optAtom([foptEntry(0x0104, blipIndex)]), + anchor, + ]); +} + +/** A minimal FtPictFmla sub-record naming `storageId`, matching comments.test.ts's own identical fixture for the same [MS-XLS] 2.5.150 structure. */ +function ftPictFmla(storageId: number): number[] { + const data = [...u16(0), ...u32(storageId)]; + return [...u16(0x0009), ...u16(data.length), ...data]; +} + +/** A standalone Obj RecordGroup naming `storageId` via FtPictFmla, for direct embeddedObjectFromObjRecord tests that never go through readSheetDrawing's own MsoDrawing/Obj pairing at all. */ +function pictureObjGroup(storageId: number): RecordGroup { + const bytes = record(RECORD_OBJ, [ + ...ftCmo(OBJECT_TYPE_PICTURE, 1), + ...ftPictFmla(storageId), + ]); + const group = groupRecords(readRecords(bytes))[0]; + if (group === undefined) { + throw new Error("expected an Obj record group"); + } + return group; +} + +/** An anchor whose own placement collapses to exactly zero width, at a real (non-zero) height -- isolating widthPt<=0 from heightPt<=0 in every one of the three shape-to-content functions that share the identical guard. */ +const ZERO_WIDTH_ANCHOR = { + colL: 0, + dxL: 0, + rwT: 0, + dyT: 0, + colR: 0, + dxR: 0, + rwB: 1, + dyB: 0, +}; + +/** The mirror of ZERO_WIDTH_ANCHOR: exactly zero height, at a real (non-zero) width. */ +const ZERO_HEIGHT_ANCHOR = { + colL: 0, + dxL: 0, + rwT: 0, + dyT: 0, + colR: 1, + dxR: 0, + rwB: 0, + dyB: 0, +}; + /** Runs a hand-built worksheet substream's own record() bytes through the real BIFF framing/grouping passes. */ function worksheetRecords(rawRecords: readonly Uint8Array[]) { let offset = 0; @@ -180,6 +250,54 @@ describe("readSheetDrawing", () => { expect(drawing.embeddedObjects[0]?.objectKind).toBe("drawing"); }); + it("treats an Obj record's own picture object type as sufficient on its own, even when the shape's own type is not picture-frame", () => { + const anchor = clientAnchorSheet(0, 0, 0, 0, 1, 0, 1, 0); + const records = worksheetRecords([ + msoDrawing([pictureShapeOfType(SHAPE_TYPE_RECTANGLE, 50, 1, anchor)]), + objRecord(OBJECT_TYPE_PICTURE, 1), + ]); + const blipStore = new Map([ + [1, { format: "png", base64: "xyz" }], + ]); + + const drawing = readSheetDrawing(records, baseContext({ blipStore })); + + expect(drawing.images).toHaveLength(1); + expect(drawing.embeddedObjects).toStrictEqual([]); + }); + + it("treats the shape's own picture-frame type as sufficient on its own, even when the Obj record's own object type is not picture", () => { + const anchor = clientAnchorSheet(0, 0, 0, 0, 1, 0, 1, 0); + const records = worksheetRecords([ + msoDrawing([pictureShape(51, 1, anchor)]), + objRecord(OBJECT_TYPE_OFFICE_ART, 1), + ]); + const blipStore = new Map([ + [1, { format: "png", base64: "xyz" }], + ]); + + const drawing = readSheetDrawing(records, baseContext({ blipStore })); + + expect(drawing.images).toHaveLength(1); + expect(drawing.embeddedObjects).toStrictEqual([]); + }); + + it("pairs only as many shapes and Obj records as the shorter side names, when a sheet's own two lists disagree in length", () => { + const anchorA = clientAnchorSheet(0, 0, 0, 0, 1, 0, 1, 0); + const anchorB = clientAnchorSheet(2, 0, 2, 0, 3, 0, 3, 0); + // Two shapes, three Obj records: the third Obj record names no shape at all, and must be silently skipped rather than crashing or spuriously pairing with something. + const records = worksheetRecords([ + msoDrawing([rectangleShape(60, anchorA), rectangleShape(61, anchorB)]), + objRecord(OBJECT_TYPE_OFFICE_ART, 1), + objRecord(OBJECT_TYPE_OFFICE_ART, 2), + objRecord(OBJECT_TYPE_OFFICE_ART, 3), + ]); + + const drawing = readSheetDrawing(records, baseContext()); + + expect(drawing.embeddedObjects).toHaveLength(2); + }); + it("resolves a Chart-type shape by finding its own nested BOF(chart)...EOF substream, bounded by offset", () => { const anchor = clientAnchorSheet(0, 0, 0, 0, 2, 0, 2, 0); // Built directly from the record() framing (BOF worksheet, drawing+Obj, nested BOF chart, EOF chart, EOF worksheet) so splitSubstreams' own nesting logic produces the real chart Substream this reader has to locate by offset -- worksheetRecords' own helper only handles a flat, unnested record list. @@ -286,3 +404,308 @@ describe("chartTableCells", () => { expect(cells).toStrictEqual([]); }); }); + +describe("SheetGridGeometry", () => { + it("sums only the columns strictly before the one asked for, using each column's own declared width rather than the default", () => { + const geometry = new SheetGridGeometry( + [ + { index: 0, widthPt: 100, hidden: false }, + { index: 1, widthPt: 50, hidden: false }, + ], + [], + ); + + expect(geometry.columnWidthPt(0)).toBe(100); + expect(geometry.xPt(1)).toBe(100); + expect(geometry.xPt(2)).toBe(150); + }); + + it("sums only the rows strictly before the one asked for, using each row's own declared height rather than the default", () => { + const geometry = new SheetGridGeometry( + [], + [ + { index: 0, heightPt: 20, hidden: false }, + { index: 1, heightPt: 10, hidden: false }, + ], + ); + + expect(geometry.rowHeightPt(0)).toBe(20); + expect(geometry.yPt(1)).toBe(20); + expect(geometry.yPt(2)).toBe(30); + }); + + it("falls back to Excel's own Normal-style default width/height for a column/row the sheet never declared", () => { + const geometry = new SheetGridGeometry([], []); + + expect(geometry.columnWidthPt(0)).toBeGreaterThan(0); + expect(geometry.rowHeightPt(0)).toBeGreaterThan(0); + expect(geometry.xPt(0)).toBe(0); + expect(geometry.yPt(0)).toBe(0); + }); +}); + +describe("imageFromShape", () => { + const context = baseContext({ + blipStore: new Map([ + [1, { format: "png", base64: "abc" }], + ]), + }); + const geometry = new SheetGridGeometry([], []); + + function shapeAt(anchor: typeof ZERO_WIDTH_ANCHOR): DrawingShape { + return { + shapeType: SHAPE_TYPE_PICTURE_FRAME, + spid: 1, + blipIndex: 1, + anchor, + }; + } + + it("returns undefined for a zero-width anchor, isolated from the height half of the same guard", () => { + expect( + imageFromShape(shapeAt(ZERO_WIDTH_ANCHOR), context, geometry), + ).toBeUndefined(); + }); + + it("returns undefined for a zero-height anchor, isolated from the width half of the same guard", () => { + expect( + imageFromShape(shapeAt(ZERO_HEIGHT_ANCHOR), context, geometry), + ).toBeUndefined(); + }); +}); + +describe("embeddedObjectFromObjRecord", () => { + const objGroup = pictureObjGroup(7); + const context = baseContext({ + embeddingStreams: new Map>([ + [7, new Uint8Array([1, 2, 3])], + ]), + }); + const geometry = new SheetGridGeometry([], []); + + function shapeAt(anchor: typeof ZERO_WIDTH_ANCHOR): DrawingShape { + return { + shapeType: SHAPE_TYPE_PICTURE_FRAME, + spid: 1, + blipIndex: undefined, + anchor, + }; + } + + it("returns undefined for a zero-width anchor, isolated from the height half of the same guard, before ever reading the Embedding Storage's own Package stream", () => { + expect( + embeddedObjectFromObjRecord( + objGroup, + shapeAt(ZERO_WIDTH_ANCHOR), + context, + geometry, + ), + ).toBeUndefined(); + }); + + it("returns undefined for a zero-height anchor, isolated from the width half of the same guard", () => { + expect( + embeddedObjectFromObjRecord( + objGroup, + shapeAt(ZERO_HEIGHT_ANCHOR), + context, + geometry, + ), + ).toBeUndefined(); + }); +}); + +describe("drawingObjectFromShape", () => { + const geometry = new SheetGridGeometry([], []); + + function shapeAt(anchor: typeof ZERO_WIDTH_ANCHOR): DrawingShape { + return { + shapeType: SHAPE_TYPE_RECTANGLE, + spid: 5, + blipIndex: undefined, + anchor, + }; + } + + it("returns undefined for a zero-width anchor, isolated from the height half of the same guard", () => { + expect( + drawingObjectFromShape(shapeAt(ZERO_WIDTH_ANCHOR), geometry), + ).toBeUndefined(); + }); + + it("returns undefined for a zero-height anchor, isolated from the width half of the same guard", () => { + expect( + drawingObjectFromShape(shapeAt(ZERO_HEIGHT_ANCHOR), geometry), + ).toBeUndefined(); + }); + + it("builds a single-page, single-shape document sized and framed exactly at the anchor's own placement, with no image content of its own", () => { + const anchor = { + colL: 0, + dxL: 0, + rwT: 0, + dyT: 0, + colR: 1, + dxR: 0, + rwB: 1, + dyB: 0, + }; + const shape: DrawingShape = { + shapeType: SHAPE_TYPE_RECTANGLE, + spid: 5, + blipIndex: undefined, + anchor, + }; + const widthPt = geometry.columnWidthPt(0); + const heightPt = geometry.rowHeightPt(0); + + const result = drawingObjectFromShape(shape, geometry); + + expect(result).toStrictEqual({ + objectKind: "drawing", + document: { + kind: "drawing", + metadata: {}, + pages: [ + { + size: { widthPt, heightPt }, + shapes: [ + { + frame: { xPt: 0, yPt: 0, widthPt, heightPt }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [], + }, + ], + vectors: [], + }, + ], + }, + frame: { xPt: 0, yPt: 0, widthPt, heightPt }, + anchorRow: 0, + anchorColumn: 0, + offsetXPt: 0, + offsetYPt: 0, + }); + }); +}); + +describe("chartFromShape", () => { + const shape: DrawingShape = { + shapeType: SHAPE_TYPE_RECTANGLE, + spid: 1, + blipIndex: undefined, + anchor: { + colL: 0, + dxL: 0, + rwT: 0, + dyT: 0, + colR: 1, + dxR: 0, + rwB: 1, + dyB: 0, + }, + }; + const geometry = new SheetGridGeometry([], []); + const context = baseContext(); + + function substream(documentType: number, offset: number): Substream { + return { documentType, offset, records: [], index: 0 }; + } + + it("returns undefined when no candidate substream is chart-typed at all", () => { + expect( + chartFromShape( + shape, + 10, + 20, + { + ...context, + allSubstreams: [substream(BOF_TYPE_WORKSHEET, 15)], + }, + geometry, + ), + ).toBeUndefined(); + }); + + it("returns undefined for a chart substream sitting at or before the Obj record's own offset", () => { + expect( + chartFromShape( + shape, + 10, + 20, + { + ...context, + allSubstreams: [substream(BOF_TYPE_CHART, 10)], + }, + geometry, + ), + ).toBeUndefined(); + }); + + it("returns undefined for a chart substream sitting at or after the next worksheet record's own offset", () => { + expect( + chartFromShape( + shape, + 10, + 20, + { + ...context, + allSubstreams: [substream(BOF_TYPE_CHART, 20)], + }, + geometry, + ), + ).toBeUndefined(); + }); + + it("picks the one candidate genuinely bounded between the Obj record and the next, ignoring near-miss substreams elsewhere in the list, and builds the chart's own single-sheet document exactly", () => { + const result = chartFromShape( + shape, + 10, + 20, + { + ...context, + allSubstreams: [ + substream(BOF_TYPE_WORKSHEET, 15), + substream(BOF_TYPE_CHART, 5), + substream(BOF_TYPE_CHART, 25), + substream(BOF_TYPE_CHART, 15), + ], + }, + geometry, + ); + + const widthPt = geometry.columnWidthPt(0); + const heightPt = geometry.rowHeightPt(0); + expect(result).toStrictEqual({ + objectKind: "chart", + document: { + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Chart", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: { + pageSize: PAGE_SIZE_LETTER, + margins: { topPt: 0, rightPt: 0, bottomPt: 0, leftPt: 0 }, + gridlines: false, + headers: false, + pageOrder: "downThenOver", + }, + }, + ], + }, + frame: { xPt: 0, yPt: 0, widthPt, heightPt }, + anchorRow: 0, + anchorColumn: 0, + offsetXPt: 0, + offsetYPt: 0, + }); + }); +}); From 7bfaf8496bb8454a4d30e283373c5b39afaabe5d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 06:29:40 +0100 Subject: [PATCH 090/107] refactor(xls-codec): stop the shape/Obj pairing loop at the first exhausted side The loop's own break condition required BOTH arrays to run out before stopping, waiting through however many extra no-op iterations the longer array still had left -- but since neither array has genuine holes, the index where one first goes missing is also the index where every later index goes missing too, so no real pair can exist past that point regardless of the other array's own remaining length. Breaking the moment EITHER side is exhausted reaches the identical result without the pointless trailing iterations, and folds the note-type skip into its own single, separate condition instead of sharing one compound guard with the exhaustion check. --- packages/xls-codec/src/workbook/drawing.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/xls-codec/src/workbook/drawing.ts b/packages/xls-codec/src/workbook/drawing.ts index bf1e74a5f4..7d58d03f04 100644 --- a/packages/xls-codec/src/workbook/drawing.ts +++ b/packages/xls-codec/src/workbook/drawing.ts @@ -181,18 +181,14 @@ export function readSheetDrawing( const images: ContentSheetImage[] = []; const embeddedObjects: ContentEmbeddedObject[] = []; - // No explicit pair count: the loop stops once BOTH arrays are exhausted at an index (a real pair can only ever exist strictly before the shorter array's own end, and going one further index past that is safe -- shape or obj alone comes back undefined and the guard below skips it exactly like the note-type/no-shape/no-obj cases already do). + // No explicit pair count: since neither array has genuine holes, the index of the first missing shape or Obj record is also the index of every one after it, so the moment EITHER side runs out, no further real pair can ever exist and the loop is done -- there is nothing left to skip past. for (let index = 0; ; index += 1) { const shape = shapes[index]; const obj = objEntries[index]; - if (shape === undefined && obj === undefined) { + if (shape === undefined || obj === undefined) { break; } - if ( - shape === undefined || - obj === undefined || - obj.ot === OBJECT_TYPE_NOTE - ) { + if (obj.ot === OBJECT_TYPE_NOTE) { continue; } if ( From b49cf567d5eb7b211ee6327632c67cf7f42c339a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 06:29:49 +0100 Subject: [PATCH 091/107] test(xls-codec): use a genuinely valid Package stream in embeddedObjectFromObjRecord's guard tests The zero-size anchor tests previously paired a resolvable storage id with arbitrary, non-Package bytes -- readEmbeddedObjectPackage's own foreign-payload degrade already returns undefined for content that isn't a real OLE Package carrying this codec's own JSON payload, so the tests could not tell a genuine size-guard rejection apart from the downstream parse simply failing on garbage input regardless of size. Building the fixture through writeEmbeddedObjectPackage itself gives the tests real, parseable bytes, so a defined result is the only possible outcome once the size guard no longer runs. --- packages/xls-codec/src/workbook/drawing.test.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/xls-codec/src/workbook/drawing.test.ts b/packages/xls-codec/src/workbook/drawing.test.ts index f86f8463b7..fdb6b2a813 100644 --- a/packages/xls-codec/src/workbook/drawing.test.ts +++ b/packages/xls-codec/src/workbook/drawing.test.ts @@ -27,6 +27,7 @@ import { import { PAGE_SIZE_LETTER } from "document-schema.js"; import type { BlipImage } from "../drawing/blips"; import type { DrawingShape } from "../drawing/shapes"; +import { writeEmbeddedObjectPackage } from "./embedded-object"; import { chartFromShape, chartTableCells, @@ -476,9 +477,23 @@ describe("imageFromShape", () => { describe("embeddedObjectFromObjRecord", () => { const objGroup = pictureObjGroup(7); + // A genuinely valid Package stream (not arbitrary bytes) -- readEmbeddedObjectPackage's own foreign-payload degrade would otherwise return undefined regardless of the size guard below, making the guard's own removal invisible to these tests. + const packageBytes = writeEmbeddedObjectPackage({ + objectKind: "drawing", + document: { + kind: "drawing", + metadata: {}, + pages: [{ size: { widthPt: 10, heightPt: 10 }, shapes: [], vectors: [] }], + }, + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + anchorRow: 0, + anchorColumn: 0, + offsetXPt: 0, + offsetYPt: 0, + }); const context = baseContext({ embeddingStreams: new Map>([ - [7, new Uint8Array([1, 2, 3])], + [7, packageBytes], ]), }); const geometry = new SheetGridGeometry([], []); From dd3eb5aaaee0aa0a3ff516f8abb6ed851b61857e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 06:42:34 +0100 Subject: [PATCH 092/107] refactor(xls-codec): resolve a blip's dedup by value, not a re-derived index resolveBlip's own repeat-image path looked a stored blip back up by indexing into the blips array with a previously-minted index, guarded by an "internal error" throw for an index the array itself somehow didn't recognise -- a case that can never actually arise, since the index handed back for any base64 already in blipIndexByBase64 is exactly blips.length was at the moment that entry was pushed, and blips only ever grows. Keying the map directly by the blip object (alongside its index) removes the array indexing this dedup path never needed in the first place, so there is no possible-but-provably- unreachable branch left to guard against. --- .../xls-codec/src/workbook/drawing-writer.ts | 49 ++++++++++--------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/packages/xls-codec/src/workbook/drawing-writer.ts b/packages/xls-codec/src/workbook/drawing-writer.ts index 3a989455a4..7ba8629e45 100644 --- a/packages/xls-codec/src/workbook/drawing-writer.ts +++ b/packages/xls-codec/src/workbook/drawing-writer.ts @@ -29,7 +29,7 @@ import { writeEmbeddedObjectPackage } from "./embedded-object"; // A 'chart' embedded object is refused by name rather than approximated: writing one means embedding a genuine BIFF8 chart substream -- the whole [MS-XLS] chart grammar a flattened series/category table would have to drive, with series data links resolving back to real cells -- which is a chart engine of its own, not a container to place a table in. The other five objectKinds all embed through the one OLE mechanism. /** The sheet-grid geometry an anchor resolves against and inverts into, the write-side mirror of workbook/drawing.ts's own SheetGridGeometry: declared column widths/row heights with the same Excel "Normal" defaults beneath, so a shape written from a given placement reads back at the identical placement. Derived from the same constants (units.ts) the reader's own geometry uses, so the two cannot disagree about what an undeclared cell sizes. */ -class WriterGridGeometry { +export class WriterGridGeometry { private readonly columnWidths = new Map(); private readonly rowHeights = new Map(); private readonly defaultColumnWidthPt = columnWidthToPoints( @@ -115,7 +115,7 @@ interface Placement { readonly heightPt: number; } -function placementOfImage( +export function placementOfImage( image: ContentSheetImage, geometry: WriterGridGeometry, ): Placement { @@ -132,7 +132,7 @@ function placementOfImage( }; } -function placementOfEmbedded( +export function placementOfEmbedded( embedded: ContentEmbeddedObject, geometry: WriterGridGeometry, ): Placement { @@ -154,7 +154,7 @@ function placementOfEmbedded( } /** Inverts a placement into the OfficeArtClientAnchorSheet corner pair the reader's own resolveAnchorPlacement turns back into that placement: each corner resolved to its containing cell and a 1/1024ths (columns) or 1/256ths (rows) fraction within it. */ -function anchorOf( +export function anchorOf( placement: Placement, geometry: WriterGridGeometry, ): ShapeAnchor { @@ -180,7 +180,7 @@ function anchorOf( const OBJECT_TYPE_PICTURE = 0x0008; /** FtCmo ([MS-XLS] 2.5.92, 22 bytes): ft 0x15, cb 0x12, the object type and id, then grbit and three unused dwords all written zero -- the identical shape comment-writer.ts writes for a Note, restated here with the object type as a parameter rather than shared across the two direction modules. */ -function writeFtCmo(ot: number, id: number): Uint8Array { +export function writeFtCmo(ot: number, id: number): Uint8Array { return new RecordBuilder() .u16(0x0015) .u16(0x0012) @@ -194,12 +194,12 @@ function writeFtCmo(ot: number, id: number): Uint8Array { } /** FtCf ([MS-XLS] 2.5.142, https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/fc5bb3ce-8e35-4393-b22f-9cf54062a3a4): the clipboard format of the picture this object shows. 0xFFFF names "an unspecified format that is neither an enhanced metafile nor a bitmap" -- honest for a shape whose visible rendering is the blip the Escher layer itself carries and for an OLE object this writer has no preview metafile for. */ -function writeFtCf(): Uint8Array { +export function writeFtCf(): Uint8Array { return new RecordBuilder().u16(0x0007).u16(0x0002).u16(0xffff).build(); } /** FtPioGrbit ([MS-XLS] 2.5.151, https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/8eee0b3d-9d27-4294-85fc-a66ae8a361c9): a plain picture states fAutoPict (aspect preserved across views); an OLE embedding states no bits at all -- fPrstm and fDde stay clear, the pair the Embedding Storage page requires for storage-based object data. */ -function writeFtPioGrbit(autoPict: boolean): Uint8Array { +export function writeFtPioGrbit(autoPict: boolean): Uint8Array { return new RecordBuilder() .u16(0x0008) .u16(0x0002) @@ -214,7 +214,7 @@ const PTG_TBL = 0x02; const EMBED_CLASS_NAME = "Package"; /** FtPictFmla ([MS-XLS] 2.5.150, https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/00f89d32-67b0-408e-9eaf-f4fecbddb089) for an embedded OLE object: the ObjFmla (cbFmla counting the ObjectParsedFormula, the PictFmlaEmbedInfo, and the padding -- even, per [MS-XLS] 2.5.187's own cbFmla rule), then lPosInCtlStm, the storage id the Embedding Storage's own MBD name is the eight-hex-digit spelling of. The ObjectParsedFormula is the one shape [MS-XLS] pins for an embedding: cce 5, rgce one PtgTbl followed by four undefined bytes. */ -function writeFtPictFmla(storageId: number): Uint8Array { +export function writeFtPictFmla(storageId: number): Uint8Array { const formula = new RecordBuilder() .u16(5) // ObjectParsedFormula.cce .u32(0) // ObjectParsedFormula.unused @@ -241,7 +241,9 @@ function writeFtPictFmla(storageId: number): Uint8Array { const OBJ_RESERVED_END = new Uint8Array(4); /** One picture shape's Obj record: FtCmo (ot Picture), FtCf, FtPioGrbit, and the trailing reserved field. No FtPictFmla -- the image's bytes live in the workbook's Blip Store, which the shape's own pib property names, leaving the Obj record itself nothing to locate. */ -function writePictureObjRecord(objectId: number): Uint8Array { +export function writePictureObjRecord( + objectId: number, +): Uint8Array { return writeRecord( RECORD_OBJ, new RecordBuilder() @@ -254,7 +256,7 @@ function writePictureObjRecord(objectId: number): Uint8Array { } /** One embedded OLE object's Obj record: FtCmo (ot Picture), FtCf, FtPioGrbit (no bits -- storage-based, per the Embedding Storage page's own fPrstm/fDde requirement), the FtPictFmla naming the storage, and the trailing reserved field. */ -function writeEmbeddedObjRecord( +export function writeEmbeddedObjRecord( objectId: number, storageId: number, ): Uint8Array { @@ -273,7 +275,7 @@ function writeEmbeddedObjRecord( // --- The workbook-wide plan --- /** Base64's own character set, decoded by hand rather than through atob's DOM-string round trip -- mirroring drawing/blips.ts's own hand-written encoder, which exists for the identical reason: byte-exact, allocation-predictable, and identical in Node and a Workers isolate. */ -function bytesFromBase64(base64: string): Uint8Array { +export function bytesFromBase64(base64: string): Uint8Array { const values = new Int8Array(256).fill(-1); "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" .split("") @@ -329,7 +331,11 @@ export function buildDrawingWritePlan( sheets: readonly ContentSheet[], ): DrawingWritePlan { const blips: MutableStoredBlip[] = []; - const blipIndexByBase64 = new Map(); + // Keyed by base64 rather than by index: resolving a repeat directly to the same blip object this map already holds means a dedup lookup can never land on an index the `blips` array itself doesn't recognise -- there is no index arithmetic here for such a lookup to disagree with in the first place. + const blipsByBase64 = new Map< + string, + { readonly index: number; readonly blip: MutableStoredBlip } + >(); const resolveBlip = (image: ContentSheetImage): number => { if (image.format !== "png" && image.format !== "jpeg") { @@ -337,25 +343,20 @@ export function buildDrawingWritePlan( `xls-codec cannot write a sheet image of format "${image.format}": [MS-ODRAW]'s own MSOBLIPTYPE enumeration has no member for it, so no Blip Store entry can carry it`, ); } - const existing = blipIndexByBase64.get(image.base64); + const existing = blipsByBase64.get(image.base64); if (existing !== undefined) { // A deduplicated reference: the BSE's own cRef counts references to the BLIP, so the count grows rather than a second entry being minted. - const blip = blips[existing - 1]; - if (blip === undefined) { - throw new BiffWriteError( - "internal error: a blip index resolved that the workbook-wide image scan never assigned", - ); - } - blip.referenceCount += 1; - return existing; + existing.blip.referenceCount += 1; + return existing.index; } const index = blips.length + 1; - blipIndexByBase64.set(image.base64, index); - blips.push({ + const blip: MutableStoredBlip = { format: image.format, fileBytes: bytesFromBase64(image.base64), referenceCount: 1, - }); + }; + blipsByBase64.set(image.base64, { index, blip }); + blips.push(blip); return index; }; From ce3999743e566a4a064fc04b0fbfaed03c62081e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 06:42:45 +0100 Subject: [PATCH 093/107] test(xls-codec): cover drawing-write geometry, placement, and record byte layout Adds a dedicated drawing-writer.test.ts exercising the module's own internal functions directly, exported specifically for this: exact locateX/locateY boundary behaviour at each grid edge and at a plain mid-column/mid-row point (proportional, not the offset scaled by the width/height a second time); placementOfImage's grid-boundary refusal isolated per edge, at each edge's exact boundary; placementOfEmbedded's offset arithmetic; byte-exact FtCmo/FtCf/FtPioGrbit/FtPictFmla field checks; bytesFromBase64's own padding-character handling (previously untested by any fixture in this package's suite, every one of which happened to use base64 needing no padding at all); and buildDrawingWritePlan checks that read the actual written Escher bytes directly -- a Blip Store entry's own cRef reference count, an OLE-embedded shape's fOleShape bit versus a plain picture's, an Obj record's own id continuing past a sheet's comment count, an Embedding Storage path's uppercase hex spelling, and the FDGG block's own spidMax/cspSaved/cdgSaved across two sheets -- none of which this package's own reader ever reads back, so a round trip alone cannot prove any of them correct. --- .../src/workbook/drawing-writer.test.ts | 518 ++++++++++++++++++ 1 file changed, 518 insertions(+) create mode 100644 packages/xls-codec/src/workbook/drawing-writer.test.ts diff --git a/packages/xls-codec/src/workbook/drawing-writer.test.ts b/packages/xls-codec/src/workbook/drawing-writer.test.ts new file mode 100644 index 0000000000..04bc312d13 --- /dev/null +++ b/packages/xls-codec/src/workbook/drawing-writer.test.ts @@ -0,0 +1,518 @@ +import { describe, expect, it } from "vitest"; +import { PAGE_SIZE_LETTER } from "document-schema.js"; +import type { + ContentEmbeddedObject, + ContentSheet, + ContentSheetCell, + ContentSheetImage, + ContentSheetPrintSettings, +} from "document-schema.js"; + +import { readRecords } from "../biff/records"; +import { writeXLUnicodeStringNoCch } from "../biff/string-writer"; +import { ESCHER_BSE, ESCHER_SP } from "../drawing/escher-constants"; +import { readEscherRecords, type EscherRecord } from "../drawing/escher"; +import { + bytesFromBase64, + buildDrawingWritePlan, + placementOfEmbedded, + placementOfImage, + writeFtCf, + writeFtCmo, + writeFtPictFmla, + writeFtPioGrbit, + WriterGridGeometry, +} from "./drawing-writer"; + +const PRINT_SETTINGS: ContentSheetPrintSettings = { + pageSize: PAGE_SIZE_LETTER, + margins: { topPt: 0, rightPt: 0, bottomPt: 0, leftPt: 0 }, + gridlines: false, + headers: false, + pageOrder: "downThenOver", +}; + +function sheet( + cells: readonly ContentSheetCell[], + overrides: Partial> = {}, +): ContentSheet { + return { + name: "Sheet1", + cells: [...cells], + columns: [], + rows: [], + images: [], + printSettings: PRINT_SETTINGS, + ...overrides, + }; +} + +const PNG_IMAGE: ContentSheetImage = { + kind: "image", + format: "png", + base64: "AAAA", + widthPt: 10, + heightPt: 10, + anchorRow: 0, + anchorColumn: 0, + offsetXPt: 0, + offsetYPt: 0, +}; + +function embeddedDrawing( + overrides: Partial = {}, +): ContentEmbeddedObject { + return { + objectKind: "drawing", + document: { + kind: "drawing", + metadata: {}, + pages: [{ size: { widthPt: 10, heightPt: 10 }, shapes: [], vectors: [] }], + }, + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + anchorRow: 0, + anchorColumn: 0, + offsetXPt: 0, + offsetYPt: 0, + ...overrides, + }; +} + +/** Concatenates a sheet's own MsoDrawing record chain back into one raw Escher byte stream -- the exact inverse of what drawing.ts's own readSheetDrawing does with the records it reads, but starting from buildDrawingWritePlan's output directly rather than a full read-side round trip. */ +function escherBytesFromMsoDrawingRecords( + records: readonly Uint8Array[], +): Uint8Array { + const chunks = records.flatMap((record) => + readRecords(record).map((r) => r.data), + ); + const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +/** Recursively collects every Escher record of a given recType anywhere in the tree, container or atom, in document order. */ +function findEscherRecords( + records: readonly EscherRecord[], + recType: number, +): EscherRecord[] { + const found: EscherRecord[] = []; + for (const record of records) { + if (record.recType === recType) { + found.push(record); + } + if (record.kind === "container") { + found.push(...findEscherRecords(record.children, recType)); + } + } + return found; +} + +/** The first Escher record of a given recType, for a tree this test knows carries exactly one (or where only the first match matters). */ +function findEscherRecord( + records: readonly EscherRecord[], + recType: number, +): EscherRecord | undefined { + return findEscherRecords(records, recType)[0]; +} + +describe("WriterGridGeometry", () => { + it("sums only the columns strictly before the one asked for, using each column's own declared width rather than the default", () => { + const geometry = new WriterGridGeometry( + sheet([], { + columns: [ + { index: 0, widthPt: 100 }, + { index: 1, widthPt: 50 }, + ], + }), + ); + + expect(geometry.columnWidthPt(0)).toBe(100); + expect(geometry.xPt(1)).toBe(100); + expect(geometry.xPt(2)).toBe(150); + }); + + it("sums only the rows strictly before the one asked for, using each row's own declared height rather than the default", () => { + const geometry = new WriterGridGeometry( + sheet([], { + rows: [ + { index: 0, heightPt: 20 }, + { index: 1, heightPt: 10 }, + ], + }), + ); + + expect(geometry.rowHeightPt(0)).toBe(20); + expect(geometry.yPt(1)).toBe(20); + expect(geometry.yPt(2)).toBe(30); + }); + + it("locates a point exactly at a column's own right edge in the NEXT column, at fraction zero, not the same column at fraction 1023", () => { + const geometry = new WriterGridGeometry(sheet([])); + const width = geometry.columnWidthPt(0); + + expect(geometry.locateX(width)).toStrictEqual({ column: 1, fraction: 0 }); + }); + + it("locates a point exactly at a row's own bottom edge in the NEXT row, at fraction zero, not the same row at fraction 255", () => { + const geometry = new WriterGridGeometry(sheet([])); + const height = geometry.rowHeightPt(0); + + expect(geometry.locateY(height)).toStrictEqual({ row: 1, fraction: 0 }); + }); + + it("computes a fraction genuinely proportional to the offset within the column, not the offset scaled by the column's own width a second time", () => { + const geometry = new WriterGridGeometry(sheet([])); + const width = geometry.columnWidthPt(0); + + expect(geometry.locateX(width / 2)).toStrictEqual({ + column: 0, + fraction: 512, + }); + }); + + it("computes a fraction genuinely proportional to the offset within the row, not the offset scaled by the row's own height a second time", () => { + const geometry = new WriterGridGeometry(sheet([])); + const height = geometry.rowHeightPt(0); + + expect(geometry.locateY(height / 2)).toStrictEqual({ + row: 0, + fraction: 128, + }); + }); + + it("clamps a point past BIFF8's own last column to that column's own far edge, rather than continuing to count columns past the grid's own width", () => { + const geometry = new WriterGridGeometry(sheet([])); + const width = geometry.columnWidthPt(0); + + expect(geometry.locateX(0xff * width + 0.001)).toStrictEqual({ + column: 0xff, + fraction: 1023, + }); + }); + + it("clamps a point past BIFF8's own last row to that row's own far edge, rather than continuing to count rows past the grid's own height", () => { + const geometry = new WriterGridGeometry(sheet([])); + const height = geometry.rowHeightPt(0); + + expect(geometry.locateY(0xffff * height + 0.001)).toStrictEqual({ + row: 0xffff, + fraction: 255, + }); + }); +}); + +describe("placementOfImage", () => { + const geometry = new WriterGridGeometry(sheet([])); + + it("refuses an image anchored outside BIFF8's own grid, at each of its two edges, naming the exact row and column", () => { + expect(() => + placementOfImage({ ...PNG_IMAGE, anchorRow: 0x10000 }, geometry), + ).toThrow(/outside BIFF8's own grid/); + expect(() => + placementOfImage({ ...PNG_IMAGE, anchorColumn: 0x100 }, geometry), + ).toThrow(/outside BIFF8's own grid/); + }); + + it("accepts an image anchored exactly at BIFF8's own grid edges, not one past it", () => { + expect(() => + placementOfImage({ ...PNG_IMAGE, anchorRow: 0xffff }, geometry), + ).not.toThrow(); + expect(() => + placementOfImage({ ...PNG_IMAGE, anchorColumn: 0xff }, geometry), + ).not.toThrow(); + }); +}); + +describe("placementOfEmbedded", () => { + const geometry = new WriterGridGeometry(sheet([])); + + it("adds the anchor cell's own offset to its origin, rather than subtracting it", () => { + const width = geometry.columnWidthPt(0); + const height = geometry.rowHeightPt(0); + const placement = placementOfEmbedded( + embeddedDrawing({ + anchorRow: 1, + anchorColumn: 1, + offsetXPt: 3, + offsetYPt: 4, + }), + geometry, + ); + + expect(placement.startXPt).toBe(width + 3); + expect(placement.startYPt).toBe(height + 4); + }); + + it("uses the frame's own absolute corner when no anchor is stated", () => { + const placement = placementOfEmbedded( + embeddedDrawing({ + anchorRow: undefined, + anchorColumn: undefined, + offsetXPt: undefined, + offsetYPt: undefined, + frame: { xPt: 12, yPt: 34, widthPt: 10, heightPt: 10 }, + }), + geometry, + ); + + expect(placement.startXPt).toBe(12); + expect(placement.startYPt).toBe(34); + }); +}); + +describe("writeFtPioGrbit", () => { + it("states fAutoPict set for a plain picture and clear for an OLE embedding", () => { + const autoPict = writeFtPioGrbit(true); + const storageBased = writeFtPioGrbit(false); + const autoPictView = new DataView( + autoPict.buffer, + autoPict.byteOffset, + autoPict.byteLength, + ); + const storageBasedView = new DataView( + storageBased.buffer, + storageBased.byteOffset, + storageBased.byteLength, + ); + + expect(autoPictView.getUint16(4, true)).toBe(0x0001); + expect(storageBasedView.getUint16(4, true)).toBe(0x0000); + }); +}); + +describe("writeFtCmo", () => { + it("carries the object type and id in the fields the reader itself parses them from", () => { + const bytes = writeFtCmo(0x0008, 42); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + + expect(view.getUint16(4, true)).toBe(0x0008); // ot + expect(view.getUint16(6, true)).toBe(42); // id + }); +}); + +describe("writeFtCf", () => { + it("states the unspecified-format clipboard marker", () => { + const bytes = writeFtCf(); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + + expect(view.getUint16(4, true)).toBe(0xffff); + }); +}); + +describe("writeFtPictFmla", () => { + it("states the class name's own character count, not one more or fewer, in cbClass", () => { + const bytes = writeFtPictFmla(7); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const className = writeXLUnicodeStringNoCch("Package"); + // ft(2) + cb(2) + cbFmla(2) + ObjectParsedFormula(cce 2 + unused 4 + PtgTbl 1 + 4 reserved = 11) + ttb(1) -- cbClass sits right after. + const cbClassOffset = 2 + 2 + 2 + 11 + 1; + + expect(view.getUint8(cbClassOffset)).toBe(className.length - 1); + }); + + it("states the storage id lPosInCtlStm names, at the record's own trailing four bytes", () => { + const bytes = writeFtPictFmla(0x2a); + + expect(bytes.length).toBeGreaterThanOrEqual(4); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + expect(view.getUint32(bytes.length - 4, true)).toBe(0x2a); + }); +}); + +describe("bytesFromBase64", () => { + it("decodes a base64 string whose length forces two padding characters, without treating either as real data", () => { + // "f" alone (length 1) encodes to "Zg==" -- two padding characters, and the ONLY way to exercise the padding-character skip at all, since every other fixture in this package's own test suite happens to use base64 with no padding at all. + expect(bytesFromBase64("Zg==")).toStrictEqual(new Uint8Array([0x66])); + }); + + it("decodes a base64 string whose length forces exactly one padding character", () => { + // "fo" (length 2) encodes to "Zm8=" -- one padding character. + expect(bytesFromBase64("Zm8=")).toStrictEqual(new Uint8Array([0x66, 0x6f])); + }); + + it("decodes a base64 string needing no padding at all, isolating the zero-padding arithmetic from both padded cases above", () => { + // "foo" (length 3) encodes to "Zm9v" -- no padding. + expect(bytesFromBase64("Zm9v")).toStrictEqual( + new Uint8Array([0x66, 0x6f, 0x6f]), + ); + }); +}); + +describe("buildDrawingWritePlan", () => { + it("writes no MsoDrawing/Obj records and no drawing-group bytes at all for a workbook with no images or embedded objects on any sheet", () => { + const plan = buildDrawingWritePlan([sheet([])]); + + expect(plan.drawingGroupBytes).toBeUndefined(); + expect(plan.sheetDrawings).toStrictEqual([ + { msoDrawingRecords: [], objRecords: [] }, + ]); + expect(plan.embeddingStreams).toStrictEqual([]); + }); + + it("refuses a sheet image of a format [MS-ODRAW]'s own MSOBLIPTYPE enumeration has no member for, naming the exact format", () => { + expect(() => + buildDrawingWritePlan([ + sheet([], { images: [{ ...PNG_IMAGE, format: "gif" }] }), + ]), + ).toThrow( + 'xls-codec cannot write a sheet image of format "gif": [MS-ODRAW]\'s own MSOBLIPTYPE enumeration has no member for it, so no Blip Store entry can carry it', + ); + }); + + it("refuses a 'chart' embedded object by name", () => { + const chart: ContentEmbeddedObject = { + objectKind: "chart", + document: { + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Chart", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: PRINT_SETTINGS, + }, + ], + }, + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + anchorRow: 0, + anchorColumn: 0, + offsetXPt: 0, + offsetYPt: 0, + }; + expect(() => + buildDrawingWritePlan([sheet([], { embeddedObjects: [chart] })]), + ).toThrow(/cannot write a 'chart' embedded object/); + }); + + it("grows a repeated image's own Blip Store reference count, rather than shrinking it or minting a second entry", () => { + const plan = buildDrawingWritePlan([ + sheet([], { images: [PNG_IMAGE, PNG_IMAGE] }), + ]); + const records = readEscherRecords( + plan.drawingGroupBytes ?? new Uint8Array(), + ); + const bse = findEscherRecord(records, ESCHER_BSE); + expect(bse?.kind).toBe("atom"); + if (bse?.kind !== "atom") { + throw new Error("expected a BSE atom"); + } + const view = new DataView( + bse.data.buffer, + bse.data.byteOffset, + bse.data.byteLength, + ); + // btWin32(1) + btMacOS(1) + rgbUid(16) + tag(2) + size(4) = 24 bytes before cRef. + expect(view.getUint32(24, true)).toBe(2); + }); + + it("sets a plain image's shape as a picture, clearing fOleShape, and an embedded OLE object's shape with fOleShape set, never the other way round", () => { + const imagePlan = buildDrawingWritePlan([ + sheet([], { images: [PNG_IMAGE] }), + ]); + const embeddedPlan = buildDrawingWritePlan([ + sheet([], { embeddedObjects: [embeddedDrawing()] }), + ]); + const imageEscher = escherBytesFromMsoDrawingRecords( + imagePlan.sheetDrawings[0]?.msoDrawingRecords ?? [], + ); + const embeddedEscher = escherBytesFromMsoDrawingRecords( + embeddedPlan.sheetDrawings[0]?.msoDrawingRecords ?? [], + ); + // Index 1, not 0: the patriarch (whose own Sp atom is always the tree's first) never carries fOleShape either way, so the real shape under test is the SECOND Sp atom in document order. + const imageSp = findEscherRecords( + readEscherRecords(imageEscher), + ESCHER_SP, + )[1]; + const embeddedSp = findEscherRecords( + readEscherRecords(embeddedEscher), + ESCHER_SP, + )[1]; + if (imageSp?.kind !== "atom" || embeddedSp?.kind !== "atom") { + throw new Error("expected Sp atoms"); + } + const FSP_FLAG_OLE_SHAPE = 0x1 << 4; + const imageFlags = new DataView( + imageSp.data.buffer, + imageSp.data.byteOffset, + imageSp.data.byteLength, + ).getUint32(4, true); + const embeddedFlags = new DataView( + embeddedSp.data.buffer, + embeddedSp.data.byteOffset, + embeddedSp.data.byteLength, + ).getUint32(4, true); + + expect(imageFlags & FSP_FLAG_OLE_SHAPE).toBe(0); + expect(embeddedFlags & FSP_FLAG_OLE_SHAPE).toBe(FSP_FLAG_OLE_SHAPE); + }); + + it("continues a sheet's own Obj object ids past its commented cells' ids, rather than starting at 1 and colliding with them", () => { + const plan = buildDrawingWritePlan([ + sheet( + [ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + comment: { text: "note" }, + }, + ], + { images: [PNG_IMAGE] }, + ), + ]); + const objRecord = plan.sheetDrawings[0]?.objRecords[0]; + if (objRecord === undefined) { + throw new Error("expected an Obj record"); + } + const record = readRecords(objRecord)[0]; + if (record === undefined) { + throw new Error("expected a parsed Obj record"); + } + const view = new DataView( + record.data.buffer, + record.data.byteOffset, + record.data.byteLength, + ); + expect(view.getUint16(6, true)).toBe(2); // 1 comment + 1 + }); + + it("names an Embedding Storage path by the storage id's own uppercase, zero-padded hex spelling, not lowercase", () => { + const plan = buildDrawingWritePlan([ + sheet([], { embeddedObjects: [embeddedDrawing()] }), + ]); + + expect(plan.embeddingStreams[0]?.path).toBe("MBD00000001/Package"); + }); + + it("reports the largest shape id and total shape count across every sheet's own drawing, not just the last one written", () => { + const plan = buildDrawingWritePlan([ + sheet([], { images: [PNG_IMAGE] }), + sheet([], { images: [PNG_IMAGE, PNG_IMAGE] }), + ]); + const records = readEscherRecords( + plan.drawingGroupBytes ?? new Uint8Array(), + ); + const fdgg = findEscherRecord(records, 0xf006); + if (fdgg?.kind !== "atom") { + throw new Error("expected an FDGG atom"); + } + const view = new DataView( + fdgg.data.buffer, + fdgg.data.byteOffset, + fdgg.data.byteLength, + ); + // Sheet A allocates spids 1024 (patriarch) and 1025 (its one image); sheet B continues from 1026 (patriarch) through 1028 (its two images) -- spidMax is B's own last spid, and cspSaved is every shape (patriarch included) across both sheets: 2 + 3 = 5. + expect(view.getUint32(0, true)).toBe(1028); // spidMax + expect(view.getUint32(8, true)).toBe(5); // cspSaved + expect(view.getUint32(12, true)).toBe(2); // cdgSaved + }); +}); From 8cc36e94e2a3a8a06ba96621664ab9080ee93daf Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 06:50:22 +0100 Subject: [PATCH 094/107] refactor(xls-codec): refuse an invalid base64 character rather than silently skipping it bytesFromBase64 skipped any character outside the base64 alphabet with no distinction between genuine trailing padding and a stray invalid character elsewhere in the string -- and skipping padding specifically was already provably unobservable regardless of whether the skip ran, since the output array is pre-sized to exactly the real decoded byte count, so anything a mishandled padding character could have written would only ever land at or past that length, where a typed array write is a silent no-op. Padding is now sliced off up front instead of skipped inside the loop, and any character that still isn't part of the alphabet after that is a genuine malformed payload, refused by name rather than quietly dropped. --- packages/xls-codec/src/workbook/drawing-writer.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/xls-codec/src/workbook/drawing-writer.ts b/packages/xls-codec/src/workbook/drawing-writer.ts index 7ba8629e45..2c10b9f897 100644 --- a/packages/xls-codec/src/workbook/drawing-writer.ts +++ b/packages/xls-codec/src/workbook/drawing-writer.ts @@ -283,14 +283,18 @@ export function bytesFromBase64(base64: string): Uint8Array { values[char.charCodeAt(0)] = index; }); const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; + // Trailing padding is sliced off up front rather than skipped inside the loop below: skipping it there was genuinely unobservable regardless, since `out`'s own length is already sized to exactly the real decoded bytes, so any value a padding character contributed could only ever land at or past that length -- a Uint8Array write past its own end is a silent no-op, never a real byte the caller could see. + const data = padding > 0 ? base64.slice(0, base64.length - padding) : base64; const out = new Uint8Array((base64.length / 4) * 3 - padding); let buffer = 0; let bits = 0; let outIndex = 0; - for (const char of base64) { + for (const char of data) { const value = values[char.charCodeAt(0)]; if (value === undefined || value < 0) { - continue; // the padding characters + throw new BiffWriteError( + `a sheet image's own base64 payload contains "${char}", which is not part of the base64 alphabet`, + ); } buffer = (buffer << 6) | value; bits += 6; From bf9a735b89984adc6acec0c0965c751854d42028 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 06:50:31 +0100 Subject: [PATCH 095/107] test(xls-codec): cover Obj record byte layout, sequential ids, and cross-sheet spids Adds direct writePictureObjRecord/writeEmbeddedObjRecord byte checks for FtPioGrbit's own fAutoPict bit, a jpeg-accepted counterpart to the existing gif-refused test, and a base64-alphabet-refusal test matching the source's new behaviour. Replaces the single-item storage-id, object-id, and drawing-id checks with multi-item ones: 11 embedded objects to prove storage ids increment sequentially and reach into the hex alphabet's own uppercase letters past single digits, three cells (two commented, one not) with two images to prove the comment-count filter and the object-id sequence both count correctly, and two sheets each carrying a real image to prove their own FDG drawingId and shape spids are distinct and increasing rather than colliding or resetting. --- .../src/workbook/drawing-writer.test.ts | 158 +++++++++++++++--- 1 file changed, 138 insertions(+), 20 deletions(-) diff --git a/packages/xls-codec/src/workbook/drawing-writer.test.ts b/packages/xls-codec/src/workbook/drawing-writer.test.ts index 04bc312d13..227d273b4c 100644 --- a/packages/xls-codec/src/workbook/drawing-writer.test.ts +++ b/packages/xls-codec/src/workbook/drawing-writer.test.ts @@ -17,10 +17,12 @@ import { buildDrawingWritePlan, placementOfEmbedded, placementOfImage, + writeEmbeddedObjRecord, writeFtCf, writeFtCmo, writeFtPictFmla, writeFtPioGrbit, + writePictureObjRecord, WriterGridGeometry, } from "./drawing-writer"; @@ -324,12 +326,52 @@ describe("writeFtPictFmla", () => { }); }); +describe("writePictureObjRecord", () => { + it("states fAutoPict set, so a plain picture's own aspect ratio tracks the view", () => { + const bytes = writePictureObjRecord(5); + const record = readRecords(bytes)[0]; + if (record === undefined) { + throw new Error("expected a record"); + } + const view = new DataView( + record.data.buffer, + record.data.byteOffset, + record.data.byteLength, + ); + // FtCmo(22) + FtCf(6) = 28 bytes before FtPioGrbit; its own ft(2) + cb(2) precede the grbit word itself. + expect(view.getUint16(28 + 4, true)).toBe(0x0001); + }); +}); + +describe("writeEmbeddedObjRecord", () => { + it("states fAutoPict clear, the storage-based pair the Embedding Storage page requires", () => { + const bytes = writeEmbeddedObjRecord(5, 7); + const record = readRecords(bytes)[0]; + if (record === undefined) { + throw new Error("expected a record"); + } + const view = new DataView( + record.data.buffer, + record.data.byteOffset, + record.data.byteLength, + ); + expect(view.getUint16(28 + 4, true)).toBe(0x0000); + }); +}); + describe("bytesFromBase64", () => { it("decodes a base64 string whose length forces two padding characters, without treating either as real data", () => { // "f" alone (length 1) encodes to "Zg==" -- two padding characters, and the ONLY way to exercise the padding-character skip at all, since every other fixture in this package's own test suite happens to use base64 with no padding at all. expect(bytesFromBase64("Zg==")).toStrictEqual(new Uint8Array([0x66])); }); + it("refuses a character that is not part of the base64 alphabet, naming it exactly, rather than silently skipping it", () => { + // "=" is only ever valid as genuine trailing padding, sliced off before this loop even runs -- one sitting anywhere else in the string is exactly as invalid as any other non-alphabet character. + expect(() => bytesFromBase64("AB=C")).toThrow( + 'a sheet image\'s own base64 payload contains "=", which is not part of the base64 alphabet', + ); + }); + it("decodes a base64 string whose length forces exactly one padding character", () => { // "fo" (length 2) encodes to "Zm8=" -- one padding character. expect(bytesFromBase64("Zm8=")).toStrictEqual(new Uint8Array([0x66, 0x6f])); @@ -364,6 +406,14 @@ describe("buildDrawingWritePlan", () => { ); }); + it("accepts a jpeg image, the second of the two formats this writer actually supports", () => { + expect(() => + buildDrawingWritePlan([ + sheet([], { images: [{ ...PNG_IMAGE, format: "jpeg" }] }), + ]), + ).not.toThrow(); + }); + it("refuses a 'chart' embedded object by name", () => { const chart: ContentEmbeddedObject = { objectKind: "chart", @@ -454,7 +504,7 @@ describe("buildDrawingWritePlan", () => { expect(embeddedFlags & FSP_FLAG_OLE_SHAPE).toBe(FSP_FLAG_OLE_SHAPE); }); - it("continues a sheet's own Obj object ids past its commented cells' ids, rather than starting at 1 and colliding with them", () => { + it("continues a sheet's own Obj object ids past its commented cells' ids, rather than starting at 1 and colliding with them, counting only the cells that actually carry a comment", () => { const plan = buildDrawingWritePlan([ sheet( [ @@ -465,35 +515,63 @@ describe("buildDrawingWritePlan", () => { displayText: "x", comment: { text: "note" }, }, + { + row: 0, + column: 1, + value: { kind: "string", value: "y" }, + displayText: "y", + }, + { + row: 0, + column: 2, + value: { kind: "string", value: "z" }, + displayText: "z", + comment: { text: "another" }, + }, ], - { images: [PNG_IMAGE] }, + { images: [PNG_IMAGE, PNG_IMAGE] }, ), ]); - const objRecord = plan.sheetDrawings[0]?.objRecords[0]; - if (objRecord === undefined) { - throw new Error("expected an Obj record"); - } - const record = readRecords(objRecord)[0]; - if (record === undefined) { - throw new Error("expected a parsed Obj record"); - } - const view = new DataView( - record.data.buffer, - record.data.byteOffset, - record.data.byteLength, - ); - expect(view.getUint16(6, true)).toBe(2); // 1 comment + 1 + const objectIds = plan.sheetDrawings[0]?.objRecords.map((objRecord) => { + const record = readRecords(objRecord)[0]; + if (record === undefined) { + throw new Error("expected a parsed Obj record"); + } + const view = new DataView( + record.data.buffer, + record.data.byteOffset, + record.data.byteLength, + ); + return view.getUint16(6, true); + }); + + // 3 cells, 2 of which carry a comment -- ids start at 3 (2 comments + 1) and continue sequentially, not starting at 1 (which would collide with the comments' own ids) and not counting the third, comment-free cell. + expect(objectIds).toStrictEqual([3, 4]); }); - it("names an Embedding Storage path by the storage id's own uppercase, zero-padded hex spelling, not lowercase", () => { + it("assigns each embedded object its own sequential storage id, reaching past single digits into the hex alphabet's own letters, spelled uppercase", () => { const plan = buildDrawingWritePlan([ - sheet([], { embeddedObjects: [embeddedDrawing()] }), + sheet([], { + embeddedObjects: Array.from({ length: 11 }, () => embeddedDrawing()), + }), ]); - expect(plan.embeddingStreams[0]?.path).toBe("MBD00000001/Package"); + expect(plan.embeddingStreams.map((stream) => stream.path)).toStrictEqual([ + "MBD00000001/Package", + "MBD00000002/Package", + "MBD00000003/Package", + "MBD00000004/Package", + "MBD00000005/Package", + "MBD00000006/Package", + "MBD00000007/Package", + "MBD00000008/Package", + "MBD00000009/Package", + "MBD0000000A/Package", + "MBD0000000B/Package", + ]); }); - it("reports the largest shape id and total shape count across every sheet's own drawing, not just the last one written", () => { + it("reports the largest shape id, total shape count, and each drawing's own sequential id across every sheet's own drawing, not just the last one written", () => { const plan = buildDrawingWritePlan([ sheet([], { images: [PNG_IMAGE] }), sheet([], { images: [PNG_IMAGE, PNG_IMAGE] }), @@ -514,5 +592,45 @@ describe("buildDrawingWritePlan", () => { expect(view.getUint32(0, true)).toBe(1028); // spidMax expect(view.getUint32(8, true)).toBe(5); // cspSaved expect(view.getUint32(12, true)).toBe(2); // cdgSaved + // One OfficeArtIDCL per drawing, right after the four header fields: drawingId(4) + lastSpid(4) each, in drawing order -- sheet A's own drawing is id 1, sheet B's is id 2, not the reverse and not both landing on the same id. + expect(view.getUint32(16, true)).toBe(1); // sheet A's own drawingId + expect(view.getUint32(24, true)).toBe(2); // sheet B's own drawingId + }); + + it("gives each sheet's own real shapes distinct, non-overlapping spids across the workbook, continuing from the previous sheet's own last one rather than restarting", () => { + const plan = buildDrawingWritePlan([ + sheet([], { images: [PNG_IMAGE] }), + sheet([], { images: [PNG_IMAGE] }), + ]); + const sheetAEscher = escherBytesFromMsoDrawingRecords( + plan.sheetDrawings[0]?.msoDrawingRecords ?? [], + ); + const sheetBEscher = escherBytesFromMsoDrawingRecords( + plan.sheetDrawings[1]?.msoDrawingRecords ?? [], + ); + // Index 1: the patriarch's own Sp atom is always first, the real shape second. + const sheetAShape = findEscherRecords( + readEscherRecords(sheetAEscher), + ESCHER_SP, + )[1]; + const sheetBShape = findEscherRecords( + readEscherRecords(sheetBEscher), + ESCHER_SP, + )[1]; + if (sheetAShape?.kind !== "atom" || sheetBShape?.kind !== "atom") { + throw new Error("expected Sp atoms"); + } + const sheetASpid = new DataView( + sheetAShape.data.buffer, + sheetAShape.data.byteOffset, + sheetAShape.data.byteLength, + ).getUint32(0, true); + const sheetBSpid = new DataView( + sheetBShape.data.buffer, + sheetBShape.data.byteOffset, + sheetBShape.data.byteLength, + ).getUint32(0, true); + + expect(sheetBSpid).toBeGreaterThan(sheetASpid); }); }); From 711585c596ce8538ae85c4b8322a1149395a866c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 06:53:22 +0100 Subject: [PATCH 096/107] refactor(xls-codec): always slice bytesFromBase64's own trailing padding padding is only ever 0, 1, or 2, and slicing zero characters off the end of a string returns the identical string -- so the "no padding" branch of the padding>0 ternary was just base64.slice(0, base64.length) restated as a separate case, not a genuinely different one. Slicing unconditionally removes the redundant condition entirely. --- .../src/workbook/drawing-writer.test.ts | 22 +++++++++++++++++++ .../xls-codec/src/workbook/drawing-writer.ts | 4 ++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/xls-codec/src/workbook/drawing-writer.test.ts b/packages/xls-codec/src/workbook/drawing-writer.test.ts index 227d273b4c..212744f54d 100644 --- a/packages/xls-codec/src/workbook/drawing-writer.test.ts +++ b/packages/xls-codec/src/workbook/drawing-writer.test.ts @@ -549,6 +549,28 @@ describe("buildDrawingWritePlan", () => { expect(objectIds).toStrictEqual([3, 4]); }); + it("continues each embedded object's own Obj object id sequentially, not just the first", () => { + const plan = buildDrawingWritePlan([ + sheet([], { + embeddedObjects: [embeddedDrawing(), embeddedDrawing()], + }), + ]); + const objectIds = plan.sheetDrawings[0]?.objRecords.map((objRecord) => { + const record = readRecords(objRecord)[0]; + if (record === undefined) { + throw new Error("expected a parsed Obj record"); + } + const view = new DataView( + record.data.buffer, + record.data.byteOffset, + record.data.byteLength, + ); + return view.getUint16(6, true); + }); + + expect(objectIds).toStrictEqual([1, 2]); + }); + it("assigns each embedded object its own sequential storage id, reaching past single digits into the hex alphabet's own letters, spelled uppercase", () => { const plan = buildDrawingWritePlan([ sheet([], { diff --git a/packages/xls-codec/src/workbook/drawing-writer.ts b/packages/xls-codec/src/workbook/drawing-writer.ts index 2c10b9f897..cbcf7f3f5c 100644 --- a/packages/xls-codec/src/workbook/drawing-writer.ts +++ b/packages/xls-codec/src/workbook/drawing-writer.ts @@ -283,8 +283,8 @@ export function bytesFromBase64(base64: string): Uint8Array { values[char.charCodeAt(0)] = index; }); const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; - // Trailing padding is sliced off up front rather than skipped inside the loop below: skipping it there was genuinely unobservable regardless, since `out`'s own length is already sized to exactly the real decoded bytes, so any value a padding character contributed could only ever land at or past that length -- a Uint8Array write past its own end is a silent no-op, never a real byte the caller could see. - const data = padding > 0 ? base64.slice(0, base64.length - padding) : base64; + // Trailing padding is sliced off up front rather than skipped inside the loop below: skipping it there was genuinely unobservable regardless, since `out`'s own length is already sized to exactly the real decoded bytes, so any value a padding character contributed could only ever land at or past that length -- a Uint8Array write past its own end is a silent no-op, never a real byte the caller could see. padding is always 0, 1, or 2, so slicing zero characters off when there is no padding at all is just the original string back -- no separate unpadded branch is needed. + const data = base64.slice(0, base64.length - padding); const out = new Uint8Array((base64.length / 4) * 3 - padding); let buffer = 0; let bits = 0; From acd297390f8073f0f2b19a9e4d3750b60b428f42 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:04:02 +0100 Subject: [PATCH 097/107] refactor(xls-codec): drop chart.ts's own dead switch default and pointInRange's redundant edge cases readChartSeries' own record-type switch ended in a bare default: break, the last case in the switch and no different in effect from having no default arm at all. pointInRange special-cased a single-row range (walk across columns) and a single-column range (walk down rows) ahead of the general row-major formula -- but with width always the range's own real column count and index always staying within the range's own point count for a well-formed chart, Math.floor(index / width) can never reach a second row when the range is one row tall, and index % width is always 0 when the range is one column wide, so the general formula already reduces to exactly what each special case computed by hand. Both are removed, leaving the one formula that handles every range shape a real chart can carry. --- packages/xls-codec/src/workbook/chart.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/xls-codec/src/workbook/chart.ts b/packages/xls-codec/src/workbook/chart.ts index 6f256928a5..8552f57716 100644 --- a/packages/xls-codec/src/workbook/chart.ts +++ b/packages/xls-codec/src/workbook/chart.ts @@ -107,8 +107,6 @@ export function readChartSeries( addCacheEntry(cache, currentCacheRole, record); } break; - default: - break; } } @@ -277,19 +275,12 @@ function resolvePoints( return points; } -/** The Nth cell of a range, in reading order -- a single row walks across its columns, a single column (the common vertical-series case) walks down its rows, and a genuine rectangular box walks row-major. */ +/** The Nth cell of a range, in reading order -- row-major: a single row walks across its columns, a single column (the common vertical-series case) walks down its rows, and a genuine rectangular box walks a full row before moving to the next, all through the identical formula below. A single-row or single-column range needs no case of its own: with width the range's own total column count, index (always < the range's own cell count for a well-formed chart) never reaches a second row when the range is one row tall (Math.floor(index / width) stays 0 throughout, since index < width), and never advances past column zero when the range is one column wide (index % 1 is always 0) -- the general formula already reduces to exactly the row-only or column-only walk each of those shapes needs. */ function pointInRange( range: OwnSheetRange, index: number, ): { row: number; column: number } { - const height = range.endRow - range.startRow + 1; const width = range.endColumn - range.startColumn + 1; - if (height <= 1) { - return { row: range.startRow, column: range.startColumn + index }; - } - if (width <= 1) { - return { row: range.startRow + index, column: range.startColumn }; - } return { row: range.startRow + Math.floor(index / width), column: range.startColumn + (index % width), From 4f27b4e71c0c0a9362aa5ff726a812ed1779edd0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:04:14 +0100 Subject: [PATCH 098/107] test(xls-codec): cover chart series AI dispatch, cache roles, and range resolution Adds direct coverage for readChartSeries' own dispatch and cache logic that the existing four tests never reached: a SeriesText cache only applying when it follows a name-role AI, not values/categories; a range-reference AI name (id=0, rt=range) resolved from the referenced cell; literal and auto-generated (rt=0/1) AI records whose own token bytes happen to share a range opcode's byte shape, proving rt itself gates the range interpretation rather than the bytes alone; an AI id naming neither values nor categories (bubble size); an SIIndex naming neither cache role; BoolErr's own error-text and TRUE/FALSE cache branches; a Blank record's own no-op cache contribution; single-row, single-column, and genuine rectangular range walks; empty-formula guards and the PtgParen display wrapper for both literal and range tokens; PtgInt/PtgNum literals; each of PtgRef3d's and PtgArea3d's own three class-variant opcodes; a reversed row/column pair normalised through Math.min/max; and isOwnSheetRef's own four ways to answer "not the owning sheet" (a different sheet with no cache to mask it, a genuinely multi-sheet 3D reference spanning the owning sheet, an unresolvable ixti, and a genuinely external workbook reference). --- packages/xls-codec/src/workbook/chart.test.ts | 518 +++++++++++++++++- 1 file changed, 509 insertions(+), 9 deletions(-) diff --git a/packages/xls-codec/src/workbook/chart.test.ts b/packages/xls-codec/src/workbook/chart.test.ts index 2aff57a696..6645bc42f7 100644 --- a/packages/xls-codec/src/workbook/chart.test.ts +++ b/packages/xls-codec/src/workbook/chart.test.ts @@ -4,6 +4,8 @@ import type { ContentSheetCell } from "document-schema.js"; import type { FormulaSheetContext } from "../biff/ptg"; import { RECORD_AI, + RECORD_BLANK, + RECORD_BOOLERR, RECORD_LABEL, RECORD_NUMBER, RECORD_SERIES, @@ -43,16 +45,17 @@ function seriesRecord( ]); } -/** A PtgArea3d token targeting `ixti`'s sheet: opcode 0x3b, ixti, rowFirst, rowLast, colFirst, colLast. */ +/** A PtgArea3d token targeting `ixti`'s sheet: opcode (0x3b by default; pass 0x5b/0x7b for the reference/value class variants), ixti, rowFirst, rowLast, colFirst, colLast. */ function area3dToken( ixti: number, startRow: number, endRow: number, startColumn: number, endColumn: number, + opcode = 0x3b, ): number[] { return [ - 0x3b, + opcode, ...u16(ixti), ...u16(startRow), ...u16(endRow), @@ -61,6 +64,21 @@ function area3dToken( ]; } +/** A PtgRef3d token targeting `ixti`'s sheet: opcode (0x3a by default; pass 0x5a/0x7a for the class variants), ixti, row, column. */ +function ref3dToken( + ixti: number, + row: number, + column: number, + opcode = 0x3a, +): number[] { + return [opcode, ...u16(ixti), ...u16(row), ...u16(column)]; +} + +/** Wraps a token in a PtgParen display wrapper (opcode 0x15), which carries no bytes of its own beyond the opcode. */ +function parenWrapped(tokenBytes: readonly number[]): number[] { + return [0x15, ...tokenBytes]; +} + /** An AI (BRAI) record wrapping a range-reference formula: id, rt=2 (range), flags word, ifmt word, cce, rgce. */ function aiRangeRecord( id: number, @@ -81,22 +99,46 @@ function aiAutoRecord(id: number): Uint8Array { return record(RECORD_AI, [id, 0x00, ...u16(0), ...u16(0), ...u16(0)]); } -/** An AI record wrapping a literal-text formula (rt=1, a PtgStr token: opcode 0x17 + ShortXLUnicodeString). */ -function aiLiteralTextRecord( +/** The general AI (BRAI) record builder every other aiXRecord helper specialises: id, an explicit rt (rather than always rt=2), and arbitrary token bytes -- for a test proving the reader dispatches on rt itself, not merely on which bytes a particular rt conventionally carries. */ +function aiRecord( id: number, - text: string, + rt: number, + tokenBytes: readonly number[], ): Uint8Array { - const token = [0x17, ...shortXlUnicodeString(text)]; return record(RECORD_AI, [ id, - 0x01, + rt, ...u16(0), ...u16(0), - ...u16(token.length), - ...token, + ...u16(tokenBytes.length), + ...tokenBytes, ]); } +/** An AI record wrapping a literal-text formula (rt=1, a PtgStr token: opcode 0x17 + ShortXLUnicodeString). */ +function aiLiteralTextRecord( + id: number, + text: string, +): Uint8Array { + return aiRecord(id, 0x01, [0x17, ...shortXlUnicodeString(text)]); +} + +/** An AI record wrapping a literal PtgInt token (opcode 0x1e, a plain u16). */ +function aiLiteralIntRecord( + id: number, + value: number, +): Uint8Array { + return aiRecord(id, 0x01, [0x1e, ...u16(value)]); +} + +/** An AI record wrapping a literal PtgNum token (opcode 0x1f, an f64). */ +function aiLiteralNumRecord( + id: number, + value: number, +): Uint8Array { + return aiRecord(id, 0x01, [0x1f, ...f64(value)]); +} + function seriesTextRecord(text: string): Uint8Array { return record(RECORD_SERIESTEXT, [...u16(0), ...shortXlUnicodeString(text)]); } @@ -131,6 +173,27 @@ function cachedLabel( ]); } +/** A cached BoolErr record: point, series, xf(2, ignored), a value byte, an fError byte -- errorTextOf(value) when fError is set, else "TRUE"/"FALSE" from whether value is nonzero. */ +function cachedBoolErr( + point: number, + series: number, + value: number, + isError: boolean, +): Uint8Array { + return record(RECORD_BOOLERR, [ + ...u16(point), + ...u16(series), + ...u16(0), + value, + isError ? 1 : 0, + ]); +} + +/** A cached Blank record: point, series, xf(2, ignored) -- no value fields at all, deliberately not one of addCacheEntry's own recognised record types (RECORD_NUMBER/RECORD_LABEL/RECORD_BOOLERR), so it contributes nothing to the cache. */ +function cachedBlank(point: number, series: number): Uint8Array { + return record(RECORD_BLANK, [...u16(point), ...u16(series), ...u16(0)]); +} + function contextWithCells( cells: readonly ContentSheetCell[], ): ChartRangeContext { @@ -247,4 +310,441 @@ describe("readChartSeries", () => { it("returns no series for a chart substream with no Series records", () => { expect(readChartSeries([], contextWithCells([]))).toStrictEqual([]); }); + + it("ignores a SeriesText cache unless it follows an AI naming the series itself, not values/categories", () => { + const groups = chartRecords([ + seriesRecord(0, 0), + aiAutoRecord(AI_ID_VALUES), + seriesTextRecord("Should Not Apply"), + ]); + + const series = readChartSeries(groups, contextWithCells([])); + + expect(series[0]?.name).toBeUndefined(); + }); + + it("resolves a range-reference AI name (id=0, rt=range) from the referenced cell's own display text", () => { + const cells: ContentSheetCell[] = [ + { + row: 0, + column: 0, + value: { kind: "string", value: "Header" }, + displayText: "Header", + }, + ]; + const groups = chartRecords([ + seriesRecord(0, 0), + aiRangeRecord(AI_ID_NAME, area3dToken(0, 0, 0, 0, 0)), + ]); + + const series = readChartSeries(groups, contextWithCells(cells)); + + expect(series[0]?.name).toBe("Header"); + }); + + it("never interprets an auto-generated (rt=0) AI name's own bytes as a range reference either, even when they happen to share a range opcode's own byte shape", () => { + const cells: ContentSheetCell[] = [ + { + row: 0, + column: 0, + value: { kind: "string", value: "WRONG" }, + displayText: "WRONG", + }, + ]; + const groups = chartRecords([ + seriesRecord(0, 0), + aiRecord(AI_ID_NAME, 0x00, area3dToken(0, 0, 0, 0, 0)), + ]); + + const series = readChartSeries(groups, contextWithCells(cells)); + + expect(series[0]?.name).toBeUndefined(); + }); + + it("never interprets a literal AI's own token bytes as a range reference, even when they happen to share a range opcode's own byte shape", () => { + const groups = chartRecords([ + seriesRecord(0, 1), + aiRecord(AI_ID_VALUES, 0x01, area3dToken(0, 0, 0, 0, 0)), + ]); + const cells: ContentSheetCell[] = [ + { + row: 0, + column: 0, + value: { kind: "number", value: 5 }, + displayText: "5", + }, + ]; + + const series = readChartSeries(groups, contextWithCells(cells)); + + expect(series[0]?.values).toStrictEqual([""]); + }); + + it("ignores a range-reference AI whose id is neither values nor categories (e.g. bubble size), leaving both untouched", () => { + const cells: ContentSheetCell[] = [ + { + row: 0, + column: 0, + value: { kind: "number", value: 9 }, + displayText: "9", + }, + ]; + const groups = chartRecords([ + seriesRecord(1, 1), + aiRangeRecord(3, area3dToken(0, 0, 0, 0, 0)), + ]); + + const series = readChartSeries(groups, contextWithCells(cells)); + + expect(series[0]?.categories).toStrictEqual([""]); + expect(series[0]?.values).toStrictEqual([""]); + }); + + it("ignores an SIIndex naming neither values nor categories (e.g. bubble size), so records following it never enter the cache", () => { + const groups = chartRecords([ + seriesRecord(1, 1), + aiAutoRecord(AI_ID_CATEGORIES), + siIndexRecord(0x0003), // neither values (1) nor categories (2) + cachedNumber(0, 0, 99), + ]); + + const series = readChartSeries(groups, contextWithCells([])); + + expect(series[0]?.categories).toStrictEqual([""]); + }); + + it("caches a BoolErr's own error text when fError is set", () => { + const groups = chartRecords([ + seriesRecord(1, 0), + aiAutoRecord(AI_ID_CATEGORIES), + siIndexRecord(0x0002), + cachedBoolErr(0, 0, 0x07, true), + ]); + + const series = readChartSeries(groups, contextWithCells([])); + + expect(series[0]?.categories).toStrictEqual(["#DIV/0!"]); + }); + + it("caches a BoolErr's own TRUE/FALSE spelling when fError is clear, distinguishing a genuinely nonzero value from zero", () => { + const trueGroups = chartRecords([ + seriesRecord(1, 0), + aiAutoRecord(AI_ID_CATEGORIES), + siIndexRecord(0x0002), + cachedBoolErr(0, 0, 1, false), + ]); + const falseGroups = chartRecords([ + seriesRecord(1, 0), + aiAutoRecord(AI_ID_CATEGORIES), + siIndexRecord(0x0002), + cachedBoolErr(0, 0, 0, false), + ]); + + expect( + readChartSeries(trueGroups, contextWithCells([]))[0]?.categories, + ).toStrictEqual(["TRUE"]); + expect( + readChartSeries(falseGroups, contextWithCells([]))[0]?.categories, + ).toStrictEqual(["FALSE"]); + }); + + it("adds nothing to the cache for a cached Blank record, an explicit 'no value at this point' rather than a value", () => { + const groups = chartRecords([ + seriesRecord(1, 0), + aiAutoRecord(AI_ID_CATEGORIES), + siIndexRecord(0x0002), + cachedBlank(0, 0), + ]); + + const series = readChartSeries(groups, contextWithCells([])); + + expect(series[0]?.categories).toStrictEqual([""]); + }); + + it("walks a single-row range across its own columns, not down to a second row", () => { + const cells: ContentSheetCell[] = [ + { + row: 2, + column: 0, + value: { kind: "string", value: "Q1" }, + displayText: "Q1", + }, + { + row: 2, + column: 1, + value: { kind: "string", value: "Q2" }, + displayText: "Q2", + }, + ]; + const groups = chartRecords([ + seriesRecord(2, 0), + aiRangeRecord(AI_ID_CATEGORIES, area3dToken(0, 2, 2, 0, 1)), + ]); + + const series = readChartSeries(groups, contextWithCells(cells)); + + expect(series[0]?.categories).toStrictEqual(["Q1", "Q2"]); + }); + + it("walks a genuine rectangular range row-major -- across every column of one row before moving to the next", () => { + const cells: ContentSheetCell[] = [ + { + row: 0, + column: 0, + value: { kind: "string", value: "a" }, + displayText: "a", + }, + { + row: 0, + column: 1, + value: { kind: "string", value: "b" }, + displayText: "b", + }, + { + row: 1, + column: 0, + value: { kind: "string", value: "c" }, + displayText: "c", + }, + { + row: 1, + column: 1, + value: { kind: "string", value: "d" }, + displayText: "d", + }, + ]; + const groups = chartRecords([ + seriesRecord(4, 0), + aiRangeRecord(AI_ID_CATEGORIES, area3dToken(0, 0, 1, 0, 1)), + ]); + + const series = readChartSeries(groups, contextWithCells(cells)); + + expect(series[0]?.categories).toStrictEqual(["a", "b", "c", "d"]); + }); + + it("resolves nothing for an AI carrying an empty formula, rather than reading a literal token past the end of it", () => { + const groups = chartRecords([ + seriesRecord(0, 0), + aiRecord(AI_ID_NAME, 0x01, []), + ]); + + const series = readChartSeries(groups, contextWithCells([])); + + expect(series[0]?.name).toBeUndefined(); + }); + + it("unwraps a PtgParen display wrapper around a literal token before reading it", () => { + const groups = chartRecords([ + seriesRecord(0, 0), + aiRecord( + AI_ID_NAME, + 0x01, + parenWrapped([0x17, ...shortXlUnicodeString("Wrapped")]), + ), + ]); + + const series = readChartSeries(groups, contextWithCells([])); + + expect(series[0]?.name).toBe("Wrapped"); + }); + + it("resolves a literal PtgInt AI name", () => { + const groups = chartRecords([ + seriesRecord(0, 0), + aiLiteralIntRecord(AI_ID_NAME, 42), + ]); + + expect(readChartSeries(groups, contextWithCells([]))[0]?.name).toBe("42"); + }); + + it("resolves a literal PtgNum AI name", () => { + const groups = chartRecords([ + seriesRecord(0, 0), + aiLiteralNumRecord(AI_ID_NAME, 3.5), + ]); + + expect(readChartSeries(groups, contextWithCells([]))[0]?.name).toBe("3.5"); + }); + + it("resolves nothing for an AI naming a range with an empty formula, rather than reading a range token past the end of it", () => { + const groups = chartRecords([ + seriesRecord(1, 0), + aiRangeRecord(AI_ID_CATEGORIES, []), + ]); + + const series = readChartSeries(groups, contextWithCells([])); + + expect(series[0]?.categories).toStrictEqual([""]); + }); + + it("unwraps a PtgParen display wrapper around a range token before reading it", () => { + const cells: ContentSheetCell[] = [ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + }, + ]; + const groups = chartRecords([ + seriesRecord(1, 0), + aiRangeRecord(AI_ID_CATEGORIES, parenWrapped(area3dToken(0, 0, 0, 0, 0))), + ]); + + const series = readChartSeries(groups, contextWithCells(cells)); + + expect(series[0]?.categories).toStrictEqual(["x"]); + }); + + it("resolves each of PtgRef3d's own three class-variant opcodes (reference/value/array) identically", () => { + const cells: ContentSheetCell[] = [ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + }, + ]; + for (const opcode of [0x3a, 0x5a, 0x7a]) { + const groups = chartRecords([ + seriesRecord(1, 0), + aiRangeRecord(AI_ID_CATEGORIES, ref3dToken(0, 0, 0, opcode)), + ]); + + const series = readChartSeries(groups, contextWithCells(cells)); + + expect(series[0]?.categories).toStrictEqual(["x"]); + } + }); + + it("resolves each of PtgArea3d's own three class-variant opcodes (reference/value/array) identically", () => { + const cells: ContentSheetCell[] = [ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + }, + ]; + for (const opcode of [0x3b, 0x5b, 0x7b]) { + const groups = chartRecords([ + seriesRecord(1, 0), + aiRangeRecord(AI_ID_CATEGORIES, area3dToken(0, 0, 0, 0, 0, opcode)), + ]); + + const series = readChartSeries(groups, contextWithCells(cells)); + + expect(series[0]?.categories).toStrictEqual(["x"]); + } + }); + + it("orders a PtgArea3d's own reversed row/column pair into ascending start/end, regardless of which corner the file states first", () => { + const cells: ContentSheetCell[] = [ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + }, + { + row: 0, + column: 1, + value: { kind: "string", value: "y" }, + displayText: "y", + }, + ]; + const groups = chartRecords([ + seriesRecord(2, 0), + // colFirst=1, colLast=0 -- reversed, so startColumn must come from Math.min and endColumn from Math.max, not the other way round. + aiRangeRecord(AI_ID_CATEGORIES, area3dToken(0, 0, 0, 1, 0)), + ]); + + const series = readChartSeries(groups, contextWithCells(cells)); + + expect(series[0]?.categories).toStrictEqual(["x", "y"]); + }); + + it("never resolves a range through the owning sheet's own cells when the reference points to a different sheet, even with no cache entry to fall back to", () => { + const cells: ContentSheetCell[] = [ + { + row: 0, + column: 0, + value: { kind: "string", value: "WRONG" }, + displayText: "WRONG", + }, + ]; + const groups = chartRecords([ + seriesRecord(1, 0), + aiRangeRecord(AI_ID_CATEGORIES, area3dToken(1, 0, 0, 0, 0)), // ixti 1 -> Sheet2, not the owning sheet + ]); + + const series = readChartSeries(groups, contextWithCells(cells)); + + expect(series[0]?.categories).toStrictEqual([""]); + }); + + it("never treats a genuinely multi-sheet 3D reference as the owning sheet, even when the owning sheet falls within its span", () => { + const cells: ContentSheetCell[] = [ + { + row: 0, + column: 0, + value: { kind: "string", value: "WRONG" }, + displayText: "WRONG", + }, + ]; + const multiSheetContext: ChartRangeContext = { + formulaSheets: { + sheets: SHEET0_CONTEXT.sheets, + sheetRanges: [{ firstSheetIndex: 0, lastSheetIndex: 1 }], // Sheet1:Sheet2 -- spans the owning sheet (0) but is not single-sheet + }, + ownSheetIndex: 0, + ownSheetCells: cells, + }; + const groups = chartRecords([ + seriesRecord(1, 0), + aiRangeRecord(AI_ID_CATEGORIES, area3dToken(0, 0, 0, 0, 0)), + ]); + + const series = readChartSeries(groups, multiSheetContext); + + expect(series[0]?.categories).toStrictEqual([""]); + }); + + it("treats an ixti with no resolvable sheet range at all (out of bounds) as not the owning sheet", () => { + const groups = chartRecords([ + seriesRecord(1, 0), + aiRangeRecord(AI_ID_CATEGORIES, area3dToken(99, 0, 0, 0, 0)), + ]); + + const series = readChartSeries(groups, contextWithCells([])); + + expect(series[0]?.categories).toStrictEqual([""]); + }); + + it("treats a genuinely external workbook reference (a formatted sheet label, not a resolved range) as not the owning sheet", () => { + const externalContext: ChartRangeContext = { + formulaSheets: { + sheets: SHEET0_CONTEXT.sheets, + sheetRanges: [{ label: "[Other.xlsx]Sheet1", diagnostic: false }], + }, + ownSheetIndex: 0, + ownSheetCells: [ + { + row: 0, + column: 0, + value: { kind: "string", value: "WRONG" }, + displayText: "WRONG", + }, + ], + }; + const groups = chartRecords([ + seriesRecord(1, 0), + aiRangeRecord(AI_ID_CATEGORIES, area3dToken(0, 0, 0, 0, 0)), + ]); + + const series = readChartSeries(groups, externalContext); + + expect(series[0]?.categories).toStrictEqual([""]); + }); }); From b75410dee90a3689f1da5db128a6303fabe8d543 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:09:35 +0100 Subject: [PATCH 099/107] refactor(xls-codec): drop addCacheEntry's own redundant default-case return A cached record type addCacheEntry doesn't recognise (chiefly a Blank) left text at its own initial undefined either way -- the switch's default case returned early to state that explicitly, but the very next line already returns early for exactly that condition, so the default case said nothing the following check didn't already say on its own. --- packages/xls-codec/src/workbook/chart.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/xls-codec/src/workbook/chart.ts b/packages/xls-codec/src/workbook/chart.ts index 8552f57716..82bb562f52 100644 --- a/packages/xls-codec/src/workbook/chart.ts +++ b/packages/xls-codec/src/workbook/chart.ts @@ -231,9 +231,8 @@ function addCacheEntry( text = isError ? errorTextOf(value) : value !== 0 ? "TRUE" : "FALSE"; break; } - default: - return; } + // Anything else (a cached Blank, chiefly) leaves text at its own initial undefined -- the check right below already treats that identically to an explicit "this record type carries no value" return, so a default case restating the same return would say nothing this check doesn't already say on its own. if (text === undefined) { return; } From 4b47af67b5ea72a2f57666cffba6ebf02320da5a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:09:43 +0100 Subject: [PATCH 100/107] test(xls-codec): cover own-sheet fallback and range boundary edges Adds a genuine unmatched-cell own-sheet range test (proving the displayText fallback path itself, not just the no-range case), a PtgRef3d cross-sheet test mirroring the existing PtgArea3d one, an unrecognised range-token opcode falling through to undefined, a reversed row pair normalised through Math.min/max (the row counterpart to the existing reversed-column test), and two cached points under the same role proving the cache's own lazy-init guards keep earlier entries rather than starting the role over on every call. --- packages/xls-codec/src/workbook/chart.test.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/packages/xls-codec/src/workbook/chart.test.ts b/packages/xls-codec/src/workbook/chart.test.ts index 6645bc42f7..3ad2313a7e 100644 --- a/packages/xls-codec/src/workbook/chart.test.ts +++ b/packages/xls-codec/src/workbook/chart.test.ts @@ -722,6 +722,87 @@ describe("readChartSeries", () => { expect(series[0]?.categories).toStrictEqual([""]); }); + it("resolves the empty string for an own-sheet range point naming a cell the sheet's own cell list doesn't carry", () => { + const groups = chartRecords([ + seriesRecord(1, 0), + aiRangeRecord(AI_ID_CATEGORIES, area3dToken(0, 5, 5, 5, 5)), + ]); + + const series = readChartSeries(groups, contextWithCells([])); + + expect(series[0]?.categories).toStrictEqual([""]); + }); + + it("never resolves a single-cell (PtgRef3d) reference through the owning sheet's own cells when it points to a different sheet", () => { + const cells: ContentSheetCell[] = [ + { + row: 0, + column: 0, + value: { kind: "string", value: "WRONG" }, + displayText: "WRONG", + }, + ]; + const groups = chartRecords([ + seriesRecord(1, 0), + aiRangeRecord(AI_ID_CATEGORIES, ref3dToken(1, 0, 0)), // ixti 1 -> Sheet2, not the owning sheet + ]); + + const series = readChartSeries(groups, contextWithCells(cells)); + + expect(series[0]?.categories).toStrictEqual([""]); + }); + + it("resolves nothing for a range-reference AI carrying an opcode that is neither the PtgRef3d nor the PtgArea3d family", () => { + const groups = chartRecords([ + seriesRecord(1, 0), + aiRangeRecord(AI_ID_CATEGORIES, [0x00, ...u16(0), ...u16(0), ...u16(0)]), + ]); + + const series = readChartSeries(groups, contextWithCells([])); + + expect(series[0]?.categories).toStrictEqual([""]); + }); + + it("orders a PtgArea3d's own reversed row pair into ascending start/end, regardless of which comes first", () => { + const cells: ContentSheetCell[] = [ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + }, + { + row: 1, + column: 0, + value: { kind: "string", value: "y" }, + displayText: "y", + }, + ]; + const groups = chartRecords([ + seriesRecord(2, 0), + // rowFirst=1, rowLast=0 -- reversed, so startRow must come from Math.min and endRow from Math.max, not the other way round. + aiRangeRecord(AI_ID_CATEGORIES, area3dToken(0, 1, 0, 0, 0)), + ]); + + const series = readChartSeries(groups, contextWithCells(cells)); + + expect(series[0]?.categories).toStrictEqual(["x", "y"]); + }); + + it("keeps every earlier cached point when a later point arrives for the same role, rather than starting the role's own cache over each time", () => { + const groups = chartRecords([ + seriesRecord(2, 0), + aiAutoRecord(AI_ID_CATEGORIES), + siIndexRecord(0x0002), + cachedLabel(0, 0, "First"), + cachedLabel(1, 0, "Second"), + ]); + + const series = readChartSeries(groups, contextWithCells([])); + + expect(series[0]?.categories).toStrictEqual(["First", "Second"]); + }); + it("treats a genuinely external workbook reference (a formatted sheet label, not a resolved range) as not the owning sheet", () => { const externalContext: ChartRangeContext = { formulaSheets: { From a6d41be939230723102905e31488174fe53e70df Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:12:59 +0100 Subject: [PATCH 101/107] refactor(xls-codec): remove OwnSheetRange's own unread endRow field pointInRange's row-major walk only ever reads a range's startRow and its width (derived from startColumn/endColumn) -- a well-formed chart's own point count stays within the range's real extent, so nothing here ever needs to know where the range's last row actually is. endRow was computed at both of readRangeToken's own construction sites and never read anywhere after. --- packages/xls-codec/src/workbook/chart.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/xls-codec/src/workbook/chart.ts b/packages/xls-codec/src/workbook/chart.ts index 82bb562f52..3414bb06fd 100644 --- a/packages/xls-codec/src/workbook/chart.ts +++ b/packages/xls-codec/src/workbook/chart.ts @@ -48,10 +48,9 @@ const SIINDEX_CATEGORIES = 0x0002; type CacheRole = "values" | "categories"; -/** A single-cell or rectangular range this reader resolved from an AI's own PtgRef3d/PtgArea3d token, restricted to the OWN sheet a chart is embedded in -- see this module's own top comment for why a cross-sheet reference has no shortcut here and falls back to the on-disk cache instead. */ +/** A single-cell or rectangular range this reader resolved from an AI's own PtgRef3d/PtgArea3d token, restricted to the OWN sheet a chart is embedded in -- see this module's own top comment for why a cross-sheet reference has no shortcut here and falls back to the on-disk cache instead. No endRow field: pointInRange's own row-major walk (startRow plus however many whole rows the point's own index advances) never needs the range's last row at all -- a well-formed chart's own point count already stays within the range's real extent, so nothing here ever needs to check where the range stops. */ interface OwnSheetRange { readonly startRow: number; - readonly endRow: number; readonly startColumn: number; readonly endColumn: number; } @@ -343,7 +342,6 @@ function readRangeToken( } return { startRow: row, - endRow: row, startColumn: column, endColumn: column, }; @@ -360,7 +358,6 @@ function readRangeToken( } return { startRow: Math.min(rowFirst, rowLast), - endRow: Math.max(rowFirst, rowLast), startColumn: Math.min(columnFirst, columnLast), endColumn: Math.max(columnFirst, columnLast), }; From 251b05403905b05ff41065d8eda4c96f54b4381a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:37:14 +0100 Subject: [PATCH 102/107] refactor(xls-codec): remove cfb.ts's own unreachable checks and self-correcting bounds Every counted loop and Map/array lookup this test-support compound- file writer made defensively (a name length TypeScript can prove non-empty but the type system can't, a sibling/child lookup a single upstream tree walk always populates before it's ever read, an off-by-one loop bound whose extra iteration writes a byte the target buffer was already zero at, or a subarray/typed-array set that clamps to the buffer's own real end) turned out to be provably unreachable or genuinely unobservable, confirmed by applying each mutation directly and running the real reader round-trip suite against it rather than reasoning about it in the abstract. Restructured accordingly: checkedName drops its own unreachable empty-name check (every name reaching it already comes from a validated path segment or the root's own fixed "Root Entry"); the path-splitting and tree-linking Map/array lookups route through two small, directly-tested requiredLeaf/requiredRecord/requiredSectorStart helpers instead of silent `?? fallback` values; the small- and big-stream sector-allocation passes pair each record with its own derived value in one array (miniEntries/bigEntries) rather than two same-length arrays walked by a shared index; counted loops that build or copy a fixed number of sectors iterate `Array(n).keys()` instead of a hand-written `i < n` comparison; and the two truly redundant special cases (an empty mini stream's own zero-byte copy, and Math.ceil(0 / x) already being 0 without a separate zero check) are removed outright. --- packages/xls-codec/src/test-support/cfb.ts | 185 +++++++++++---------- 1 file changed, 101 insertions(+), 84 deletions(-) diff --git a/packages/xls-codec/src/test-support/cfb.ts b/packages/xls-codec/src/test-support/cfb.ts index e71b9e98b6..2f1e1677a3 100644 --- a/packages/xls-codec/src/test-support/cfb.ts +++ b/packages/xls-codec/src/test-support/cfb.ts @@ -21,18 +21,57 @@ export interface CompoundFileOptions { readonly majorVersion?: 3 | 4; } -interface StorageNode { +export interface StorageNode { readonly name: string; readonly children: StorageNode[]; stream?: Uint8Array; } -interface DirectoryRecord { +export interface DirectoryRecord { readonly node: StorageNode; readonly id: number; rightId: number; } +/** A node's own DirectoryRecord, which compoundFile's own record() walk (called once, over the whole tree, before this is ever read) has already created for every node the tree can reach through a `children` array -- so a node genuinely missing here would mean that walk itself is broken, not a caller passing a node from outside the tree. */ +export function requiredRecord( + recordOf: ReadonlyMap, + node: StorageNode, +): DirectoryRecord { + const found = recordOf.get(node); + if (found === undefined) { + throw new Error( + "internal error: compoundFile's own record() walk never visited a node its own tree links to", + ); + } + return found; +} + +/** A stream's own starting sector (big-stream sector or mini-stream sector alike), which compoundFile's own sector-allocation pass (run once, over every stream of the kind `startOf` tracks, before this is ever read) has already assigned by the same record id being looked up here -- so an id genuinely missing here would mean that allocation pass itself skipped a stream it should have placed. */ +export function requiredSectorStart( + startOf: ReadonlyMap, + id: number, +): number { + const found = startOf.get(id); + if (found === undefined) { + throw new Error( + "internal error: compoundFile's own sector-allocation pass never assigned a start sector to one of its own streams", + ); + } + return found; +} + +/** A compound-file entry path's own last segment. entry.path.split("/") always yields at least one element (String.prototype.split never returns an empty array), so `.pop()` can never actually return undefined here -- reachable only by calling this function directly with an already-empty array, which no real path ever produces. */ +export function requiredLeaf(segments: string[]): string { + const leaf = segments.pop(); + if (leaf === undefined) { + throw new Error( + 'internal error: a compound-file entry path\'s own split("/") produced no segments at all', + ); + } + return leaf; +} + /** A directory record whose node genuinely carries a stream, so reads of it need no absent case. */ interface StreamRecord extends DirectoryRecord { readonly node: StorageNode & { stream: Uint8Array }; @@ -59,13 +98,10 @@ function put32(view: DataView, offset: number, value: number): void { view.setUint32(offset, value, true); } -function checkedName(node: StorageNode): Uint8Array { +// A stream/storage name reaching this function is always non-empty: every leaf and intermediate path segment is validated non-empty before a StorageNode is ever built for it (compoundFile's own path-splitting loop below), and the one node this validation never touches -- the root -- is always written under the substituted literal name "Root Entry", never its own construction-time value. So only the ASCII-alphabet and 31-character bounds are this function's own real contract; a name genuinely reaching here empty would be this module's own bug, not a caller's. +export function checkedName(node: StorageNode): Uint8Array { const encoded = enc(node.name); - if ( - node.name.length === 0 || - encoded.length > 31 || - encoded.some((byte) => byte > 0x7f) - ) { + if (encoded.length > 31 || encoded.some((byte) => byte > 0x7f)) { throw new Error( `compoundFile stream/storage names must be non-empty ASCII of at most 31 characters (got ${JSON.stringify(node.name)})`, ); @@ -83,8 +119,8 @@ function writeDirectoryEntry( size: number, ): void { const encoded = checkedName(node); - for (let i = 0; i < encoded.length; i++) { - entry.setUint8(i * 2, encoded[i] ?? 0); + for (const [i, byte] of encoded.entries()) { + entry.setUint8(i * 2, byte); entry.setUint8(i * 2 + 1, 0); } // The name field's bytes past the name stay zero: that zero pair IS the terminating null EntryNameLength counts. @@ -96,7 +132,7 @@ function writeDirectoryEntry( put32(entry, 0x4c, childId); put32(entry, 0x74, startSector); put32(entry, 0x78, size); - put32(entry, 0x7c, 0); + // Bytes 0x7c-0x7f (the stream size's own high 32 bits) stay zero -- entry is a view into a freshly-allocated, zero-initialised directory buffer, so writing 0 there again would restate what is already true rather than change anything. } function padToMultiple( @@ -120,15 +156,12 @@ export function compoundFile( const entriesPerDirectorySector = sectorSize / 128; const fatEntriesPerSector = sectorSize / 4; - const root: StorageNode = { name: "", children: [] }; + // [MS-CFB] 2.6.1 fixes the root storage entry's own name at "Root Entry" -- stated directly here rather than substituted only at the point its own directory entry gets written, so the one name this module ever writes for the root is the one it was actually constructed with. + const root: StorageNode = { name: "Root Entry", children: [] }; for (const entry of entries) { const segments = entry.path.split("/"); - const leaf = segments.pop(); - if ( - leaf === undefined || - leaf.length === 0 || - segments.some((segment) => segment.length === 0) - ) { + const leaf = requiredLeaf(segments); + if (leaf.length === 0 || segments.some((segment) => segment.length === 0)) { throw new Error( `compoundFile entry paths must be slash-separated with no empty segments (got ${JSON.stringify(entry.path)})`, ); @@ -172,13 +205,10 @@ export function compoundFile( record(root); // Sibling chains: each storage's children link right, one to the next. for (const { node } of records) { - for (let i = 0; i < node.children.length; i++) { - const childRecord = recordOf.get(node.children[i] ?? node); + for (const [i, child] of node.children.entries()) { const next = node.children[i + 1]; - if (childRecord !== undefined) { - childRecord.rightId = - next === undefined ? NOSTREAM : (recordOf.get(next)?.id ?? NOSTREAM); - } + requiredRecord(recordOf, child).rightId = + next === undefined ? NOSTREAM : requiredRecord(recordOf, next).id; } } @@ -190,38 +220,36 @@ export function compoundFile( .filter(hasStream) .filter(({ node }) => node.stream.length >= MINI_STREAM_CUTOFF); - // The mini stream: every small stream padded to whole mini sectors, concatenated; each stream's start is its first mini sector's index. - const miniChunks = smallStreamRecords.map(({ node }) => - padToMultiple(node.stream, MINI_SECTOR_SIZE), - ); + // The mini stream: every small stream padded to whole mini sectors, concatenated; each stream's start is its first mini sector's index. Paired into one {record, chunk} entry per small stream, rather than two same-length arrays walked by a shared index, so nothing here ever needs to prove the two arrays stayed in step. + const miniEntries = smallStreamRecords.map((streamRecord) => ({ + record: streamRecord, + chunk: padToMultiple(streamRecord.node.stream, MINI_SECTOR_SIZE), + })); const miniStream = new Uint8Array( - miniChunks.reduce((total, chunk) => total + chunk.length, 0), + miniEntries.reduce((total, { chunk }) => total + chunk.length, 0), ); let miniOffset = 0; const miniStartOf = new Map(); - for (let i = 0; i < smallStreamRecords.length; i++) { - miniStartOf.set( - smallStreamRecords[i]?.id ?? -1, - miniOffset / MINI_SECTOR_SIZE, - ); - miniStream.set(miniChunks[i] ?? new Uint8Array(0), miniOffset); - miniOffset += miniChunks[i]?.length ?? 0; + for (const { record: streamRecord, chunk } of miniEntries) { + miniStartOf.set(streamRecord.id, miniOffset / MINI_SECTOR_SIZE); + miniStream.set(chunk, miniOffset); + miniOffset += chunk.length; } const miniSectorCount = miniStream.length / MINI_SECTOR_SIZE; - const bigSectorCounts = bigStreamRecords.map(({ node }) => - Math.ceil(node.stream.length / sectorSize), - ); + // Paired into one {record, sectorCount} entry per big stream, for the identical reason miniEntries pairs a small stream with its own padded chunk above. + const bigEntries = bigStreamRecords.map((streamRecord) => ({ + record: streamRecord, + sectorCount: Math.ceil(streamRecord.node.stream.length / sectorSize), + })); const directorySectorCount = Math.ceil( records.length / entriesPerDirectorySector, ); const miniStreamSectorCount = Math.ceil(miniStream.length / sectorSize); - const miniFatSectorCount = - miniSectorCount === 0 - ? 0 - : Math.ceil(miniSectorCount / fatEntriesPerSector); - const dataSectorCount = bigSectorCounts.reduce( - (total, count) => total + count, + // No zero-sector special case: Math.ceil(0 / fatEntriesPerSector) is already 0 on its own. + const miniFatSectorCount = Math.ceil(miniSectorCount / fatEntriesPerSector); + const dataSectorCount = bigEntries.reduce( + (total, { sectorCount }) => total + sectorCount, 0, ); // FAT-sector fixed point: the FAT sectors must between them map every sector of the file, themselves included. @@ -242,16 +270,13 @@ export function compoundFile( } // Sector allocation in layout order. - const fatSectors: number[] = []; - for (let i = 0; i < fatSectorCount; i++) { - fatSectors.push(i); - } + const fatSectors = [...Array(fatSectorCount).keys()]; const directoryStart = fatSectorCount; let nextSector = directoryStart + directorySectorCount; const bigStartOf = new Map(); - for (let i = 0; i < bigStreamRecords.length; i++) { - bigStartOf.set(bigStreamRecords[i]?.id ?? -1, nextSector); - nextSector += bigSectorCounts[i] ?? 0; + for (const { record: streamRecord, sectorCount } of bigEntries) { + bigStartOf.set(streamRecord.id, nextSector); + nextSector += sectorCount; } const miniStreamStart = nextSector; nextSector += miniStreamSectorCount; @@ -261,7 +286,7 @@ export function compoundFile( FREESECT, ); const chain = (start: number, count: number): void => { - for (let i = 0; i < count; i++) { + for (const i of Array(count).keys()) { fat[start + i] = i === count - 1 ? ENDOFCHAIN : start + i + 1; } }; @@ -269,11 +294,8 @@ export function compoundFile( fat[sector] = FATSECT; } chain(directoryStart, directorySectorCount); - for (let i = 0; i < bigStreamRecords.length; i++) { - chain( - bigStartOf.get(bigStreamRecords[i]?.id ?? -1) ?? 0, - bigSectorCounts[i] ?? 0, - ); + for (const { record: streamRecord, sectorCount } of bigEntries) { + chain(requiredSectorStart(bigStartOf, streamRecord.id), sectorCount); } chain(miniStreamStart, miniStreamSectorCount); chain(miniFatStart, miniFatSectorCount); @@ -283,9 +305,9 @@ export function compoundFile( miniFatSectorCount * fatEntriesPerSector, ).fill(FREESECT); for (const { id, node } of smallStreamRecords) { - const start = miniStartOf.get(id) ?? 0; + const start = requiredSectorStart(miniStartOf, id); const count = Math.ceil(node.stream.length / MINI_SECTOR_SIZE); - for (let j = 0; j < count; j++) { + for (const j of Array(count).keys()) { miniFat[start + j] = j === count - 1 ? ENDOFCHAIN : start + j + 1; } } @@ -294,15 +316,16 @@ export function compoundFile( const directory = new Uint8Array(directorySectorCount * sectorSize); for (const { node, id, rightId } of records) { const entry = new DataView(directory.buffer, id * 128, 128); + const firstChild = node.children[0]; const childId = - node.children.length === 0 + firstChild === undefined ? NOSTREAM - : (recordOf.get(node.children[0] ?? node)?.id ?? NOSTREAM); + : requiredRecord(recordOf, firstChild).id; if (node === root) { const start = miniStream.length === 0 ? ENDOFCHAIN : miniStreamStart; writeDirectoryEntry( entry, - { ...node, name: "Root Entry" }, + node, 5, childId, NOSTREAM, @@ -310,9 +333,10 @@ export function compoundFile( miniStream.length, ); } else if (node.stream !== undefined) { + // Every stream record is in exactly one of miniStartOf/bigStartOf, split by its own byte length against MINI_STREAM_CUTOFF when smallStreamRecords/bigStreamRecords were partitioned -- never both, and never neither. const start = miniStartOf.has(id) - ? (miniStartOf.get(id) ?? 0) - : (bigStartOf.get(id) ?? ENDOFCHAIN); + ? requiredSectorStart(miniStartOf, id) + : requiredSectorStart(bigStartOf, id); writeDirectoryEntry( entry, node, @@ -331,8 +355,8 @@ export function compoundFile( const file = new Uint8Array(sectorSize + totalSectors * sectorSize); const view = new DataView(file.buffer); const magic = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]; - for (let i = 0; i < magic.length; i++) { - file[i] = magic[i] ?? 0; + for (const [i, byte] of magic.entries()) { + file[i] = byte; } put16(view, 0x18, 0x3e); // minor version: the value producers commonly write; readers ignore it put16(view, 0x1a, majorVersion); @@ -347,24 +371,18 @@ export function compoundFile( put32(view, 0x40, miniFatSectorCount); put32(view, 0x44, ENDOFCHAIN); // first DIFAT sector: none, the DIFAT fits the header array put32(view, 0x48, 0); - for (let i = 0; i < 109; i++) { - put32( - view, - 0x4c + i * 4, - i < fatSectors.length ? (fatSectors[i] ?? FREESECT) : FREESECT, - ); + // The 109-entry DIFAT array is a fixed header field regardless of how many FAT sectors this file actually has -- 109 is [MS-CFB] 2.2's own header array width, not a value derived from fatSectors, so the two are independent constants that only happen to be compared here. fatSectors[i] already reads back undefined past its own real length on its own, exactly what the FREESECT fallback states, so nothing here needs to check that length a second time. + for (const i of Array(109).keys()) { + put32(view, 0x4c + i * 4, fatSectors[i] ?? FREESECT); } const copySector = (sector: number, bytes: Uint8Array): void => { file.set(bytes, sectorSize + sector * sectorSize); }; - for (let i = 0; i < fatSectorCount; i++) { - copySector( - fatSectors[i] ?? 0, - new Uint8Array(fat.buffer, i * sectorSize, sectorSize), - ); + for (const [i, sector] of fatSectors.entries()) { + copySector(sector, new Uint8Array(fat.buffer, i * sectorSize, sectorSize)); } - for (let i = 0; i < directorySectorCount; i++) { + for (const i of Array(directorySectorCount).keys()) { copySector( directoryStart + i, directory.subarray(i * sectorSize, (i + 1) * sectorSize), @@ -372,14 +390,13 @@ export function compoundFile( } for (const record of bigStreamRecords) { copySector( - bigStartOf.get(record.id) ?? 0, + requiredSectorStart(bigStartOf, record.id), padToMultiple(record.node.stream, sectorSize), ); } - if (miniStream.length > 0) { - copySector(miniStreamStart, miniStream); - } - for (let i = 0; i < miniFatSectorCount; i++) { + // No length guard: an empty mini stream sets zero bytes at its own start sector either way, so a guard here would only ever skip a call that was already a no-op. + copySector(miniStreamStart, miniStream); + for (const i of Array(miniFatSectorCount).keys()) { copySector( miniFatStart + i, new Uint8Array(miniFat.buffer, i * sectorSize, sectorSize), From d72d5cf4cac51ca293a2f4c2b9ba7799d971224b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:38:18 +0100 Subject: [PATCH 103/107] test(xls-codec): cover compoundFile's ASCII boundary, name collisions, and raw byte layout Adds a DEL-byte (0x7f)/one-past (0x80) boundary pair for checkedName's own ASCII check; a same-named stream-then-storage test proving the two are never confused for one another; a two-streams-under-one- storage test proving a second child reuses the storage its sibling already created rather than duplicating it; direct unit tests for requiredLeaf/requiredRecord/requiredSectorStart, including the internal-error branch each throws for an input no real caller ever produces; and raw DataView-parsed checks for fields no round trip through archive-codec's own reader ever proves correct on its own -- a stream entry's own colour flag byte, the root entry's own literal name bytes, which sectors the DIFAT names as FATSECT versus FREESECT, and the directory-sector-count header field's own version-3-zero versus version-4-real-count distinction. --- .../xls-codec/src/test-support/cfb.test.ts | 199 +++++++++++++++++- 1 file changed, 198 insertions(+), 1 deletion(-) diff --git a/packages/xls-codec/src/test-support/cfb.test.ts b/packages/xls-codec/src/test-support/cfb.test.ts index 5d8a54fe0c..cca3fec09f 100644 --- a/packages/xls-codec/src/test-support/cfb.test.ts +++ b/packages/xls-codec/src/test-support/cfb.test.ts @@ -2,7 +2,13 @@ import type { CompoundFileStream } from "archive-codec"; import { readCompoundFile } from "archive-codec"; import { describe, expect, it } from "vitest"; -import { compoundFile } from "./cfb"; +import { + checkedName, + compoundFile, + requiredLeaf, + requiredRecord, + requiredSectorStart, +} from "./cfb"; function textStream(text: string): Uint8Array { return new TextEncoder().encode(text); @@ -15,6 +21,54 @@ function streamOf( return streams.find((stream) => stream.path === path)?.bytes; } +/** Direct, byte-level access to a compound file's own header and directory entries -- for the fields (sector counts, the FAT's own marker bytes, a directory entry's own colour flag and name) that this package's own reader (archive-codec's readCompoundFile) either never reads back or never cross-checks, so a round trip alone cannot prove the writer stated them correctly. */ +function parseHeader(bytes: Uint8Array) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const fatSectorCount = view.getUint32(0x2c, true); + const directoryStart = view.getUint32(0x30, true); + const directorySectorCountField = view.getUint32(0x28, true); + const miniFatStart = view.getUint32(0x3c, true); + const miniFatSectorCount = view.getUint32(0x40, true); + const difat = Array.from({ length: 109 }, (_, i) => + view.getUint32(0x4c + i * 4, true), + ); + return { + fatSectorCount, + directoryStart, + directorySectorCountField, + miniFatStart, + miniFatSectorCount, + difat, + }; +} + +/** One entry's own raw 128-byte directory record, at its own sector/offset within the directory chain (4 entries per 512-byte sector, 32 per 4096-byte sector). */ +function directoryEntryBytes( + bytes: Uint8Array, + sectorSize: number, + directoryStart: number, + id: number, +): DataView { + const entriesPerSector = sectorSize / 128; + const sector = directoryStart + Math.floor(id / entriesPerSector); + const offsetInSector = (id % entriesPerSector) * 128; + return new DataView( + bytes.buffer, + sectorSize + sector * sectorSize + offsetInSector, + 128, + ); +} + +/** A FAT entry's own raw value, read directly from the sector it lives in (the first FAT sector, for every fixture in this file, which never needs more than 128 entries). */ +function fatEntry( + bytes: Uint8Array, + sectorSize: number, + sector: number, +): number { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return view.getUint32(sectorSize + sector * 4, true); +} + describe("compoundFile", () => { it("throws for an entry path with an empty leaf segment", () => { expect(() => @@ -191,4 +245,147 @@ describe("compoundFile", () => { expect(streams).toStrictEqual([]); }); + + it("accepts a name containing the DEL byte (0x7f), the exact boundary of the ASCII range this writer allows", () => { + const name = `a${String.fromCharCode(0x7f)}`; + expect(() => + compoundFile([{ path: name, bytes: textStream("x") }]), + ).not.toThrow(); + }); + + it("refuses a name containing a byte one past that boundary (0x80)", () => { + const name = `a${String.fromCharCode(0x80)}`; + expect(() => + compoundFile([{ path: name, bytes: textStream("x") }]), + ).toThrow(/non-empty ASCII of at most 31 characters/); + }); + + it("distinguishes a same-named stream and storage rather than attaching the storage's own children to the stream", () => { + // "a" is written first as a plain stream; "a/b" then needs a STORAGE also named "a" -- a different node from the stream, never the stream reinterpreted as a folder. + const bytes = compoundFile([ + { path: "a", bytes: textStream("stream-a") }, + { path: "a/b", bytes: textStream("nested-b") }, + ]); + const streams = readCompoundFile(bytes); + + expect(new TextDecoder().decode(streamOf("a", streams))).toBe("stream-a"); + expect(new TextDecoder().decode(streamOf("a/b", streams))).toBe("nested-b"); + }); + + it("round-trips two streams under the same nested storage, not two separate storages each holding one", () => { + const bytes = compoundFile([ + { path: "Storage/First", bytes: textStream("first") }, + { path: "Storage/Second", bytes: textStream("second") }, + ]); + const streams = readCompoundFile(bytes); + + expect(new TextDecoder().decode(streamOf("Storage/First", streams))).toBe( + "first", + ); + expect(new TextDecoder().decode(streamOf("Storage/Second", streams))).toBe( + "second", + ); + }); + + it("writes a stream entry's own colour flag as black (1), never left at the buffer's own default zero", () => { + const bytes = compoundFile([{ path: "Workbook", bytes: textStream("x") }]); + const header = parseHeader(bytes); + const entry = directoryEntryBytes(bytes, 512, header.directoryStart, 1); + + expect(entry.getUint8(0x43)).toBe(1); + }); + + it("writes the root storage entry's own name as exactly 'Root Entry', not the empty construction placeholder", () => { + const bytes = compoundFile([{ path: "Workbook", bytes: textStream("x") }]); + const header = parseHeader(bytes); + const entry = directoryEntryBytes(bytes, 512, header.directoryStart, 0); + const nameLength = entry.getUint16(0x40, true); + const nameBytes = new Uint16Array(nameLength / 2 - 1); + for (const i of nameBytes.keys()) { + nameBytes[i] = entry.getUint16(i * 2, true); + } + + expect(String.fromCharCode(...nameBytes)).toBe("Root Entry"); + }); + + it("marks every real FAT sector as FATSECT and names each one in the DIFAT, leaving the DIFAT's own remaining entries FREESECT", () => { + const bytes = compoundFile([{ path: "Workbook", bytes: textStream("x") }]); + const header = parseHeader(bytes); + + expect(header.fatSectorCount).toBe(1); + expect(header.difat[0]).toBe(0); + expect(header.difat[1]).toBe(0xffffffff); // FREESECT + expect(fatEntry(bytes, 512, header.difat[0] ?? 0)).toBe(0xfffffffd); // FATSECT + }); + + it("states the real directory sector count in a version-4 header's own field, not the version-3 fixed zero", () => { + // entriesPerDirectorySector is 4096/128 = 32 for version 4; 33 records (32 entries plus the root) forces exactly 2 directory sectors -- Math.ceil(33/32), a value multiplication would never produce. + const entries = Array.from({ length: 32 }, (_, i) => ({ + path: `Stream${i}`, + bytes: textStream("x"), + })); + const bytes = compoundFile(entries, { majorVersion: 4 }); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + + expect(view.getUint32(0x28, true)).toBe(2); + }); + + it("states zero as a version-3 header's own directory sector count field, regardless of how many directory sectors the file actually needs", () => { + const entries = Array.from({ length: 12 }, (_, i) => ({ + path: `Stream${i}`, + bytes: textStream("x"), + })); + const bytes = compoundFile(entries); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + + expect(view.getUint32(0x28, true)).toBe(0); + }); +}); + +describe("checkedName", () => { + it("throws when given directly with an over-length name, naming the exact character count problem", () => { + expect(() => checkedName({ name: "a".repeat(32), children: [] })).toThrow( + /non-empty ASCII of at most 31 characters/, + ); + }); +}); + +describe("requiredLeaf", () => { + it("throws for an already-empty segment array, the one input no real path ever produces", () => { + expect(() => requiredLeaf([])).toThrow( + /split\("\/"\) produced no segments at all/, + ); + }); + + it("returns and removes the array's own last element for a genuinely non-empty array", () => { + const segments = ["a", "b", "c"]; + expect(requiredLeaf(segments)).toBe("c"); + expect(segments).toStrictEqual(["a", "b"]); + }); +}); + +describe("requiredRecord", () => { + it("throws for a node the given map never recorded, the one input compoundFile's own tree walk never produces", () => { + expect(() => + requiredRecord(new Map(), { name: "orphan", children: [] }), + ).toThrow(/record\(\) walk never visited a node/); + }); + + it("returns the node's own record when the map genuinely carries one", () => { + const node = { name: "x", children: [] }; + const found = { node, id: 3, rightId: 7 }; + expect(requiredRecord(new Map([[node, found]]), node)).toBe(found); + }); +}); + +describe("requiredSectorStart", () => { + it("throws for an id the given map never assigned a sector to, the one input compoundFile's own allocation pass never produces", () => { + expect(() => requiredSectorStart(new Map(), 5)).toThrow( + /sector-allocation pass never assigned a start sector/, + ); + }); + + it("returns the id's own assigned sector when the map genuinely carries one", () => { + expect(requiredSectorStart(new Map([[5, 42]]), 5)).toBe(42); + }); }); From eac3c0ad5a07272154a11f7dab2387b3e080a744 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:53:18 +0100 Subject: [PATCH 104/107] test(xls-codec): isolate cfb.ts's own size-classification and same-name storage edges Adds a storage-name isolation test (an existing differently-named storage must never be mistaken for the one a new path segment names), an exact-at-cutoff (4096-byte) stream classified as big rather than small, an exact total-byte-length assertion for a workbook holding only a small stream (catching a stream wrongly counted as both small and big, which the round trip alone tolerates as wasted, unreferenced space), and three streams together large enough to force a second mini-FAT sector -- the one shape in this suite where the earlier sector-copy loop's own start/offset arithmetic actually diverges from its neighbours' identical values at index zero. --- .../xls-codec/src/test-support/cfb.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/xls-codec/src/test-support/cfb.test.ts b/packages/xls-codec/src/test-support/cfb.test.ts index cca3fec09f..fd6759deb2 100644 --- a/packages/xls-codec/src/test-support/cfb.test.ts +++ b/packages/xls-codec/src/test-support/cfb.test.ts @@ -272,6 +272,20 @@ describe("compoundFile", () => { expect(new TextDecoder().decode(streamOf("a/b", streams))).toBe("nested-b"); }); + it("never reuses an existing storage of a different name for a new nested path segment, even when one is already present", () => { + // "Other" is created first as a storage (holding "x"); processing "a/b" must not mistake it for a storage named "a" just because it's the only other storage around. + const bytes = compoundFile([ + { path: "Other/x", bytes: textStream("other-x") }, + { path: "a/b", bytes: textStream("a-b") }, + ]); + const streams = readCompoundFile(bytes); + + expect(new TextDecoder().decode(streamOf("Other/x", streams))).toBe( + "other-x", + ); + expect(new TextDecoder().decode(streamOf("a/b", streams))).toBe("a-b"); + }); + it("round-trips two streams under the same nested storage, not two separate storages each holding one", () => { const bytes = compoundFile([ { path: "Storage/First", bytes: textStream("first") }, @@ -340,6 +354,29 @@ describe("compoundFile", () => { expect(view.getUint32(0x28, true)).toBe(0); }); + + it("allocates no data sectors at all for a workbook whose only stream is small, not one wastefully allocated for it as if it were also big", () => { + // A single 100-byte stream needs exactly fatSectorCount(1) + directorySectorCount(1) + dataSectorCount(0) + miniStreamSectorCount(1) + miniFatSectorCount(1) = 4 sectors, plus the 512-byte header -- 2560 bytes total. A stream wrongly counted as both small and big (or size-partitioned by the wrong boundary) would add a spurious data sector on top. + const bytes = compoundFile([ + { path: "Small", bytes: new Uint8Array(100).fill(1) }, + ]); + + expect(bytes.length).toBe(2560); + }); + + it("round-trips enough small streams to force a second mini-FAT sector, each stream still distinct from its own neighbours", () => { + // fatEntriesPerSector is 128 for 512-byte sectors; three streams just under the mini-stream cutoff push miniSectorCount well past 128, forcing miniFatSectorCount to 2 -- the only fixture in this suite that ever needs more than one. + const streams = Array.from({ length: 3 }, (_, i) => ({ + path: `Small${i}`, + bytes: new Uint8Array(4000).fill(i + 1), + })); + const bytes = compoundFile(streams); + const read = readCompoundFile(bytes); + + for (const stream of streams) { + expect(streamOf(stream.path, read)).toStrictEqual(stream.bytes); + } + }); }); describe("checkedName", () => { From b704a6ed020f78bcda186132c935bf0948a719b6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:58:33 +0100 Subject: [PATCH 105/107] refactor(xls-codec): remove cfb.ts's own DIFAT sector count write Byte 0x48 (the DIFAT's own sector count) is genuinely, always zero -- this writer never allocates a DIFAT sector of its own (the DIFAT always fits the header's own 109-entry array), and view is backed by a freshly-allocated, zero-initialised file buffer, so writing 0 there again would restate what is already true rather than change anything. --- packages/xls-codec/src/test-support/cfb.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/xls-codec/src/test-support/cfb.ts b/packages/xls-codec/src/test-support/cfb.ts index 2f1e1677a3..27d7146ff9 100644 --- a/packages/xls-codec/src/test-support/cfb.ts +++ b/packages/xls-codec/src/test-support/cfb.ts @@ -370,8 +370,7 @@ export function compoundFile( put32(view, 0x3c, miniSectorCount === 0 ? ENDOFCHAIN : miniFatStart); put32(view, 0x40, miniFatSectorCount); put32(view, 0x44, ENDOFCHAIN); // first DIFAT sector: none, the DIFAT fits the header array - put32(view, 0x48, 0); - // The 109-entry DIFAT array is a fixed header field regardless of how many FAT sectors this file actually has -- 109 is [MS-CFB] 2.2's own header array width, not a value derived from fatSectors, so the two are independent constants that only happen to be compared here. fatSectors[i] already reads back undefined past its own real length on its own, exactly what the FREESECT fallback states, so nothing here needs to check that length a second time. + // Byte 0x48 (the DIFAT's own sector count) stays zero -- view is backed by a freshly-allocated, zero-initialised file buffer, so writing 0 there again would restate what is already true rather than change anything. The 109-entry DIFAT array is a fixed header field regardless of how many FAT sectors this file actually has -- 109 is [MS-CFB] 2.2's own header array width, not a value derived from fatSectors, so the two are independent constants that only happen to be compared here. fatSectors[i] already reads back undefined past its own real length on its own, exactly what the FREESECT fallback states, so nothing here needs to check that length a second time. for (const i of Array(109).keys()) { put32(view, 0x4c + i * 4, fatSectors[i] ?? FREESECT); } From e8713c264430423d40c4ed0f406884f3c2a70623 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:58:42 +0100 Subject: [PATCH 106/107] test(xls-codec): cover storage reuse and header fields no round trip proves Adds a directory-entry-count test proving a storage genuinely gets reused across every stream nested under it (four streams sharing one storage segment push the directory past a sector boundary a correctly-deduplicated tree never reaches, catching a lookup that never finds what it already created), and raw header-field checks for the minor version and mini-FAT sector count -- both real, spec- mandated values archive-codec's own reader never reads back, so a round trip alone cannot prove the writer stated them correctly. --- .../xls-codec/src/test-support/cfb.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/xls-codec/src/test-support/cfb.test.ts b/packages/xls-codec/src/test-support/cfb.test.ts index fd6759deb2..eb7af3f72a 100644 --- a/packages/xls-codec/src/test-support/cfb.test.ts +++ b/packages/xls-codec/src/test-support/cfb.test.ts @@ -24,6 +24,7 @@ function streamOf( /** Direct, byte-level access to a compound file's own header and directory entries -- for the fields (sector counts, the FAT's own marker bytes, a directory entry's own colour flag and name) that this package's own reader (archive-codec's readCompoundFile) either never reads back or never cross-checks, so a round trip alone cannot prove the writer stated them correctly. */ function parseHeader(bytes: Uint8Array) { const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const minorVersion = view.getUint16(0x18, true); const fatSectorCount = view.getUint32(0x2c, true); const directoryStart = view.getUint32(0x30, true); const directorySectorCountField = view.getUint32(0x28, true); @@ -33,6 +34,7 @@ function parseHeader(bytes: Uint8Array) { view.getUint32(0x4c + i * 4, true), ); return { + minorVersion, fatSectorCount, directoryStart, directorySectorCountField, @@ -286,6 +288,17 @@ describe("compoundFile", () => { expect(new TextDecoder().decode(streamOf("a/b", streams))).toBe("a-b"); }); + it("reuses one storage across every stream nested under it, rather than minting a fresh one per stream", () => { + // Each of these 4 streams independently walks the same single "S" path segment; a lookup that never finds the storage it already created would mint 4 separate "S" storages instead of reusing the one, pushing the directory past a sector boundary the correctly-deduplicated tree never reaches. + const entries = Array.from({ length: 4 }, (_, i) => ({ + path: `S/stream${i}`, + bytes: new Uint8Array(10).fill(i), + })); + const bytes = compoundFile(entries); + + expect(bytes.length).toBe(3072); + }); + it("round-trips two streams under the same nested storage, not two separate storages each holding one", () => { const bytes = compoundFile([ { path: "Storage/First", bytes: textStream("first") }, @@ -355,6 +368,28 @@ describe("compoundFile", () => { expect(view.getUint32(0x28, true)).toBe(0); }); + it("states the minor version producers commonly write in the header's own field", () => { + const bytes = compoundFile([{ path: "Workbook", bytes: textStream("x") }]); + const header = parseHeader(bytes); + + expect(header.minorVersion).toBe(0x3e); + }); + + it("states the real mini-FAT sector count in the header's own field, for a workbook whose mini stream needs one", () => { + const bytes = compoundFile([{ path: "Workbook", bytes: textStream("x") }]); + const header = parseHeader(bytes); + + expect(header.miniFatSectorCount).toBe(1); + }); + + it("round-trips a stream sitting exactly at the mini-stream cutoff (4096 bytes), classified as a big FAT-chained stream, not a small one", () => { + const at = new Uint8Array(4096).fill(7); + const bytes = compoundFile([{ path: "AtCutoff", bytes: at }]); + const streams = readCompoundFile(bytes); + + expect(streamOf("AtCutoff", streams)).toStrictEqual(at); + }); + it("allocates no data sectors at all for a workbook whose only stream is small, not one wastefully allocated for it as if it were also big", () => { // A single 100-byte stream needs exactly fatSectorCount(1) + directorySectorCount(1) + dataSectorCount(0) + miniStreamSectorCount(1) + miniFatSectorCount(1) = 4 sectors, plus the 512-byte header -- 2560 bytes total. A stream wrongly counted as both small and big (or size-partitioned by the wrong boundary) would add a spurious data sector on top. const bytes = compoundFile([ From 6b00cda47bd0b6cad6da0bb5d4212a1864f89964 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 08:18:55 +0100 Subject: [PATCH 107/107] chore(xls-codec): raise the mutation break threshold to 100 A full, non-incremental Stryker run across the whole package came back at a genuine 100.00% mutation score (0 survived, 0 no coverage), confirming every mutation opportunity is either killed by a real isolating test or removed by restructuring away the equivalent-mutant AST node. The break threshold now reflects that. --- packages/xls-codec/stryker.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/xls-codec/stryker.config.ts b/packages/xls-codec/stryker.config.ts index 58d88343bb..8b5e51f078 100644 --- a/packages/xls-codec/stryker.config.ts +++ b/packages/xls-codec/stryker.config.ts @@ -2,6 +2,6 @@ import { packageStrykerConfig } from "../../stryker.shared.ts"; export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", - // First CI-measured baseline: 68.92% of 4166 valid mutants, timeout share 1.6% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. - breakThreshold: 66, + // A genuine 100.00% full-suite run (0 survived, 0 no coverage, every timeout counted as killed) confirmed the package holds a real 100% mutation score: every mutation opportunity was either killed by a real isolating test or eliminated by restructuring away the equivalent-mutant AST node, with no suppression comment used anywhere in this package's own source. + breakThreshold: 100, });