From f740193c756452be99980aaffc58143a80f916e8 Mon Sep 17 00:00:00 2001 From: ryu <213640356+ryuhzk@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:48:10 +0800 Subject: [PATCH 1/2] feat(xlsx): support autoFilter and conditionalRules in stream writer --- src/xlsx/stream-writer.ts | 39 +++++++++++++++++++++++++---- src/xlsx/worksheet-writer.ts | 2 +- test/xlsx-stream-write.test.ts | 45 ++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/src/xlsx/stream-writer.ts b/src/xlsx/stream-writer.ts index f7275ef8..6608931f 100644 --- a/src/xlsx/stream-writer.ts +++ b/src/xlsx/stream-writer.ts @@ -12,9 +12,11 @@ // memory is O(distinct styles), independent of row count. import type { + AutoFilter, CellValue, CellStyle, ColumnDef, + ConditionalRule, FreezePane, MergeRange, RowDef, @@ -32,6 +34,7 @@ import { hasRowAttributes, rowAttributes, serializeCell, + serializeConditionalFormatting, type ResolvedCell, } from "./worksheet-writer" import type { SharedStringsCollector } from "./worksheet-writer" @@ -86,6 +89,16 @@ export interface StreamWriterOptions { * do not have to be known before the rows are streamed. */ merges?: Array + /** + * 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[] } /** @@ -195,6 +208,10 @@ export interface XlsxStreamSheet { rowDefs?: Map /** Merged ranges for this sheet; see {@link StreamWriterOptions.merges}. */ merges?: Array + /** 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. */ @@ -205,7 +222,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, @@ -752,9 +770,13 @@ export function writeXlsxStream( rows: AsyncIterable | Iterable, options: XlsxWriteStreamOptions, ): ReadableStream { - 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, + ) } /** @@ -895,13 +917,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 = "" + if (part === 0 && sheet.autoFilter) { + sheetTail += xmlSelfClose("autoFilter", { ref: sheet.autoFilter.range }) + } if (part === 0 && sheet.merges?.length) { sheetTail += serializeMergeCells(sheet.merges) } + if (part === 0 && sheet.conditionalRules?.length) { + sheetTail += serializeConditionalFormatting(sheet.conditionalRules, styles).join("") + } sheetTail += "" const closeChunk = chunker.push(sheetTail) if (closeChunk) yield closeChunk diff --git a/src/xlsx/worksheet-writer.ts b/src/xlsx/worksheet-writer.ts index 059734a5..5f936b2f 100644 --- a/src/xlsx/worksheet-writer.ts +++ b/src/xlsx/worksheet-writer.ts @@ -1616,7 +1616,7 @@ function serializeFontProps(font: FontStyle): string[] { * Serialize conditional formatting rules into `` 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[] { diff --git a/test/xlsx-stream-write.test.ts b/test/xlsx-stream-write.test.ts index c2856f30..a29f08df 100644 --- a/test/xlsx-stream-write.test.ts +++ b/test/xlsx-stream-write.test.ts @@ -355,6 +355,51 @@ 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('') + expect(sheetXml).toContain(" Date: Fri, 4 Sep 2026 13:02:37 +0800 Subject: [PATCH 2/2] feat(xlsx): support filter columns in stream writer autoFilter --- src/xlsx/stream-writer.ts | 3 ++- src/xlsx/worksheet-writer.ts | 42 +++++++++++++++++++++------------- test/xlsx-stream-write.test.ts | 37 ++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 17 deletions(-) diff --git a/src/xlsx/stream-writer.ts b/src/xlsx/stream-writer.ts index 6608931f..88cde8e8 100644 --- a/src/xlsx/stream-writer.ts +++ b/src/xlsx/stream-writer.ts @@ -33,6 +33,7 @@ import { cellRef, hasRowAttributes, rowAttributes, + serializeAutoFilter, serializeCell, serializeConditionalFormatting, type ResolvedCell, @@ -923,7 +924,7 @@ async function* xlsxStreamEntries( // writer (and ECMA-376): autoFilter → mergeCells → conditionalFormatting. let sheetTail = "" if (part === 0 && sheet.autoFilter) { - sheetTail += xmlSelfClose("autoFilter", { ref: sheet.autoFilter.range }) + sheetTail += serializeAutoFilter(sheet.autoFilter) } if (part === 0 && sheet.merges?.length) { sheetTail += serializeMergeCells(sheet.merges) diff --git a/src/xlsx/worksheet-writer.ts b/src/xlsx/worksheet-writer.ts index 5f936b2f..381cc644 100644 --- a/src/xlsx/worksheet-writer.ts +++ b/src/xlsx/worksheet-writer.ts @@ -3,6 +3,7 @@ import { toRanges } from "../cell-utils" import type { + AutoFilter, RowDef, WriteSheet, CellValue, @@ -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 ── @@ -1610,6 +1596,30 @@ function serializeFontProps(font: FontStyle): string[] { return parts } +// ── Auto Filter Serialization ──────────────────────────────────── + +/** + * Serialize an `` element, including optional `` 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 ───────────────────────── /** diff --git a/test/xlsx-stream-write.test.ts b/test/xlsx-stream-write.test.ts index a29f08df..d1b61c39 100644 --- a/test/xlsx-stream-write.test.ts +++ b/test/xlsx-stream-write.test.ts @@ -400,6 +400,43 @@ describe("writeXlsxStream", () => { 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('') + expect(sheetXml).toContain('') + expect(sheetXml).toContain('') + expect(sheetXml).toContain('') + expect(sheetXml).toContain("") + expect(sheetXml).toContain("") + expect(sheetXml).not.toContain('') + + const workbook = await readXlsx(bytes) + expect(workbook.sheets[0].autoFilter).toEqual({ + range: "A1:C4", + columns: [{ colIndex: 0, filters: ["Active", "Pending"] }], + }) + }) }) // ═══════════════════════════════════════════════════════════════════════