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
28 changes: 27 additions & 1 deletion src/model/worksheet.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { decodeAddress } from '../utils/address'
import type { RangeBox } from '../utils/range'
import { decodeRange, type RangeBox } from '../utils/range'
import type { Cell, CellValue } from './cell'
import { Column } from './column'
import type { DataValidation } from './data-validation'
Expand Down Expand Up @@ -60,7 +60,33 @@ export class Worksheet {
return [...this.cols.keys()].sort((a, b) => a - b).map((n) => this.cols.get(n)!)
}

/**
* Merge a range, e.g. `'A1:F1'`. Every covered cell is materialized and
* SHARES the master's (top-left) style object, so styling the master —
* before or after merging — styles the whole range. This matches exceljs:
* spreadsheet apps paint each underlying cell of a merged range, so a fill
* carried only by the master would render over one grid position.
*/
merge(range: string): void {
this.recordMerge(range)
const box = decodeRange(range)
const master = this.getCell(box.top, box.left)
for (let r = box.top; r <= box.bottom; r += 1) {
for (let c = box.left; c <= box.right; c += 1) {
if (r === box.top && c === box.left) continue
this.getCell(r, c).style = master.style
}
}
}

/**
* Record a merged range WITHOUT touching covered-cell styles. This is the
* xlsx reader's entry: files carry each covered cell's own style index
* (e.g. distinct border xfs on a merge's edges), and `<mergeCells>` appears
* after `<sheetData>` — propagating the master's style here would clobber
* styles that were just parsed. ExcelJS's reader preserves them the same way.
*/
recordMerge(range: string): void {
this.mergeRanges.push(range)
}

Expand Down
105 changes: 105 additions & 0 deletions src/xlsx/merged-styles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { expect, test } from 'vitest'
import { createWorkbook } from '../model/workbook'
import { readXlsx } from './read'
import { writeXlsx } from './write'

const BLUE = { type: 'pattern', pattern: 'solid', fgColor: 'FF005BA1' } as const

test('a fill set on the master after merging paints the whole merged range', async () => {
const wb = createWorkbook()
const ws = wb.addSheet('S')
ws.merge('A1:C2')
const master = ws.cell('A1')
master.value = 'Title'
master.style.fill = { ...BLUE }
const bytes = await writeXlsx(wb)

const ExcelJS = (await import('exceljs')).default
const oracle = new ExcelJS.Workbook()
// @ts-expect-error -- @types/node 22 Buffer<ArrayBuffer> vs non-generic Buffer in exceljs decl
await oracle.xlsx.load(Buffer.from(bytes))
const sheet = oracle.getWorksheet('S')
for (const ref of ['A1', 'B1', 'C1', 'A2', 'B2', 'C2']) {
const fill = sheet?.getCell(ref).fill
expect(fill, `${ref} carries the merged fill`).toMatchObject({
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF005BA1' },
})
}
expect(sheet?.getCell('A1').value).toBe('Title')
})

test('merged cells share the master style object, before or after styling', () => {
const wb = createWorkbook()
const ws = wb.addSheet('S')
ws.cell('A1').style.font = { bold: true } // styled BEFORE the merge
ws.merge('A1:B1')
expect(ws.cell('B1').style).toBe(ws.cell('A1').style)
expect(ws.cell('B1').style.font?.bold).toBe(true)

ws.merge('A3:B3') // styled AFTER the merge
ws.cell('A3').style.fill = { ...BLUE }
expect(ws.cell('B3').style.fill).toEqual(BLUE)
})

test('merging materializes its rows, so addRow lands below the merged block', () => {
const wb = createWorkbook()
const ws = wb.addSheet('S')
ws.cell('A1').value = 'Title'
ws.merge('A2:F2') // spacer row with no values, exceljs-style
const header = ws.addRow(['a', 'b'])
expect(header.number).toBe(3)
})

