@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 +3 -2
- package/src/biff8_fixtures/synthetic.xls +0 -0
- package/src/biff8_fixtures/synthetic_dates.xls +0 -0
- package/src/biff8_reader.test.ts +123 -0
- package/src/biff8_reader.ts +345 -0
- package/src/excel_parser.test.ts +9 -4
- package/src/excel_parser.ts +7 -1
- package/src/index.ts +8 -1
- package/src/ooxml_zip.ts +1 -1
- package/src/types.ts +0 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/xlsx",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
29
|
+
"vitest": "^4.1.9"
|
|
29
30
|
}
|
|
30
31
|
}
|
|
Binary file
|
|
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
|
+
}
|
package/src/excel_parser.test.ts
CHANGED
|
@@ -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("
|
|
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 =
|
|
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).
|
|
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 () => {
|
package/src/excel_parser.ts
CHANGED
|
@@ -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 "
|
|
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
|
-
}
|