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
65 changes: 57 additions & 8 deletions src/xlsx/stream-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
ConditionalRule,
FreezePane,
MergeRange,
OutlineProperties,
RowDef,
SpreadsheetStreamWriter,
} from "../_types"
Expand Down Expand Up @@ -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"
/**
Expand Down Expand Up @@ -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<number, RowDef>
/** Merged ranges for this sheet; see {@link StreamWriterOptions.merges}. */
Expand All @@ -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
Expand Down Expand Up @@ -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<string, number> = {}
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) {
Expand Down Expand Up @@ -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<number, RowDef> | undefined
private merges: Array<MergeRange | string> | undefined
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -771,11 +795,32 @@ export function writeXlsxStream(
rows: AsyncIterable<XlsxStreamRow> | Iterable<XlsxStreamRow>,
options: XlsxWriteStreamOptions,
): ReadableStream<Uint8Array> {
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,
)
}
Expand Down Expand Up @@ -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<CellValue | StreamStyledCell> | null = headerFromColumns(sheet.columns)
Expand Down
132 changes: 132 additions & 0 deletions test/xlsx-stream-outlines.test.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array>): Promise<Uint8Array> {
return new Uint8Array(await new Response(stream).arrayBuffer())
}

async function sheetXml(bytes: Uint8Array, index = 1): Promise<string> {
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: '<outlinePr summaryBelow="0" summaryRight="0"/>',
},
{
properties: { summaryBelow: true, summaryRight: true },
xml: '<outlinePr summaryBelow="1" summaryRight="1"/>',
},
{ properties: { summaryBelow: false }, xml: '<outlinePr summaryBelow="0"/>' },
{ properties: { summaryRight: false }, xml: '<outlinePr summaryRight="0"/>' },
{ properties: {}, xml: "<outlinePr/>" },
])("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(`<sheetPr>${xml}</sheetPr><sheetViews>`)
})

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("<sheetPr")
})

it("preserves placement on every rollover sheet alongside repeated headers", async () => {
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("<sheetPr")
})