diff --git a/docs/PARITY.md b/docs/PARITY.md index 3acdf2d..e9dde88 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -567,6 +567,14 @@ The reader opens `content.xml` and `meta.xml`. It does not open default cell styles and all page setup — so a LibreOffice-authored file reads back with its direct formatting only. +A column's `table:default-cell-style-name` _is_ read, and fills in for +cells that name no style of their own. That matters because LibreOffice +puts a column's number format there rather than on the cells: without it +a LibreOffice document came back with its values and none of its formats. +A column pointing at a named style — `"Default"`, which it writes on the +columns it did not format — still resolves to nothing, for the reason +above. + See [What ODS carries](../README.md#what-ods-carries) for the consequences worth knowing before relying on it. diff --git a/docs/SPEC-COVERAGE.md b/docs/SPEC-COVERAGE.md index afe92dc..6d1d258 100644 --- a/docs/SPEC-COVERAGE.md +++ b/docs/SPEC-COVERAGE.md @@ -51,7 +51,7 @@ Corpus under `test/fixtures`: 35 OOXML workbooks (455 element names, 245 attribu ## OpenDocument (OASIS ODF 1.3) -153 elements and 224 attributes in the spreadsheet-relevant namespaces. 116 are named somewhere in `src/`; 261 are not. +153 elements and 224 attributes in the spreadsheet-relevant namespaces. 117 are named somewhere in `src/`; 260 are not. ### In an ODF document here, not yet looked at @@ -61,19 +61,18 @@ office:settings ### In an ODF document here, looked at and left -| name | why | -| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `number:boolean-style` | a data style for booleans. Excel has no format code for one — it shows TRUE/FALSE — so there is nothing to map it to | -| `number:fill-character` | the character Excel's `*` repeats to pad a cell. `*` is not in hucre's format model at either end | -| `table:automatic-find-labels` | formula label lookup — no formula engine | -| `table:calculation-settings` | calculation preferences — hucre has no formula engine | -| `table:case-sensitive` | formula comparison rule — no formula engine | -| `table:default-cell-style-name` | **open** — a column's default cell style. hucre reads direct formatting only, and `PARITY.md` records that the ODS reader does not open `styles.xml`; this is the `content.xml` half of the same gap | -| `table:iteration` | iterative calculation settings — no formula engine | -| `table:maximum-difference` | iteration convergence bound — no formula engine | -| `table:null-year` | the century a two-digit year resolves into, for the formula parser | -| `table:use-regular-expressions` | formula matching rule — no formula engine | -| `table:use-wildcards` | formula matching rule — no formula engine | +| name | why | +| ------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `number:boolean-style` | a data style for booleans. Excel has no format code for one — it shows TRUE/FALSE — so there is nothing to map it to | +| `number:fill-character` | the character Excel's `*` repeats to pad a cell. `*` is not in hucre's format model at either end | +| `table:automatic-find-labels` | formula label lookup — no formula engine | +| `table:calculation-settings` | calculation preferences — hucre has no formula engine | +| `table:case-sensitive` | formula comparison rule — no formula engine | +| `table:iteration` | iterative calculation settings — no formula engine | +| `table:maximum-difference` | iteration convergence bound — no formula engine | +| `table:null-year` | the century a two-digit year resolves into, for the formula parser | +| `table:use-regular-expressions` | formula matching rule — no formula engine | +| `table:use-wildcards` | formula matching rule — no formula engine | ### Not named in the source @@ -187,7 +186,7 @@ table:tracked-changes -
164 attributes +
163 attributes ``` number:calendar @@ -246,7 +245,6 @@ table:database-table-name table:date-end table:date-start table:date-value -table:default-cell-style-name table:display-border table:display-duplicates table:display-filter-buttons diff --git a/scripts/spec-coverage.mjs b/scripts/spec-coverage.mjs index c6bda76..1652903 100644 --- a/scripts/spec-coverage.mjs +++ b/scripts/spec-coverage.mjs @@ -329,7 +329,7 @@ const REVIEWED_ODF = new Map( "table:named-expressions": "read and written since #557, workbook-level. ODF scopes a name to a sheet by putting the block inside that ``, which is not done yet; `` — a formula rather than a range — has no field to land in", "table:default-cell-style-name": - "**open** — a column's default cell style. hucre reads direct formatting only, and `PARITY.md` records that the ODS reader does not open `styles.xml`; this is the `content.xml` half of the same gap", + "read since #558 — a column's default fills in for cells that name no style of their own, which is where LibreOffice puts a column's number format. A column naming a style from `styles.xml` still resolves to nothing, and `PARITY.md` says so", }), ) diff --git a/src/ods/reader.ts b/src/ods/reader.ts index aa74391..422de86 100644 --- a/src/ods/reader.ts +++ b/src/ods/reader.ts @@ -601,6 +601,30 @@ function parseCellValue(cell: XmlElement): CellValue { * `` — a formula rather than a range — has no * `NamedRange` to land in and is left alone. */ +/** + * The default cell style of each column, expanded by its repeat count. + * + * `table:default-cell-style-name` names an automatic style in + * `content.xml`, which this reader parses — so the format it points at is + * reachable. A column naming a style from `styles.xml` instead + * (LibreOffice writes `"Default"` on the columns it did not format) + * resolves to nothing, which is the right answer: that style *is* the + * absence of formatting, and `PARITY.md` records that `styles.xml` is not + * opened. + */ +function readColumnDefaultStyles(table: XmlElement): Array { + const out: Array = [] + for (const column of findChildren(table, "table-column")) { + const style = column.attrs["table:default-cell-style-name"] + const repeat = Math.min( + Math.max(Number(column.attrs["table:number-columns-repeated"] ?? "1") || 1, 1), + MAX_COL_INDEX + 1, + ) + for (let i = 0; i < repeat && out.length <= MAX_COL_INDEX; i++) out.push(style) + } + return out +} + function parseNamedExpressions(spreadsheet: XmlElement): NamedRange[] | undefined { const block = findChild(spreadsheet, "named-expressions") if (!block) return undefined @@ -687,6 +711,13 @@ function parseContentXml( const cells = new Map() const tableRows = findChildren(table, "table-row") + // LibreOffice puts a column's format on the column rather than on its + // cells: ``, + // where `ce1` names the data style. A date column's `yyyy-mm-dd` lives + // there and nowhere else, so a document read without this came back + // with its values and none of its formats. See #464. + const columnDefaultStyles = readColumnDefaultStyles(table) + let currentRow = 0 let pendingEmptyRows = 0 @@ -715,6 +746,10 @@ function parseContentXml( hyperlink?: Hyperlink }> = [] + // Which column the next entry starts at, so a cell that names no + // style can be given its column's default. + let colIndex = 0 + for (const child of tableRow.children) { if (typeof child === "string") continue const local = child.local || child.tag @@ -724,7 +759,9 @@ function parseContentXml( const colSpan = Number(child.attrs["table:number-columns-spanned"] ?? "1") const rowSpan = Number(child.attrs["table:number-rows-spanned"] ?? "1") const value = parseCellValue(child) - const styleName = child.attrs["table:style-name"] + // A cell's own style wins; the column's default only fills in + // for cells that named none. + const styleName = child.attrs["table:style-name"] ?? columnDefaultStyles[colIndex] const formulaAttr = child.attrs["table:formula"] const formula = formulaAttr ? odsFormulaToExcel(formulaAttr) : undefined const { hyperlink } = extractTextAndHyperlink(child) @@ -738,6 +775,7 @@ function parseContentXml( formula, hyperlink, }) + colIndex += Number.isFinite(colRepeat) && colRepeat > 0 ? colRepeat : 1 } else if (local === "covered-table-cell") { const colRepeat = Number(child.attrs["table:number-columns-repeated"] ?? "1") cellEntries.push({ @@ -747,6 +785,10 @@ function parseContentXml( rowSpan: 1, isCovered: true, }) + // A covered cell still occupies its columns, so the count has + // to advance or every default after a merge lands one column + // to the left. + colIndex += Number.isFinite(colRepeat) && colRepeat > 0 ? colRepeat : 1 } } diff --git a/test/ods-column-default-style.test.ts b/test/ods-column-default-style.test.ts new file mode 100644 index 0000000..e961336 --- /dev/null +++ b/test/ods-column-default-style.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest" +import { readFileSync } from "node:fs" +import { readOds } from "../src/ods/reader" +import { writeOds } from "../src/ods/writer" + +// ═══════════════════════════════════════════════════════════════════════ +// LibreOffice puts a column's format on the column, not on its cells. +// `` names an +// automatic style, and that style names the data style — so a date +// column's `yyyy-mm-dd` lives there and nowhere else. The cells +// themselves say `table:style-name="Default"`, a *named* style in +// `styles.xml`, which this reader does not open. +// +// hucre read neither, so a LibreOffice document came back with its values +// and none of its formats. +// +// The last open item in `SPEC-COVERAGE.md`: `table:default-cell-style-name` +// was in the grammar, in `libreoffice-basic.ods`, and nowhere in `src/`. +// +// Only the `content.xml` half is reachable. A column pointing at a named +// style — `"Default"`, which LibreOffice writes on the unformatted +// columns — still resolves to nothing, because that style is in +// `styles.xml`. PARITY records that, and this does not change it. +// ═══════════════════════════════════════════════════════════════════════ + +const LIBREOFFICE = "test/fixtures/third-party/libreoffice-basic.ods" + +describe("a column's default style reaches its cells", () => { + it("gives the LibreOffice date column its format back", async () => { + const bytes = new Uint8Array(readFileSync(LIBREOFFICE)) + const wb = await readOds(bytes, { readStyles: true }) + const sheet = wb.sheets[0]! + + // Column C (index 2) is the date column; its style is on the column. + const dateCell = sheet.cells?.get("1,2") + + expect(dateCell?.style?.numFmt).toBe("yyyy-mm-dd") + }) + + it("without readStyles, nothing changes", async () => { + // Styles are not being read, so a column default is not information + // the caller asked for — the same rule the rest of the reader follows. + const bytes = new Uint8Array(readFileSync(LIBREOFFICE)) + const wb = await readOds(bytes) + + expect(wb.sheets[0]!.cells?.get("1,2")?.style).toBeUndefined() + }) + + it("and the values are untouched either way", async () => { + const bytes = new Uint8Array(readFileSync(LIBREOFFICE)) + const wb = await readOds(bytes, { readStyles: true }) + + expect(wb.sheets[0]!.rows[0]).toEqual(["Name", "Qty", "Date", "Active", "Total"]) + expect(wb.sheets[0]!.rows[1]![2]).toBeInstanceOf(Date) + }) +}) + +describe("a cell's own style still wins", () => { + it("over the column it sits in", async () => { + // hucre's own writer puts the style on the cell. That must not be + // overridden by a column default, whichever order they are read in. + const bytes = await writeOds({ + sheets: [ + { + name: "S", + rows: [[1, 2]], + cells: new Map([["0,0", { value: 1, style: { numFmt: "0.000" } }]]), + }, + ], + }) + const cell = (await readOds(bytes, { readStyles: true })).sheets[0]!.cells?.get("0,0") + + expect(cell?.style?.numFmt).toBe("0.000") + }) +}) + +describe("what is still not reachable", () => { + it("a column naming a style from styles.xml resolves to nothing", async () => { + // LibreOffice writes `table:default-cell-style-name="Default"` on the + // columns it did not format. `Default` lives in `styles.xml`, which + // this reader does not open, so those cells stay unstyled — which is + // the correct answer here, since the default style is the absence of + // formatting. + const bytes = new Uint8Array(readFileSync(LIBREOFFICE)) + const wb = await readOds(bytes, { readStyles: true }) + + expect(wb.sheets[0]!.cells?.get("1,0")?.style?.numFmt).toBeUndefined() + }) +}) diff --git a/tsconfig.cli.json b/tsconfig.cli.json index e84c474..282ec60 100644 --- a/tsconfig.cli.json +++ b/tsconfig.cli.json @@ -29,6 +29,7 @@ "test/xlsb-short-records.test.ts", "test/xlsx-stream-parity.test.ts", "test/indexed-color-palette.test.ts", + "test/ods-column-default-style.test.ts", "test/write-model.test.ts", "test/builder-coverage.test.ts" ], diff --git a/tsconfig.json b/tsconfig.json index 5a147aa..57e4870 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -49,6 +49,7 @@ "test/xlsb-short-records.test.ts", "test/xlsx-stream-parity.test.ts", "test/indexed-color-palette.test.ts", + "test/ods-column-default-style.test.ts", "test/builder-coverage.test.ts" ] }