diff --git a/README.md b/README.md index 0cd55b1..a5e5ff1 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,17 @@ Anything a cell carries works there — `style`, `formula`, `richText`, `hyperlink`, `checkbox`. `cells` still takes a `"row,col"` map, and wins where both describe the same position. +A whole row or column can carry a format of its own. `rowDefs` takes a +`style` beside `height` and the rest — written as Excel writes a +formatted row, `` — and `columns[i].style` +does the same for a column. Either one is also stamped onto the cells in +it that have no style of their own; where a row and a column both cover +such a cell, the row's wins. + +```ts +rowDefs: new Map([[9, { height: 20, style: { font: { bold: true }, numFmt: "#,##0.00" } }]]) +``` + Features: cell styles, auto column widths, merged cells, freeze/split panes, auto-filter (with per-column value filters — ``; custom/dynamic/colour criteria are not emitted), data validation, hyperlinks, images (PNG/JPEG/GIF/SVG/WebP), comments, tables, conditional formatting (all 15 rule types, with their dxf styles), named ranges, print settings, page breaks, sheet protection, workbook protection, rich text, shared/array/dynamic formulas, sparklines, textboxes, background images, number formats, hidden sheets, Excel 2024 native checkboxes, HTML/Markdown/JSON/TSV export, template engine. ### Auto Column Width @@ -756,19 +767,19 @@ The ODS reader and writer model the same narrow set, so **ODS → ODS is lossless**. The loss shows up when converting _into_ ODS from a format that models more, which in practice means XLSX. -| | ODS | -| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| Cell values — string, number, boolean, date | yes | -| Formulas, including the `of:=` translation | yes | -| Merged cells | yes | -| Cell styles | **six facets only**: bold, italic, font size, font colour, background colour, number format | -| Document properties | six fields: title, subject, creator, description, keywords, created | -| Borders, alignment, font name, underline, strikethrough | no | -| Column widths, row heights, hidden rows and columns | no | -| Freeze and split panes, sheet views, tab colour | no | -| Data validation, conditional formatting, auto-filter | no | -| Named ranges, tables, images, page setup, sheet protection | no | -| Hidden sheets | no | +| | ODS | +| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Cell values — string, number, boolean, date | yes | +| Formulas, including the `of:=` translation | yes | +| Merged cells | yes | +| Cell styles | **six facets only**: bold, italic, font size, font colour, background colour, number format | +| Document properties | six fields: title, subject, creator, description, keywords, created | +| Borders, alignment, font name, underline, strikethrough | no | +| Column widths, row heights, row formats, hidden rows and columns | no | +| Freeze and split panes, sheet views, tab colour | no | +| Data validation, conditional formatting, auto-filter | no | +| Named ranges, tables, images, page setup, sheet protection | no | +| Hidden sheets | no | So `readXlsx` → `writeOds` keeps values, formulas, merges and those six style facets, and drops the rest **silently** — there is no warning, diff --git a/docs/PARITY.md b/docs/PARITY.md index 95db837..9cd3bb5 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -85,10 +85,10 @@ printing, copies, DPI and printer defaults; the exception is `r:id`, which points at a binary printer-settings part and has no portable meaning — headers and footers, sheet views and tab colours, hidden and very-hidden sheets, tables, row and column -definitions, manual page breaks, outline properties, sparklines, text -boxes, background images, images, document properties, named ranges, the -1904 date system, workbook protection, theme colours, Excel 2024 -checkboxes, and encryption. +definitions (a row or column format included), manual page breaks, +outline properties, sparklines, text boxes, background images, images, +document properties, named ranges, the 1904 date system, workbook +protection, theme colours, Excel 2024 checkboxes, and encryption. ### Formula results are write-only in practice @@ -250,6 +250,17 @@ Only the **trailing** box shrinks. Interior positions are untouched: a value at D1 still sits at index 3 behind three nulls, and an empty row between two populated ones is still there. +### A row format and the cells already in it + +A row's format (``) is the format of the cells that +are not in the row (ECMA-376 Part 1 §18.3.1.73). A cell that is present +keeps its own `s`, and that includes `s="0"` or no `s` at all. With +`readStyles`, such a cell inside a formatted row reads back with +`style: {}`. That says the file chose the default format, and the writer +keeps it at `s="0"` instead of giving it the row's format, as it does +for a cell the caller left without a style. Without the `{}`, read → +write moved the row's format onto it. Found in review of #612. + ### `Sheet.rows` is a dense rectangle Every row is an array, every row is the same length, and no element is @@ -603,11 +614,11 @@ document properties (six fields), and six style facets — bold, italic, font size, font colour, background colour, number format. Not modelled in **either** direction: borders, alignment, font name, -underline, strikethrough, column widths, row heights, hidden rows and -columns, freeze and split panes, data validation, conditional formatting, -auto-filter, tables, images, page setup, sheet protection, tab colour, -hidden sheets, and `time` cells (which read back as the raw ISO duration -string). +underline, strikethrough, column widths, row heights, row formats, hidden +rows and columns, freeze and split panes, data validation, conditional +formatting, auto-filter, tables, images, page setup, sheet protection, +tab colour, hidden sheets, and `time` cells (which read back as the raw +ISO duration string). Named ranges left that list: `` carries them both ways. Only workbook-level ones — ODF scopes a name to a sheet by diff --git a/docs/SPEC-COVERAGE.md b/docs/SPEC-COVERAGE.md index 6d1d258..c54e8eb 100644 --- a/docs/SPEC-COVERAGE.md +++ b/docs/SPEC-COVERAGE.md @@ -10,7 +10,7 @@ Corpus under `test/fixtures`: 35 OOXML workbooks (455 element names, 245 attribu ## SpreadsheetML (ECMA-376 Part 1) -360 complex types. **31** names appear in the corpus and are _nowhere_ in `src/`; 6 appear in the corpus and occur in `src/` only inside a longer string, which usually means a writer emits them in a template; 644 the source switches on directly; 494 it knows but the corpus does not use; 917 are in neither. +360 complex types. **30** names appear in the corpus and are _nowhere_ in `src/`; 6 appear in the corpus and occur in `src/` only inside a longer string, which usually means a writer emits them in a template; 645 the source switches on directly; 494 it knows but the corpus does not use; 917 are in neither. ### Not yet looked at @@ -38,7 +38,6 @@ Corpus under `test/fixtures`: 35 OOXML workbooks (455 element names, 245 attribu | `lowestEdited` | attribute | writer provenance — not modelled | | `rupBuild` | attribute | writer provenance — not modelled | | `quotePrefix` | attribute | measured, inert — marks a cell forced to text by a leading apostrophe. The value is already a string in the file, so `rows` is unaffected. Every occurrence in the corpus is `"0"`: openpyxl writes it explicitly false, Excel omits it. Nothing observed to carry | -| `customFormat` | attribute | restates that the row has a style, which the style says | | `spans` | attribute | a hint at which columns a row uses. hucre derives that from the cells themselves, which is the authority — Excel treats a wrong `spans` as advisory too | | `activeCell` | attribute | view state — not modelled | | `baseColWidth` | attribute | measured, inert — the base every column width is relative to. Deriving `defaultColWidth` from it needs the normal font's maximum digit width, which hucre cannot measure. Every occurrence in the corpus is `8`, the schema default, and Excel omits it entirely — so nothing real is lost today | diff --git a/scripts/spec-coverage.mjs b/scripts/spec-coverage.mjs index 1652903..21be056 100644 --- a/scripts/spec-coverage.mjs +++ b/scripts/spec-coverage.mjs @@ -275,7 +275,6 @@ const REVIEWED = new Map( // Out of scope by design. filterPrivacy: "privacy flag, no data — not modelled", pivotButton: "pivot UI affordance — pivots are round-trip only", - customFormat: "restates that the row has a style, which the style says", outlineLevelCol: "summary of the per-column outline levels hucre reads", outlineLevelRow: "summary of the per-row outline levels hucre reads", diff --git a/src/_types.ts b/src/_types.ts index 33b49e2..c81437b 100644 --- a/src/_types.ts +++ b/src/_types.ts @@ -271,7 +271,9 @@ export interface ColumnDef { /** * Default style for every cell in the column. Applies whether the rows * come from {@link WriteSheet.data} or {@link WriteSheet.rows} — on the - * `data[]` path the generated header row gets it too. + * `data[]` path the generated header row gets it too. A row's + * {@link RowDef.style} takes its place, `numFmt` included, on the cells + * of that row (#601). */ style?: CellStyle /** Number format. Folded into {@link style}; an explicit `style.numFmt` wins. */ @@ -908,6 +910,15 @@ export interface RowDef { outlineLevel?: number /** Whether this outline group is collapsed */ collapsed?: boolean + /** + * Default style for the whole row — ``. On + * write it also applies to every cell in the row that has no style of + * its own, and wins over {@link ColumnDef.style} there. Read back only + * with `readStyles`. A cell the file left at the default format inside + * such a row reads back with `style: {}`, so writing it again keeps it + * unformatted rather than filling it from the row. See #601. + */ + style?: CellStyle } // ── Sheet ────────────────────────────────────────────────────────── diff --git a/src/builder.ts b/src/builder.ts index 84e5498..4d8d86e 100644 --- a/src/builder.ts +++ b/src/builder.ts @@ -239,7 +239,7 @@ export class SheetBuilder { return this } - /** Set row-level properties — height, hidden, outline level, collapsed. */ + /** Set row-level properties — height, hidden, outline level, collapsed, format. */ rowDef( row: number, def: NonNullable extends Map ? T : never, diff --git a/src/sheet-ops.ts b/src/sheet-ops.ts index 3b722c3..97c911b 100644 --- a/src/sheet-ops.ts +++ b/src/sheet-ops.ts @@ -956,7 +956,9 @@ export function cloneSheet(sheet: Sheet, newName: string): Sheet { if (sheet.rowDefs && sheet.rowDefs.size > 0) { const rowDefs = new Map() for (const [key, def] of sheet.rowDefs) { - rowDefs.set(key, { ...def }) + // The style is an object of its own, so a spread alone would share + // it between the two sheets. See #601. + rowDefs.set(key, def.style ? { ...def, style: cloneCellStyle(def.style) } : { ...def }) } cloned.rowDefs = rowDefs } diff --git a/src/xlsx/stream-writer.ts b/src/xlsx/stream-writer.ts index 6832a4b..62bc7c9 100644 --- a/src/xlsx/stream-writer.ts +++ b/src/xlsx/stream-writer.ts @@ -81,11 +81,11 @@ export interface StreamWriterOptions { */ repeatHeaders?: boolean /** - * Row-level properties — `height`, `hidden`, `outlineLevel`, `collapsed` — - * keyed by 0-based row index, counted the way the caller sees it: the key - * keeps rising past a rollover, where the per-sheet index restarts at 0. - * Consulted as each row is emitted, so it costs nothing per row that has - * no entry. + * Row-level properties — `height`, `hidden`, `outlineLevel`, `collapsed`, + * `style` — keyed by 0-based row index, counted the way the caller sees + * it: the key keeps rising past a rollover, where the per-sheet index + * restarts at 0. Consulted as each row is emitted, so it costs nothing + * per row that has no entry. */ rowDefs?: Map /** @@ -301,16 +301,22 @@ class RowSerializer { rowDef?: RowDef, ): string | null { const cellElements: string[] = [] + // A row format reaches the cells that carry no style of their own and + // wins over the column's there, as it does in writeXlsx. See #601. + const rowStyle = rowDef?.style for (let c = 0; c < values.length; c++) { const colDef = this.columns?.[c] const raw = values[c] const styled = isStreamStyledCell(raw) const value = styled ? (raw.value ?? null) : (raw as CellValue) - let style: CellStyle | undefined = (styled && raw.style) || colDef?.style + const own = (styled && raw.style) || undefined + let style: CellStyle | undefined = own || rowStyle || colDef?.style - // If numFmt on column but not in style, merge - if (colDef?.numFmt && (!style || !style.numFmt)) { + // If numFmt on column but not in style, merge — except into a row + // format, which replaces the column's outright, number format + // included, the way writeXlsx resolves it. See #601. + if (colDef?.numFmt && (!style || !style.numFmt) && (own || !rowStyle)) { style = { ...style, numFmt: colDef.numFmt } } @@ -324,11 +330,12 @@ class RowSerializer { if (cellXml) cellElements.push(cellXml) } - const attrs = rowAttributes(rowIndex, rowDef) + const attrs = rowAttributes(rowIndex, rowDef, this.styles) if (cellElements.length === 0) { // A row with no cells still has to be emitted when its definition asks - // for something — a height, or a hidden/grouped row the caller wants. + // for something — a height, a format, or a hidden/grouped row the + // caller wants. return hasRowAttributes(rowDef) ? xmlSelfClose("row", attrs) : null } return xmlElement("row", attrs, cellElements) diff --git a/src/xlsx/styles-writer.ts b/src/xlsx/styles-writer.ts index f274f01..b3009f1 100644 --- a/src/xlsx/styles-writer.ts +++ b/src/xlsx/styles-writer.ts @@ -311,8 +311,11 @@ export function createStylesCollector( const numFmtMap = new Map() let nextNumFmtId = 164 - // Cell XFs — default xf at index 0 - const defaultXfKey = "nf:0|f:0|fl:0|b:0|a:|p:" + // Cell XFs — default xf at index 0. The key has to be spelled the way + // `registerXf` builds one, checkbox flag included, or a style that sets + // nothing — `{}` — never finds it and gets a second, identical xf. A + // cell read back as explicitly unformatted (#601) relies on landing here. + const defaultXfKey = "nf:0|f:0|fl:0|b:0|a:|p:|cb:0" const xfs: XfEntry[] = [ { key: defaultXfKey, diff --git a/src/xlsx/worksheet-writer.ts b/src/xlsx/worksheet-writer.ts index 88307b6..94d205e 100644 --- a/src/xlsx/worksheet-writer.ts +++ b/src/xlsx/worksheet-writer.ts @@ -126,7 +126,8 @@ export function colToLetter(col: number): string { */ export function rowAttributes( rowIndex: number, - rowDef?: RowDef, + rowDef: RowDef | undefined, + styles: StylesCollector, ): Record { const attrs: Record = { r: rowIndex + 1 } if (rowDef?.height !== undefined) { @@ -136,6 +137,16 @@ export function rowAttributes( if (rowDef?.hidden) attrs["hidden"] = 1 if (rowDef?.outlineLevel) attrs["outlineLevel"] = rowDef.outlineLevel if (rowDef?.collapsed) attrs["collapsed"] = 1 + // §18.3.1.73: `s` is the row's format only when `customFormat` is 1 — + // without it Excel ignores `s`. Index 0 is the default xf and needs + // neither, as on ``; a row with no style is unchanged. See #601. + if (rowDef?.style) { + const styleId = styles.addStyle(rowDef.style) + if (styleId !== 0) { + attrs["s"] = styleId + attrs["customFormat"] = 1 + } + } return attrs } @@ -146,7 +157,8 @@ export function hasRowAttributes(rowDef?: RowDef): boolean { (rowDef.height !== undefined || Boolean(rowDef.hidden) || Boolean(rowDef.outlineLevel) || - Boolean(rowDef.collapsed)) + Boolean(rowDef.collapsed) || + rowDef.style !== undefined) ) } @@ -561,9 +573,7 @@ export function writeWorksheetXml( for (let r = 0; r < rowCount; r++) { const row = resolvedRows[r] const rowDef = sheet.rowDefs?.get(r) - const hasRowDef = - rowDef && - (rowDef.height !== undefined || rowDef.hidden || rowDef.outlineLevel || rowDef.collapsed) + const hasRowDef = hasRowAttributes(rowDef) if ((!row || row.length === 0) && !hasRowDef) continue @@ -600,7 +610,7 @@ export function writeWorksheetXml( // floor. The cell fast path below it pays because there are 1.2M // cells to a sheet's 100k rows — a twelfth of the allocations is a // twelfth of the prize. See #472. - const rowAttrs = rowAttributes(r, rowDef) + const rowAttrs = rowAttributes(r, rowDef, styles) if (hasAnyCells) { rowElements.push(xmlElement("row", rowAttrs, cellElements)) } else { @@ -609,6 +619,20 @@ export function writeWorksheetXml( } } + // A row definition past the last row of data — a formatted row below a + // table, or a sheet with only `rowDefs` — has no cells to carry it, and + // the loop above stops at the data. It still needs its ``, or its + // format, height or hidden flag never reaches the file. Only the + // definitions are visited, so a far-away row costs nothing in between. + if (sheet.rowDefs) { + const trailing = [...sheet.rowDefs.keys()] + .filter((r) => r >= rowCount && hasRowAttributes(sheet.rowDefs!.get(r))) + .sort((a, b) => a - b) + for (const r of trailing) { + rowElements.push(xmlSelfClose("row", rowAttributes(r, sheet.rowDefs.get(r), styles))) + } + } + parts.push(xmlElement("sheetData", undefined, rowElements.length > 0 ? rowElements : "")) // ── SheetProtection (OOXML: after sheetData, before autoFilter) ── @@ -823,6 +847,17 @@ function columnCellStyle(col: ColumnDef | undefined): CellStyle | undefined { function resolveRows(sheet: WriteSheet): Array> { const resolved: Array> = [] + // A row format goes on the cells as well as on ``, the way a + // column format does — Excel writes the row's `s` onto every cell + // already in it, and a reader that looks only at the cells should see + // the format too. It wins over the column's where both cover a cell + // that has no style of its own, `numFmt` included: a cell's own format + // first, then its row's, then its column's. Excel for Mac showed the + // same order for an empty cell where a formatted row crosses a formatted + // column. A cell read back with `style: {}` was left at the default + // format by the file, and keeps it: `{}` is its own style. See #601. + const rowStyle = (r: number): CellStyle | undefined => sheet.rowDefs?.get(r)?.style + if (sheet.data && sheet.columns) { // Object-based data with column keys const keys = sheet.columns.map((col) => col.key) @@ -831,11 +866,12 @@ function resolveRows(sheet: WriteSheet): Array> { const hasHeaders = sheet.columns.some((col) => col.header) if (hasHeaders) { const headerRow: Array = [] + const rowFormat = rowStyle(0) for (let c = 0; c < sheet.columns.length; c++) { const col = sheet.columns[c] headerRow.push({ value: col.header ?? col.key ?? null, - style: col.style, + style: rowFormat ?? col.style, }) } resolved.push(headerRow) @@ -843,12 +879,13 @@ function resolveRows(sheet: WriteSheet): Array> { for (const obj of sheet.data) { const row: Array = [] + const rowFormat = rowStyle(resolved.length) for (let c = 0; c < keys.length; c++) { const key = keys[c] const raw = key !== undefined ? (obj[key] ?? null) : null const cell: ResolvedCell = { value: isHyperlinkValue(raw) ? raw.text : raw, - style: columnCellStyle(sheet.columns[c]), + style: rowFormat ?? columnCellStyle(sheet.columns[c]), } if (isHyperlinkValue(raw)) cell.hyperlink = toHyperlink(raw) row.push(cell) @@ -864,13 +901,14 @@ function resolveRows(sheet: WriteSheet): Array> { // exempt — hucre cannot tell which of the caller's rows is one. for (const row of sheet.rows) { const resolvedRow: Array = [] + const rowFormat = rowStyle(resolved.length) for (let c = 0; c < row.length; c++) { // `writeXlsx` lifts an inline cell object into `cells` before this // runs, so the entry is a value by then. Reading it through // `toCellValue` keeps `resolveRows` correct for a caller that // reached it another way, rather than emitting `[object Object]`. const value = toCellValue(row[c]!) - const style = sheet.columns ? columnCellStyle(sheet.columns[c]) : undefined + const style = rowFormat ?? (sheet.columns ? columnCellStyle(sheet.columns[c]) : undefined) resolvedRow.push(style ? { value, style } : { value }) } resolved.push(resolvedRow) @@ -896,7 +934,7 @@ function resolveRows(sheet: WriteSheet): Array> { const existing = row[c] row[c] = { value: cellOverride.value ?? existing?.value ?? null, - style: cellOverride.style ?? existing?.style, + style: cellOverride.style ?? existing?.style ?? rowStyle(r), checkbox: cellOverride.checkbox ?? existing?.checkbox, formula: cellOverride.formula ?? existing?.formula, formulaResult: cellOverride.formulaResult ?? existing?.formulaResult, diff --git a/src/xlsx/worksheet.ts b/src/xlsx/worksheet.ts index 4e0bdee..cd8752c 100644 --- a/src/xlsx/worksheet.ts +++ b/src/xlsx/worksheet.ts @@ -515,12 +515,16 @@ function worksheetParser( // lacking an `r` attribute get sequential columns within this row. currentRowNum = Number(attrs["r"]) || currentRowNum + 1 implicitCol = 0 + // `r` is optional on `` (§18.3.1.73): the attributes below + // take the row number worked out above, as the cells do. They + // used to read `attrs["r"]` again, which is NaN without it, so + // a height, hidden flag, outline level or format was dropped. // Parse row-level attributes: ht, customHeight, hidden if ( attrs["ht"] && (attrs["customHeight"] === "1" || attrs["customHeight"] === "true") ) { - const rowNum = Number(attrs["r"]) - 1 // 0-based + const rowNum = currentRowNum - 1 // 0-based const height = Number(attrs["ht"]) if (!Number.isNaN(rowNum) && !Number.isNaN(height)) { const existing = rowDefs.get(rowNum) ?? {} @@ -529,7 +533,7 @@ function worksheetParser( } } if (attrs["hidden"] === "1" || attrs["hidden"] === "true") { - const rowNum = Number(attrs["r"]) - 1 + const rowNum = currentRowNum - 1 if (!Number.isNaN(rowNum)) { const existing = rowDefs.get(rowNum) ?? {} existing.hidden = true @@ -537,7 +541,7 @@ function worksheetParser( } } if (attrs["outlineLevel"]) { - const rowNum = Number(attrs["r"]) - 1 + const rowNum = currentRowNum - 1 const level = Number(attrs["outlineLevel"]) if (!Number.isNaN(rowNum) && !Number.isNaN(level) && level > 0) { const existing = rowDefs.get(rowNum) ?? {} @@ -546,13 +550,37 @@ function worksheetParser( } } if (attrs["collapsed"] === "1" || attrs["collapsed"] === "true") { - const rowNum = Number(attrs["r"]) - 1 + const rowNum = currentRowNum - 1 if (!Number.isNaN(rowNum)) { const existing = rowDefs.get(rowNum) ?? {} existing.collapsed = true rowDefs.set(rowNum, existing) } } + // The row's format. §18.3.1.73 makes `s` mean something only + // when `customFormat` is set, and Excel ignores it otherwise, + // so a bare `s` is not read as one. It used to be dropped + // either way, which lost a formatted row on the way through. + // See #601. + if ( + ctx.readStyles && + ctx.styles && + attrs["s"] !== undefined && + (attrs["customFormat"] === "1" || attrs["customFormat"] === "true") + ) { + const rowNum = currentRowNum - 1 + const styleIndex = Number(attrs["s"]) + if (!Number.isNaN(rowNum) && styleIndex >= 0) { + const rowStyle = resolveStyle(ctx.styles, styleIndex) + if (Object.keys(rowStyle).length > 0) { + const existing = rowDefs.get(rowNum) ?? {} + // Each row gets its own copy, as each column does: + // `resolveStyle` hands out shared records. + existing.style = cloneCellStyle(rowStyle) + rowDefs.set(rowNum, existing) + } + } + } } break case "c": @@ -1026,6 +1054,13 @@ function worksheetParser( // // Under `readStyles: true` those cells do carry information // and still count. See #492. + // A cell that is present in a row with a format keeps its own + // `s`, 0 or absent included (§18.3.1.73): the row's format is + // for the cells that are not there. The model says so with an + // empty `style`, which the writer emits as the default format + // instead of filling the cell from the row. See #601. + const inFormattedRow = + ctx.readStyles === true && rowDefs.get(effRow)?.style !== undefined const carriesData = cellValueText !== "" || inlineText !== "" || @@ -1044,6 +1079,7 @@ function worksheetParser( // `writeXlsxStream` defaults to inline strings. cellType === "inlineStr" || (ctx.readStyles && cellStyleIndex >= 0) || + inFormattedRow || (ctx.styles && cellStyleIndex >= 0 ? (ctx.styles.cellXfs[cellStyleIndex]?.hasCheckboxFeature ?? false) : false) @@ -1067,6 +1103,7 @@ function worksheetParser( cellFormulaCm, effRow, effCol, + inFormattedRow, ) // Track max dimensions if (effRow >= 0 && effCol >= 0) { @@ -1794,6 +1831,7 @@ function processCell( formulaCm?: boolean, fallbackRow?: number, fallbackCol?: number, + inFormattedRow?: boolean, ): void { // When the `r` attribute is missing, fall back to implicit row/col position // (parity with the streaming reader). @@ -2052,6 +2090,7 @@ function processCell( // in sparse mode, where `cells` is the only place a value can live. const hasDetails = ctx.sparse || + inFormattedRow || formula !== undefined || richText !== undefined || (ctx.readStyles && ctx.styles && styleIndex >= 0) || @@ -2119,6 +2158,12 @@ function processCell( }) } } + // Left at the default format inside a formatted row: `{}` says the + // file chose that, where no `style` at all would let the writer fill + // the cell from the row. See the caller and #601. + if (inFormattedRow && cell.style === undefined) { + cell.style = {} + } const cellKey = `${row},${col}` assertCellMapCapacity(cells, cellKey, ctx.sheetName) cells.set(cellKey, cell) diff --git a/test/clone-sheet-coverage.test.ts b/test/clone-sheet-coverage.test.ts index 66c6306..cd15db0 100644 --- a/test/clone-sheet-coverage.test.ts +++ b/test/clone-sheet-coverage.test.ts @@ -53,7 +53,7 @@ const FULL_SHEET: Required = { ], cells: new Map([["0,0", FULL_CELL]]), columns: [{ width: 12, style: { font: { name: "Arial" } } }], - rowDefs: new Map([[0, { height: 30, hidden: true }]]), + rowDefs: new Map([[0, { height: 30, hidden: true, style: { numFmt: "0.00" } }]]), defaultRowHeight: 24, defaultColWidth: 18, merges: [{ startRow: 0, startCol: 0, endRow: 0, endCol: 1 }], @@ -131,6 +131,7 @@ describe("cloneSheet carries every field of Sheet", () => { expect(copy.slicers![0]).not.toBe(FULL_SHEET.slicers[0]) expect(copy.timelines![0]).not.toBe(FULL_SHEET.timelines[0]) expect(copy.threadedComments![0]).not.toBe(FULL_SHEET.threadedComments[0]) + expect(copy.rowDefs!.get(0)!.style).not.toBe(FULL_SHEET.rowDefs.get(0)!.style) copy.rowBreaks!.push(99) copy.sparklines![0]!.dataRange = "Z1:Z9" diff --git a/test/coverage-xlsx-worksheet.test.ts b/test/coverage-xlsx-worksheet.test.ts index c6e6e97..ef14614 100644 --- a/test/coverage-xlsx-worksheet.test.ts +++ b/test/coverage-xlsx-worksheet.test.ts @@ -148,16 +148,21 @@ describe("row definitions", () => { expect(s.rowDefs).toBeUndefined() }) - it("drops row flags on a row that carries no row number", () => { - // Row properties are keyed by row index; with no `r` there is - // nothing to key them to, so the flags are dropped even though the - // row's cells are still read. + it("keys row flags on a row with no row number to its position", () => { + // `r` is optional on `` (§18.3.1.73). The row's cells were + // already numbered from its position; its flags used to be read from + // `r` alone and dropped without it. See #601. const s = data( ``, ) expect(s.rows).toEqual([[1]]) - expect(s.rowDefs).toBeUndefined() + expect(s.rowDefs?.get(0)).toEqual({ + height: 40, + hidden: true, + outlineLevel: 2, + collapsed: true, + }) }) it("stops parsing rows once maxRows is reached", () => { diff --git a/test/real-files.test.ts b/test/real-files.test.ts index 3bc2780..993e5b7 100644 --- a/test/real-files.test.ts +++ b/test/real-files.test.ts @@ -531,6 +531,26 @@ describe("workbooks written by Excel, not by this test suite", () => { }) }) + // Excel formats a whole row by writing `s` and `customFormat="1"` on + // ``. Rows 3 and 4 here have a format and no cells at all, so the + // `` is the only place it lives — and the reader used to drop it. + // See #601. + describe("excel-styleonly.xlsx", () => { + it("#601 — reads the row formats Excel wrote", async () => { + const wb = await readXlsx(bytes("excel-styleonly.xlsx"), { readStyles: true }) + const rowDefs = wb.sheets[0]!.rowDefs! + + expect(rowDefs.get(0)?.style?.font?.bold).toBe(true) + for (const r of [1, 2, 3]) { + expect(rowDefs.get(r)?.style?.numFmt).toBe("0.00") + expect(rowDefs.get(r)?.style?.fill).toMatchObject({ + pattern: "solid", + fgColor: { rgb: "DDEBF7" }, + }) + } + }) + }) + // A test that reads no bytes would pass just as green. This one fails // loudly if test/fixtures/ ever stops being on disk. it("is actually reading the files", () => { diff --git a/test/row-style.test.ts b/test/row-style.test.ts new file mode 100644 index 0000000..a01ac06 --- /dev/null +++ b/test/row-style.test.ts @@ -0,0 +1,406 @@ +import { describe, expect, it } from "vitest" +import { writeXlsx } from "../src/xlsx/writer" +import { readXlsx } from "../src/xlsx/reader" +import { writeXlsxStream } from "../src/xlsx/stream-writer" +import { openXlsx, saveXlsx } from "../src/xlsx/roundtrip" +import { ZipReader } from "../src/zip/reader" +import { ZipWriter } from "../src/zip/writer" +import type { CellStyle, RowDef, WriteSheet } from "../src/_types" + +// ═══════════════════════════════════════════════════════════════════════ +// #601 — a row format. +// +// Excel writes a formatted row as `` +// (ECMA-376 Part 1 §18.3.1.73), and stamps the same `s` onto the cells +// already in the row. `RowDef` had height, hidden and outline but no +// style, so the writer had nothing to say and the reader dropped the +// attribute: a row format in an Excel file was lost on the way through. +// Columns have had the equivalent since #439 §W. +// ═══════════════════════════════════════════════════════════════════════ + +async function part(bytes: Uint8Array, path: string): Promise { + return new TextDecoder().decode(await new ZipReader(bytes).extract(path)) +} + +async function sheetXml(sheet: WriteSheet): Promise { + return part(await writeXlsx({ sheets: [sheet] }), "xl/worksheets/sheet1.xml") +} + +/** Rebuild an archive with one part rewritten, to stand in for another tool's output. */ +async function rewrite( + bytes: Uint8Array, + path: string, + edit: (xml: string) => string, +): Promise { + const all = await new ZipReader(bytes).extractAll() + const zw = new ZipWriter() + for (const [name, data] of all) { + zw.add( + name, + name === path ? new TextEncoder().encode(edit(new TextDecoder().decode(data))) : data, + ) + } + return zw.build() +} + +async function collect(stream: ReadableStream): Promise { + return new Uint8Array(await new Response(stream).arrayBuffer()) +} + +const TOTAL: CellStyle = { + numFmt: "0.00", + fill: { type: "pattern", pattern: "solid", fgColor: { rgb: "FFDDEBF7" } }, +} +const ITALIC: CellStyle = { font: { italic: true } } + +/** The `s` a `` carries, or undefined. */ +const rowS = (xml: string, r: number): string | undefined => + xml.match(new RegExp(`]*? s="(\\d+)"`))?.[1] + +describe("a row format reaches the file", () => { + it("writes s and customFormat on , beside the height", async () => { + const xml = await sheetXml({ + name: "S", + rows: [ + ["Name", "Qty"], + ["Total", 3], + ], + rowDefs: new Map([[1, { height: 20, style: TOTAL }]]), + }) + + expect(xml).toMatch(//) + expect(xml).toMatch(//) + }) + + it("writes a row that has a format and no cells", async () => { + const xml = await sheetXml({ + name: "S", + rows: [["a"], [], ["c"]], + rowDefs: new Map([[1, { style: TOTAL }]]), + }) + + expect(xml).toMatch(//) + }) + + it("stamps the cells in the row that carry no style of their own", async () => { + const xml = await sheetXml({ + name: "S", + rows: [["Total", 3]], + cells: new Map([["0,1", { value: 3, style: ITALIC }]]), + rowDefs: new Map([[0, { style: TOTAL }]]), + }) + + const s = rowS(xml, 1) + expect(s).toBeDefined() + expect(xml).toContain(``) + // B1 has a style of its own, so it keeps it. + expect(xml).toMatch(//) + expect(xml.match(//)![1]).not.toBe(s) + }) + + it("lets the row format win over a column format on a cell neither styles", async () => { + // Excel applies whichever format came last; a row format is applied + // after the sheet's column formats here. Not verified in Excel. + const bytes = await writeXlsx({ + sheets: [ + { + name: "S", + rows: [["a"], ["b"]], + columns: [{ style: ITALIC }], + rowDefs: new Map([[1, { style: TOTAL }]]), + }, + ], + }) + + const cells = (await readXlsx(bytes, { readStyles: true })).sheets[0]!.cells! + expect(cells.get("0,0")!.style!.font!.italic).toBe(true) + expect(cells.get("1,0")!.style!.numFmt).toBe("0.00") + expect(cells.get("1,0")!.style!.font?.italic).toBeUndefined() + }) + + it("stamps the cells written from data[] as well as rows", async () => { + const bytes = await writeXlsx({ + sheets: [ + { + name: "S", + columns: [{ key: "n", header: "N" }], + data: [{ n: 1 }, { n: 2 }], + rowDefs: new Map([[2, { style: TOTAL }]]), + }, + ], + }) + + const cells = (await readXlsx(bytes, { readStyles: true })).sheets[0]!.cells! + expect(cells.get("2,0")!.style!.numFmt).toBe("0.00") + expect(cells.get("1,0")?.style?.numFmt).toBeUndefined() + }) + + it("stamps a cell a cells override adds to the row", async () => { + const bytes = await writeXlsx({ + sheets: [ + { + name: "S", + rows: [["a"]], + cells: new Map([["0,3", { value: 4 }]]), + rowDefs: new Map([[0, { style: TOTAL }]]), + }, + ], + }) + + const cells = (await readXlsx(bytes, { readStyles: true })).sheets[0]!.cells! + expect(cells.get("0,3")!.style!.numFmt).toBe("0.00") + }) + + it("is written by the streaming writer too", async () => { + const bytes = await collect( + writeXlsxStream([["Total", 3], [], ["x"]], { + name: "S", + rowDefs: new Map([ + [0, { style: TOTAL }], + [1, { style: TOTAL }], + ]), + }), + ) + + const xml = await part(bytes, "xl/worksheets/sheet1.xml") + const s = rowS(xml, 1) + expect(s).toBeDefined() + expect(xml).toMatch(//) + expect(xml).toContain(``) + expect(xml).toContain(``) + expect(xml).toMatch(//) + }) + + it("lets the row format win over a column number format, streamed or not", async () => { + // The streaming writer folds a column's numFmt into whatever style a + // cell ends up with. A row format replaces the column's outright in + // writeXlsx, so it has to do the same here or the two writers disagree. + const columns = [{ numFmt: "0%" }] + const rowDefs = new Map([[1, { style: ITALIC }]]) + const streamed = await collect(writeXlsxStream([[0.5], [0.5]], { name: "S", columns, rowDefs })) + const buffered = await writeXlsx({ + sheets: [{ name: "S", rows: [[0.5], [0.5]], columns, rowDefs }], + }) + + for (const bytes of [streamed, buffered]) { + const cells = (await readXlsx(bytes, { readStyles: true })).sheets[0]!.cells! + expect(cells.get("0,0")!.style!.numFmt).toBe("0%") + expect(cells.get("1,0")!.style!.font!.italic).toBe(true) + expect(cells.get("1,0")!.style!.numFmt).toBeUndefined() + } + }) + + it("leaves a row with no format byte-for-byte as it was", async () => { + // The new attributes go out only for a style; a row definition without + // one writes exactly the `` and cells it wrote before. + const xml = await sheetXml({ + name: "S", + rows: [ + ["a", 1], + ["b", 2], + ], + rowDefs: new Map([[1, { height: 20 }]]), + }) + + expect(xml).toContain( + '01' + + '12', + ) + expect(xml).not.toContain("customFormat") + }) +}) + +describe("a row format survives a read", () => { + it("reads back as RowDef.style", async () => { + const bytes = await writeXlsx({ + sheets: [ + { + name: "S", + rows: [["Name"], ["Total"]], + rowDefs: new Map([[1, { height: 20, style: TOTAL }]]), + }, + ], + }) + + const def = (await readXlsx(bytes, { readStyles: true })).sheets[0]!.rowDefs!.get(1)! + + expect(def.height).toBe(20) + expect(def.style!.numFmt).toBe("0.00") + expect(def.style!.fill).toMatchObject({ pattern: "solid", fgColor: { rgb: "DDEBF7" } }) + }) + + it("reads a row that has only a format", async () => { + const bytes = await writeXlsx({ + sheets: [{ name: "S", rows: [["a"], [], ["c"]], rowDefs: new Map([[1, { style: TOTAL }]]) }], + }) + + const def = (await readXlsx(bytes, { readStyles: true })).sheets[0]!.rowDefs!.get(1) + + expect(def?.style?.numFmt).toBe("0.00") + }) + + it("ignores s when customFormat does not say the row is formatted", async () => { + // §18.3.1.73: `s` applies to the row only when `customFormat` is 1. + const bytes = await writeXlsx({ + sheets: [{ name: "S", rows: [["a"]], rowDefs: new Map([[0, { style: TOTAL }]]) }], + }) + const patched = await rewrite(bytes, "xl/worksheets/sheet1.xml", (xml) => + xml.replace('customFormat="1"', 'customFormat="0"'), + ) + + const sheet = (await readXlsx(patched, { readStyles: true })).sheets[0]! + + expect(sheet.rowDefs?.get(0)?.style).toBeUndefined() + }) + + it("leaves the format out when styles are not being read", async () => { + const bytes = await writeXlsx({ + sheets: [ + { + name: "S", + rows: [["a"]], + rowDefs: new Map([[0, { height: 20, style: TOTAL }]]), + }, + ], + }) + + const def = (await readXlsx(bytes)).sheets[0]!.rowDefs!.get(0)! + + expect(def).toEqual({ height: 20 }) + }) + + it("gives each row its own style object", async () => { + const bytes = await writeXlsx({ + sheets: [ + { + name: "S", + rows: [["a"], ["b"]], + rowDefs: new Map([ + [0, { style: TOTAL }], + [1, { style: TOTAL }], + ]), + }, + ], + }) + + const rowDefs = (await readXlsx(bytes, { readStyles: true })).sheets[0]!.rowDefs! + + expect(rowDefs.get(0)!.style).not.toBe(rowDefs.get(1)!.style) + rowDefs.get(0)!.style!.numFmt = "changed" + expect(rowDefs.get(1)!.style!.numFmt).toBe("0.00") + }) +}) + +describe("a row format with no data to sit in", () => { + // The writer's row loop stopped at the last row of data, so a row + // definition past it — a formatted row below a table, or a sheet with + // only `rowDefs` — had no `` and its format was lost. + it("writes a formatted row below the data", async () => { + const xml = await sheetXml({ + name: "S", + rows: [["a"], ["b"]], + rowDefs: new Map([[9, { style: TOTAL }]]), + }) + + expect(xml).toMatch(//) + }) + + it("writes a sheet that has only row definitions", async () => { + const xml = await sheetXml({ + name: "S", + rowDefs: new Map([ + [4, { height: 30 }], + [2, { style: TOTAL }], + ]), + }) + + // In row order, whatever order the map was built in. + expect(xml).toMatch( + //, + ) + }) + + it("still writes nothing for a definition that says nothing", async () => { + const xml = await sheetXml({ name: "S", rows: [["a"]], rowDefs: new Map([[5, {}]]) }) + + expect(xml).not.toContain(' { + // `r` is optional on ``; the reader numbered the cells from the + // row's position but read the row's own attributes from `r`, which is + // NaN without it, so the format and height were dropped. + it("keeps its format and height", async () => { + const bytes = await writeXlsx({ + sheets: [ + { + name: "S", + rows: [["a"], ["b"]], + rowDefs: new Map([[1, { height: 20, style: TOTAL }]]), + }, + ], + }) + const patched = await rewrite(bytes, "xl/worksheets/sheet1.xml", (xml) => + xml.replace(/ { + // §18.3.1.73: a row's `s` is the format of the cells that are *not* in + // the row. A `` that is present keeps its own `s`, 0 included, so a + // cell the file explicitly left unformatted must not pick up the row's + // format on the way through. Found in review of #612. + async function source(cellB1: string): Promise { + const bytes = await writeXlsx({ + sheets: [ + { + name: "S", + rows: [["a", "b"]], + rowDefs: new Map([[0, { style: TOTAL }]]), + }, + ], + }) + return rewrite(bytes, "xl/worksheets/sheet1.xml", (xml) => + xml.replace(/]*>/, cellB1), + ) + } + + for (const [label, cellB1] of [ + ['s="0"', ''], + ["no s at all", ''], + ] as const) { + it(`keeps B1 at the default format through readXlsx → writeXlsx (${label})`, async () => { + const wb = await readXlsx(await source(cellB1), { readStyles: true }) + const s = wb.sheets[0]! + const xml = await sheetXml({ name: s.name, rows: s.rows, cells: s.cells, rowDefs: s.rowDefs }) + + expect(xml).toMatch(//) + expect(xml).toMatch(//) + }) + + it(`keeps B1 at the default format through openXlsx → saveXlsx (${label})`, async () => { + const book = await openXlsx(await source(cellB1), { readStyles: true }) + const xml = await part(await saveXlsx(book), "xl/worksheets/sheet1.xml") + + expect(xml).toMatch(//) + expect(xml).toMatch(//) + }) + } + + it("still gives a cell the caller left unstyled the row's format", async () => { + const xml = await sheetXml({ + name: "S", + rows: [["a", "b"]], + rowDefs: new Map([[0, { style: TOTAL }]]), + }) + + expect(xml).toMatch(//) + }) +}) diff --git a/test/xlsx-write-read-parity.test.ts b/test/xlsx-write-read-parity.test.ts index 8d9eaa7..41cd8f6 100644 --- a/test/xlsx-write-read-parity.test.ts +++ b/test/xlsx-write-read-parity.test.ts @@ -289,12 +289,15 @@ const SHEET_FIELDS: { [K in keyof Required]: Entry } colBreaks: { value: [1], read: (sheet) => sheet.colBreaks }, rowDefs: { - value: new Map([[1, { height: 30, hidden: false, outlineLevel: 1 }]]), + value: new Map([ + [1, { height: 30, hidden: false, outlineLevel: 1, style: { numFmt: "0.00" } }], + ]), read: (sheet) => ({ height: sheet.rowDefs?.get(1)?.height, outlineLevel: sheet.rowDefs?.get(1)?.outlineLevel, + numFmt: sheet.rowDefs?.get(1)?.style?.numFmt, }), - expected: { height: 30, outlineLevel: 1 }, + expected: { height: 30, outlineLevel: 1, numFmt: "0.00" }, }, defaultRowHeight: {