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
37 changes: 24 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<row s="N" customFormat="1">` — 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 — `<filters><filter val="…"/></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
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 20 additions & 9 deletions docs/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 (`<row s customFormat>`) 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
Expand Down Expand Up @@ -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: `<table:named-expressions>` carries them
both ways. Only workbook-level ones — ODF scopes a name to a sheet by
Expand Down
3 changes: 1 addition & 2 deletions docs/SPEC-COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down
1 change: 0 additions & 1 deletion scripts/spec-coverage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
const text = readFileSync(p, "utf8")
corpusText += text + "\n"
// A quoted literal — `case "sheetPr"` — is how a reader switches.
for (const m of text.matchAll(/["'`]([A-Za-z_][\w:.\-]*)["'`]/g)) exact.add(m[1])

Check warning on line 84 in scripts/spec-coverage.mjs

View workflow job for this annotation

GitHub Actions / node lts (TZ=Asia/Tokyo)

eslint(no-useless-escape)

Unnecessary escape character '-'

Check warning on line 84 in scripts/spec-coverage.mjs

View workflow job for this annotation

GitHub Actions / node lts/*

eslint(no-useless-escape)

Unnecessary escape character '-'

Check warning on line 84 in scripts/spec-coverage.mjs

View workflow job for this annotation

GitHub Actions / node 24

eslint(no-useless-escape)

Unnecessary escape character '-'
// An object key — `xmlSelfClose("calcPr", { calcId: 0 })` — is how
// a writer emits an attribute. Missing this form reported a dozen
// attributes hucre writes on every workbook as unknown to it.
Expand Down Expand Up @@ -275,7 +275,6 @@
// 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",

Expand Down
13 changes: 12 additions & 1 deletion src/_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -908,6 +910,15 @@ export interface RowDef {
outlineLevel?: number
/** Whether this outline group is collapsed */
collapsed?: boolean
/**
* Default style for the whole row — `<row s="N" customFormat="1">`. 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 ──────────────────────────────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WriteSheet["rowDefs"]> extends Map<number, infer T> ? T : never,
Expand Down
4 changes: 3 additions & 1 deletion src/sheet-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -956,7 +956,9 @@ export function cloneSheet(sheet: Sheet, newName: string): Sheet {
if (sheet.rowDefs && sheet.rowDefs.size > 0) {
const rowDefs = new Map<number, RowDef>()
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
}
Expand Down
27 changes: 17 additions & 10 deletions src/xlsx/stream-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, RowDef>
/**
Expand Down Expand Up @@ -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)) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
style = { ...style, numFmt: colDef.numFmt }
}

Expand All @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions src/xlsx/styles-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,8 +311,11 @@ export function createStylesCollector(
const numFmtMap = new Map<string, number>()
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,
Expand Down
Loading
Loading