test('a styled blank cell is written and read back by exceljs', async () => {
const wb = createWorkbook()
const ws = wb.addSheet('S')
ws.cell('A1').value = 'x'
ws.cell('A2').style.fill = { ...BLUE } // no value, only a fill
const bytes = await writeXlsx(wb)

const ExcelJS = (await import('exceljs')).default
const oracle = new ExcelJS.Workbook()
// @ts-expect-error -- @types/node 22 Buffer<ArrayBuffer> vs non-generic Buffer in exceljs decl
await oracle.xlsx.load(Buffer.from(bytes))
const cell = oracle.getWorksheet('S')?.getCell('A2')
expect(cell?.value).toBeNull()
expect(cell?.fill).toMatchObject({ fgColor: { argb: 'FF005BA1' } })
})

test('a styled blank cell round-trips through readXlsx', async () => {
const wb = createWorkbook()
const ws = wb.addSheet('S')
ws.cell('A1').value = 'x'
ws.cell('A2').style.fill = { ...BLUE }
const bytes = await writeXlsx(wb)

const restored = await readXlsx(bytes)
const cell = restored.sheets[0]?.cell('A2')
expect(cell?.value).toBeNull()
expect(cell?.style.fill).toEqual(BLUE)
})

test('reading a merge does not clobber covered cells that carry their own style', async () => {
// Excel writes each covered cell of a merge with its own xf (per-edge borders
// are the common case). The reader must keep those parsed styles: <mergeCells>
// appears after <sheetData>, so propagating the master's style on read would
// overwrite them. ExcelJS's reader preserves them the same way (verified).
const ExcelJS = (await import('exceljs')).default
const src = new ExcelJS.Workbook()
const ws = src.addWorksheet('S')
ws.mergeCells('A1:B1')
ws.getCell('A1').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFF0000' } }
// a fresh style object breaks exceljs's master/covered sharing -> distinct xf for B1
ws.getCell('B1').style = {
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0000FF' } },
}
const bytes = new Uint8Array(await src.xlsx.writeBuffer())

const wb = await readXlsx(bytes)
const sheet = wb.sheets[0]!
expect(sheet.merges).toContain('A1:B1')
expect(sheet.cell('A1').style.fill?.fgColor).toBe('FFFF0000')
expect(sheet.cell('B1').style.fill?.fgColor).toBe('FF0000FF') // preserved, not clobbered
})
11 changes: 9 additions & 2 deletions src/xlsx/worksheet-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,13 @@ export function readWorksheetInto(ws: Worksheet, xml: string, ctx: ReadContext):
isV = false
inF = false
inIs = false
if (tok.selfClosing) ref = undefined
if (tok.selfClosing) {
// A self-closing cell has no value, but when it carries a style
// (`<c r="A2" s="3"/>` — fills across merges, spacer rows) the
// style must survive the round-trip. Unstyled empties stay dropped.
if (sIndex !== undefined) finalize()
ref = undefined
}
} else if (tok.name === 'v') isV = !tok.selfClosing
// A self-closing element emits no close event, so only "enter" it when it can hold text.
// Self-closing <f/> (shared-formula member) must NOT leave the accumulator open.
Expand All @@ -154,7 +160,8 @@ export function readWorksheetInto(ws: Worksheet, xml: string, ctx: ReadContext):
else if (tok.name === 't') inT = !tok.selfClosing
else if (tok.name === 'mergeCell') {
const mref = tok.attributes['ref']
if (mref !== undefined) ws.merge(mref)
// recordMerge, not merge(): parsed covered cells keep their own styles.
if (mref !== undefined) ws.recordMerge(mref)
} else if (tok.name === 'col') {
const width = tok.attributes['width']
const min = Number(tok.attributes['min'])
Expand Down
11 changes: 10 additions & 1 deletion src/xlsx/worksheet-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,16 @@ function writeCellValue(
hyperlinks: PendingHyperlink[],
): void {
const v = cell.value
if (v === null) return
if (v === null) {
// A value-less cell still occupies the grid when it carries a style —
// fills across merged ranges, spacer rows, etc. Emit `<c r=".." s="N"/>`
// like exceljs; a blank cell with the default style stays unwritten.
if (registry !== undefined) {
const blankXf = registry.xfIndexFor(cell.style)
if (blankXf > 0) w.leaf('c', { r: cell.address, s: blankXf })
}
return
}
const xfIndex = registry !== undefined ? registry.xfIndexFor(effectiveStyle(v, cell.style)) : 0
const s = xfIndex > 0 ? xfIndex : undefined
if (typeof v === 'string') {
Expand Down
Loading