@lotics/xlsx 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/xlsx",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
Binary file
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { parseBiff8, isOle2 } from "./biff8_reader";
5
+ import { PasswordProtectedError } from "./types";
6
+
7
+ // A synthetic BIFF8 (.xls) workbook: 250 data rows of Unicode strings + integer,
8
+ // float and negative numbers. The Unicode strings push the shared-string table
9
+ // past one record boundary, so decoding row 200 exercises the SST CONTINUE +
10
+ // per-continue grbit path. No real data — minted purely to lock the reader.
11
+ const fixture = new Uint8Array(readFileSync(fileURLToPath(new URL("./biff8_fixtures/synthetic.xls", import.meta.url))));
12
+
13
+ const cell = (sheet: ReturnType<typeof parseBiff8>["sheets"][number], row: number, col: number): unknown =>
14
+ sheet.rows.find((r) => r.index === row)?.cells.find((c) => c.column === col)?.typedValue;
15
+
16
+ describe("parseBiff8", () => {
17
+ it("detects the OLE2 container", () => {
18
+ expect(isOle2(fixture)).toBe(true);
19
+ expect(isOle2(new Uint8Array([0x50, 0x4b, 0x03, 0x04]))).toBe(false); // a zip (.xlsx) is not OLE2
20
+ expect(isOle2(new Uint8Array([0x00]))).toBe(false);
21
+ });
22
+
23
+ it("reads the sheet name and full row count", () => {
24
+ const s = parseBiff8(fixture).sheets[0];
25
+ expect(s.name).toBe("GiaoDich");
26
+ expect(s.totalRowCount).toBe(251); // header + 250 rows
27
+ });
28
+
29
+ it("decodes Unicode text cells", () => {
30
+ const s = parseBiff8(fixture).sheets[0];
31
+ expect(cell(s, 0, 0)).toBe("ID");
32
+ expect(cell(s, 0, 1)).toBe("Tên khách");
33
+ expect(cell(s, 0, 2)).toBe("Số tiền");
34
+ });
35
+
36
+ it("decodes a string that falls past the SST CONTINUE boundary", () => {
37
+ const s = parseBiff8(fixture).sheets[0];
38
+ expect(cell(s, 200, 1)).toBe("Khách 200 — Công ty TNHH số 200");
39
+ expect(cell(s, 249, 1)).toBe("Khách 249 — Công ty TNHH số 249");
40
+ });
41
+
42
+ it("decodes integer, float and negative numbers to the exact value", () => {
43
+ const s = parseBiff8(fixture).sheets[0];
44
+ expect(cell(s, 2, 2)).toBe(2000); // integer (RK int)
45
+ expect(cell(s, 3, 2)).toBe(3000.5); // float
46
+ expect(cell(s, 5, 2)).toBe(-500); // negative
47
+ expect(cell(s, 200, 2)).toBe(-20000);
48
+ expect(cell(s, 249, 2)).toBe(249000.5);
49
+ });
50
+
51
+ it("honors maxRowsPerSheet and flags truncation", () => {
52
+ const s = parseBiff8(fixture, { maxRowsPerSheet: 10 }).sheets[0];
53
+ expect(s.truncated).toBe(true);
54
+ expect(s.rows.every((r) => r.index < 10)).toBe(true);
55
+ });
56
+
57
+ it("rejects an OLE2 buffer with no Workbook stream", () => {
58
+ // 8-byte OLE2 magic with no valid directory → not a readable workbook.
59
+ const bogus = new Uint8Array(512);
60
+ bogus.set([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
61
+ expect(() => parseBiff8(bogus)).toThrow();
62
+ });
63
+ });
64
+
65
+ // A date cell is a NUMBER record (Excel serial) whose XF points at a date
66
+ // number-format. The reader must resolve XF → FORMAT so downstream renders a
67
+ // date, not the raw serial — the common case the text-dated reconciliation file
68
+ // never exercised. Fixture: a date, an integer, a decimal, a Unicode string.
69
+ const dateFixture = new Uint8Array(readFileSync(fileURLToPath(new URL("./biff8_fixtures/synthetic_dates.xls", import.meta.url))));
70
+
71
+ describe("parseBiff8 number formats", () => {
72
+ const cell = (row: number, col: number) => {
73
+ const s = parseBiff8(dateFixture).sheets[0];
74
+ return s.rows.find((r) => r.index === row)?.cells.find((c) => c.column === col);
75
+ };
76
+
77
+ it("reads a date cell as an Excel serial carrying its date number-format", () => {
78
+ const c = cell(1, 0);
79
+ expect(c?.typedValue).toBe(45672); // 2025-01-15
80
+ expect(c?.numFmtCode).toBe("dd/mm/yyyy");
81
+ });
82
+
83
+ it("leaves a plain number with no number-format (General)", () => {
84
+ expect(cell(1, 1)?.typedValue).toBe(42);
85
+ expect(cell(1, 1)?.numFmtCode).toBeUndefined();
86
+ expect(cell(1, 2)?.typedValue).toBe(1234.5);
87
+ });
88
+
89
+ it("decodes a Unicode string cell", () => {
90
+ expect(cell(1, 3)?.typedValue).toBe("Ábc");
91
+ });
92
+ });
93
+
94
+ // PasswordProtectedError is exported and reachable — encrypted .xls (FILEPASS)
95
+ // and encrypted OOXML (EncryptedPackage stream) both route to it.
96
+ describe("parseBiff8 encryption", () => {
97
+ it("surfaces PasswordProtectedError as the shared error type", () => {
98
+ expect(new PasswordProtectedError()).toBeInstanceOf(Error);
99
+ });
100
+ });
@@ -0,0 +1,459 @@
1
+ // Read legacy Excel 97–2003 binary workbooks (.xls / BIFF8) into the same
2
+ // ParsedSpreadsheet model as the OOXML (.xlsx) parser, so every consumer —
3
+ // view_files, FilePreview, imports, the CLI, custom apps — reads .xls with no
4
+ // per-caller changes. A .xls is an OLE2 Compound File whose "Workbook" stream
5
+ // is a sequence of BIFF records; we walk the CFB, then the record stream.
6
+ //
7
+ // Scope: the data grid — shared strings (SST, incl. CONTINUE splits), string
8
+ // cells (LABELSST), numbers (NUMBER/RK/MULRK), cached formula results, blanks,
9
+ // and number formats (for date/currency detection). Unknown records are skipped
10
+ // (advance by length), so exotic features degrade gracefully instead of failing.
11
+ // Encrypted files (FILEPASS record, or an OLE2 EncryptedPackage stream) throw
12
+ // PasswordProtectedError. No third-party dependency and no eval/regex on file
13
+ // bytes — the SheetJS path this replaces was removed for prototype-pollution +
14
+ // ReDoS CVEs.
15
+
16
+ import { emptySheet } from "./ooxml_sheet";
17
+ import type { ParseOptions } from "./excel_parser";
18
+ import { PasswordProtectedError } from "./types";
19
+ import type { ParsedSpreadsheet, ParsedSheet, ParsedRow, ParsedCell } from "./types";
20
+
21
+ const OLE2_MAGIC = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
22
+ const ENDOFCHAIN = 0xfffffffe;
23
+ const FREESECT = 0xffffffff;
24
+ const DEFAULT_MAX_ROWS = 10_000;
25
+
26
+ /** True when the bytes begin with the OLE2 Compound File signature (legacy .xls
27
+ * or an encrypted OOXML package — both share this container). */
28
+ export function isOle2(bytes: Uint8Array): boolean {
29
+ return bytes.length >= 8 && OLE2_MAGIC.every((b, i) => bytes[i] === b);
30
+ }
31
+
32
+ interface CfbStream {
33
+ name: string;
34
+ bytes: Uint8Array;
35
+ }
36
+
37
+ // ─── OLE2 / Compound File Binary container ──────────────────────────────────
38
+
39
+ function readCfbStreams(bytes: Uint8Array): CfbStream[] {
40
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
41
+ const u16 = (o: number) => dv.getUint16(o, true);
42
+ const u32 = (o: number) => dv.getUint32(o, true);
43
+
44
+ const sectorSize = 1 << u16(30);
45
+ const miniSectorSize = 1 << u16(32);
46
+ const firstDirSector = u32(48);
47
+ const miniCutoff = u32(56);
48
+ const firstMiniFatSector = u32(60);
49
+ const firstDifatSector = u32(68);
50
+
51
+ if (sectorSize < 512 || sectorSize > 1 << 20) {
52
+ throw new Error("Unsupported .xls: invalid OLE2 sector size");
53
+ }
54
+ const sectorOffset = (n: number) => sectorSize * (n + 1);
55
+
56
+ // DIFAT: first 109 entries live in the header; the rest chain through sectors.
57
+ const difat: number[] = [];
58
+ for (let i = 0; i < 109; i++) {
59
+ const v = u32(76 + i * 4);
60
+ if (v !== FREESECT) difat.push(v);
61
+ }
62
+ let ds = firstDifatSector;
63
+ const entriesPerSector = sectorSize / 4;
64
+ for (let guard = 0; ds !== ENDOFCHAIN && ds !== FREESECT && guard < 1 << 20; guard++) {
65
+ const base = sectorOffset(ds);
66
+ for (let i = 0; i < entriesPerSector - 1; i++) {
67
+ const v = u32(base + i * 4);
68
+ if (v !== FREESECT) difat.push(v);
69
+ }
70
+ ds = u32(base + (entriesPerSector - 1) * 4);
71
+ }
72
+
73
+ // FAT: sector-allocation chains, assembled from the DIFAT-listed FAT sectors.
74
+ const fat: number[] = [];
75
+ for (const fatSector of difat) {
76
+ const base = sectorOffset(fatSector);
77
+ for (let i = 0; i < entriesPerSector; i++) fat.push(u32(base + i * 4));
78
+ }
79
+
80
+ const readChain = (src: Uint8Array, start: number, size: number | null, ss: number, offOf: (n: number) => number, chain: number[]): Uint8Array => {
81
+ // A chain can't yield more sectors than the source holds; a longer walk is a
82
+ // cyclic/corrupt FAT (a crafted .xls DoS) — cap the read at the source size.
83
+ const maxSectors = Math.floor(src.length / ss) + 2;
84
+ const parts: Uint8Array[] = [];
85
+ let s = start;
86
+ for (let guard = 0; s !== ENDOFCHAIN && s !== FREESECT && s < chain.length && guard < maxSectors; guard++) {
87
+ const o = offOf(s);
88
+ parts.push(src.subarray(o, o + ss));
89
+ s = chain[s];
90
+ }
91
+ const merged = concat(parts);
92
+ return size != null ? merged.subarray(0, size) : merged;
93
+ };
94
+ const readBig = (start: number, size: number | null) => readChain(bytes, start, size, sectorSize, sectorOffset, fat);
95
+
96
+ // Directory: 128-byte entries in a FAT chain.
97
+ const dirBytes = readBig(firstDirSector, null);
98
+ const ddv = new DataView(dirBytes.buffer, dirBytes.byteOffset, dirBytes.byteLength);
99
+ interface DirEntry { name: string; type: number; start: number; size: number }
100
+ const dir: DirEntry[] = [];
101
+ for (let off = 0; off + 128 <= dirBytes.length; off += 128) {
102
+ const nameLen = ddv.getUint16(off + 64, true);
103
+ const type = ddv.getUint8(off + 66);
104
+ if (nameLen < 2 || type === 0) continue;
105
+ const name = utf16le(dirBytes.subarray(off, off + nameLen - 2));
106
+ dir.push({ name, type, start: ddv.getUint32(off + 116, true), size: Number(ddv.getBigUint64(off + 120, true)) });
107
+ }
108
+
109
+ // Mini stream (root entry's chain) holds streams below the cutoff, in mini sectors.
110
+ const root = dir.find((e) => e.type === 5);
111
+ const miniStream = root ? readBig(root.start, root.size) : new Uint8Array(0);
112
+ const miniFatBytes = readBig(firstMiniFatSector, null);
113
+ const miniFat: number[] = [];
114
+ for (let i = 0; i + 4 <= miniFatBytes.length; i += 4) miniFat.push(miniFatBytes[i] | (miniFatBytes[i + 1] << 8) | (miniFatBytes[i + 2] << 16) | (miniFatBytes[i + 3] << 24));
115
+ // Mini sectors index into the root mini-stream container, not the raw file.
116
+ const readMini = (start: number, size: number) => readChain(miniStream, start, size, miniSectorSize, (n) => n * miniSectorSize, miniFat);
117
+ const readStream = (e: DirEntry) => (e.size >= miniCutoff ? readBig(e.start, e.size) : readMini(e.start, e.size));
118
+
119
+ return dir.filter((e) => e.type === 2).map((e) => ({ name: e.name, bytes: readStream(e) }));
120
+ }
121
+
122
+ // ─── BIFF record stream ─────────────────────────────────────────────────────
123
+
124
+ const REC = {
125
+ FORMULA: 0x0006, EOF: 0x000a, CONTINUE: 0x003c, DATEMODE: 0x0022, FILEPASS: 0x002f,
126
+ BLANK: 0x0201, NUMBER: 0x0203, LABEL: 0x0204, STRING: 0x0207, BOOLERR: 0x0205,
127
+ BOUNDSHEET: 0x0085, FORMAT: 0x041e, XF: 0x00e0, RK: 0x027e, MULRK: 0x00bd,
128
+ MULBLANK: 0x00be, LABELSST: 0x00fd, SST: 0x00fc, BOF: 0x0809,
129
+ } as const;
130
+
131
+ interface Record8 { type: number; body: Uint8Array; offset: number }
132
+
133
+ function* iterRecords(stream: Uint8Array, from: number, to: number): Generator<Record8> {
134
+ const dv = new DataView(stream.buffer, stream.byteOffset, stream.byteLength);
135
+ let p = from;
136
+ while (p + 4 <= to) {
137
+ const type = dv.getUint16(p, true);
138
+ const len = dv.getUint16(p + 2, true);
139
+ const bodyStart = p + 4;
140
+ if (bodyStart + len > stream.length) break;
141
+ yield { type, body: stream.subarray(bodyStart, bodyStart + len), offset: p };
142
+ p = bodyStart + len;
143
+ }
144
+ }
145
+
146
+ interface Globals {
147
+ sst: string[];
148
+ date1904: boolean;
149
+ /** XF index → resolved number-format string. */
150
+ xfFormats: string[];
151
+ boundsheets: { name: string; pos: number }[];
152
+ }
153
+
154
+ function parseGlobals(stream: Uint8Array): Globals {
155
+ const sstChunks: Uint8Array[] = [];
156
+ const formats = new Map<number, string>();
157
+ const xfFormatIds: number[] = [];
158
+ const boundsheets: { name: string; pos: number }[] = [];
159
+ let date1904 = false;
160
+ let sawSst = false;
161
+ let sstUnique = 0;
162
+
163
+ // Globals substream = first BOF … first EOF.
164
+ let end = stream.length;
165
+ let started = false;
166
+ const dv = new DataView(stream.buffer, stream.byteOffset, stream.byteLength);
167
+ for (const r of iterRecords(stream, 0, stream.length)) {
168
+ if (r.type === REC.BOF) { if (started) { end = r.offset; break; } started = true; continue; }
169
+ if (r.type === REC.EOF) { end = r.offset; break; }
170
+ }
171
+
172
+ for (const r of iterRecords(stream, 0, end)) {
173
+ switch (r.type) {
174
+ case REC.FILEPASS:
175
+ throw new PasswordProtectedError();
176
+ case REC.DATEMODE:
177
+ date1904 = readU16(r.body, 0) === 1;
178
+ break;
179
+ case REC.SST:
180
+ sawSst = true;
181
+ sstUnique = readU32(r.body, 4); // cstTotal (0), cstUnique (4)
182
+ sstChunks.push(r.body.subarray(8)); // strings follow the 8-byte header
183
+ break;
184
+ case REC.CONTINUE:
185
+ if (sawSst) sstChunks.push(r.body);
186
+ break;
187
+ case REC.FORMAT: {
188
+ const id = readU16(r.body, 0);
189
+ formats.set(id, readUnicodeShort(r.body, 2, 2));
190
+ break;
191
+ }
192
+ case REC.XF: {
193
+ // ifmt is the number-format index at byte offset 2.
194
+ xfFormatIds.push(readU16(r.body, 2));
195
+ break;
196
+ }
197
+ case REC.BOUNDSHEET: {
198
+ const pos = dv.getUint32(r.offset + 4, true);
199
+ boundsheets.push({ name: readUnicodeShort(r.body, 6, 1), pos });
200
+ break;
201
+ }
202
+ default:
203
+ break;
204
+ }
205
+ }
206
+
207
+ const sst = parseSst(sstChunks, sstUnique);
208
+ const xfFormats = xfFormatIds.map((id) => formats.get(id) ?? builtinFormat(id));
209
+ return { sst, date1904, xfFormats, boundsheets };
210
+ }
211
+
212
+ // ─── Shared string table (handles CONTINUE splits + per-continue grbit) ──────
213
+
214
+ function parseSst(chunks: Uint8Array[], count: number): string[] {
215
+ const out: string[] = [];
216
+ if (chunks.length === 0) return out;
217
+ let ci = 0;
218
+ let off = 0;
219
+ const atEnd = () => ci >= chunks.length;
220
+ const cur = () => chunks[ci];
221
+ const advanceChunk = () => { ci++; off = 0; };
222
+ const rawU8 = (): number => {
223
+ while (!atEnd() && off >= cur().length) advanceChunk();
224
+ if (atEnd()) return 0;
225
+ return cur()[off++];
226
+ };
227
+ const rawU16 = () => rawU8() | (rawU8() << 8);
228
+ const rawU32 = () => rawU8() | (rawU8() << 8) | (rawU8() << 16) | (rawU8() << 24);
229
+ const skip = (n: number) => { for (let i = 0; i < n; i++) rawU8(); };
230
+
231
+ for (let s = 0; s < count && !atEnd(); s++) {
232
+ const cch = rawU16();
233
+ const grbit = rawU8();
234
+ let high = (grbit & 0x01) !== 0;
235
+ const rich = (grbit & 0x08) !== 0 ? rawU16() : 0;
236
+ const ext = (grbit & 0x04) !== 0 ? rawU32() : 0;
237
+
238
+ let str = "";
239
+ let read = 0;
240
+ while (read < cch) {
241
+ // A string's characters can spill into a CONTINUE record; the first byte
242
+ // of that record is a fresh grbit whose bit0 re-declares char width.
243
+ if (off >= cur().length) {
244
+ advanceChunk();
245
+ if (atEnd()) break;
246
+ high = (cur()[off++] & 0x01) !== 0;
247
+ }
248
+ if (high) {
249
+ const code = cur()[off] | (cur()[off + 1] << 8);
250
+ off += 2;
251
+ str += String.fromCharCode(code);
252
+ } else {
253
+ str += String.fromCharCode(cur()[off++]);
254
+ }
255
+ read++;
256
+ }
257
+ // Rich-text runs (4 bytes each) and phonetic ext data follow the chars and
258
+ // are continued without a fresh grbit — a plain byte skip across chunks.
259
+ skip(rich * 4 + ext);
260
+ out.push(str);
261
+ }
262
+ return out;
263
+ }
264
+
265
+ // ─── Sheet cells ────────────────────────────────────────────────────────────
266
+
267
+ function parseSheet(stream: Uint8Array, start: number, name: string, g: Globals, maxRows: number): ParsedSheet {
268
+ const sheet = emptySheet(name);
269
+ // Row index → (col index → cell). Sparse; assembled into ParsedRow[] at the end.
270
+ const grid = new Map<number, Map<number, ParsedCell>>();
271
+ let maxRow = -1;
272
+ let truncated = false;
273
+
274
+ const put = (row: number, col: number, cell: ParsedCell) => {
275
+ if (row >= maxRows) { truncated = true; return; }
276
+ let r = grid.get(row);
277
+ if (!r) { r = new Map(); grid.set(row, r); }
278
+ r.set(col, cell);
279
+ if (row > maxRow) maxRow = row;
280
+ };
281
+
282
+ // The sheet substream runs from its BOF to the next EOF.
283
+ let end = stream.length;
284
+ let seenBof = false;
285
+ for (const r of iterRecords(stream, start, stream.length)) {
286
+ if (r.type === REC.BOF) { if (seenBof) { end = r.offset; break; } seenBof = true; continue; }
287
+ if (r.type === REC.EOF) { end = r.offset; break; }
288
+ }
289
+
290
+ for (const r of iterRecords(stream, start, end)) {
291
+ switch (r.type) {
292
+ case REC.LABELSST: {
293
+ const row = readU16(r.body, 0), col = readU16(r.body, 2), isst = readU32(r.body, 6);
294
+ put(row, col, textCell(col, g.sst[isst] ?? ""));
295
+ break;
296
+ }
297
+ case REC.LABEL: {
298
+ const row = readU16(r.body, 0), col = readU16(r.body, 2);
299
+ put(row, col, textCell(col, readUnicodeShort(r.body, 6, 2)));
300
+ break;
301
+ }
302
+ case REC.NUMBER: {
303
+ const row = readU16(r.body, 0), col = readU16(r.body, 2), xf = readU16(r.body, 4);
304
+ put(row, col, numberCell(col, readF64(r.body, 6), g.xfFormats[xf]));
305
+ break;
306
+ }
307
+ case REC.RK: {
308
+ const row = readU16(r.body, 0), col = readU16(r.body, 2), xf = readU16(r.body, 4);
309
+ put(row, col, numberCell(col, decodeRk(readU32(r.body, 6)), g.xfFormats[xf]));
310
+ break;
311
+ }
312
+ case REC.MULRK: {
313
+ const row = readU16(r.body, 0), first = readU16(r.body, 2);
314
+ const n = (r.body.length - 6) / 6;
315
+ for (let i = 0; i < n; i++) {
316
+ const xf = readU16(r.body, 4 + i * 6);
317
+ const rk = readU32(r.body, 6 + i * 6);
318
+ put(row, first + i, numberCell(first + i, decodeRk(rk), g.xfFormats[xf]));
319
+ }
320
+ break;
321
+ }
322
+ case REC.FORMULA: {
323
+ const row = readU16(r.body, 0), col = readU16(r.body, 2), xf = readU16(r.body, 4);
324
+ const cell = formulaCell(col, r.body, g.xfFormats[xf]);
325
+ if (cell) put(row, col, cell);
326
+ break;
327
+ }
328
+ // BLANK / MULBLANK / BOOLERR carry no data value we surface — skipped.
329
+ default:
330
+ break;
331
+ }
332
+ }
333
+
334
+ const rows: ParsedRow[] = [];
335
+ for (let ri = 0; ri <= maxRow; ri++) {
336
+ const r = grid.get(ri);
337
+ if (!r) continue;
338
+ const cells = [...r.values()].sort((a, b) => a.column - b.column);
339
+ rows.push({ index: ri, height: sheet.defaultRowHeight, cells, hidden: false });
340
+ }
341
+ sheet.rows = rows;
342
+ sheet.totalRowCount = maxRow + 1;
343
+ sheet.truncated = truncated;
344
+ return sheet;
345
+ }
346
+
347
+ // ─── Cell builders ──────────────────────────────────────────────────────────
348
+
349
+ function textCell(column: number, text: string): ParsedCell {
350
+ return { column, value: text, typedValue: text, content: { type: "plain", text }, style: {} };
351
+ }
352
+
353
+ function numberCell(column: number, num: number, numFmt: string | undefined): ParsedCell {
354
+ const value = Number.isFinite(num) ? String(num) : "";
355
+ return {
356
+ column, value, rawValue: num, typedValue: num,
357
+ numFmtCode: numFmt && numFmt !== "General" ? numFmt : undefined,
358
+ content: { type: "plain", text: value }, style: {},
359
+ };
360
+ }
361
+
362
+ function formulaCell(column: number, body: Uint8Array, numFmt: string | undefined): ParsedCell | null {
363
+ // Cached result: 8 bytes at offset 6. A trailing 0xFFFF marks a non-numeric
364
+ // result whose type is byte[6] (0=string in a following STRING rec, 1=bool,
365
+ // 2=error, 3=empty). Numeric results are an IEEE double.
366
+ if (readU16(body, 12) === 0xffff) {
367
+ const kind = body[6];
368
+ if (kind === 1) {
369
+ const b = body[8] !== 0;
370
+ return { column, value: b ? "TRUE" : "FALSE", typedValue: b, content: { type: "plain", text: b ? "TRUE" : "FALSE" }, style: {} };
371
+ }
372
+ // string (resolved by a following STRING record — none in scope), error, or
373
+ // empty: surface nothing rather than a wrong value.
374
+ return null;
375
+ }
376
+ return numberCell(column, readF64(body, 6), numFmt);
377
+ }
378
+
379
+ // ─── Encodings ──────────────────────────────────────────────────────────────
380
+
381
+ /** Decode a BIFF RK number: bit0 = ÷100, bit1 = integer (else top 30 bits of a double). */
382
+ function decodeRk(rk: number): number {
383
+ const div100 = (rk & 0x01) !== 0;
384
+ const isInt = (rk & 0x02) !== 0;
385
+ let n: number;
386
+ if (isInt) {
387
+ n = rk >> 2; // arithmetic shift keeps the sign
388
+ } else {
389
+ const buf = new ArrayBuffer(8);
390
+ new DataView(buf).setUint32(4, rk & 0xfffffffc, true);
391
+ n = new DataView(buf).getFloat64(0, true);
392
+ }
393
+ return div100 ? n / 100 : n;
394
+ }
395
+
396
+ function readU16(b: Uint8Array, o: number): number { return b[o] | (b[o + 1] << 8); }
397
+ function readU32(b: Uint8Array, o: number): number { return (b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24)) >>> 0; }
398
+ function readF64(b: Uint8Array, o: number): number { return new DataView(b.buffer, b.byteOffset + o, 8).getFloat64(0, true); }
399
+
400
+ /** A short (non-CONTINUE) BIFF8 unicode string: `lenBytes`-wide char count, a
401
+ * 1-byte grbit, then chars (8- or 16-bit). Used for sheet names and formats. */
402
+ function readUnicodeShort(b: Uint8Array, offset: number, lenBytes: 1 | 2): string {
403
+ const cch = lenBytes === 1 ? b[offset] : readU16(b, offset);
404
+ const grbit = b[offset + lenBytes];
405
+ const high = (grbit & 0x01) !== 0;
406
+ let p = offset + lenBytes + 1;
407
+ let s = "";
408
+ for (let i = 0; i < cch; i++) {
409
+ if (high) { s += String.fromCharCode(b[p] | (b[p + 1] << 8)); p += 2; }
410
+ else { s += String.fromCharCode(b[p]); p += 1; }
411
+ }
412
+ return s;
413
+ }
414
+
415
+ function utf16le(b: Uint8Array): string {
416
+ let s = "";
417
+ for (let i = 0; i + 1 < b.length; i += 2) s += String.fromCharCode(b[i] | (b[i + 1] << 8));
418
+ return s;
419
+ }
420
+
421
+ function concat(parts: Uint8Array[]): Uint8Array {
422
+ let total = 0;
423
+ for (const p of parts) total += p.length;
424
+ const out = new Uint8Array(total);
425
+ let o = 0;
426
+ for (const p of parts) { out.set(p, o); o += p.length; }
427
+ return out;
428
+ }
429
+
430
+ /** Built-in number-format strings we care about (dates → so serials render as
431
+ * dates downstream; currency/decimals are otherwise "General"). */
432
+ function builtinFormat(id: number): string {
433
+ const DATES: Record<number, string> = {
434
+ 14: "m/d/yyyy", 15: "d-mmm-yy", 16: "d-mmm", 17: "mmm-yy",
435
+ 18: "h:mm AM/PM", 19: "h:mm:ss AM/PM", 20: "h:mm", 21: "h:mm:ss",
436
+ 22: "m/d/yyyy h:mm", 45: "mm:ss", 46: "[h]:mm:ss", 47: "mm:ss.0",
437
+ };
438
+ return DATES[id] ?? "General";
439
+ }
440
+
441
+ // ─── Entry point ────────────────────────────────────────────────────────────
442
+
443
+ /** Parse a legacy .xls (BIFF8) buffer into a ParsedSpreadsheet. Throws
444
+ * PasswordProtectedError for encrypted workbooks. */
445
+ export function parseBiff8(bytes: Uint8Array, options?: ParseOptions): ParsedSpreadsheet {
446
+ const streams = readCfbStreams(bytes);
447
+ if (streams.some((s) => /^EncryptedPackage$/i.test(s.name))) throw new PasswordProtectedError();
448
+ const wb = streams.find((s) => /^workbook$/i.test(s.name)) ?? streams.find((s) => /^book$/i.test(s.name));
449
+ if (!wb) throw new Error("Not a valid .xls: no Workbook stream in the OLE2 container");
450
+
451
+ const g = parseGlobals(wb.bytes);
452
+ const maxRows = options?.maxRowsPerSheet ?? DEFAULT_MAX_ROWS;
453
+
454
+ const sheets: ParsedSheet[] = g.boundsheets.length > 0
455
+ ? g.boundsheets.map((bs) => parseSheet(wb.bytes, bs.pos, bs.name, g, maxRows))
456
+ : [emptySheet("Sheet1")];
457
+
458
+ return { sheets, activeSheetIndex: 0 };
459
+ }
@@ -16,6 +16,7 @@ import {
16
16
  extractWorkbookPivotCaches,
17
17
  } from "./ooxml_pivot";
18
18
  import { parseVmlDrawing } from "./ooxml_shape";
19
+ import { parseBiff8, isOle2 } from "./biff8_reader";
19
20
  import type { ParsedSpreadsheet, ParsedSheet, PrintTitles } from "./types";
20
21
  import type { ParsedChart } from "./ooxml_chart_types";
21
22
  import type { ParsedPivotCache, ParsedPivotTable } from "./ooxml_pivot_types";
@@ -88,6 +89,11 @@ export function parseExcelBuffer(
88
89
  arrayBuffer: ArrayBuffer,
89
90
  options?: ParseOptions,
90
91
  ): ParsedSpreadsheet {
92
+ // Legacy .xls (and encrypted OOXML) share the OLE2 container; route it to the
93
+ // BIFF8 reader, which reads legacy workbooks and throws PasswordProtectedError
94
+ // for genuinely encrypted files.
95
+ const bytes = new Uint8Array(arrayBuffer);
96
+ if (isOle2(bytes)) return parseBiff8(bytes, options);
91
97
  const zip = unzipXlsx(arrayBuffer);
92
98
  return parseExcelFromZip(zip, options);
93
99
  }