Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ Process large files row-by-row without loading everything into memory:

```ts
import {
streamXlsRows,
streamXlsxRows,
writeXlsxStream,
writeXlsxStreamSheets,
Expand Down Expand Up @@ -363,6 +364,15 @@ for await (const row of streamXlsxRows(buffer, { range: "B2:D1000" })) {
// row.values[1..3] carry B/C/D
}

// Legacy .xls streams the same way, with the same `sheet`, `range` and
// `maxRows`. The file is read whole — a CFB container needs random
// access — and a sheet whose cells come in row order is never held
// whole; one whose records go back a row is buffered, under
// `maxTotalCells`, and yielded in order.
for await (const row of streamXlsRows(xlsBytes, { sheet: "Data" })) {
console.log(row.index, row.values)
}

// Stream write — the workbook is emitted as bytes while rows are pulled
// from the source, so peak memory doesn't grow with the row count.
function* rows() {
Expand Down Expand Up @@ -720,6 +730,9 @@ Decodes the shared-string table (with CONTINUE spanning), RK / MULRK /
number / boolean / error cells, labels, cached formula values, dates, and
merged cells. Read-only.

For a sheet too large to hold as a workbook, `streamXlsRows` yields the
same values a row at a time — see [Streaming](#streaming).

Both legacy readers surface sheet names, cell values, and merges — and
nothing else. Formulas arrive as their **cached value**, never as formula
text; styles, column widths, row heights, sheet visibility, named ranges
Expand Down Expand Up @@ -2031,6 +2044,7 @@ Zero dependencies. Pure TypeScript. The ZIP engine uses `CompressionStream`/`Dec
| `openXlsx(input, options?)` | Open for round-trip (preserves unknown parts) |
| `saveXlsx(workbook)` | Save round-trip workbook back to XLSX |
| `streamXlsxRows(input, options?)` | AsyncGenerator yielding rows one at a time |
| `streamXlsRows(input, options?)` | The same for a legacy `.xls` sheet (BIFF5/BIFF8) |
| `writeXlsxStream(rows, options)` | Constant-memory XLSX writing — returns a `ReadableStream<Uint8Array>` |
| `toWriteOptions(workbook, opts?)` | Convert a read `Workbook` into `WriteOptions`; `onDrop` reports what it could not carry |
| `XlsxStreamWriter` | Incremental XLSX writing (`addRow`/`addObject`); auto-splits past `maxRowsPerSheet` |
Expand Down
23 changes: 16 additions & 7 deletions docs/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -680,16 +680,25 @@ Code that walks a sheet generically — `sheetToObjects`, `toHtml`,
`row[i] ?? null` rather than assume a slot exists. That is what they all
do; it is written down here because nothing said so.

The streaming readers _do_ agree: `streamXlsxRows` and `streamOdsRows`
both skip an entirely empty row and keep the true index on `StreamRow`,
so a gap in the indexes is the signal.
The streaming readers _do_ agree: `streamXlsxRows`, `streamXlsRows` and
`streamOdsRows` all skip an entirely empty row and keep the true index on
`StreamRow`, so a gap in the indexes is the signal.

They do **not** agree on how many sheets they walk. `streamXlsxRows`
yields one sheet — the first, unless `sheet` names another —
They do **not** agree on how many sheets they walk. `streamXlsxRows` and
`streamXlsRows` yield one sheet — the first, unless `sheet` names another —
while `streamOdsRows` walks every sheet in the document and tags each row
with `sheetIndex`. So the same loop over a three-sheet workbook gives you
one sheet of it as `.xlsx` and all three as `.ods`, silently. Pass
`sheet` when you mean one, and read `row.sheetIndex` when you mean all.
one sheet of it as `.xlsx` or `.xls` and all three as `.ods`, silently.
Pass `sheet` when you mean one, and read `row.sheetIndex` when you mean all.

`streamXlsRows` reads the `.xls` itself whole, since a CFB container needs
random access, and streams the sheet out of it, so its memory follows the
file and its shared strings rather than the sheet. To stream, the cells
have to come in row order — the order BIFF lays its row blocks out in,
and the one Excel writes.
A sheet whose records go back a row is buffered and yielded in order
instead, holding its cells as `readXls` would, under the same
`maxTotalCells` bound.

### `streamXmlRows` gives you a row's own keys, not a rectangle

Expand Down
71 changes: 71 additions & 0 deletions src/_stream-rows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// ── Row filters shared by the streaming readers ──────────────────────
// `sheet`, `range` and `maxRows` mean the same in `streamXlsxRows` and
// `streamXlsRows`, so the two apply one implementation of each.

import type { CellValue, StreamRow } from "./_types"
import { ParseError } from "./errors"
import { parseCellRef } from "./xlsx/worksheet"

export interface RangeFilter {
startRow: number
endRow: number
startCol: number
endCol: number
}

/**
* Parse a range reference like "A1:D10" into 0-based row/col bounds.
* Single-cell refs like "B2" are also accepted (start == end).
*/
export function parseRangeFilter(ref: string): RangeFilter {
const parts = ref.split(":")
if (parts.length === 0 || parts.length > 2) {
throw new ParseError(`Invalid range reference: "${ref}"`)
}
const start = parseCellRef(parts[0]!)
const end = parts.length > 1 ? parseCellRef(parts[1]!) : start
return {
startRow: Math.min(start.row, end.row),
endRow: Math.max(start.row, end.row),
startCol: Math.min(start.col, end.col),
endCol: Math.max(start.col, end.col),
}
}

/**
* Apply the range filter to a freshly-yielded row. Returns the row to emit
* (with cells outside the column range nulled out) or `null` if the row
* itself falls outside the row range.
*
* Mirrors the batch reader (`parseWorksheet`): values stay aligned to
* their original column index, and cells outside the column window are
* masked to `null` rather than removed, so callers can still address
* `row.values[colIndex]` for columns inside the range.
*/
export function applyRangeFilter(row: StreamRow, range: RangeFilter): StreamRow | null {
if (row.index < range.startRow || row.index > range.endRow) return null
const len = Math.max(row.values.length, range.endCol + 1)
const out: CellValue[] = Array.from({ length: len }, () => null)
const upper = Math.min(row.values.length - 1, range.endCol)
for (let c = range.startCol; c <= upper; c++) {
out[c] = row.values[c] ?? null
}
return { index: row.index, values: out }
}

/** The sheet a stream reads: the first, else the one `sheetSpec` names by 0-based index or by name. */
export function resolveTargetSheet<T extends { name: string }>(
allSheets: T[],
sheetSpec?: number | string,
): T | null {
if (sheetSpec === undefined) {
// Default: first sheet
return allSheets[0] ?? null
}

if (typeof sheetSpec === "number") {
return sheetSpec >= 0 && sheetSpec < allSheets.length ? allSheets[sheetSpec] : null
}

return allSheets.find((s) => s.name === sheetSpec) ?? null
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type { ReadObjectsOptions, ReadObjectsResult, WriteObjectsTableOption } f
export { readXlsx } from "./xlsx/reader"
export { readXlsb } from "./xlsx/xlsb/reader"
export { readXls } from "./xls/reader"
export { streamXlsRows } from "./xls/stream-reader"
export { writeXlsx } from "./xlsx/writer"
export { link } from "./xlsx/hyperlink"
export { openXlsx, saveXlsx } from "./xlsx/roundtrip"
Expand Down
Loading
Loading