@lotics/xlsx 0.1.4 → 0.1.6

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.6",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -19,12 +19,13 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@formulajs/formulajs": "^4.5.6",
22
+ "@lotics/office-io": "^0.1.0",
22
23
  "fast-formula-parser": "^1.0.19",
23
24
  "fast-xml-parser": "^5.5.6",
24
25
  "fflate": "^0.8.2"
25
26
  },
26
27
  "devDependencies": {
27
28
  "exceljs": "^4.4.0",
28
- "vitest": "^4.1.7"
29
+ "vitest": "^4.1.9"
29
30
  }
30
31
  }
Binary file
@@ -0,0 +1,123 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { parseBiff8 } from "./biff8_reader";
5
+ import { isOle2, PasswordProtectedError } from "@lotics/office-io";
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
+ it("terminates on a crafted cyclic DIFAT chain instead of hanging (DoS guard)", () => {
65
+ // A DIFAT sector whose next-pointer references itself. Without a file-size
66
+ // bound + cycle detection on the DIFAT/FAT assembly, the walk runs its full
67
+ // guard (~1M iterations) and the FAT loop then balloons to billions of
68
+ // entries — a small crafted .xls hangs the event loop + exhausts memory.
69
+ // The reader must bound the walk to the sectors the file actually holds and
70
+ // fail fast. 2 KB with a valid 512-byte sector size so it passes the
71
+ // sector-size check and reaches the DIFAT walk (a shorter buffer would be
72
+ // rejected earlier).
73
+ const buf = new Uint8Array(2048);
74
+ buf.set([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
75
+ const dv = new DataView(buf.buffer);
76
+ dv.setUint16(30, 9, true); // sector shift → 512-byte sectors
77
+ dv.setUint16(32, 6, true); // mini sector shift → 64-byte mini sectors
78
+ dv.setUint32(48, 0xfffffffe, true); // firstDirSector = ENDOFCHAIN (empty dir)
79
+ dv.setUint32(60, 0xfffffffe, true); // firstMiniFatSector = ENDOFCHAIN
80
+ dv.setUint32(68, 0, true); // firstDifatSector = sector 0 …
81
+ dv.setUint32(1020, 0, true); // …whose last DIFAT entry points back to sector 0
82
+ // No workbook stream results, so a bounded parse throws quickly; an unbounded
83
+ // one would never reach this throw (it hangs/OOMs first).
84
+ expect(() => parseBiff8(buf)).toThrow();
85
+ });
86
+ });
87
+
88
+ // A date cell is a NUMBER record (Excel serial) whose XF points at a date
89
+ // number-format. The reader must resolve XF → FORMAT so downstream renders a
90
+ // date, not the raw serial — the common case the text-dated reconciliation file
91
+ // never exercised. Fixture: a date, an integer, a decimal, a Unicode string.
92
+ const dateFixture = new Uint8Array(readFileSync(fileURLToPath(new URL("./biff8_fixtures/synthetic_dates.xls", import.meta.url))));
93
+
94
+ describe("parseBiff8 number formats", () => {
95
+ const cell = (row: number, col: number) => {
96
+ const s = parseBiff8(dateFixture).sheets[0];
97
+ return s.rows.find((r) => r.index === row)?.cells.find((c) => c.column === col);
98
+ };
99
+
100
+ it("reads a date cell as an Excel serial carrying its date number-format", () => {
101
+ const c = cell(1, 0);
102
+ expect(c?.typedValue).toBe(45672); // 2025-01-15
103
+ expect(c?.numFmtCode).toBe("dd/mm/yyyy");
104
+ });
105
+
106
+ it("leaves a plain number with no number-format (General)", () => {
107
+ expect(cell(1, 1)?.typedValue).toBe(42);
108
+ expect(cell(1, 1)?.numFmtCode).toBeUndefined();
109
+ expect(cell(1, 2)?.typedValue).toBe(1234.5);
110
+ });
111
+
112
+ it("decodes a Unicode string cell", () => {
113
+ expect(cell(1, 3)?.typedValue).toBe("Ábc");
114
+ });
115
+ });
116
+
117
+ // PasswordProtectedError is exported and reachable — encrypted .xls (FILEPASS)
118
+ // and encrypted OOXML (EncryptedPackage stream) both route to it.
119
+ describe("parseBiff8 encryption", () => {
120
+ it("surfaces PasswordProtectedError as the shared error type", () => {
121
+ expect(new PasswordProtectedError()).toBeInstanceOf(Error);
122
+ });
123
+ });
@@ -0,0 +1,345 @@
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 { readCfbStreams, PasswordProtectedError } from "@lotics/office-io";
19
+ import type { ParsedSpreadsheet, ParsedSheet, ParsedRow, ParsedCell } from "./types";
20
+
21
+ const DEFAULT_MAX_ROWS = 10_000;
22
+
23
+ // ─── BIFF record stream ─────────────────────────────────────────────────────
24
+
25
+ const REC = {
26
+ FORMULA: 0x0006, EOF: 0x000a, CONTINUE: 0x003c, DATEMODE: 0x0022, FILEPASS: 0x002f,
27
+ BLANK: 0x0201, NUMBER: 0x0203, LABEL: 0x0204, STRING: 0x0207, BOOLERR: 0x0205,
28
+ BOUNDSHEET: 0x0085, FORMAT: 0x041e, XF: 0x00e0, RK: 0x027e, MULRK: 0x00bd,
29
+ MULBLANK: 0x00be, LABELSST: 0x00fd, SST: 0x00fc, BOF: 0x0809,
30
+ } as const;
31
+
32
+ interface Record8 { type: number; body: Uint8Array; offset: number }
33
+
34
+ function* iterRecords(stream: Uint8Array, from: number, to: number): Generator<Record8> {
35
+ const dv = new DataView(stream.buffer, stream.byteOffset, stream.byteLength);
36
+ let p = from;
37
+ while (p + 4 <= to) {
38
+ const type = dv.getUint16(p, true);
39
+ const len = dv.getUint16(p + 2, true);
40
+ const bodyStart = p + 4;
41
+ if (bodyStart + len > stream.length) break;
42
+ yield { type, body: stream.subarray(bodyStart, bodyStart + len), offset: p };
43
+ p = bodyStart + len;
44
+ }
45
+ }
46
+
47
+ interface Globals {
48
+ sst: string[];
49
+ date1904: boolean;
50
+ /** XF index → resolved number-format string. */
51
+ xfFormats: string[];
52
+ boundsheets: { name: string; pos: number }[];
53
+ }
54
+
55
+ function parseGlobals(stream: Uint8Array): Globals {
56
+ const sstChunks: Uint8Array[] = [];
57
+ const formats = new Map<number, string>();
58
+ const xfFormatIds: number[] = [];
59
+ const boundsheets: { name: string; pos: number }[] = [];
60
+ let date1904 = false;
61
+ let sawSst = false;
62
+ let sstUnique = 0;
63
+
64
+ // Globals substream = first BOF … first EOF.
65
+ let end = stream.length;
66
+ let started = false;
67
+ const dv = new DataView(stream.buffer, stream.byteOffset, stream.byteLength);
68
+ for (const r of iterRecords(stream, 0, stream.length)) {
69
+ if (r.type === REC.BOF) { if (started) { end = r.offset; break; } started = true; continue; }
70
+ if (r.type === REC.EOF) { end = r.offset; break; }
71
+ }
72
+
73
+ for (const r of iterRecords(stream, 0, end)) {
74
+ switch (r.type) {
75
+ case REC.FILEPASS:
76
+ throw new PasswordProtectedError();
77
+ case REC.DATEMODE:
78
+ date1904 = readU16(r.body, 0) === 1;
79
+ break;
80
+ case REC.SST:
81
+ sawSst = true;
82
+ sstUnique = readU32(r.body, 4); // cstTotal (0), cstUnique (4)
83
+ sstChunks.push(r.body.subarray(8)); // strings follow the 8-byte header
84
+ break;
85
+ case REC.CONTINUE:
86
+ if (sawSst) sstChunks.push(r.body);
87
+ break;
88
+ case REC.FORMAT: {
89
+ const id = readU16(r.body, 0);
90
+ formats.set(id, readUnicodeShort(r.body, 2, 2));
91
+ break;
92
+ }
93
+ case REC.XF: {
94
+ // ifmt is the number-format index at byte offset 2.
95
+ xfFormatIds.push(readU16(r.body, 2));
96
+ break;
97
+ }
98
+ case REC.BOUNDSHEET: {
99
+ const pos = dv.getUint32(r.offset + 4, true);
100
+ boundsheets.push({ name: readUnicodeShort(r.body, 6, 1), pos });
101
+ break;
102
+ }
103
+ default:
104
+ break;
105
+ }
106
+ }
107
+
108
+ const sst = parseSst(sstChunks, sstUnique);
109
+ const xfFormats = xfFormatIds.map((id) => formats.get(id) ?? builtinFormat(id));
110
+ return { sst, date1904, xfFormats, boundsheets };
111
+ }
112
+
113
+ // ─── Shared string table (handles CONTINUE splits + per-continue grbit) ──────
114
+
115
+ function parseSst(chunks: Uint8Array[], count: number): string[] {
116
+ const out: string[] = [];
117
+ if (chunks.length === 0) return out;
118
+ let ci = 0;
119
+ let off = 0;
120
+ const atEnd = () => ci >= chunks.length;
121
+ const cur = () => chunks[ci];
122
+ const advanceChunk = () => { ci++; off = 0; };
123
+ const rawU8 = (): number => {
124
+ while (!atEnd() && off >= cur().length) advanceChunk();
125
+ if (atEnd()) return 0;
126
+ return cur()[off++];
127
+ };
128
+ const rawU16 = () => rawU8() | (rawU8() << 8);
129
+ const rawU32 = () => rawU8() | (rawU8() << 8) | (rawU8() << 16) | (rawU8() << 24);
130
+ const skip = (n: number) => { for (let i = 0; i < n; i++) rawU8(); };
131
+
132
+ for (let s = 0; s < count && !atEnd(); s++) {
133
+ const cch = rawU16();
134
+ const grbit = rawU8();
135
+ let high = (grbit & 0x01) !== 0;
136
+ const rich = (grbit & 0x08) !== 0 ? rawU16() : 0;
137
+ const ext = (grbit & 0x04) !== 0 ? rawU32() : 0;
138
+
139
+ let str = "";
140
+ let read = 0;
141
+ while (read < cch) {
142
+ // A string's characters can spill into a CONTINUE record; the first byte
143
+ // of that record is a fresh grbit whose bit0 re-declares char width.
144
+ if (off >= cur().length) {
145
+ advanceChunk();
146
+ if (atEnd()) break;
147
+ high = (cur()[off++] & 0x01) !== 0;
148
+ }
149
+ if (high) {
150
+ const code = cur()[off] | (cur()[off + 1] << 8);
151
+ off += 2;
152
+ str += String.fromCharCode(code);
153
+ } else {
154
+ str += String.fromCharCode(cur()[off++]);
155
+ }
156
+ read++;
157
+ }
158
+ // Rich-text runs (4 bytes each) and phonetic ext data follow the chars and
159
+ // are continued without a fresh grbit — a plain byte skip across chunks.
160
+ skip(rich * 4 + ext);
161
+ out.push(str);
162
+ }
163
+ return out;
164
+ }
165
+
166
+ // ─── Sheet cells ────────────────────────────────────────────────────────────
167
+
168
+ function parseSheet(stream: Uint8Array, start: number, name: string, g: Globals, maxRows: number): ParsedSheet {
169
+ const sheet = emptySheet(name);
170
+ // Row index → (col index → cell). Sparse; assembled into ParsedRow[] at the end.
171
+ const grid = new Map<number, Map<number, ParsedCell>>();
172
+ let maxRow = -1;
173
+ let truncated = false;
174
+
175
+ const put = (row: number, col: number, cell: ParsedCell) => {
176
+ if (row >= maxRows) { truncated = true; return; }
177
+ let r = grid.get(row);
178
+ if (!r) { r = new Map(); grid.set(row, r); }
179
+ r.set(col, cell);
180
+ if (row > maxRow) maxRow = row;
181
+ };
182
+
183
+ // The sheet substream runs from its BOF to the next EOF.
184
+ let end = stream.length;
185
+ let seenBof = false;
186
+ for (const r of iterRecords(stream, start, stream.length)) {
187
+ if (r.type === REC.BOF) { if (seenBof) { end = r.offset; break; } seenBof = true; continue; }
188
+ if (r.type === REC.EOF) { end = r.offset; break; }
189
+ }
190
+
191
+ for (const r of iterRecords(stream, start, end)) {
192
+ switch (r.type) {
193
+ case REC.LABELSST: {
194
+ const row = readU16(r.body, 0), col = readU16(r.body, 2), isst = readU32(r.body, 6);
195
+ put(row, col, textCell(col, g.sst[isst] ?? ""));
196
+ break;
197
+ }
198
+ case REC.LABEL: {
199
+ const row = readU16(r.body, 0), col = readU16(r.body, 2);
200
+ put(row, col, textCell(col, readUnicodeShort(r.body, 6, 2)));
201
+ break;
202
+ }
203
+ case REC.NUMBER: {
204
+ const row = readU16(r.body, 0), col = readU16(r.body, 2), xf = readU16(r.body, 4);
205
+ put(row, col, numberCell(col, readF64(r.body, 6), g.xfFormats[xf]));
206
+ break;
207
+ }
208
+ case REC.RK: {
209
+ const row = readU16(r.body, 0), col = readU16(r.body, 2), xf = readU16(r.body, 4);
210
+ put(row, col, numberCell(col, decodeRk(readU32(r.body, 6)), g.xfFormats[xf]));
211
+ break;
212
+ }
213
+ case REC.MULRK: {
214
+ const row = readU16(r.body, 0), first = readU16(r.body, 2);
215
+ const n = (r.body.length - 6) / 6;
216
+ for (let i = 0; i < n; i++) {
217
+ const xf = readU16(r.body, 4 + i * 6);
218
+ const rk = readU32(r.body, 6 + i * 6);
219
+ put(row, first + i, numberCell(first + i, decodeRk(rk), g.xfFormats[xf]));
220
+ }
221
+ break;
222
+ }
223
+ case REC.FORMULA: {
224
+ const row = readU16(r.body, 0), col = readU16(r.body, 2), xf = readU16(r.body, 4);
225
+ const cell = formulaCell(col, r.body, g.xfFormats[xf]);
226
+ if (cell) put(row, col, cell);
227
+ break;
228
+ }
229
+ // BLANK / MULBLANK / BOOLERR carry no data value we surface — skipped.
230
+ default:
231
+ break;
232
+ }
233
+ }
234
+
235
+ const rows: ParsedRow[] = [];
236
+ for (let ri = 0; ri <= maxRow; ri++) {
237
+ const r = grid.get(ri);
238
+ if (!r) continue;
239
+ const cells = [...r.values()].sort((a, b) => a.column - b.column);
240
+ rows.push({ index: ri, height: sheet.defaultRowHeight, cells, hidden: false });
241
+ }
242
+ sheet.rows = rows;
243
+ sheet.totalRowCount = maxRow + 1;
244
+ sheet.truncated = truncated;
245
+ return sheet;
246
+ }
247
+
248
+ // ─── Cell builders ──────────────────────────────────────────────────────────
249
+
250
+ function textCell(column: number, text: string): ParsedCell {
251
+ return { column, value: text, typedValue: text, content: { type: "plain", text }, style: {} };
252
+ }
253
+
254
+ function numberCell(column: number, num: number, numFmt: string | undefined): ParsedCell {
255
+ const value = Number.isFinite(num) ? String(num) : "";
256
+ return {
257
+ column, value, rawValue: num, typedValue: num,
258
+ numFmtCode: numFmt && numFmt !== "General" ? numFmt : undefined,
259
+ content: { type: "plain", text: value }, style: {},
260
+ };
261
+ }
262
+
263
+ function formulaCell(column: number, body: Uint8Array, numFmt: string | undefined): ParsedCell | null {
264
+ // Cached result: 8 bytes at offset 6. A trailing 0xFFFF marks a non-numeric
265
+ // result whose type is byte[6] (0=string in a following STRING rec, 1=bool,
266
+ // 2=error, 3=empty). Numeric results are an IEEE double.
267
+ if (readU16(body, 12) === 0xffff) {
268
+ const kind = body[6];
269
+ if (kind === 1) {
270
+ const b = body[8] !== 0;
271
+ return { column, value: b ? "TRUE" : "FALSE", typedValue: b, content: { type: "plain", text: b ? "TRUE" : "FALSE" }, style: {} };
272
+ }
273
+ // string (resolved by a following STRING record — none in scope), error, or
274
+ // empty: surface nothing rather than a wrong value.
275
+ return null;
276
+ }
277
+ return numberCell(column, readF64(body, 6), numFmt);
278
+ }
279
+
280
+ // ─── Encodings ──────────────────────────────────────────────────────────────
281
+
282
+ /** Decode a BIFF RK number: bit0 = ÷100, bit1 = integer (else top 30 bits of a double). */
283
+ function decodeRk(rk: number): number {
284
+ const div100 = (rk & 0x01) !== 0;
285
+ const isInt = (rk & 0x02) !== 0;
286
+ let n: number;
287
+ if (isInt) {
288
+ n = rk >> 2; // arithmetic shift keeps the sign
289
+ } else {
290
+ const buf = new ArrayBuffer(8);
291
+ new DataView(buf).setUint32(4, rk & 0xfffffffc, true);
292
+ n = new DataView(buf).getFloat64(0, true);
293
+ }
294
+ return div100 ? n / 100 : n;
295
+ }
296
+
297
+ function readU16(b: Uint8Array, o: number): number { return b[o] | (b[o + 1] << 8); }
298
+ function readU32(b: Uint8Array, o: number): number { return (b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24)) >>> 0; }
299
+ function readF64(b: Uint8Array, o: number): number { return new DataView(b.buffer, b.byteOffset + o, 8).getFloat64(0, true); }
300
+
301
+ /** A short (non-CONTINUE) BIFF8 unicode string: `lenBytes`-wide char count, a
302
+ * 1-byte grbit, then chars (8- or 16-bit). Used for sheet names and formats. */
303
+ function readUnicodeShort(b: Uint8Array, offset: number, lenBytes: 1 | 2): string {
304
+ const cch = lenBytes === 1 ? b[offset] : readU16(b, offset);
305
+ const grbit = b[offset + lenBytes];
306
+ const high = (grbit & 0x01) !== 0;
307
+ let p = offset + lenBytes + 1;
308
+ let s = "";
309
+ for (let i = 0; i < cch; i++) {
310
+ if (high) { s += String.fromCharCode(b[p] | (b[p + 1] << 8)); p += 2; }
311
+ else { s += String.fromCharCode(b[p]); p += 1; }
312
+ }
313
+ return s;
314
+ }
315
+
316
+ /** Built-in number-format strings we care about (dates → so serials render as
317
+ * dates downstream; currency/decimals are otherwise "General"). */
318
+ function builtinFormat(id: number): string {
319
+ const DATES: Record<number, string> = {
320
+ 14: "m/d/yyyy", 15: "d-mmm-yy", 16: "d-mmm", 17: "mmm-yy",
321
+ 18: "h:mm AM/PM", 19: "h:mm:ss AM/PM", 20: "h:mm", 21: "h:mm:ss",
322
+ 22: "m/d/yyyy h:mm", 45: "mm:ss", 46: "[h]:mm:ss", 47: "mm:ss.0",
323
+ };
324
+ return DATES[id] ?? "General";
325
+ }
326
+
327
+ // ─── Entry point ────────────────────────────────────────────────────────────
328
+
329
+ /** Parse a legacy .xls (BIFF8) buffer into a ParsedSpreadsheet. Throws
330
+ * PasswordProtectedError for encrypted workbooks. */
331
+ export function parseBiff8(bytes: Uint8Array, options?: ParseOptions): ParsedSpreadsheet {
332
+ const streams = readCfbStreams(bytes);
333
+ if (streams.some((s) => /^EncryptedPackage$/i.test(s.name))) throw new PasswordProtectedError();
334
+ const wb = streams.find((s) => /^workbook$/i.test(s.name)) ?? streams.find((s) => /^book$/i.test(s.name));
335
+ if (!wb) throw new Error("Not a valid .xls: no Workbook stream in the OLE2 container");
336
+
337
+ const g = parseGlobals(wb.bytes);
338
+ const maxRows = options?.maxRowsPerSheet ?? DEFAULT_MAX_ROWS;
339
+
340
+ const sheets: ParsedSheet[] = g.boundsheets.length > 0
341
+ ? g.boundsheets.map((bs) => parseSheet(wb.bytes, bs.pos, bs.name, g, maxRows))
342
+ : [emptySheet("Sheet1")];
343
+
344
+ return { sheets, activeSheetIndex: 0 };
345
+ }
@@ -655,7 +655,7 @@ EST</v></c></row>
655
655
  expect(result.sheets[0].rows[0].cells[0].style.fontStrike).toBe(true);
