@lotics/xlsx 0.1.5 → 0.1.7
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/AGENTS.md +382 -0
- package/package.json +5 -3
- package/src/biff8_reader.test.ts +54 -18
- package/src/biff8_reader.ts +10 -119
- 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/AGENTS.md
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
# @lotics/xlsx — the spreadsheet (.xlsx) read / build / write reference
|
|
2
|
+
|
|
3
|
+
A self-contained spreadsheet engine: it parses `.xlsx` (and legacy `.xls`) into an immutable
|
|
4
|
+
model, builds workbooks from scratch or from rows, evaluates formulas, authors native pivot
|
|
5
|
+
tables, and writes real `.xlsx` bytes — with **no third-party spreadsheet library** in the
|
|
6
|
+
export path. This file is the "reach for what" guide; the **exact API** of any function is its
|
|
7
|
+
source, which ships with the package: read `node_modules/@lotics/xlsx/src/<module>.ts`.
|
|
8
|
+
|
|
9
|
+
- **The barrel is the stable surface.** `import { parseExcelBuffer, exportWorkbook } from "@lotics/xlsx"`.
|
|
10
|
+
Everything documented as "barrel" below comes from there.
|
|
11
|
+
- **Subpath imports** (`import { parseBiff8 } from "@lotics/xlsx/biff8_reader"`) reach internals
|
|
12
|
+
that are NOT in the stability contract — the formula engine, the legacy reader, the template
|
|
13
|
+
helper. They work and are documented here where they're the right tool, but they can change
|
|
14
|
+
shape between versions; prefer the barrel when it covers the task.
|
|
15
|
+
- **Never hand-roll xlsx XML or reach for `exceljs`/`xlsx`/SheetJS.** They are not dependencies.
|
|
16
|
+
If a primitive is missing, add it to this package — every consumer inherits it.
|
|
17
|
+
|
|
18
|
+
## The two type families — read this first
|
|
19
|
+
|
|
20
|
+
The whole package is organized around two shapes. Pick the right one and everything else follows.
|
|
21
|
+
|
|
22
|
+
- **`ParsedSpreadsheet` — the immutable snapshot.** What `parseExcelBuffer` returns. A plain
|
|
23
|
+
read-only tree: `sheets[]` → `rows[]` → `cells[]`, plus merges, images, charts, tables, pivots,
|
|
24
|
+
conditional formats. **If you only READ a file, stay here** — never build a model you won't mutate.
|
|
25
|
+
- **`WorkbookModel` — the mutable model.** Sparse cell storage, a deduplicated `StyleRegistry`,
|
|
26
|
+
undo/redo commands. **Build this when you WRITE** — every builder (`buildDataWorkbook`,
|
|
27
|
+
`buildPivotTable`, the raw `SheetModel`) produces one, and `exportWorkbook` consumes one.
|
|
28
|
+
To edit an existing file, parse to a snapshot then `loadWorkbookFromSnapshot(parsed)`.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
bytes ─parseExcelBuffer→ ParsedSpreadsheet ─loadWorkbookFromSnapshot→ WorkbookModel ─exportWorkbook→ bytes
|
|
32
|
+
(read here) (mutate here)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`exportWorkbook` takes a `WorkbookModel`, never a snapshot. A snapshot is a dead end for writing.
|
|
36
|
+
|
|
37
|
+
## Reach by task
|
|
38
|
+
|
|
39
|
+
### Read an `.xlsx` (or `.xls`) — `excel_parser.ts` (barrel)
|
|
40
|
+
|
|
41
|
+
`parseExcelBuffer(arrayBuffer, options?) → ParsedSpreadsheet`. **Synchronous.** It takes an
|
|
42
|
+
**`ArrayBuffer`**, not a Node `Buffer` — see the slicing gotcha below. It auto-detects an OLE2
|
|
43
|
+
container (legacy `.xls` or an encrypted `.xlsx`) and routes it to the BIFF8 reader; genuinely
|
|
44
|
+
encrypted files throw `PasswordProtectedError` (re-exported from the barrel for your catch site).
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { readFileSync } from "node:fs";
|
|
48
|
+
import { parseExcelBuffer } from "@lotics/xlsx";
|
|
49
|
+
|
|
50
|
+
const buf = readFileSync("report.xlsx");
|
|
51
|
+
// Buffer is a view over a possibly-pooled ArrayBuffer — slice to its own bytes:
|
|
52
|
+
const wb = parseExcelBuffer(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
|
|
53
|
+
|
|
54
|
+
for (const sheet of wb.sheets) {
|
|
55
|
+
for (const row of sheet.rows) { // sparse: only populated rows, in order
|
|
56
|
+
for (const cell of row.cells) { // sparse: only populated cols, in order
|
|
57
|
+
cell.column; // 1-based column index
|
|
58
|
+
cell.value; // the DISPLAY string (number-formatted, e.g. "1.234,00")
|
|
59
|
+
cell.rawValue; // the raw number, when the cell is numeric (no reparse of `value`)
|
|
60
|
+
cell.typedValue; // lossless typed value string|number|boolean|null (round-trip source)
|
|
61
|
+
cell.formula; // formula text WITHOUT a leading "=", e.g. "SUM(A1:A5)"
|
|
62
|
+
cell.numFmtCode; // e.g. "#,##0.00", "dd/mm/yyyy"
|
|
63
|
+
cell.error; // "#DIV/0!" etc. for error cells
|
|
64
|
+
cell.content; // { type: "plain"|"rich_text"|"hyperlink", ... }
|
|
65
|
+
cell.style; // CellStyle (fonts, fill, borders, alignment)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- **`value` is the formatted display string; `rawValue`/`typedValue` are the machine values.**
|
|
72
|
+
Do arithmetic on `rawValue`/`typedValue`, never `parseFloat(cell.value)` — the display string
|
|
73
|
+
is locale/format-mangled.
|
|
74
|
+
- **Rows and cells are sparse and index-bearing.** `row.index` (1-based) and `cell.column`
|
|
75
|
+
(1-based) are the truth; do not assume `rows[0]` is row 1 or `cells[0]` is column A.
|
|
76
|
+
- **Merges, hidden state, extras:** `sheet.mergedCells` (`{startRow,startCol,endRow,endCol}`,
|
|
77
|
+
1-based), `sheet.hidden` (hidden/veryHidden sheets are parsed, only tagged — data consumers can
|
|
78
|
+
still read them), `sheet.freezePane`, `sheet.charts`, `sheet.tables`, `sheet.pivotTables`,
|
|
79
|
+
`sheet.conditionalFormats`, `sheet.images`, `sheet.dataValidations`. Workbook-scope:
|
|
80
|
+
`wb.namedRanges`, `wb.pivotCaches`, `wb.fullCalcOnLoad`.
|
|
81
|
+
- **Row cap.** `options.maxRowsPerSheet` defaults to **10 000** (a renderer guard). For full data
|
|
82
|
+
extraction pass `{ maxRowsPerSheet: Infinity }` and check `sheet.truncated` to see if the cap bit.
|
|
83
|
+
- `parseExcelFromZip(zip, options?)` (barrel) parses from an already-unzipped
|
|
84
|
+
`Record<string, Uint8Array>` (from `unzipXlsx`, also barrel). Use it when you need BOTH the
|
|
85
|
+
snapshot AND the raw ZIP entries — pass that same `zip` back to `exportWorkbook` as the
|
|
86
|
+
`originalZip` to preserve unmodified parts (see Export).
|
|
87
|
+
|
|
88
|
+
### Legacy `.xls` (BIFF8) — `biff8_reader.ts` (subpath)
|
|
89
|
+
|
|
90
|
+
The modern parser reads only zip-based OOXML `.xlsx`. A pre-2007 `.xls` is an OLE2 Compound File
|
|
91
|
+
of BIFF records — a completely different container. **`parseExcelBuffer` handles this for you:** it
|
|
92
|
+
detects the OLE2 magic and routes to `parseBiff8`, returning the same `ParsedSpreadsheet` shape, so
|
|
93
|
+
most callers never touch this module. Reach for it directly only when you already hold the bytes as
|
|
94
|
+
a `Uint8Array` and know it's a legacy workbook:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { parseBiff8 } from "@lotics/xlsx/biff8_reader";
|
|
98
|
+
const parsed = parseBiff8(new Uint8Array(bytes), { maxRowsPerSheet: Infinity });
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`parseBiff8(bytes: Uint8Array, options?: ParseOptions) → ParsedSpreadsheet`. **Scope is the data
|
|
102
|
+
grid**, so consumers get uniform cell values with no per-caller `.xls` handling:
|
|
103
|
+
|
|
104
|
+
- Reads: shared strings (`SST`, including `CONTINUE`-split records), string cells (`LABELSST`,
|
|
105
|
+
`LABEL`), numbers (`NUMBER`, `RK`, `MULRK`), boolean/numeric cached formula results, multi-sheet
|
|
106
|
+
workbooks (`BOUNDSHEET`), the 1900/1904 date mode (`DATEMODE`), and number-format codes
|
|
107
|
+
(`FORMAT`/`XF`) — enough to detect dates and currency so serials render correctly downstream.
|
|
108
|
+
- **Does NOT surface:** cell styling (returns `style: {}`), merged cells, charts, images, column
|
|
109
|
+
widths, row heights, or hidden state. Blank/`MULBLANK`/`BOOLERR` cells are skipped. A formula
|
|
110
|
+
whose cached result is a **string** or **error** yields nothing (the resolving `STRING` record is
|
|
111
|
+
out of scope) rather than a wrong value.
|
|
112
|
+
- Encrypted `.xls` (a `FILEPASS` record or an OLE2 `EncryptedPackage` stream) throws
|
|
113
|
+
`PasswordProtectedError`. A non-BIFF8 OLE2 container throws a plain descriptive error.
|
|
114
|
+
- No third-party dependency and no regex/eval over file bytes — this path replaced SheetJS, which
|
|
115
|
+
was removed for prototype-pollution + ReDoS CVEs. Do not reintroduce SheetJS.
|
|
116
|
+
|
|
117
|
+
### Build a sheet from rows — `data_workbook.ts` (barrel)
|
|
118
|
+
|
|
119
|
+
The common "I have records, give me a styled `.xlsx`" path. Declare each column once with a `type`
|
|
120
|
+
and a `value` accessor; the helper writes a bold frozen header, per-type number formats (dates as
|
|
121
|
+
real Excel serials), right-aligned numerics, and auto-fit widths.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { buildDataWorkbook, exportWorkbook } from "@lotics/xlsx";
|
|
125
|
+
|
|
126
|
+
const wb = buildDataWorkbook({
|
|
127
|
+
sheetName: "Phí", // sanitized to Excel's rules, ≤31 chars
|
|
128
|
+
currencyFormat: '#,##0" ₫"', // default "#,##0"
|
|
129
|
+
dateFormat: "dd/mm/yyyy", // default "dd/mm/yyyy"
|
|
130
|
+
columns: [
|
|
131
|
+
{ header: "Container", value: (r) => r.container }, // type defaults to "text"
|
|
132
|
+
{ header: "Ngày", type: "date", value: (r) => r.date }, // Date | "YYYY-MM-DD"
|
|
133
|
+
{ header: "Số tiền", type: "currency", value: (r) => r.amount },// number | numeric string
|
|
134
|
+
],
|
|
135
|
+
rows,
|
|
136
|
+
});
|
|
137
|
+
const bytes = exportWorkbook(wb);
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
- `DataColumnType` = `"text" | "integer" | "number" | "currency" | "date"`. `integer`→`#,##0`,
|
|
141
|
+
`number`→`#,##0.##`, `currency`→`currencyFormat`, `date`→`dateFormat`; numerics right-align.
|
|
142
|
+
- A `date` accessor returns a `Date` or an ISO `YYYY-MM-DD` string → stored as an Excel serial.
|
|
143
|
+
An **unparseable** date/number is written as its text so nothing is silently dropped.
|
|
144
|
+
- `value` returning `null`/`undefined`/`""` leaves the cell truly empty. Column `width` (in
|
|
145
|
+
characters) overrides the auto-fit; auto-fit samples the first 1000 rows.
|
|
146
|
+
- **Multi-sheet:** `buildDataSheet(opts, styles)` builds ONE `SheetModel` into a caller-supplied
|
|
147
|
+
`StyleRegistry`. Every sheet in one workbook must share the SAME registry (styles are stored by
|
|
148
|
+
index into it) — build each with `buildDataSheet` against one `new StyleRegistry()`, then
|
|
149
|
+
`new WorkbookModel({ sheets, activeSheetIndex: 0, styles })`.
|
|
150
|
+
|
|
151
|
+
### Build cell-by-cell — `workbook_model.ts` (barrel)
|
|
152
|
+
|
|
153
|
+
When the layout isn't a flat table (forms, invoices, dashboards), drive `SheetModel` directly.
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
import { WorkbookModel, SheetModel, StyleRegistry, exportWorkbook } from "@lotics/xlsx";
|
|
157
|
+
|
|
158
|
+
const styles = new StyleRegistry();
|
|
159
|
+
const sheet = new SheetModel("Invoice", styles);
|
|
160
|
+
|
|
161
|
+
sheet.set("A1", "Total", { fontBold: true }); // set(ref, value, style?, numFmt?, formula?)
|
|
162
|
+
sheet.set("B1", 1234.5, undefined, "#,##0.00"); // value + number-format code
|
|
163
|
+
sheet.merge("A1:C1", "Header", { horizontalAlign: "center" });
|
|
164
|
+
sheet.setColumnWidth("A", 24); // letter or 0-based index
|
|
165
|
+
sheet.setRowHeight(1, 22); // row is 1-based
|
|
166
|
+
sheet.setFreeze("A2"); // freeze row 1
|
|
167
|
+
sheet.headerRow(1, ["A", "B", "C"], { background: "#D9E1F2" }); // bold+centered+bordered row
|
|
168
|
+
|
|
169
|
+
const wb = new WorkbookModel({ sheets: [sheet], activeSheetIndex: 0, styles });
|
|
170
|
+
const bytes = exportWorkbook(wb);
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
- `set(ref, value, style?, numFmt?, formula?) → CellModel`. `value` is `string | number | boolean
|
|
174
|
+
| null`. `style` is a `CellStyle` (interned via the registry). `numFmt` is a format-code STRING
|
|
175
|
+
(`"#,##0.00"`, `"dd/mm/yyyy"`, `"General"`). `formula` is the formula text **WITHOUT `=`** (see
|
|
176
|
+
Formulas).
|
|
177
|
+
- `SheetModel` also exposes `getCell`, `deleteCell`, `colWidths`/`rowHeights` maps,
|
|
178
|
+
`hiddenRows`/`hiddenCols` sets, `mergedCells`, `getUsedRange()`, and `view`
|
|
179
|
+
(`showGridLines`, `zoomScale`, `tabColor`, …).
|
|
180
|
+
- `setWithStyleIndex(ref, value, styleIndex, numFmtCode?, formula?, originalXfIndex?)` is the hot
|
|
181
|
+
path for bulk fills: it skips the display-value/content materialization. If you later need
|
|
182
|
+
`cell.displayValue` for such a cell, call `materializeDisplayValue(cell)` (barrel).
|
|
183
|
+
- **Edit a parsed file:** `loadWorkbookFromSnapshot(parsed, date1904?)` turns a `ParsedSpreadsheet`
|
|
184
|
+
into a `WorkbookModel`, interning every style and marking the registry clean so a value-only edit
|
|
185
|
+
keeps the export fast path (see Export).
|
|
186
|
+
- **`buildTemplateWorkbook(...)`** (subpath `@lotics/xlsx/template_builder`) is sugar over the above
|
|
187
|
+
for one or several sheets: `buildTemplateWorkbook((s) => {...})` or
|
|
188
|
+
`buildTemplateWorkbook({ name, build }, ...)`.
|
|
189
|
+
|
|
190
|
+
### Styling — `types.ts` (`CellStyle`) + `style_helpers.ts` + `ooxml_color.ts` (barrel)
|
|
191
|
+
|
|
192
|
+
A `CellStyle` is a flat options bag interned by the `StyleRegistry` (Excel's `cellXfs` model — one
|
|
193
|
+
stored entry per distinct style). Every field is optional:
|
|
194
|
+
|
|
195
|
+
- **Font:** `fontBold`, `fontItalic`, `fontStrike`, `fontColor` (`#RRGGBB`), `fontSize` (points),
|
|
196
|
+
`fontName`, `fontUnderline` (`"single"|"double"`), `fontVertAlign` (`"superscript"|"subscript"`).
|
|
197
|
+
- **Fill:** `backgroundColor` (`#RRGGBB`), `patternType`, `gradientData` (`{type, degree, stops}`).
|
|
198
|
+
- **Alignment:** `horizontalAlign` (`"left"|"center"|"right"`), `verticalAlign`
|
|
199
|
+
(`"top"|"middle"|"bottom"`), `wrapText`, `shrinkToFit`, `indent`, `textRotation`.
|
|
200
|
+
- **Borders:** `borderTop`/`borderRight`/`borderBottom`/`borderLeft`/`borderDiagonal`, each a
|
|
201
|
+
`BorderStyle` = `{ width, style, color? }` where `style` is an OOXML border style name
|
|
202
|
+
(`"thin"`, `"medium"`, `"thick"`, `"dashed"`, `"double"`, …).
|
|
203
|
+
|
|
204
|
+
Reach for the shortcuts instead of hand-writing four border objects:
|
|
205
|
+
|
|
206
|
+
- `allBorders(style?="thin", color?="#000000")` → all four sides; `topBorder(...)` /
|
|
207
|
+
`bottomBorder(style?="medium", ...)` → one side. Spread into a `CellStyle`.
|
|
208
|
+
- Colors: `normalizeColor`, `prefixHex`, `hexToArgb`, `hexToOoxmlRgb`, `applyTint` — canonical
|
|
209
|
+
`#RRGGBB` in the model; the writer converts to OOXML `AARRGGBB` on export.
|
|
210
|
+
|
|
211
|
+
### Formulas — `set(...)`'s 5th arg + `formula_engine.ts` (subpath)
|
|
212
|
+
|
|
213
|
+
Two facts govern everything here:
|
|
214
|
+
|
|
215
|
+
1. **A cell carries formula text AND a cached value.** `sheet.set(ref, cachedValue, style, numFmt,
|
|
216
|
+
formulaText)`. `exportWorkbook` writes both: `<f>formulaText</f>` and `<v>cachedValue</v>`.
|
|
217
|
+
2. **The writer does NOT recompute.** It also stamps `<calcPr fullCalcOnLoad="1"/>` on the workbook,
|
|
218
|
+
so **Excel/LibreOffice recompute every formula the moment they open your file** — a formula
|
|
219
|
+
written with `null` as its cached value still shows the right number in a spreadsheet app.
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
sheet.set("D2", 15, undefined, undefined, "SUM(A2:C2)"); // formula text has NO leading "="
|
|
223
|
+
sheet.set("D3", null, undefined, undefined, "SUM(A3:C3)"); // cached value unknown → Excel fills it
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
The gap: a **non-Excel** consumer that reads the cached `<v>` (this package's own parser/renderer, a
|
|
227
|
+
PDF/print pipeline, another tool) sees `null`/stale until it recomputes. To fill correct cached
|
|
228
|
+
values yourself before export:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
import { recomputeAll } from "@lotics/xlsx/formula_engine";
|
|
232
|
+
recomputeAll(workbook); // evaluates every formula cell, overwriting cached values
|
|
233
|
+
const bytes = exportWorkbook(workbook);
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
- `recomputeAll(workbook)` and `recomputeIfStale(workbook)` (the latter only recomputes when the
|
|
237
|
+
workbook declares `fullCalcOnLoad`) are the convenience entry points. For incremental recalc while
|
|
238
|
+
editing, construct a `FormulaEngine(workbook)` and call `buildGraph()` + `evaluateMissing()` /
|
|
239
|
+
`evaluateAll()`, or `onCellChanged(sheet, ref, formula)` after each edit.
|
|
240
|
+
- **Unsupported functions:** `LAMBDA`, `LET`, dynamic-array spill, and cube functions are not
|
|
241
|
+
implemented (no open-source engine covers them) — don't emit workbooks that depend on them and
|
|
242
|
+
expect this package to evaluate the result. Excel still will, on open.
|
|
243
|
+
- **When you set a cell from a raw user/agent string that may or may not be a formula**, use the
|
|
244
|
+
command layer instead: `setCellValueCommand` treats a leading `=` as a formula and strips it for
|
|
245
|
+
you (see Editing). The raw `set()`/`setWithStyleIndex()` path expects the `=` already removed.
|
|
246
|
+
|
|
247
|
+
### Native pivot tables — `pivot_builder.ts` (barrel)
|
|
248
|
+
|
|
249
|
+
`buildPivotTable(workbook, spec) → PivotTableModel` authors a **real, refreshable Excel
|
|
250
|
+
PivotTable** (not a pre-rendered cross-tab): `exportWorkbook` serializes it into genuine
|
|
251
|
+
`pivotCacheDefinition` / `pivotCacheRecords` / `pivotTable` OOXML parts, so the user can refresh and
|
|
252
|
+
re-pivot it in Excel.
|
|
253
|
+
|
|
254
|
+
**v1 shape — the 2D cross-tab:** exactly one row field, one column field, one data (value) field,
|
|
255
|
+
and zero or more page (report-filter) fields.
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
import { buildPivotTable } from "@lotics/xlsx";
|
|
259
|
+
|
|
260
|
+
buildPivotTable(workbook, {
|
|
261
|
+
name: "PivotByPort", // unique within the workbook
|
|
262
|
+
source: { sheetName: "Data", ref: "A1:E500" },// first row is the header
|
|
263
|
+
rowField: 0, // 0-based column index WITHIN the range
|
|
264
|
+
colField: 2,
|
|
265
|
+
data: { field: 4, fn: "sum", numFmtId: "3" }, // fn defaults to "sum"
|
|
266
|
+
pageFields: [1], // optional report filters
|
|
267
|
+
location: { sheetName: "Pivot", anchor: "A3" },
|
|
268
|
+
});
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
- `data.fn` is a `PivotAggregateFn`: `"sum" | "count" | "countNums" | "average" | "min" | "max" |
|
|
272
|
+
"product"`. `rowGrandTotals`/`colGrandTotals` default `true`; `styleName` defaults
|
|
273
|
+
`"PivotStyleLight16"`.
|
|
274
|
+
- **`data.numFmtId` is a BUILT-IN Excel number-format ID** (e.g. `"3"` = `#,##0`), a string, NOT a
|
|
275
|
+
format code. A custom code like `'#,##0" ₫"'` needs a registered style, which v1 does not wire up.
|
|
276
|
+
- Validation throws (not silently) on an out-of-range field index or a field reused in more than one
|
|
277
|
+
role (row/column/data/page). The returned model is already attached to the target sheet; just
|
|
278
|
+
export.
|
|
279
|
+
- The read side of pivots (`ParsedPivotTable`, `ParsedPivotCache`, `PivotTableModel`) is barrel-
|
|
280
|
+
exported for inspecting pivots parsed out of an existing file.
|
|
281
|
+
|
|
282
|
+
### Tables, charts, sparklines — authored via the parsed structures (barrel types)
|
|
283
|
+
|
|
284
|
+
There is **no dedicated `buildTable`/`buildChart` helper.** These features are read-first: the
|
|
285
|
+
parser fills `sheet.tables` (`ParsedTable`), `sheet.charts` (`ParsedChart`), `sheet.sparklineGroups`,
|
|
286
|
+
etc., and they **round-trip through `exportWorkbook` unchanged.** To AUTHOR one, construct the parsed
|
|
287
|
+
structure and push it onto the sheet array on the `SheetModel`; the writer serializes
|
|
288
|
+
`sheet.tables` → `xl/tables/*.xml` and `sheet.charts` → `xl/charts/*.xml` + drawing anchors.
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
sheet.tables.push({
|
|
292
|
+
name: "tblFees", displayName: "tblFees", ref: "A1:D20",
|
|
293
|
+
headerRow: true, totalsRow: false, autoFilter: true, styleName: "TableStyleMedium2",
|
|
294
|
+
columns: [ { id: 1, name: "Container" }, { id: 2, name: "Amount" }, /* … match the range */ ],
|
|
295
|
+
});
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
A `ParsedChart` needs `chartType` (`"bar"|"col"|"line"|"pie"|"area"|"scatter"|…`), `series[]`
|
|
299
|
+
(`{ name?, values: number[], color? }`), optional `categories`/`axes`/`legendPosition`, and an
|
|
300
|
+
`anchor` (`{ from:{row,col}, to:{row,col} }`, 0-based). Treat this as a lower-level path than
|
|
301
|
+
`buildPivotTable`: you're assembling the read-model by hand, so match the column set to the range and
|
|
302
|
+
verify the output opens in Excel.
|
|
303
|
+
|
|
304
|
+
### Edit with undo/redo — `spreadsheet_commands.ts` (barrel)
|
|
305
|
+
|
|
306
|
+
Every mutation that needs to be undoable flows through a command factory that captures inverse state.
|
|
307
|
+
Each returns a `Command` (`{ description, execute, undo }`); run it and push it onto your own history
|
|
308
|
+
(or `command_history.ts`). The full set is barrel-exported: `setCellValueCommand`,
|
|
309
|
+
`setCellStyleCommand`, `setRangeStyleCommand`, `setRangeNumFmtCommand`, `deleteCellCommand`,
|
|
310
|
+
`clearRangeCommand`, `mergeCellsCommand`/`unmergeCellsCommand`, `insertRowsCommand`/
|
|
311
|
+
`deleteRowsCommand`, `insertColsCommand`/`deleteColsCommand`, `setColWidthCommand`/
|
|
312
|
+
`setRowHeightCommand`, `setNamedRangeCommand`, `addSheetCommand`/`deleteSheetCommand`/
|
|
313
|
+
`renameSheetCommand`/`reorderSheetsCommand`, `setHiddenRowsCommand`/`setHiddenColsCommand`,
|
|
314
|
+
`sortColumnCommand`/`multiSortCommand`, `setFreezePanesCommand`, `setZoomCommand`,
|
|
315
|
+
`addConditionalFormatCommand`/`deleteConditionalFormatCommand`, `insertCellsShiftDownCommand`, and
|
|
316
|
+
`batchCommand` (compose several into one undo step).
|
|
317
|
+
|
|
318
|
+
- `setCellValueCommand(workbook, engine, sheetIndex, row, col, newValue)` — `newValue` is the raw
|
|
319
|
+
string; a leading `=` is detected and stored as a formula (with `null` cached value for the
|
|
320
|
+
`engine` to compute). Pass a `FormulaEngine` so dependents recalc; pass `undefined` to skip recalc.
|
|
321
|
+
- Row/col insert/delete re-key populated cells and shift formula references (via
|
|
322
|
+
`adjustMergedCells*` / `expandFormulaRangesForRowInsert`, also barrel-exported) — this is why they
|
|
323
|
+
must be commands, not direct `SheetModel` edits.
|
|
324
|
+
|
|
325
|
+
### Export to `.xlsx` bytes — `xlsx_writer.ts` (barrel)
|
|
326
|
+
|
|
327
|
+
`exportWorkbook(workbook, originalZip?) → Uint8Array`. **Synchronous.** Returns a fresh
|
|
328
|
+
zip (`fflate` level 6). Write it to disk:
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
import { writeFileSync } from "node:fs";
|
|
332
|
+
import { exportWorkbook } from "@lotics/xlsx";
|
|
333
|
+
|
|
334
|
+
const bytes = exportWorkbook(wb); // Uint8Array
|
|
335
|
+
writeFileSync("out.xlsx", Buffer.from(bytes)); // Buffer.from wraps the bytes for fs
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
- **`originalZip` preserves unmodified parts.** When you parsed with `parseExcelFromZip(zip)` and
|
|
339
|
+
edited the resulting model, pass that same `zip` back: `exportWorkbook(wb, zip)`. Parts the writer
|
|
340
|
+
regenerates (worksheets, shared strings, workbook, styles) are rebuilt; everything else
|
|
341
|
+
(drawings, media, themes, VBA) passes through byte-for-byte.
|
|
342
|
+
- **Styles-passthrough fast path.** If `originalZip` is supplied AND no style was mutated
|
|
343
|
+
(`workbook.styles.dirty === false`, which `loadWorkbookFromSnapshot` sets up) AND the original had
|
|
344
|
+
`xl/styles.xml`, the writer reuses cells' original `s=` indices and skips rebuilding styles
|
|
345
|
+
entirely — the dominant path for value-only template fills. Touching any style (interning a new
|
|
346
|
+
`CellStyle` or a new numFmt code) flips `dirty` and takes the full rebuild.
|
|
347
|
+
|
|
348
|
+
## Gotchas
|
|
349
|
+
|
|
350
|
+
- **`parseExcelBuffer` takes an `ArrayBuffer`, not a Node `Buffer`.** A `Buffer` is a view over a
|
|
351
|
+
possibly-shared/pooled `ArrayBuffer`, so `buf.buffer` may contain other files' bytes. Always slice
|
|
352
|
+
to the view's own window: `buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)`.
|
|
353
|
+
Passing `buf.buffer` directly can hand the parser garbage tail bytes.
|
|
354
|
+
- **Formula text has NO leading `=` on the model path.** `sheet.set(ref, v, s, fmt, "SUM(A1:A5)")`,
|
|
355
|
+
not `"=SUM(...)"`. The barrel `set`/`setWithStyleIndex` write the string verbatim into `<f>`, and
|
|
356
|
+
OOXML formulas omit the `=`. The ONLY place a `=` is expected is `setCellValueCommand`, which
|
|
357
|
+
parses raw user input and strips it.
|
|
358
|
+
- **The writer never recomputes; it relies on `fullCalcOnLoad`.** A formula written with a `null` or
|
|
359
|
+
stale cached value is correct in Excel (which recalcs on open) but wrong to any consumer that
|
|
360
|
+
trusts the cached `<v>` without recomputing. Call `recomputeAll(workbook)` before export when a
|
|
361
|
+
non-Excel reader will consume the file.
|
|
362
|
+
- **Multi-sheet workbooks must share ONE `StyleRegistry`.** Cell styles are stored by index into a
|
|
363
|
+
single registry; sheets built against different registries render with each other's styles when
|
|
364
|
+
merged. Use `buildDataSheet(opts, styles)` / `new SheetModel(name, styles)` with one shared
|
|
365
|
+
registry, never `buildDataWorkbook` per sheet.
|
|
366
|
+
- **Dates are Excel serial numbers plus a date number-format code, not `Date` objects.**
|
|
367
|
+
`buildDataWorkbook`'s `date` columns convert for you (from a `Date` via LOCAL `YYYY-MM-DD`, or from
|
|
368
|
+
an ISO string). On the raw model path, store the serial and set a date `numFmt`
|
|
369
|
+
(`parseToExcelSerial(iso)` from `@lotics/xlsx/numfmt_type` computes one; it interprets the ISO date
|
|
370
|
+
in UTC and anchors at 1899-12-30 for the Lotus-1900-leap-year bug).
|
|
371
|
+
- **`maxRowsPerSheet` defaults to 10 000 and silently truncates.** For a full extract pass
|
|
372
|
+
`{ maxRowsPerSheet: Infinity }` and check `sheet.truncated`.
|
|
373
|
+
- **`cell.value` is the formatted display string.** Compute on `cell.rawValue`/`cell.typedValue`;
|
|
374
|
+
never `parseFloat(cell.value)`.
|
|
375
|
+
- **Legacy `.xls` is data-only.** No styles, merges, widths, charts, or images survive a `.xls`
|
|
376
|
+
read, and string/error formula results are dropped. If a downstream step needs those, it must come
|
|
377
|
+
from an `.xlsx`.
|
|
378
|
+
- **Pivot `data.numFmtId` is a built-in numeric ID string, not a format code** — a custom currency
|
|
379
|
+
code won't apply in v1.
|
|
380
|
+
- **`setWithStyleIndex` leaves `displayValue` empty** (it's the export fast path). If you read
|
|
381
|
+
`cell.displayValue` off such a cell before exporting, it's `""` until you call
|
|
382
|
+
`materializeDisplayValue(cell)`.
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/xlsx",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/index.ts",
|
|
7
7
|
"./*": "./src/*.ts"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
|
-
"src"
|
|
10
|
+
"src",
|
|
11
|
+
"AGENTS.md"
|
|
11
12
|
],
|
|
12
13
|
"publishConfig": {
|
|
13
14
|
"access": "public"
|
|
@@ -19,12 +20,13 @@
|
|
|
19
20
|
},
|
|
20
21
|
"dependencies": {
|
|
21
22
|
"@formulajs/formulajs": "^4.5.6",
|
|
23
|
+
"@lotics/office-io": "^0.1.0",
|
|
22
24
|
"fast-formula-parser": "^1.0.19",
|
|
23
25
|
"fast-xml-parser": "^5.5.6",
|
|
24
26
|
"fflate": "^0.8.2"
|
|
25
27
|
},
|
|
26
28
|
"devDependencies": {
|
|
27
29
|
"exceljs": "^4.4.0",
|
|
28
|
-
"vitest": "^4.1.
|
|
30
|
+
"vitest": "^4.1.9"
|
|
29
31
|
}
|
|
30
32
|
}
|
package/src/biff8_reader.test.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
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 {
|
|
4
|
+
import { parseBiff8 } from "./biff8_reader";
|
|
5
|
+
import { loadWorkbookFromSnapshot } from "./workbook_model";
|
|
6
|
+
import { isOle2, PasswordProtectedError } from "@lotics/office-io";
|
|
6
7
|
|
|
7
8
|
// A synthetic BIFF8 (.xls) workbook: 250 data rows of Unicode strings + integer,
|
|
8
9
|
// float and negative numbers. The Unicode strings push the shared-string table
|
|
@@ -28,30 +29,42 @@ describe("parseBiff8", () => {
|
|
|
28
29
|
|
|
29
30
|
it("decodes Unicode text cells", () => {
|
|
30
31
|
const s = parseBiff8(fixture).sheets[0];
|
|
31
|
-
expect(cell(s,
|
|
32
|
-
expect(cell(s,
|
|
33
|
-
expect(cell(s,
|
|
32
|
+
expect(cell(s, 1, 1)).toBe("ID");
|
|
33
|
+
expect(cell(s, 1, 2)).toBe("Tên khách");
|
|
34
|
+
expect(cell(s, 1, 3)).toBe("Số tiền");
|
|
34
35
|
});
|
|
35
36
|
|
|
36
37
|
it("decodes a string that falls past the SST CONTINUE boundary", () => {
|
|
37
38
|
const s = parseBiff8(fixture).sheets[0];
|
|
38
|
-
expect(cell(s,
|
|
39
|
-
expect(cell(s,
|
|
39
|
+
expect(cell(s, 201, 2)).toBe("Khách 200 — Công ty TNHH số 200");
|
|
40
|
+
expect(cell(s, 250, 2)).toBe("Khách 249 — Công ty TNHH số 249");
|
|
40
41
|
});
|
|
41
42
|
|
|
42
43
|
it("decodes integer, float and negative numbers to the exact value", () => {
|
|
43
44
|
const s = parseBiff8(fixture).sheets[0];
|
|
44
|
-
expect(cell(s,
|
|
45
|
-
expect(cell(s,
|
|
46
|
-
expect(cell(s,
|
|
47
|
-
expect(cell(s,
|
|
48
|
-
expect(cell(s,
|
|
45
|
+
expect(cell(s, 3, 3)).toBe(2000); // integer (RK int)
|
|
46
|
+
expect(cell(s, 4, 3)).toBe(3000.5); // float
|
|
47
|
+
expect(cell(s, 6, 3)).toBe(-500); // negative
|
|
48
|
+
expect(cell(s, 201, 3)).toBe(-20000);
|
|
49
|
+
expect(cell(s, 250, 3)).toBe(249000.5);
|
|
49
50
|
});
|
|
50
51
|
|
|
51
52
|
it("honors maxRowsPerSheet and flags truncation", () => {
|
|
52
53
|
const s = parseBiff8(fixture, { maxRowsPerSheet: 10 }).sheets[0];
|
|
53
54
|
expect(s.truncated).toBe(true);
|
|
54
|
-
expect(s.rows.every((r) => r.index
|
|
55
|
+
expect(s.rows.every((r) => r.index <= 10)).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("emits the ParsedSpreadsheet 1-based convention: A1 survives model load and export refs", () => {
|
|
59
|
+
// The regression that motivated normalization: 0-based BIFF indices fed
|
|
60
|
+
// loadWorkbookFromSnapshot's 1-based A1-ref construction, mapping the whole
|
|
61
|
+
// first column to invalid refs (colNumToLetters(0) === ""). The outcome
|
|
62
|
+
// that matters: the .xls's first cell lands at A1 in the model.
|
|
63
|
+
const wb = loadWorkbookFromSnapshot(parseBiff8(fixture));
|
|
64
|
+
const sheet = wb.sheets[0];
|
|
65
|
+
expect(sheet.cells.get("A1")?.value).toBe("ID");
|
|
66
|
+
expect(sheet.cells.get("B1")?.value).toBe("Tên khách");
|
|
67
|
+
expect(sheet.cells.has("0")).toBe(false);
|
|
55
68
|
});
|
|
56
69
|
|
|
57
70
|
it("rejects an OLE2 buffer with no Workbook stream", () => {
|
|
@@ -60,6 +73,29 @@ describe("parseBiff8", () => {
|
|
|
60
73
|
bogus.set([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
|
|
61
74
|
expect(() => parseBiff8(bogus)).toThrow();
|
|
62
75
|
});
|
|
76
|
+
|
|
77
|
+
it("terminates on a crafted cyclic DIFAT chain instead of hanging (DoS guard)", () => {
|
|
78
|
+
// A DIFAT sector whose next-pointer references itself. Without a file-size
|
|
79
|
+
// bound + cycle detection on the DIFAT/FAT assembly, the walk runs its full
|
|
80
|
+
// guard (~1M iterations) and the FAT loop then balloons to billions of
|
|
81
|
+
// entries — a small crafted .xls hangs the event loop + exhausts memory.
|
|
82
|
+
// The reader must bound the walk to the sectors the file actually holds and
|
|
83
|
+
// fail fast. 2 KB with a valid 512-byte sector size so it passes the
|
|
84
|
+
// sector-size check and reaches the DIFAT walk (a shorter buffer would be
|
|
85
|
+
// rejected earlier).
|
|
86
|
+
const buf = new Uint8Array(2048);
|
|
87
|
+
buf.set([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
|
|
88
|
+
const dv = new DataView(buf.buffer);
|
|
89
|
+
dv.setUint16(30, 9, true); // sector shift → 512-byte sectors
|
|
90
|
+
dv.setUint16(32, 6, true); // mini sector shift → 64-byte mini sectors
|
|
91
|
+
dv.setUint32(48, 0xfffffffe, true); // firstDirSector = ENDOFCHAIN (empty dir)
|
|
92
|
+
dv.setUint32(60, 0xfffffffe, true); // firstMiniFatSector = ENDOFCHAIN
|
|
93
|
+
dv.setUint32(68, 0, true); // firstDifatSector = sector 0 …
|
|
94
|
+
dv.setUint32(1020, 0, true); // …whose last DIFAT entry points back to sector 0
|
|
95
|
+
// No workbook stream results, so a bounded parse throws quickly; an unbounded
|
|
96
|
+
// one would never reach this throw (it hangs/OOMs first).
|
|
97
|
+
expect(() => parseBiff8(buf)).toThrow();
|
|
98
|
+
});
|
|
63
99
|
});
|
|
64
100
|
|
|
65
101
|
// A date cell is a NUMBER record (Excel serial) whose XF points at a date
|
|
@@ -75,19 +111,19 @@ describe("parseBiff8 number formats", () => {
|
|
|
75
111
|
};
|
|
76
112
|
|
|
77
113
|
it("reads a date cell as an Excel serial carrying its date number-format", () => {
|
|
78
|
-
const c = cell(
|
|
114
|
+
const c = cell(2, 1);
|
|
79
115
|
expect(c?.typedValue).toBe(45672); // 2025-01-15
|
|
80
116
|
expect(c?.numFmtCode).toBe("dd/mm/yyyy");
|
|
81
117
|
});
|
|
82
118
|
|
|
83
119
|
it("leaves a plain number with no number-format (General)", () => {
|
|
84
|
-
expect(cell(
|
|
85
|
-
expect(cell(
|
|
86
|
-
expect(cell(
|
|
120
|
+
expect(cell(2, 2)?.typedValue).toBe(42);
|
|
121
|
+
expect(cell(2, 2)?.numFmtCode).toBeUndefined();
|
|
122
|
+
expect(cell(2, 3)?.typedValue).toBe(1234.5);
|
|
87
123
|
});
|
|
88
124
|
|
|
89
125
|
it("decodes a Unicode string cell", () => {
|
|
90
|
-
expect(cell(
|
|
126
|
+
expect(cell(2, 4)?.typedValue).toBe("Ábc");
|
|
91
127
|
});
|
|
92
128
|
});
|
|
93
129
|
|
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 = {
|
|
@@ -336,7 +237,7 @@ function parseSheet(stream: Uint8Array, start: number, name: string, g: Globals,
|
|
|
336
237
|
const r = grid.get(ri);
|
|
337
238
|
if (!r) continue;
|
|
338
239
|
const cells = [...r.values()].sort((a, b) => a.column - b.column);
|
|
339
|
-
rows.push({ index: ri, height: sheet.defaultRowHeight, cells, hidden: false });
|
|
240
|
+
rows.push({ index: ri + 1, height: sheet.defaultRowHeight, cells, hidden: false });
|
|
340
241
|
}
|
|
341
242
|
sheet.rows = rows;
|
|
342
243
|
sheet.totalRowCount = maxRow + 1;
|
|
@@ -346,14 +247,19 @@ function parseSheet(stream: Uint8Array, start: number, name: string, g: Globals,
|
|
|
346
247
|
|
|
347
248
|
// ─── Cell builders ──────────────────────────────────────────────────────────
|
|
348
249
|
|
|
250
|
+
// BIFF stores 0-based row/col on disk; ParsedSpreadsheet is 1-based (the OOXML
|
|
251
|
+
// parser's convention, and what loadWorkbookFromSnapshot's A1-ref construction
|
|
252
|
+
// requires — colNumToLetters(0) is "", so a 0-based column would silently land
|
|
253
|
+
// every first-column cell at an invalid ref). Builders receive the raw BIFF
|
|
254
|
+
// column and normalize at this single escape point.
|
|
349
255
|
function textCell(column: number, text: string): ParsedCell {
|
|
350
|
-
return { column, value: text, typedValue: text, content: { type: "plain", text }, style: {} };
|
|
256
|
+
return { column: column + 1, value: text, typedValue: text, content: { type: "plain", text }, style: {} };
|
|
351
257
|
}
|
|
352
258
|
|
|
353
259
|
function numberCell(column: number, num: number, numFmt: string | undefined): ParsedCell {
|
|
354
260
|
const value = Number.isFinite(num) ? String(num) : "";
|
|
355
261
|
return {
|
|
356
|
-
column, value, rawValue: num, typedValue: num,
|
|
262
|
+
column: column + 1, value, rawValue: num, typedValue: num,
|
|
357
263
|
numFmtCode: numFmt && numFmt !== "General" ? numFmt : undefined,
|
|
358
264
|
content: { type: "plain", text: value }, style: {},
|
|
359
265
|
};
|
|
@@ -367,7 +273,7 @@ function formulaCell(column: number, body: Uint8Array, numFmt: string | undefine
|
|
|
367
273
|
const kind = body[6];
|
|
368
274
|
if (kind === 1) {
|
|
369
275
|
const b = body[8] !== 0;
|
|
370
|
-
return { column, value: b ? "TRUE" : "FALSE", typedValue: b, content: { type: "plain", text: b ? "TRUE" : "FALSE" }, style: {} };
|
|
276
|
+
return { column: column + 1, value: b ? "TRUE" : "FALSE", typedValue: b, content: { type: "plain", text: b ? "TRUE" : "FALSE" }, style: {} };
|
|
371
277
|
}
|
|
372
278
|
// string (resolved by a following STRING record — none in scope), error, or
|
|
373
279
|
// empty: surface nothing rather than a wrong value.
|
|
@@ -412,21 +318,6 @@ function readUnicodeShort(b: Uint8Array, offset: number, lenBytes: 1 | 2): strin
|
|
|
412
318
|
return s;
|
|
413
319
|
}
|
|
414
320
|
|
|
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
321
|
/** Built-in number-format strings we care about (dates → so serials render as
|
|
431
322
|
* dates downstream; currency/decimals are otherwise "General"). */
|
|
432
323
|
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
|
-
}
|