Skip to content
Open
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
40 changes: 35 additions & 5 deletions src/xlsx/stream-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@
// memory is O(distinct styles), independent of row count.

import type {
AutoFilter,
CellValue,
CellStyle,
ColumnDef,
ConditionalRule,
FreezePane,
MergeRange,
RowDef,
Expand All @@ -31,7 +33,9 @@ import {
cellRef,
hasRowAttributes,
rowAttributes,
serializeAutoFilter,
serializeCell,
serializeConditionalFormatting,
type ResolvedCell,
} from "./worksheet-writer"
import type { SharedStringsCollector } from "./worksheet-writer"
Expand Down Expand Up @@ -86,6 +90,16 @@ export interface StreamWriterOptions {
* do not have to be known before the rows are streamed.
*/
merges?: Array<MergeRange | string>
/**
* Auto-filter range. Written after the sheet data of the first sheet
* (and before merges), matching the buffered writer.
*/
autoFilter?: AutoFilter
/**
* Conditional formatting rules. Written after the sheet data of the first
* sheet, matching the buffered writer.
*/
conditionalRules?: ConditionalRule[]
}

/**
Expand Down Expand Up @@ -195,6 +209,10 @@ export interface XlsxStreamSheet {
rowDefs?: Map<number, RowDef>
/** Merged ranges for this sheet; see {@link StreamWriterOptions.merges}. */
merges?: Array<MergeRange | string>
/** Auto-filter for this sheet; see {@link StreamWriterOptions.autoFilter}. */
autoFilter?: AutoFilter
/** Conditional formatting for this sheet; see {@link StreamWriterOptions.conditionalRules}. */
conditionalRules?: ConditionalRule[]
/** Overrides the workbook-level rollover cap for this sheet alone. */
maxRowsPerSheet?: number
/** Overrides the workbook-level header repetition for this sheet alone. */
Expand All @@ -205,7 +223,8 @@ export interface XlsxStreamSheet {
* Workbook-wide options for {@link writeXlsxStreamSheets}.
*
* The per-sheet half of {@link XlsxWriteStreamOptions} — `name`,
* `columns`, `freezePane`, `rowDefs`, `merges` — moves to
* `columns`, `freezePane`, `rowDefs`, `merges`, `autoFilter`,
* `conditionalRules` — moves to
* {@link XlsxStreamSheet}, since a multi-sheet workbook has one of each
* *per sheet*. What is left is what
* a workbook has exactly one of: the date system, the string strategy,
Expand Down Expand Up @@ -752,9 +771,13 @@ export function writeXlsxStream(
rows: AsyncIterable<XlsxStreamRow> | Iterable<XlsxStreamRow>,
options: XlsxWriteStreamOptions,
): ReadableStream<Uint8Array> {
const { name, columns, freezePane, rowDefs, merges, ...workbook } = options
const { name, columns, freezePane, rowDefs, merges, autoFilter, conditionalRules, ...workbook } =
options

return writeXlsxStreamSheets([{ name, rows, columns, freezePane, rowDefs, merges }], workbook)
return writeXlsxStreamSheets(
[{ name, rows, columns, freezePane, rowDefs, merges, autoFilter, conditionalRules }],
workbook,
)
}

/**
Expand Down Expand Up @@ -895,13 +918,20 @@ async function* xlsxStreamEntries(
}
}

// Merges belong to the sheet's first part: a rollover splits one
// Tail elements belong to the sheet's first part: a rollover splits one
// logical sheet into several, and a range copied onto the continuation
// would cover rows it was never meant to.
// would cover rows it was never meant to. Order matches the buffered
// writer (and ECMA-376): autoFilter → mergeCells → conditionalFormatting.
let sheetTail = "</sheetData>"
if (part === 0 && sheet.autoFilter) {
sheetTail += serializeAutoFilter(sheet.autoFilter)
}
if (part === 0 && sheet.merges?.length) {
sheetTail += serializeMergeCells(sheet.merges)
}
if (part === 0 && sheet.conditionalRules?.length) {
sheetTail += serializeConditionalFormatting(sheet.conditionalRules, styles).join("")
}
sheetTail += "</worksheet>"
const closeChunk = chunker.push(sheetTail)
if (closeChunk) yield closeChunk
Expand Down
44 changes: 27 additions & 17 deletions src/xlsx/worksheet-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { toRanges } from "../cell-utils"
import type {
AutoFilter,
RowDef,
WriteSheet,
CellValue,
Expand Down Expand Up @@ -568,22 +569,7 @@ export function writeWorksheetXml(

// ── Auto Filter (OOXML: after sheetProtection, before mergeCells) ──
if (sheet.autoFilter) {
if (sheet.autoFilter.columns && sheet.autoFilter.columns.length > 0) {
const filterChildren: string[] = []
for (const col of sheet.autoFilter.columns) {
if (col.filters && col.filters.length > 0) {
const filterElements = col.filters.map((v) => xmlSelfClose("filter", { val: v }))
filterChildren.push(
xmlElement("filterColumn", { colId: col.colIndex }, [
xmlElement("filters", undefined, filterElements),
]),
)
}
}
parts.push(xmlElement("autoFilter", { ref: sheet.autoFilter.range }, filterChildren))
} else {
parts.push(xmlSelfClose("autoFilter", { ref: sheet.autoFilter.range }))
}
parts.push(serializeAutoFilter(sheet.autoFilter))
}

// ── Merge Cells ──
Expand Down Expand Up @@ -1610,13 +1596,37 @@ function serializeFontProps(font: FontStyle): string[] {
return parts
}

// ── Auto Filter Serialization ────────────────────────────────────

/**
* Serialize an `<autoFilter>` element, including optional `<filterColumn>` children
* when column criteria are configured.
*/
export function serializeAutoFilter(autoFilter: AutoFilter): string {
if (autoFilter.columns && autoFilter.columns.length > 0) {
const filterChildren: string[] = []
for (const col of autoFilter.columns) {
if (col.filters && col.filters.length > 0) {
const filterElements = col.filters.map((v) => xmlSelfClose("filter", { val: v }))
filterChildren.push(
xmlElement("filterColumn", { colId: col.colIndex }, [
xmlElement("filters", undefined, filterElements),
]),
)
}
}
return xmlElement("autoFilter", { ref: autoFilter.range }, filterChildren)
}
return xmlSelfClose("autoFilter", { ref: autoFilter.range })
}

// ── Conditional Formatting Serialization ─────────────────────────

/**
* Serialize conditional formatting rules into `<conditionalFormatting>` XML blocks.
* Rules are grouped by range (sqref) — multiple rules on the same range go into one element.
*/
function serializeConditionalFormatting(
export function serializeConditionalFormatting(
rules: ConditionalRule[],
styles: StylesCollector,
): string[] {
Expand Down
82 changes: 82 additions & 0 deletions test/xlsx-stream-write.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,88 @@ describe("writeXlsxStream", () => {

expect(streamedBook.sheets[0].rows).toEqual(bufferedBook.sheets[0].rows)
})

it("emits autoFilter and conditionalFormatting in the worksheet XML", async () => {
const bytes = await collect(
writeXlsxStream(
[
["Name", "Score"],
["Alice", 95],
["Bob", 60],
],
{
name: "Data",
autoFilter: { range: "A1:B3" },
conditionalRules: [
{
type: "cellIs",
priority: 1,
operator: "greaterThan",
formula: "80",
range: "B2:B3",
style: { font: { bold: true } },
},
],
},
),
)

const zip = new ZipReader(bytes)
const sheetXml = new TextDecoder().decode(await zip.extract("xl/worksheets/sheet1.xml"))

expect(sheetXml).toContain('<autoFilter ref="A1:B3"/>')
expect(sheetXml).toContain("<conditionalFormatting")
expect(sheetXml).toContain('sqref="B2:B3"')
expect(sheetXml).toContain('type="cellIs"')
expect(sheetXml).toContain('operator="greaterThan"')

// Round-trip through the reader.
const workbook = await readXlsx(bytes)
expect(workbook.sheets[0].autoFilter).toEqual({ range: "A1:B3" })
expect(workbook.sheets[0].conditionalRules).toHaveLength(1)
expect(workbook.sheets[0].conditionalRules![0]).toMatchObject({
type: "cellIs",
operator: "greaterThan",
range: "B2:B3",
})
})

it("emits autoFilter filterColumn children when columns are configured", async () => {
const bytes = await collect(
writeXlsxStream(
[
["Status", "Name", "Value"],
["Active", "Alice", 100],
["Pending", "Bob", 200],
["Active", "Charlie", 300],
],
{
name: "Filtered",
autoFilter: {
range: "A1:C4",
columns: [{ colIndex: 0, filters: ["Active", "Pending"] }],
},
},
),
)

const zip = new ZipReader(bytes)
const sheetXml = new TextDecoder().decode(await zip.extract("xl/worksheets/sheet1.xml"))

expect(sheetXml).toContain('<autoFilter ref="A1:C4">')
expect(sheetXml).toContain('<filterColumn colId="0">')
expect(sheetXml).toContain('<filter val="Active"/>')
expect(sheetXml).toContain('<filter val="Pending"/>')
expect(sheetXml).toContain("</filterColumn>")
expect(sheetXml).toContain("</autoFilter>")
expect(sheetXml).not.toContain('<autoFilter ref="A1:C4"/>')

const workbook = await readXlsx(bytes)
expect(workbook.sheets[0].autoFilter).toEqual({
range: "A1:C4",
columns: [{ colIndex: 0, filters: ["Active", "Pending"] }],
})
})
})

// ═══════════════════════════════════════════════════════════════════════
Expand Down
Loading