diff --git a/src/model/worksheet.ts b/src/model/worksheet.ts index f35d909..d709f55 100644 --- a/src/model/worksheet.ts +++ b/src/model/worksheet.ts @@ -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' @@ -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 `` appears + * after `` — 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) } diff --git a/src/xlsx/merged-styles.test.ts b/src/xlsx/merged-styles.test.ts new file mode 100644 index 0000000..26982f8 --- /dev/null +++ b/src/xlsx/merged-styles.test.ts @@ -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 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 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: + // appears after , 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 +}) diff --git a/src/xlsx/worksheet-reader.ts b/src/xlsx/worksheet-reader.ts index 3a4129c..6262ade 100644 --- a/src/xlsx/worksheet-reader.ts +++ b/src/xlsx/worksheet-reader.ts @@ -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 + // (`` — 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 (shared-formula member) must NOT leave the accumulator open. @@ -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']) diff --git a/src/xlsx/worksheet-writer.ts b/src/xlsx/worksheet-writer.ts index 351fdb1..2efb31b 100644 --- a/src/xlsx/worksheet-writer.ts +++ b/src/xlsx/worksheet-writer.ts @@ -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 `` + // 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') {