656
656
  });
657
657
 
658
- test("skips hidden rows", async () => {
658
+ test("keeps hidden rows, flagged hidden", async () => {
659
659
  const workbook = new ExcelJS.Workbook();
660
660
  const ws = workbook.addWorksheet("HiddenRows");
661
661
 
@@ -669,11 +669,16 @@ EST</v></c></row>
669
669
 
670
670
  const result = await parseExcelFile("https://example.com/test.xlsx");
671
671
 
672
-
673
- const rowIndices = result.sheets[0].rows.map((r) => r.index);
672
+ const rows = result.sheets[0].rows;
673
+ const rowIndices = rows.map((r) => r.index);
674
+ // Hidden rows are real data — kept (so they round-trip and cross-sheet refs
675
+ // resolve) but flagged hidden so renderers can skip them.
674
676
  expect(rowIndices).toContain(1);
675
- expect(rowIndices).not.toContain(2);
677
+ expect(rowIndices).toContain(2);
676
678
  expect(rowIndices).toContain(3);
679
+ expect(rows.find((r) => r.index === 1)?.hidden).toBe(false);
680
+ expect(rows.find((r) => r.index === 2)?.hidden).toBe(true);
681
+ expect(rows.find((r) => r.index === 3)?.hidden).toBe(false);
677
682
  });
678
683
 
679
684
  test("marks hidden columns", async () => {
@@ -16,6 +16,8 @@ import {
16
16
  extractWorkbookPivotCaches,
17
17
  } from "./ooxml_pivot";
18
18
  import { parseVmlDrawing } from "./ooxml_shape";
19
+ import { parseBiff8 } from "./biff8_reader";
20
+ import { isOle2 } from "@lotics/office-io";
19
21
  import type { ParsedSpreadsheet, ParsedSheet, PrintTitles } from "./types";
20
22
  import type { ParsedChart } from "./ooxml_chart_types";
21
23
  import type { ParsedPivotCache, ParsedPivotTable } from "./ooxml_pivot_types";
@@ -37,7 +39,6 @@ export type {
37
39
  BorderStyle,
38
40
  MergedCellRange,
39
41
  } from "./types";
40
- export { PasswordProtectedError } from "./types";
41
42
 
42
43
  export type {
43
44
  ParsedConditionalFormat,
@@ -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
  }
package/src/index.ts CHANGED
@@ -35,7 +35,6 @@ export type {
35
35
  HeaderFooter,
36
36
  } from "./types";
37
37
 
38
- export { PasswordProtectedError } from "./types";
39
38
 
40
39
  // -----------------------------------------------------------------------------
41
40
  // Workbook model & cell storage
@@ -100,6 +99,14 @@ export {
100
99
 
101
100
  export type { ParseOptions } from "./excel_parser";
102
101
 
102
+ // -----------------------------------------------------------------------------
103
+ // Errors
104
+ // -----------------------------------------------------------------------------
105
+
106
+ // Single definition lives in @lotics/office-io (the .doc reader throws the same
107
+ // class); the barrel keeps it in the public API for external catch sites.
108
+ export { PasswordProtectedError } from "@lotics/office-io";
109
+
103
110
  export { exportWorkbook } from "./xlsx_writer";
104
111
 
105
112
  // -----------------------------------------------------------------------------
package/src/ooxml_zip.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { unzipSync } from "fflate";
2
- import { PasswordProtectedError } from "./types";
2
+ import { PasswordProtectedError } from "@lotics/office-io";
3
3
 
4
4
  // OLE2 Compound Document magic bytes — used by legacy .xls and encrypted .xlsx
5
5
  const OLE2_MAGIC = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
package/src/types.ts CHANGED
@@ -227,13 +227,3 @@ export interface MergedCellRange {
227
227
  endCol: number;
228
228
  }
229
229
 
230
- // =============================================================================
231
- // Errors
232
- // =============================================================================
233
-
234
- export class PasswordProtectedError extends Error {
235
- constructor() {
236
- super("This file is password-protected and cannot be previewed");
237
- this.name = "PasswordProtectedError";
238
- }
239
- }