From 1ff6737fdfd750bc22fecba046abf33ad161b2be Mon Sep 17 00:00:00 2001 From: Mathias Picker <48158184+MathiasWP@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:04:08 +0200 Subject: [PATCH] fix(xlsx): preserve outline summary placement in streaming writers --- src/xlsx/stream-writer.ts | 65 +++++++++++++-- test/xlsx-stream-outlines.test.ts | 132 ++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 test/xlsx-stream-outlines.test.ts diff --git a/src/xlsx/stream-writer.ts b/src/xlsx/stream-writer.ts index 88cde8e8..c6431eea 100644 --- a/src/xlsx/stream-writer.ts +++ b/src/xlsx/stream-writer.ts @@ -19,6 +19,7 @@ import type { ConditionalRule, FreezePane, MergeRange, + OutlineProperties, RowDef, SpreadsheetStreamWriter, } from "../_types" @@ -58,6 +59,8 @@ export interface StreamWriterOptions { columns?: ColumnDef[] /** Freeze pane */ freezePane?: FreezePane + /** Outline summary placement, preserved on every rollover sheet. */ + outlineProperties?: OutlineProperties /** Date system. Default: "1900" */ dateSystem?: "1900" | "1904" /** @@ -205,6 +208,8 @@ export interface XlsxStreamSheet { columns?: ColumnDef[] /** Freeze pane for this sheet. */ freezePane?: FreezePane + /** Outline summary placement for this sheet, including its rollover sheets. */ + outlineProperties?: OutlineProperties /** Row-level properties for this sheet; see {@link StreamWriterOptions.rowDefs}. */ rowDefs?: Map /** Merged ranges for this sheet; see {@link StreamWriterOptions.merges}. */ @@ -223,7 +228,7 @@ export interface XlsxStreamSheet { * Workbook-wide options for {@link writeXlsxStreamSheets}. * * The per-sheet half of {@link XlsxWriteStreamOptions} — `name`, - * `columns`, `freezePane`, `rowDefs`, `merges`, `autoFilter`, + * `columns`, `freezePane`, `outlineProperties`, `rowDefs`, `merges`, `autoFilter`, * `conditionalRules` — moves to * {@link XlsxStreamSheet}, since a multi-sheet workbook has one of each * *per sheet*. What is left is what @@ -367,10 +372,27 @@ class RowSerializer { // ── Shared Sheet Scaffolding ──────────────────────────────────────── -/** Build sheetView + sheetFormatPr + cols (same on every emitted sheet). */ -function buildSheetPrelude(columns: ColumnDef[] | undefined, freezePane: FreezePane | undefined) { +/** Build sheetPr + sheetViews + sheetFormatPr + cols (same on every emitted sheet). */ +function buildSheetPrelude( + columns: ColumnDef[] | undefined, + freezePane: FreezePane | undefined, + outlineProperties: OutlineProperties | undefined, +) { const parts: string[] = [] + // Row outline levels alone cannot express parent-first trees (#592). + // Match the buffered writer, placing sheetPr before sheetViews per OOXML. + if (outlineProperties) { + const attrs: Record = {} + if (outlineProperties.summaryBelow !== undefined) { + attrs["summaryBelow"] = outlineProperties.summaryBelow ? 1 : 0 + } + if (outlineProperties.summaryRight !== undefined) { + attrs["summaryRight"] = outlineProperties.summaryRight ? 1 : 0 + } + parts.push(xmlElement("sheetPr", undefined, [xmlSelfClose("outlinePr", attrs)])) + } + // SheetViews (freeze panes) const sheetViewParts: string[] = [] if (freezePane) { @@ -525,6 +547,7 @@ export class XlsxStreamWriter implements SpreadsheetStreamWriter { private sheetName: string private columns: ColumnDef[] | undefined private freezePane: FreezePane | undefined + private outlineProperties: OutlineProperties | undefined private dateSystem: "1900" | "1904" private rowDefs: Map | undefined private merges: Array | undefined @@ -548,6 +571,7 @@ export class XlsxStreamWriter implements SpreadsheetStreamWriter { this.sheetName = options.name this.columns = options.columns this.freezePane = options.freezePane + this.outlineProperties = options.outlineProperties this.dateSystem = options.dateSystem ?? "1900" this.maxRowsPerSheet = options.maxRowsPerSheet ?? XLSX_MAX_ROWS_PER_SHEET this.repeatHeaders = options.repeatHeaders ?? true @@ -644,7 +668,7 @@ export class XlsxStreamWriter implements SpreadsheetStreamWriter { const sheetCount = this.sheetFragments.length // Build the same view/columns prelude for every emitted sheet. - const sheetPrelude = buildSheetPrelude(this.columns, this.freezePane) + const sheetPrelude = buildSheetPrelude(this.columns, this.freezePane, this.outlineProperties) // Build ZIP archive const zip = new ZipWriter() @@ -771,11 +795,32 @@ export function writeXlsxStream( rows: AsyncIterable | Iterable, options: XlsxWriteStreamOptions, ): ReadableStream { - const { name, columns, freezePane, rowDefs, merges, autoFilter, conditionalRules, ...workbook } = - options + const { + name, + columns, + freezePane, + outlineProperties, + rowDefs, + merges, + autoFilter, + conditionalRules, + ...workbook + } = options return writeXlsxStreamSheets( - [{ name, rows, columns, freezePane, rowDefs, merges, autoFilter, conditionalRules }], + [ + { + name, + rows, + columns, + freezePane, + outlineProperties, + rowDefs, + merges, + autoFilter, + conditionalRules, + }, + ], workbook, ) } @@ -862,7 +907,11 @@ async function* xlsxStreamEntries( styles, sharedStrings, }) - const prelude = buildSheetPrelude(sheet.columns, sheet.freezePane).join("") + const prelude = buildSheetPrelude( + sheet.columns, + sheet.freezePane, + sheet.outlineProperties, + ).join("") const cursor = createRowCursor(sheet.rows) let headerRow: Array | null = headerFromColumns(sheet.columns) diff --git a/test/xlsx-stream-outlines.test.ts b/test/xlsx-stream-outlines.test.ts new file mode 100644 index 00000000..075fc001 --- /dev/null +++ b/test/xlsx-stream-outlines.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest" +import type { OutlineProperties } from "../src/_types" +import { + XlsxStreamWriter, + writeXlsxStream, + writeXlsxStreamSheets, + type XlsxWriteStreamOptions, +} from "../src/xlsx/stream-writer" +import { readXlsx } from "../src/xlsx/reader" +import { ZipReader } from "../src/zip/reader" + +async function drain(stream: ReadableStream): Promise { + return new Uint8Array(await new Response(stream).arrayBuffer()) +} + +async function sheetXml(bytes: Uint8Array, index = 1): Promise { + return new TextDecoder().decode( + await new ZipReader(bytes).extract(`xl/worksheets/sheet${index}.xml`), + ) +} + +const writers = [ + { + name: "writeXlsxStream", + write: (rows: string[][], options: XlsxWriteStreamOptions) => + drain(writeXlsxStream(rows, options)), + }, + { + name: "XlsxStreamWriter", + write: (rows: string[][], options: XlsxWriteStreamOptions) => { + const writer = new XlsxStreamWriter(options) + for (const row of rows) writer.addRow(row) + return writer.finish() + }, + }, +] + +// #592: row outline levels survived streaming, but the sheet-level placement +// did not, so parent-first trees were interpreted as summary-below outlines. +describe.each(writers)("$name outline placement", ({ write }) => { + it.each<{ properties: OutlineProperties; xml: string }>([ + { + properties: { summaryBelow: false, summaryRight: false }, + xml: '', + }, + { + properties: { summaryBelow: true, summaryRight: true }, + xml: '', + }, + { properties: { summaryBelow: false }, xml: '' }, + { properties: { summaryRight: false }, xml: '' }, + { properties: {}, xml: "" }, + ])("preserves $properties without inventing omitted attributes", async ({ properties, xml }) => { + const bytes = await write([["Parent"], ["Child"]], { + name: "Tree", + outlineProperties: properties, + rowDefs: new Map([[1, { outlineLevel: 1 }]]), + freezePane: { rows: 1 }, + }) + const sheet = (await readXlsx(bytes)).sheets[0]! + // The reader omits an empty outlinePr; XML below still checks it is emitted. + expect(sheet.outlineProperties ?? {}).toEqual(properties) + expect(sheet.rowDefs?.get(1)?.outlineLevel).toBe(1) + expect(sheet.freezePane).toEqual({ rows: 1 }) + const worksheet = await sheetXml(bytes) + // sheetPr must precede sheetViews in the OOXML worksheet sequence. + expect(worksheet).toContain(`${xml}`) + }) + + it("keeps the existing default when outlineProperties is omitted", async () => { + const bytes = await write([["Parent"], ["Child"]], { + name: "Tree", + rowDefs: new Map([[1, { outlineLevel: 1 }]]), + }) + expect((await readXlsx(bytes)).sheets[0]!.outlineProperties).toBeUndefined() + expect(await sheetXml(bytes)).not.toContain(" { + const properties = { summaryBelow: false, summaryRight: false } + const bytes = await write([["Parent"], ["Child"], ["Sibling"]], { + name: "Tree", + columns: [{ header: "Title" }], + maxRowsPerSheet: 3, + outlineProperties: properties, + rowDefs: new Map([ + [2, { outlineLevel: 1 }], + [3, { outlineLevel: 1 }], + ]), + }) + const workbook = await readXlsx(bytes) + expect(workbook.sheets.map((sheet) => sheet.name)).toEqual(["Tree", "Tree_2"]) + for (const sheet of workbook.sheets) expect(sheet.outlineProperties).toEqual(properties) + expect(workbook.sheets[1]!.rows).toEqual([["Title"], ["Sibling"]]) + expect(workbook.sheets[1]!.rowDefs?.get(1)?.outlineLevel).toBe(1) + }) +}) + +it("keeps outline placement local to each logical sheet, including rollover", async () => { + const bytes = await drain( + writeXlsxStreamSheets( + [ + { + name: "Above", + rows: [["Parent"], ["Child"], ["Sibling"]], + outlineProperties: { summaryBelow: false, summaryRight: false }, + }, + { + name: "Below", + rows: [["Child"], ["Parent"]], + outlineProperties: { summaryBelow: true, summaryRight: true }, + }, + { name: "Default", rows: [["Plain"]] }, + ], + { maxRowsPerSheet: 2, repeatHeaders: false }, + ), + ) + const workbook = await readXlsx(bytes) + expect(workbook.sheets.map((sheet) => sheet.name)).toEqual([ + "Above", + "Above_2", + "Below", + "Default", + ]) + expect(workbook.sheets.map((sheet) => sheet.outlineProperties)).toEqual([ + { summaryBelow: false, summaryRight: false }, + { summaryBelow: false, summaryRight: false }, + { summaryBelow: true, summaryRight: true }, + undefined, + ]) + expect(await sheetXml(bytes, 4)).not.toContain("