@lotics/xlsx 0.1.5 → 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_reader.test.ts +25 -2
- package/src/biff8_reader.ts +1 -115
- package/src/excel_parser.test.ts +9 -4
- package/src/excel_parser.ts +2 -2
- 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
|
}
|
package/src/biff8_reader.test.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { parseBiff8
|
|
5
|
-
import { PasswordProtectedError } from "
|
|
4
|
+
import { parseBiff8 } from "./biff8_reader";
|
|
5
|
+
import { isOle2, PasswordProtectedError } from "@lotics/office-io";
|
|
6
6
|
|
|
7
7
|
// A synthetic BIFF8 (.xls) workbook: 250 data rows of Unicode strings + integer,
|
|
8
8
|
// float and negative numbers. The Unicode strings push the shared-string table
|
|
@@ -60,6 +60,29 @@ describe("parseBiff8", () => {
|
|
|
60
60
|
bogus.set([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
|
|
61
61
|
expect(() => parseBiff8(bogus)).toThrow();
|
|
62
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
|
+
});
|
|
63
86
|
});
|
|
64
87
|
|
|
65
88
|
// A date cell is a NUMBER record (Excel serial) whose XF points at a date
|
package/src/biff8_reader.ts
CHANGED
|
@@ -15,110 +15,11 @@
|
|
|
15
15
|
|
|
16
16
|
import { emptySheet } from "./ooxml_sheet";
|
|
17
17
|
import type { ParseOptions } from "./excel_parser";
|
|
18
|
-
import { PasswordProtectedError } from "
|
|
18
|
+
import { readCfbStreams, PasswordProtectedError } from "@lotics/office-io";
|
|
19
19
|
import type { ParsedSpreadsheet, ParsedSheet, ParsedRow, ParsedCell } from "./types";
|
|
20
20
|
|
|
21
|
-
const OLE2_MAGIC = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
|
|
22
|
-
const ENDOFCHAIN = 0xfffffffe;
|
|
23
|
-
const FREESECT = 0xffffffff;
|
|
24
21
|
const DEFAULT_MAX_ROWS = 10_000;
|
|
25
22
|
|
|
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
23
|
// ─── BIFF record stream ─────────────────────────────────────────────────────
|
|
123
24
|
|
|
124
25
|
const REC = {
|
|
@@ -412,21 +313,6 @@ function readUnicodeShort(b: Uint8Array, offset: number, lenBytes: 1 | 2): strin
|
|
|
412
313
|
return s;
|
|
413
314
|
}
|
|
414
315
|
|
|
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
316
|
/** Built-in number-format strings we care about (dates → so serials render as
|
|
431
317
|
* dates downstream; currency/decimals are otherwise "General"). */
|
|
432
318
|
function builtinFormat(id: number): string {
|
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,7 +16,8 @@ import {
|
|
|
16
16
|
extractWorkbookPivotCaches,
|
|
17
17
|
} from "./ooxml_pivot";
|
|
18
18
|
import { parseVmlDrawing } from "./ooxml_shape";
|
|
19
|
-
import { parseBiff8
|
|
19
|
+
import { parseBiff8 } from "./biff8_reader";
|
|
20
|
+
import { isOle2 } from "@lotics/office-io";
|
|
20
21
|
import type { ParsedSpreadsheet, ParsedSheet, PrintTitles } from "./types";
|
|
21
22
|
import type { ParsedChart } from "./ooxml_chart_types";
|
|
22
23
|
import type { ParsedPivotCache, ParsedPivotTable } from "./ooxml_pivot_types";
|
|
@@ -38,7 +39,6 @@ export type {
|
|
|
38
39
|
BorderStyle,
|
|
39
40
|
MergedCellRange,
|
|
40
41
|
} from "./types";
|
|
41
|
-
export { PasswordProtectedError } from "./types";
|
|
42
42
|
|
|
43
43
|
export type {
|
|
44
44
|
ParsedConditionalFormat,
|
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
|
-
}
|