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..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); @@ -70,20 +69,35 @@ 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", () => { - // 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; } diff --git a/packages/xls-codec/src/biff/font.test.ts b/packages/xls-codec/src/biff/font.test.ts index 97e792dc2d..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, @@ -33,11 +34,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, ); }); @@ -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", () => { @@ -72,11 +145,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 +158,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..88d6158469 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, }); @@ -103,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 }); @@ -118,7 +135,7 @@ describe("paperSelectionFor", () => { paperCode: selection?.code ?? -1, portrait: selection?.portrait ?? true, }), - ).toEqual(size); + ).toStrictEqual(size); } }); }); @@ -134,7 +151,7 @@ describe("Setup flag packing", () => { }; expect( unpackSetupFlags(packSetupFlags({ ...PORTRAIT_LETTER, ...flags })), - ).toEqual(flags); + ).toStrictEqual(flags); } }); @@ -156,7 +173,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-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); + }, + ); +}); 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..772bca202b --- /dev/null +++ b/packages/xls-codec/src/biff/ptg-writer.test.ts @@ -0,0 +1,771 @@ +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")).toStrictEqual(compiled("1")); + }); + + it("skips a leading tab", () => { + expect(compiled("\t1")).toStrictEqual(compiled("1")); + }); + + it("skips a leading newline", () => { + expect(compiled("\n1")).toStrictEqual(compiled("1")); + }); + + it("skips a leading carriage return", () => { + expect(compiled("\r1")).toStrictEqual(compiled("1")); + }); + + it("skips whitespace of every kind between tokens", () => { + expect(compiled(" 1 \t+\n2\r ")).toStrictEqual(compiled("1+2")); + }); + }); + + describe("integer literals", () => { + it("compiles a plain integer as PtgInt", () => { + expect(compiled("42")).toStrictEqual([PTG_INT, ...u16le(42)]); + }); + + it("compiles zero as PtgInt", () => { + expect(compiled("0")).toStrictEqual([PTG_INT, ...u16le(0)]); + }); + + it("compiles PTG_INT_MAX itself as PtgInt", () => { + expect(compiled(String(PTG_INT_MAX))).toStrictEqual([ + 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))).toStrictEqual([PTG_NUM, ...f64le(value)]); + }); + + it("compiles a decimal literal as PtgNum even when its value is a whole number", () => { + expect(compiled("4.0")).toStrictEqual([PTG_NUM, ...f64le(4)]); + }); + + it("compiles a fractional literal as PtgNum", () => { + expect(compiled("3.14159")).toStrictEqual([PTG_NUM, ...f64le(3.14159)]); + }); + + it("compiles an exponent literal as PtgNum", () => { + 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)).toStrictEqual(f64le(3.14159)); + }); + }); + + describe("string literals", () => { + it("compiles a plain string as PtgStr", () => { + expect(compiled('"hi"')).toStrictEqual([ + PTG_STR, + 2, + 0x00, + "h".charCodeAt(0), + "i".charCodeAt(0), + ]); + }); + + it("compiles the empty string", () => { + expect(compiled('""')).toStrictEqual([PTG_STR, 0, 0x00]); + }); + + it("un-escapes a doubled quote inside a string literal", () => { + expect(compiled('"it""s"')).toStrictEqual([ + 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")).toStrictEqual([PTG_BOOL, 1]); + }); + + it("compiles FALSE as PtgBool false", () => { + 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!")).toStrictEqual([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)).toStrictEqual([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/, + ); + }); + + 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", () => { + 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")).toStrictEqual([ + PTG_REF_VALUE, + ...u16le(0), + ...u16le(0 | COLUMN_RELATIVE_BIT | ROW_RELATIVE_BIT), + ]); + }); + + it("compiles a fully absolute reference", () => { + expect(compiled("$A$1")).toStrictEqual([ + PTG_REF_VALUE, + ...u16le(0), + ...u16le(0), + ]); + }); + + it("compiles a column-absolute, row-relative reference", () => { + expect(compiled("$A1")).toStrictEqual([ + PTG_REF_VALUE, + ...u16le(0), + ...u16le(0 | ROW_RELATIVE_BIT), + ]); + }); + + it("compiles a column-relative, row-absolute reference", () => { + expect(compiled("A$1")).toStrictEqual([ + 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")).toStrictEqual([ + PTG_REF_VALUE, + ...u16le(76), + ...u16le(54 | COLUMN_RELATIVE_BIT | ROW_RELATIVE_BIT), + ]); + }); + + it("compiles a relative area (range)", () => { + expect(compiled("A1:B2")).toStrictEqual([ + 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")).toStrictEqual([ + 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"/, + ); + }); + + 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("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(); + }); + + 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", () => { + 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`)).toStrictEqual([ + PTG_INT, + ...u16le(1), + PTG_INT, + ...u16le(2), + opcode, + ]); + }); + + it("compiles string concatenation", () => { + expect(compiled("1&2")).toStrictEqual([ + 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`)).toStrictEqual([ + 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`)).toStrictEqual([ + PTG_INT, + ...u16le(1), + PTG_INT, + ...u16le(2), + opcode, + ]); + }); + + it("compiles exponentiation", () => { + expect(compiled("1^2")).toStrictEqual([ + PTG_INT, + ...u16le(1), + PTG_INT, + ...u16le(2), + PTG_POWER, + ]); + }); + + it("compiles a trailing percent operator", () => { + 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`)).toStrictEqual([PTG_INT, ...u16le(1), opcode]); + }); + + it("compiles explicit parentheses", () => { + expect(compiled("(1+2)")).toStrictEqual([ + PTG_INT, + ...u16le(1), + PTG_INT, + ...u16le(2), + PTG_ADD, + PTG_PAREN, + ]); + }); + }); + + // 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([ + PTG_INT, + ...u16le(1), + PTG_UMINUS, + PTG_INT, + ...u16le(2), + PTG_POWER, + ]); + }); + + it("binds additive operators tighter than concatenation", () => { + expect(compiled("1&2+3")).toStrictEqual([ + 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")).toStrictEqual([ + 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")).toStrictEqual([ + 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%")).toStrictEqual([ + 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")).toStrictEqual([ + 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)")).toStrictEqual([ + 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()")).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()")).toStrictEqual([ + PTG_FUNCVAR_VALUE, + 0, + ...u16le(0x0004), + ]); + }); + + it("compiles a variable-arity call with several arguments", () => { + expect(compiled("SUM(1,2,3)")).toStrictEqual([ + 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)")).toStrictEqual([ + 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)")).toStrictEqual([ + 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,)")).toStrictEqual([ + 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")')).toStrictEqual([ + 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/, + ); + }); + }); +}); diff --git a/packages/xls-codec/src/biff/ptg-writer.ts b/packages/xls-codec/src/biff/ptg-writer.ts index 0b5e291aab..ca66596ba6 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 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[] { const tokens: Token[] = []; let index = 0; @@ -155,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; @@ -210,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({ type: "eof", text: "" }); + // 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; } @@ -266,14 +270,9 @@ class FormulaParser { this.sourceText = sourceText; } + // 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 { - 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 +456,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 +573,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 +619,23 @@ 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) { + // "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": - 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; diff --git a/packages/xls-codec/src/biff/ptg.test.ts b/packages/xls-codec/src/biff/ptg.test.ts index 6aea8b620d..dc3d6d43ee 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, @@ -152,6 +153,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( @@ -263,12 +276,54 @@ 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, 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( @@ -293,6 +348,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" }], @@ -303,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" }], @@ -313,20 +412,56 @@ 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: [] }; - 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(); }); 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(); }); 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 +534,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 @@ -446,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); @@ -480,12 +659,41 @@ 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", () => { 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/ptg.ts b/packages/xls-codec/src/biff/ptg.ts index d644587e46..7c6d7955f9 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. @@ -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; @@ -590,7 +586,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; @@ -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; diff --git a/packages/xls-codec/src/biff/record-writer.test.ts b/packages/xls-codec/src/biff/record-writer.test.ts index 9aa642a220..877bac51e2 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", () => { @@ -37,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", () => { @@ -46,7 +55,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..6573f368c4 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. @@ -29,7 +33,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 +44,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 +52,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 +64,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,25 +76,29 @@ 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", () => { // 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", () => { @@ -99,6 +109,40 @@ 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"); + }); +}); + +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; 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); } diff --git a/packages/xls-codec/src/biff/string-writer.test.ts b/packages/xls-codec/src/biff/string-writer.test.ts index 92514c9b34..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); @@ -43,9 +53,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 +74,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 +105,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..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); @@ -40,10 +36,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; 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..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. */ @@ -117,11 +115,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; } diff --git a/packages/xls-codec/src/biff/substreams.test.ts b/packages/xls-codec/src/biff/substreams.test.ts index b8a483237d..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 { @@ -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); }); @@ -106,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", () => { @@ -135,7 +138,7 @@ describe("groupRecords", () => { ), ); - expect(groups).toEqual([ + expect(groups).toStrictEqual([ { type: RECORD_CF12, blocks: [bytes(1), bytes(2, 3)], offset: 0 }, ]); }); @@ -148,7 +151,7 @@ describe("groupRecords", () => { data: bytes(...new Array(12).fill(0)), }), ), - ).toThrow(BiffFormatError); + ).toThrow("ContinueFrt12 record with no preceding record to continue"); }); }); @@ -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", () => { @@ -253,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", () => { @@ -280,17 +307,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 +335,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 +357,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/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); + }); +}); diff --git a/packages/xls-codec/src/biff/xf-colors.test.ts b/packages/xls-codec/src/biff/xf-colors.test.ts index ef944e1d66..f84fea0526 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", () => { @@ -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", () => { @@ -81,7 +86,7 @@ describe("DEFAULT_PALETTE_HEX_TO_ICV", () => { resolvedIcv === undefined ? undefined : resolveIcvColor(resolvedIcv, undefined), - ).toEqual(color); + ).toStrictEqual(color); } }); }); @@ -96,13 +101,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", () => { @@ -153,16 +162,26 @@ 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", () => { 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 +192,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 +206,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 +227,7 @@ describe("resolveFillBackground", () => { 11, undefined, ); - expect(result).toEqual({ + expect(result).toStrictEqual({ kind: "pattern", patternType: "mediumGray", backgroundColor: { r: 0, g: 1, b: 0 }, @@ -226,7 +247,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 +258,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 +268,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 +301,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", () => { @@ -297,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); @@ -304,4 +342,83 @@ 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. 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. + { + 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); + }, + ); }); diff --git a/packages/xls-codec/src/biff/xf-colors.ts b/packages/xls-codec/src/biff/xf-colors.ts index 6fdbe20c7d..30cde8edfb 100644 --- a/packages/xls-codec/src/biff/xf-colors.ts +++ b/packages/xls-codec/src/biff/xf-colors.ts @@ -266,16 +266,12 @@ export function resolveIcvColor( icv: number, palette: readonly Color[] | undefined, ): Color | undefined { - if (icv >= 0 && icv < FIXED_COLOR_TABLE.length) { + // 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 && 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 { @@ -294,10 +290,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,19 +306,16 @@ 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; - 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; }; - 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), @@ -339,7 +334,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 +377,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 +432,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 }; 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 new file mode 100644 index 0000000000..8a3c514e5d --- /dev/null +++ b/packages/xls-codec/src/container.test.ts @@ -0,0 +1,168 @@ +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"; +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).toStrictEqual(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).toStrictEqual(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)).toStrictEqual(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", () => { + // 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( + "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'", () => { + const bytes = compoundFile([ + { path: "Book", bytes: minimalWorkbookStream() }, + ]); + + 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]) }, + ]); + + 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); + }); + + 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)}`, ); diff --git a/packages/xls-codec/src/content.test.ts b/packages/xls-codec/src/content.test.ts index 6350fb3027..922ecf2046 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, @@ -283,7 +284,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 +310,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 +340,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 +398,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 +418,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 +443,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 +464,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, }); @@ -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({ @@ -553,7 +619,7 @@ describe("readXlsContent", () => { }), ); - expect(readXlsContent(bytes).sheets[0]?.dataValidations).toEqual([ + expect(readXlsContent(bytes).sheets[0]?.dataValidations).toStrictEqual([ { type: "decimal", operator: "greaterThan", @@ -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); @@ -610,7 +718,7 @@ describe("readXlsContent", () => { }), ); - expect(readXlsContent(bytes).sheets[0]?.conditionalFormats).toEqual([ + expect(readXlsContent(bytes).sheets[0]?.conditionalFormats).toStrictEqual([ { type: "cellIs", operator: "greaterThan", @@ -621,6 +729,58 @@ 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).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", () => { const bytes = xlsFile( workbookStream({ @@ -683,7 +843,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 +908,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 +940,7 @@ describe("readXlsContent", () => { }), ); - expect(readXlsContent(bytes).sheets[0]?.cells).toEqual([ + expect(readXlsContent(bytes).sheets[0]?.cells).toStrictEqual([ { row: 2, column: 1, @@ -803,9 +963,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", () => { @@ -1226,6 +1386,157 @@ 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("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( + 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( @@ -1281,11 +1592,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 +1628,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 +1643,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 +1670,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 +1702,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 +1738,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 +1923,7 @@ describe("readXlsContent", () => { }), ); - expect(readXlsContent(bytes).names).toEqual([ + expect(readXlsContent(bytes).names).toStrictEqual([ { name: "SalesData", refersTo: "Sheet1!$A$1:$B$2" }, ]); }); @@ -1636,7 +1953,7 @@ describe("readXlsContent", () => { }), ); - expect(readXlsContent(bytes).names).toEqual([ + expect(readXlsContent(bytes).names).toStrictEqual([ { name: "LocalRange", refersTo: "Sheet2!$A$3:$A$5", @@ -1682,7 +1999,7 @@ describe("readXlsContent", () => { }), ); - expect(readXlsContent(bytes).names).toEqual([ + expect(readXlsContent(bytes).names).toStrictEqual([ { name: "SecondSheet", refersTo: "Sheet2!$A$1:$A$1", @@ -1711,7 +2028,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 +2036,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 +2088,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 +2103,7 @@ describe("readXlsContent", () => { sheets: [{ name: "Sheet1", records: [] }], }), ); - expect(readXlsContent(bytes).metadata).toEqual({}); + expect(readXlsContent(bytes).metadata).toStrictEqual({}); }); }); }); @@ -1833,7 +2151,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 +2235,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 +2312,7 @@ describe("readXlsContent schema conformance", () => { ), ); - expect(kinds).toEqual( + expect(kinds).toStrictEqual( new Set(["date", "percentage", "string", "boolean", "error", "empty"]), ); }); @@ -2050,7 +2368,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 +2463,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 +2475,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 +2512,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 +2529,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(); }); @@ -2232,6 +2550,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([ @@ -2248,7 +2597,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 +2613,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 +2638,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 +2666,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 +2693,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 +2803,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 +2818,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/content.ts b/packages/xls-codec/src/content.ts index 5b24629158..ff98b4d76b 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); + }); } } @@ -773,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, + }; } /** @@ -916,8 +907,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/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); } 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. */ diff --git a/packages/xls-codec/src/drawing/escher.test.ts b/packages/xls-codec/src/drawing/escher.test.ts index dee27f4096..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", () => { @@ -10,7 +15,7 @@ describe("readEscherRecords", () => { const records = readEscherRecords(bytes); - expect(records).toEqual([ + expect(records).toStrictEqual([ { kind: "atom", recInstance: 0x234, @@ -35,7 +40,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 +49,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,26 +67,68 @@ 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", () => { - 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(); }); }); @@ -113,7 +160,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, @@ -130,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]), + }); + }); }); 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..dde8a8fd72 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,25 @@ 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 = [ + // 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), + 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 +119,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); }; diff --git a/packages/xls-codec/src/drawing/shapes.test.ts b/packages/xls-codec/src/drawing/shapes.test.ts index 2118a691f1..16da704283 100644 --- a/packages/xls-codec/src/drawing/shapes.test.ts +++ b/packages/xls-codec/src/drawing/shapes.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it } from "vitest"; +import { + ESCHER_CLIENT_ANCHOR, + ESCHER_DG_CONTAINER, + ESCHER_OPT, +} from "./escher-constants"; import { readSheetShapes } from "./shapes"; import { clientAnchorSheet, + escherAtom, escherContainer, foptEntry, optAtom, @@ -36,12 +42,40 @@ 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("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("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", () => { @@ -51,7 +85,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 +112,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 +139,79 @@ describe("readSheetShapes", () => { const shapes = readSheetShapes(bytes); - expect(shapes.map((shape) => shape.spid)).toEqual([31]); + 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("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", () => { @@ -122,6 +228,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/drawing/shapes.ts b/packages/xls-codec/src/drawing/shapes.ts index 198382743f..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 { BiffFormatError } 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,17 +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) { - if (!(error instanceof BiffFormatError)) { - throw error; - } - } - } + // 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 }; } @@ -144,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; } } 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/, + ); + }); +}); 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); 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 }; } 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..eb7af3f72a --- /dev/null +++ b/packages/xls-codec/src/test-support/cfb.test.ts @@ -0,0 +1,463 @@ +import type { CompoundFileStream } from "archive-codec"; +import { readCompoundFile } from "archive-codec"; +import { describe, expect, it } from "vitest"; + +import { + checkedName, + compoundFile, + requiredLeaf, + requiredRecord, + requiredSectorStart, +} 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; +} + +/** 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); + 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 { + minorVersion, + 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(() => + 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)).toStrictEqual(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)).toStrictEqual(first); + expect(streamOf("Second", streams)).toStrictEqual(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)).toStrictEqual(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)).toStrictEqual(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)).toStrictEqual(compoundFile(entries)); + }); + + it("round-trips a compound file with no streams at all", () => { + const bytes = compoundFile([]); + const streams = readCompoundFile(bytes); + + 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("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("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") }, + { 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); + }); + + 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([ + { 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", () => { + 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); + }); +}); diff --git a/packages/xls-codec/src/test-support/cfb.ts b/packages/xls-codec/src/test-support/cfb.ts index e71b9e98b6..27d7146ff9 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); @@ -346,25 +370,18 @@ 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); - for (let i = 0; i < 109; i++) { - put32( - view, - 0x4c + i * 4, - i < fatSectors.length ? (fatSectors[i] ?? FREESECT) : FREESECT, - ); + // 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); } 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 +389,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), 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); + }); }); diff --git a/packages/xls-codec/src/workbook/chart.test.ts b/packages/xls-codec/src/workbook/chart.test.ts index bd52d86b06..3ad2313a7e 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 { @@ -195,7 +258,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 +277,7 @@ describe("readChartSeries", () => { const series = readChartSeries(groups, contextWithCells([])); - expect(series).toEqual([ + expect(series).toStrictEqual([ { name: undefined, categories: ["Q1"], values: ["42"] }, ]); }); @@ -239,12 +302,530 @@ 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([]); + }); + + 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("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: { + 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([""]); }); }); diff --git a/packages/xls-codec/src/workbook/chart.ts b/packages/xls-codec/src/workbook/chart.ts index 6f256928a5..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; } @@ -107,8 +106,6 @@ export function readChartSeries( addCacheEntry(cache, currentCacheRole, record); } break; - default: - break; } } @@ -233,9 +230,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; } @@ -277,19 +273,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), @@ -353,7 +342,6 @@ function readRangeToken( } return { startRow: row, - endRow: row, startColumn: column, endColumn: column, }; @@ -370,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), }; 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..593396f9a9 --- /dev/null +++ b/packages/xls-codec/src/workbook/comment-writer.test.ts @@ -0,0 +1,195 @@ +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, type CommentedCell } from "./comment-writer"; + +function commentedCell( + row: number, + column: number, + text: string, + author?: string, +): CommentedCell { + 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([])).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")).toStrictEqual({ + 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")).toStrictEqual({ + 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")).toStrictEqual({ + 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).toStrictEqual({ + text: "Fifth", + author: "E", + }); + expect(comments.get("0:9")?.comment).toStrictEqual({ + text: "First-by-row", + author: "A", + }); + expect(comments.get("0:2")?.comment).toStrictEqual({ + 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).toStrictEqual([ + [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).toStrictEqual([ + 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).toStrictEqual([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/); + }); + + 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(); + }, 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([ + 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/comments.test.ts b/packages/xls-codec/src/workbook/comments.test.ts index c2139f0716..bcaf73198c 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)))); @@ -23,7 +27,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 +40,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 +53,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 +79,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" }, @@ -107,4 +111,87 @@ 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", () => { + // 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); + }); + + 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..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) { @@ -129,7 +126,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(); 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..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)); } @@ -273,7 +274,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 +308,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 +330,30 @@ 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 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)", () => { @@ -344,7 +368,72 @@ describe("readCondFmt12Group", () => { ), ); - expect(readCondFmt12Group(groups, 0, NO_SHEETS).formats).toEqual([]); + 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", () => { @@ -363,7 +452,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 +508,7 @@ describe("readCondFmt12Group", () => { const result = readCondFmt12Group(groups, 0, NO_SHEETS); - expect(result.formats).toEqual([ + expect(result.formats).toStrictEqual([ { kind: "iconSet", iconSetType: "3TrafficLights1", @@ -474,7 +563,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,11 +591,69 @@ describe("readCondFmt12Group", () => { cf12Record(ct, []), ); const result = readCondFmt12Group(groups, 0, NO_SHEETS); - expect(result.formats).toEqual([]); + expect(result.formats).toStrictEqual([]); expect(result.recordsConsumed).toBe(2); } }); + 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), @@ -554,7 +701,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 +719,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 +732,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 +760,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 +787,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 +796,7 @@ describe("readCondFmt12Group", () => { priority: 0, stopIfTrue: false, ranges: ONE_RANGE, + style: undefined, }, ]); }); @@ -750,8 +898,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 +918,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 +928,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,10 +957,11 @@ 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", () => { + 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]), { @@ -818,12 +973,22 @@ 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", ]); }); + 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][] = [ @@ -842,7 +1007,7 @@ describe("readCondFmt12Group", () => { }), ); const result = readCondFmt12Group(groups, 0, NO_SHEETS); - expect(result.formats).toEqual([ + expect(result.formats).toStrictEqual([ { kind, text: "needle", @@ -875,7 +1040,7 @@ describe("readCondFmt12Group", () => { const result = readCondFmt12Group(groups, 0, NO_SHEETS); - expect(result.formats).toEqual([ + expect(result.formats).toStrictEqual([ { kind: "containsText", text: "needle", @@ -917,7 +1082,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 +1097,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 +1111,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-12.ts b/packages/xls-codec/src/workbook/conditional-format-12.ts index 341d12085e..398c6125f7 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 { @@ -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,10 +525,8 @@ 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; - } + } 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; } } @@ -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.test.ts b/packages/xls-codec/src/workbook/conditional-format-ex.test.ts index 61dad28219..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, @@ -153,7 +154,7 @@ describe("readCfEx", () => { const result = readCfEx(cfExGroup, targets, NO_SHEETS); - expect(result).toEqual({ + expect(result).toStrictEqual({ kind: "containsText", text: "needle", priority: 3, @@ -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. @@ -382,7 +424,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-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-write.test.ts b/packages/xls-codec/src/workbook/conditional-format-write.test.ts new file mode 100644 index 0000000000..f56cd77f46 --- /dev/null +++ b/packages/xls-codec/src/workbook/conditional-format-write.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +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", () => { + 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, + }); + }); +}); + +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/workbook/conditional-format-write.ts b/packages/xls-codec/src/workbook/conditional-format-write.ts index a6d593167f..927cc28a34 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; @@ -100,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; @@ -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(); @@ -305,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(); } @@ -339,9 +330,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,43 +344,50 @@ 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( +export function textRuleFormula( rule: Extract< 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('"', '""')}"`; @@ -406,7 +404,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 { @@ -421,6 +419,7 @@ function writeCf12Record( rule: Exclude, ipriority: number, icvOf: (color: Color) => number, + anchor: ContentSheetRange, ): Uint8Array { let ct: number; let icfTemplate: number; @@ -454,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; } @@ -484,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; } @@ -502,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; } @@ -513,15 +517,9 @@ 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); + rgbCt = writeCfFilter(0, 0); dxf = writeDxfn(rule.style, icvOf); break; } @@ -532,26 +530,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 +568,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 +596,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 +638,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 +662,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. @@ -674,28 +681,26 @@ 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, ): Uint8Array[] { const rules = sheet.conditionalFormats ?? []; - if (rules.length === 0) { - return []; - } 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`, - ); - } - const cf12Rules: { - readonly rule: Exclude; - readonly nID: number; - }[] = []; + validateRuleCount(rules.length); + 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 +709,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]; } diff --git a/packages/xls-codec/src/workbook/conditional-format.test.ts b/packages/xls-codec/src/workbook/conditional-format.test.ts index b72afc850c..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))); } @@ -139,7 +154,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 +173,7 @@ describe("readCondFmtGroup", () => { const result = readCondFmtGroup(groups, 0, NO_SHEETS); - expect(result.formats).toEqual([ + expect(result.formats).toStrictEqual([ { operator: "between", formula1: "1", @@ -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).toEqual([]); + 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), @@ -210,7 +263,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 +283,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 }, }); @@ -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 }, @@ -265,13 +420,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 +439,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 +451,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 +461,7 @@ describe("readCondFmtGroup", () => { const result = readCondFmtGroup(groups, 0, NO_SHEETS); - expect(result.formats).toEqual([]); + expect(result.formats).toStrictEqual([]); expect(result.recordsConsumed).toBe(1); }); @@ -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 a32738fbad..525878f1e7 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"; @@ -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(); @@ -109,9 +107,7 @@ export function parseDxfStyle( } return { fontColorIcv, fill }; } catch (err) { - if (!(err instanceof BiffFormatError)) { - throw err; - } + recoverFromFormatError(err, undefined); return undefined; } } @@ -145,9 +141,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; } } @@ -170,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, @@ -191,9 +182,7 @@ function readCf( ranges, }; } catch (err) { - if (!(err instanceof BiffFormatError)) { - throw err; - } + recoverFromFormatError(err, undefined); return undefined; } } @@ -264,9 +253,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.test.ts b/packages/xls-codec/src/workbook/data-validation.test.ts index ec0ae3b700..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"; @@ -104,7 +105,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 +237,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 }, ]); @@ -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); + }); + }); }); 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.test.ts b/packages/xls-codec/src/workbook/defined-names.test.ts new file mode 100644 index 0000000000..47a2e2e54a --- /dev/null +++ b/packages/xls-codec/src/workbook/defined-names.test.ts @@ -0,0 +1,435 @@ +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"; +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("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( + 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( + "internal error: a regex capture group this module's own callers already proved present was undefined", + ); + }); +}); + +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 5ff66fd8f2..4944b1a5dc 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; } } @@ -165,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] === "$", }; } @@ -229,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, @@ -305,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( 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..212744f54d --- /dev/null +++ b/packages/xls-codec/src/workbook/drawing-writer.test.ts @@ -0,0 +1,658 @@ +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, + writeEmbeddedObjRecord, + writeFtCf, + writeFtCmo, + writeFtPictFmla, + writeFtPioGrbit, + writePictureObjRecord, + 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("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])); + }); + + 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("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", + 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, counting only the cells that actually carry a comment", () => { + const plan = buildDrawingWritePlan([ + sheet( + [ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + 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, PNG_IMAGE] }, + ), + ]); + 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("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([], { + embeddedObjects: Array.from({ length: 11 }, () => embeddedDrawing()), + }), + ]); + + 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, 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] }), + ]); + 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 + // 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); + }); +}); diff --git a/packages/xls-codec/src/workbook/drawing-writer.ts b/packages/xls-codec/src/workbook/drawing-writer.ts index 3a989455a4..cbcf7f3f5c 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("") @@ -281,14 +283,18 @@ 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. 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; 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; @@ -329,7 +335,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 +347,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; }; diff --git a/packages/xls-codec/src/workbook/drawing.test.ts b/packages/xls-codec/src/workbook/drawing.test.ts index 0e506a00e5..fdb6b2a813 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,18 @@ 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 { writeEmbeddedObjectPackage } from "./embedded-object"; import { + chartFromShape, chartTableCells, + drawingObjectFromShape, + embeddedObjectFromObjRecord, + imageFromShape, readSheetDrawing, + SheetGridGeometry, type SheetDrawingContext, } from "./drawing"; @@ -91,6 +105,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; @@ -108,7 +179,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 +197,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 +214,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 +228,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", () => { @@ -180,6 +251,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. @@ -216,7 +335,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 +402,325 @@ describe("chartTableCells", () => { { name: undefined, categories: [""], values: [""] }, ]); - expect(cells).toEqual([]); + 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); + // 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, packageBytes], + ]), + }); + 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, + }); }); }); diff --git a/packages/xls-codec/src/workbook/drawing.ts b/packages/xls-codec/src/workbook/drawing.ts index 2058f2872c..7d58d03f04 100644 --- a/packages/xls-codec/src/workbook/drawing.ts +++ b/packages/xls-codec/src/workbook/drawing.ts @@ -61,8 +61,8 @@ export interface SheetDrawingContext { const GRID_UNITS_X = 1024; const GRID_UNITS_Y = 256; -/** The worksheet grid geometry a cell anchor resolves against -- declared column widths/row heights with Excel's own Normal-style default beneath, mirroring ooxml.js's identical SheetGridGeometry for the xlsx case. */ -class SheetGridGeometry { +/** The worksheet grid geometry a cell anchor resolves against -- declared column widths/row heights with Excel's own Normal-style default beneath, mirroring ooxml.js's identical SheetGridGeometry for the xlsx case. Exported for direct testing: a wrong declared width/height is invisible through most of this file's own integration tests, since a shape's anchor typically spans only one or two cells near the sheet's own origin, where the cumulative xPt/yPt sum an off-by-one column/row bound would silently miscompute is small enough to look identical to the correct value by coincidence. */ +export class SheetGridGeometry { private readonly columnWidths = new Map(); 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,24 +174,21 @@ 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: 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 || - obj.ot === OBJECT_TYPE_NOTE - ) { + if (shape === undefined || obj === undefined) { + break; + } + if (obj.ot === OBJECT_TYPE_NOTE) { continue; } if ( @@ -240,7 +233,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 +259,7 @@ function embeddedObjectFromObjRecord( }); } -function imageFromShape( +export function imageFromShape( shape: DrawingShape, context: SheetDrawingContext, geometry: SheetGridGeometry, @@ -296,7 +289,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 +399,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 { 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..96bc5e1e18 --- /dev/null +++ b/packages/xls-codec/src/workbook/embedded-object.test.ts @@ -0,0 +1,186 @@ +import { readOlePackage } from "archive-codec"; +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).toStrictEqual(DRAWING_DOCUMENT); + expect(result?.frame).toStrictEqual(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).toStrictEqual(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).toStrictEqual({ 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 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", + "[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; +} 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; 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..57ebd1e9d4 --- /dev/null +++ b/packages/xls-codec/src/workbook/encryption.test.ts @@ -0,0 +1,456 @@ +import * as archiveCodec from "archive-codec"; +import { + createXorObfuscationArray, + createXorObfuscationKey, + createXorObfuscationPasswordVerifier, + decryptOfficeRc4, + decryptXorObfuscationMethod1, + deriveOfficeRc4BaseHash, + md5, + XOR_OBFUSCATION_ARRAY_LENGTH, + XOR_OBFUSCATION_ROTATE_DISTANCE_METHOD1, +} from "archive-codec"; +import { describe, expect, it, vi } 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/); + }); + + 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", () => { + 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/); + }); + + 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", () => { + 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 ?? [])]).toStrictEqual(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 ?? [])]).toStrictEqual(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 ?? [])]).toStrictEqual(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 ?? [])]).toStrictEqual([ + ...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 ?? [])]).toStrictEqual(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 ?? [])]).toStrictEqual(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 ?? [])]).toStrictEqual([ + ...lbPlyPos, + ...expectedRestPlain, + ]); + }); + }); +}); 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"); } 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); + }); +}); diff --git a/packages/xls-codec/src/workbook/globals.test.ts b/packages/xls-codec/src/workbook/globals.test.ts index 6d5aefe8bf..f9f706c958 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[] { @@ -63,7 +70,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 +149,12 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sharedStrings).toEqual(["Alpha", "Beta"]); + 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", () => { @@ -161,15 +173,51 @@ 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", () => { + 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("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", () => { @@ -217,7 +265,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.cellFormats).toEqual([ + expect(globals.cellFormats).toStrictEqual([ { fontIndex: 0, formatId: 0, @@ -253,7 +301,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 +328,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", () => { @@ -296,7 +344,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 +379,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( @@ -355,7 +413,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { firstSheetIndex: 1, lastSheetIndex: 2 }, ]); }); @@ -380,7 +438,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "[Budget.xlsx]Sheet1", diagnostic: false }, ]); }); @@ -405,7 +463,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "[Budget.xlsx]Sheet1", diagnostic: false }, ]); }); @@ -430,7 +488,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "[Budget.xlsx]Sheet1", diagnostic: false }, ]); }); @@ -455,7 +513,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "[EXTERNAL]Sheet1", diagnostic: true }, ]); }); @@ -480,7 +538,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "[EXTERNAL]Sheet1", diagnostic: true }, ]); }); @@ -505,7 +563,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "[EXTERNAL]Sheet1", diagnostic: true }, ]); }); @@ -529,7 +587,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "[EXTERNAL]Sheet1", diagnostic: true }, ]); }); @@ -555,7 +613,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "[Book.xlsx]Jan:Mar", diagnostic: false }, ]); }); @@ -580,7 +638,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "[EXTERNAL]Sheet1", diagnostic: true }, ]); }); @@ -599,7 +657,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "#REF!(add-in function reference)", diagnostic: true }, ]); }); @@ -623,7 +681,7 @@ describe("readWorkbookGlobals", () => { ), ); - expect(globals.sheetRanges).toEqual([ + expect(globals.sheetRanges).toStrictEqual([ { label: "#REF!(DDE or OLE data source reference)", diagnostic: true }, ]); }); @@ -644,13 +702,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,15 +737,221 @@ 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 }, ]); }); }); +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(String.fromCharCode(0)), + ]), + ), + ).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(String.fromCharCode(1))).toBeUndefined(); + }); + + it("isolates a plain trailing file name with no marker at all", () => { + 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${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(); + }); +}); + +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 4cda79d7bd..0fbfdc84ba 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, @@ -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(); @@ -256,10 +255,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", + }); } } @@ -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); @@ -296,9 +295,7 @@ 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) { @@ -318,7 +315,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, @@ -343,8 +340,9 @@ 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 { diff --git a/packages/xls-codec/src/workbook/print-names.test.ts b/packages/xls-codec/src/workbook/print-names.test.ts index 6136291655..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. @@ -48,7 +85,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 +103,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 +121,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 +131,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", () => { @@ -121,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", () => { @@ -128,7 +237,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 +247,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,15 +263,36 @@ 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 }, }); }); + + 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({}); + }); }); diff --git a/packages/xls-codec/src/workbook/sheet-writer.ts b/packages/xls-codec/src/workbook/sheet-writer.ts index a3fec9e815..13ddc4a8ff 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"; @@ -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)]; } @@ -698,12 +693,8 @@ export function buildWorksheetSubstream( pieces.push(writeMergeCellsRecord(merges)); } - const commentedCells = sheet.cells.filter( - (cell) => cell.comment !== undefined, - ); - 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/workbook/sheet.test.ts b/packages/xls-codec/src/workbook/sheet.test.ts index 6d1e20ac46..4f6e533440 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, @@ -7,6 +9,7 @@ import { RECORD_BOTTOMMARGIN, RECORD_CF, RECORD_CF12, + RECORD_CFEX, RECORD_COLINFO, RECORD_CONDFMT, RECORD_CONDFMT12, @@ -29,11 +32,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 +46,7 @@ import { record, rkDouble, rkInteger, + shortXlUnicodeString, u16, u32, xlUnicodeString, @@ -63,7 +68,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 +85,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 +93,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 +114,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,21 +134,47 @@ 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", () => { + 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("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))); - 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 +189,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 +201,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 +209,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 +217,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 +233,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 +246,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 +254,28 @@ 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" }); + }); + + 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); }); }); @@ -257,7 +312,64 @@ describe("readSheetRecords formula cells", () => { ]), ); - expect(cells[0]?.value).toEqual({ kind: "boolean", value: true }); + 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("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", () => { @@ -276,7 +388,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 +408,79 @@ 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 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", () => { @@ -319,7 +503,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 +514,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 +533,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 +552,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", () => { @@ -394,6 +578,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", () => { @@ -412,7 +597,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", () => { @@ -477,6 +662,161 @@ 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("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 = [ @@ -606,6 +946,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 = [ @@ -640,7 +998,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 +1016,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 +1047,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 +1079,71 @@ 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("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", () => { @@ -752,8 +1173,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 +1204,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 +1224,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 +1244,67 @@ 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("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(); + } }); }); @@ -844,7 +1324,7 @@ describe("readSheetRecords grid geometry", () => { [], ); - expect(sheet.usedRange).toEqual({ + expect(sheet.usedRange).toStrictEqual({ startRow: 1, startColumn: 2, endRow: 4, @@ -868,6 +1348,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", () => { @@ -889,7 +1401,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 +1425,28 @@ describe("readSheetRecords grid geometry", () => { [], ); - expect(sheet.rows[0]).toEqual({ index: 0, hidden: false }); + 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", () => { @@ -951,7 +1486,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 +1531,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 +1564,7 @@ describe("readSheetRecords grid geometry", () => { [], ); - expect(sheet.dataValidations).toEqual([ + expect(sheet.dataValidations).toStrictEqual([ { type: "whole", operator: "between", @@ -1075,7 +1612,7 @@ describe("readSheetRecords grid geometry", () => { [], ); - expect(sheet.conditionalFormats).toEqual([ + expect(sheet.conditionalFormats).toStrictEqual([ { operator: "greaterThan", formula1: "10", @@ -1085,7 +1622,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 +1686,7 @@ describe("readSheetRecords grid geometry", () => { [], ); - expect(sheet.conditionalFormats12).toEqual([ + expect(sheet.conditionalFormats12).toStrictEqual([ { kind: "colorScale", stops: [ @@ -1162,7 +1699,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, @@ -1170,6 +1707,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( @@ -1205,7 +1797,7 @@ describe("readSheetRecords print settings", () => { [], ); - expect(sheet.print.setup).toEqual({ + expect(sheet.print.setup).toStrictEqual({ paperCode: 9, scalePercent: 80, fitWidth: 2, @@ -1229,7 +1821,7 @@ describe("readSheetRecords print settings", () => { [], ); - expect(sheet.print.marginsPt).toEqual({ + expect(sheet.print.marginsPt).toStrictEqual({ left: 36, right: 54, top: 72, @@ -1244,7 +1836,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 +1897,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 +1918,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 +1927,7 @@ describe("readSheetRecords print settings", () => { [], ); - expect(sheet.print).toEqual({ + expect(sheet.print).toStrictEqual({ marginsPt: {}, rowBreaks: [], columnBreaks: [], diff --git a/packages/xls-codec/src/workbook/sheet.ts b/packages/xls-codec/src/workbook/sheet.ts index 6fa3e4c927..c43c70d03e 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"; @@ -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; @@ -213,10 +211,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) { @@ -244,9 +242,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); } } @@ -268,18 +264,16 @@ 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) { - if (!(error instanceof BiffFormatError)) { - throw error; - } - return { kind: "array", rgce, rgcb: undefined }; + return recoverFromFormatError(error, { + kind: "array" as const, + rgce, + rgcb: undefined, + }); } } @@ -313,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: @@ -435,8 +430,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. } } @@ -537,11 +531,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; } @@ -786,7 +777,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) }; @@ -798,22 +790,19 @@ 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) { - if (!(error instanceof BiffFormatError)) { - throw error; - } - rgce = undefined; - rgcb = undefined; + // 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); } + // 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 }; } /** @@ -841,9 +830,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; } } diff --git a/packages/xls-codec/src/write.test.ts b/packages/xls-codec/src/write.test.ts index c2f93ca269..917914e455 100644 --- a/packages/xls-codec/src/write.test.ts +++ b/packages/xls-codec/src/write.test.ts @@ -15,23 +15,53 @@ 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_CONDFMT, + RECORD_CONDFMT12, RECORD_CONTINUE, + RECORD_DIMENSIONS, + RECORD_EOF, 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"; 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 { + 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"; +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. @@ -125,7 +155,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"); @@ -140,7 +170,7 @@ describe("writeXlsContent", () => { ]), ); const readBack = findCell(readXlsContent(bytes), 0, 0, 0); - expect(readBack?.value).toEqual({ + expect(readBack?.value).toStrictEqual({ kind: "string", value: "Hello, world!", }); @@ -158,19 +188,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", }); @@ -183,7 +213,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, }); @@ -199,15 +229,16 @@ 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, }); + expect(findCell(content, 0, 0, 1)?.displayText).toBe("FALSE"); }); it("round-trips every [MS-XLS]-defined error value", () => { @@ -233,7 +264,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, }); @@ -247,7 +278,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", () => { @@ -263,23 +294,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", }); @@ -299,7 +333,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", @@ -320,18 +354,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, }); @@ -352,7 +386,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"); }); @@ -394,9 +428,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", () => { @@ -421,12 +455,54 @@ 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" }, }); }); + 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([ @@ -444,8 +520,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 }, }); }); @@ -464,9 +540,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)", () => { @@ -483,9 +559,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", () => { @@ -501,9 +577,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", () => { @@ -534,9 +610,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", () => { @@ -552,9 +628,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", () => { @@ -569,8 +645,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(); }); @@ -622,7 +698,7 @@ describe("writeXlsContent", () => { for (const written of cells) { expect( findCell(content, 0, written.row, written.column)?.background, - ).toEqual(written.background); + ).toStrictEqual(written.background); } }); @@ -645,9 +721,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 }, }); }); @@ -686,8 +762,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); }); @@ -704,8 +780,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", () => { @@ -758,6 +834,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", () => { @@ -786,6 +866,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", () => { @@ -823,7 +907,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", () => { @@ -836,10 +920,39 @@ 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"); }); + 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" })])]), @@ -878,7 +991,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(); }); @@ -897,7 +1010,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); }); @@ -916,8 +1029,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", () => { @@ -934,7 +1048,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); }); @@ -947,20 +1063,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, }); @@ -970,7 +1086,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", () => { @@ -997,9 +1113,73 @@ describe("writeXlsContent", () => { sheet("Sheet1", [cell(0, 256, { kind: "number", value: 1 })]), ]), ), + ).toThrow(/outside BIFF8's own grid/); + }); + + 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("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([ + 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( @@ -1037,13 +1217,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, @@ -1063,8 +1243,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", () => { @@ -1084,8 +1264,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 }, }); @@ -1100,8 +1280,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", () => { @@ -1153,7 +1333,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", @@ -1172,7 +1352,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" }, ]); @@ -1189,7 +1369,7 @@ describe("writeXlsContent", () => { }, ], }); - expect(readXlsContent(bytes).names).toEqual([ + expect(readXlsContent(bytes).names).toStrictEqual([ { name: "_xlnm._FilterDatabase", refersTo: "Sheet1!$A$1:$C$1", @@ -1227,6 +1407,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", () => { @@ -1245,7 +1470,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', () => { @@ -1256,7 +1481,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", () => { @@ -1298,13 +1523,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", + ); }); }); @@ -1350,7 +1577,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", () => { @@ -1360,7 +1589,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", () => { @@ -1369,12 +1598,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", () => { @@ -1390,7 +1619,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", () => { @@ -1407,8 +1636,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, @@ -1430,11 +1659,40 @@ 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); }); + 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 + // 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", () => { // 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( @@ -1445,7 +1703,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", () => { @@ -1464,14 +1722,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", () => { @@ -1480,12 +1738,49 @@ 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], }); }); + 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({ + ...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( @@ -1555,9 +1850,81 @@ 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("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" }, + { 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", () => { @@ -1583,7 +1950,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", () => { @@ -1632,7 +1999,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", }); @@ -1651,7 +2018,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", }); }); @@ -1668,7 +2035,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", () => { @@ -1678,7 +2047,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", () => { @@ -1699,8 +2068,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", }); @@ -1876,7 +2247,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", () => { @@ -1927,7 +2298,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", () => { @@ -1953,7 +2324,22 @@ 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("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", () => { @@ -1993,32 +2379,170 @@ describe("writeXlsContent: data validations written (#971)", () => { ), ).toThrow(/no second formula/); }); -}); -describe("writeXlsContent: conditional formats written (#971)", () => { - it("round-trips a cellIs rule with its style colours", () => { - const rule: ContentSheetConditionalFormat = { - type: "cellIs", - ranges: [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }], - operator: "greaterThan", - formula1: "5", - style: { - textColor: { r: 1, g: 0, b: 0 }, - background: { r: 1, g: 1, b: 0.8 }, - }, - }; - const reread = readXlsContent( + it("refuses a data-validation type or operator the schema's own closed vocabularies never name", () => { + expect(() => writeXlsContent( - document([sheet("S", [], { conditionalFormats: [rule] })]), + document([ + sheet("S", [], { + dataValidations: [ + { + ranges: [ + { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }, + ], + type: "notARealType", + formula1: "5", + } as unknown as ContentSheetDataValidation, + ], + }), + ]), ), - ); - expect(reread.sheets[0]?.conditionalFormats).toEqual([rule]); - }); - - it("round-trips a style-less notBetween rule's two formulas", () => { - const rule: ContentSheetConditionalFormat = { - type: "cellIs", - ranges: [{ startRow: 0, endRow: 1, startColumn: 0, endColumn: 1 }], + ).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, + // 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, + 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("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( + writeSheetDataValidations(sheet("S", [], { dataValidations: [] })), + ).toStrictEqual([]); + }); +}); + +describe("writeXlsContent: conditional formats written (#971)", () => { + it("round-trips a cellIs rule with its style colours", () => { + const rule: ContentSheetConditionalFormat = { + type: "cellIs", + ranges: [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }], + operator: "greaterThan", + formula1: "5", + style: { + textColor: { r: 1, g: 0, b: 0 }, + background: { r: 1, g: 1, b: 0.8 }, + }, + }; + const reread = readXlsContent( + writeXlsContent( + document([sheet("S", [], { conditionalFormats: [rule] })]), + ), + ); + expect(reread.sheets[0]?.conditionalFormats).toStrictEqual([rule]); + }); + + it("round-trips a style-less notBetween rule's two formulas", () => { + const rule: ContentSheetConditionalFormat = { + type: "cellIs", + ranges: [{ startRow: 0, endRow: 1, startColumn: 0, endColumn: 1 }], operator: "notBetween", formula1: "2", formula2: "8", @@ -2028,7 +2552,30 @@ 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 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", () => { @@ -2051,6 +2598,188 @@ 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("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( + sheet("S", [], { conditionalFormats: [] }), + () => 0, + ), + ).toStrictEqual([]); + }); + + 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 rule count sitting exactly at CondFmt's own 15-bit nID field boundary", () => { + expect(() => { + validateRuleCount(0x7fff); + }).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[] = [ + { 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"]); + }); + + // 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)", () => { @@ -2077,7 +2806,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", () => { @@ -2093,7 +2822,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", () => { @@ -2106,7 +2835,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", () => { @@ -2123,7 +2852,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", () => { @@ -2142,6 +2871,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({ @@ -2170,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", @@ -2184,7 +2971,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", () => { @@ -2199,12 +2986,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({ @@ -2221,7 +3017,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", () => { @@ -2238,7 +3044,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 }]); } }); @@ -2255,7 +3061,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 }]); } }); @@ -2281,7 +3087,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 }, @@ -2338,9 +3144,953 @@ 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 }, ]); }); + + 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)).toStrictEqual([{ ...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)).toStrictEqual([{ ...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)).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", + ranges: [RANGE], + equalAverage: true, + }; + const belowNotEqual: ContentSheetConditionalFormat = { + type: "aboveAverage", + ranges: [RANGE], + aboveAverage: false, + }; + expect(roundTripped(aboveEqual)).toStrictEqual([ + { ...aboveEqual, priority: 1 }, + ]); + expect(roundTripped(belowNotEqual)).toStrictEqual([ + { ...belowNotEqual, priority: 1 }, + ]); + }); + + it("round-trips a rule declaring stopIfTrue", () => { + const rule: ContentSheetConditionalFormat = { + type: "top10", + ranges: [RANGE], + rank: 1, + stopIfTrue: true, + }; + expect(roundTripped(rule)).toStrictEqual([{ ...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)).toStrictEqual([{ ...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("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("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("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 }], + }); + 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 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( + 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("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 }), + 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; + + 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); + }); +}); + +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("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( + 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/, + ); + }); + + 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", () => { + 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("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, + 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 e7ed0d7681..01cb8614e3 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]), @@ -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)); } } @@ -201,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. */ @@ -209,16 +206,14 @@ 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) { 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) { @@ -233,11 +228,8 @@ function buildPalettePlan(sheets: readonly ContentSheet[]): PalettePlan { }; for (const sheet of sheets) { + // 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) { - // 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; - } recordFill(cell.background); record(cell.font?.color); record(cell.borders?.left?.color); @@ -261,14 +253,7 @@ 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()) { @@ -392,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, @@ -400,20 +389,16 @@ function signatureOfCellXf( verticalAlignment: "top" | "middle" | "bottom" | undefined, decoration: XfDecorationFields | undefined, ): string { - let signature = `f${formatId}|n${fontIndex}|a${alignment ?? ""}|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, + }); } -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. */ @@ -432,7 +417,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 { @@ -444,11 +429,8 @@ function buildFontPlan( xfFontFieldsOf(cell.font, palettePlan.icvOf); for (const sheet of sheets) { + // 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) { - // 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; - } const fields = fieldsOf(cell); const signature = signatureOfFont(fields); if (indexBySignature.has(signature)) { @@ -473,7 +455,7 @@ function buildFontPlan( }; } -interface CellXfPlan { +export interface CellXfPlan { readonly cellXfEntries: readonly CellXfPlanEntry[]; readonly xfIndexForCell: (cell: ContentSheetCell) => number; } @@ -483,7 +465,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, @@ -552,14 +534,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; @@ -632,7 +614,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 { @@ -642,7 +624,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", @@ -698,11 +682,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, }; } 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..5f374d721e --- /dev/null +++ b/packages/xls-codec/src/written-cells.test.ts @@ -0,0 +1,118 @@ +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 { + 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: RED }, + }), + ), + ).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: border() } })), + ).toBe(true); + }); + + it("is true when only the right border is set", () => { + expect( + cellCarriesFormatting(emptyCell({ borders: { right: border() } })), + ).toBe(true); + }); + + it("is true when only the top border is set", () => { + expect( + cellCarriesFormatting(emptyCell({ borders: { top: border() } })), + ).toBe(true); + }); + + it("is true when only the bottom border is set", () => { + expect( + cellCarriesFormatting(emptyCell({ borders: { bottom: border() } })), + ).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); + }); +}); 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, });