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
8 changes: 8 additions & 0 deletions docs/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
30 changes: 14 additions & 16 deletions docs/SPEC-COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -187,7 +186,7 @@ table:tracked-changes

</details>

<details><summary>164 attributes</summary>
<details><summary>163 attributes</summary>

```
number:calendar
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 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 24

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 lts (TZ=Asia/Tokyo)

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 @@ -329,7 +329,7 @@
"table:named-expressions":
"read and written since #557, workbook-level. ODF scopes a name to a sheet by putting the block inside that `<table:table>`, which is not done yet; `<table:named-expression>` — 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",
}),
)

Expand Down
44 changes: 43 additions & 1 deletion src/ods/reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,30 @@ function parseCellValue(cell: XmlElement): CellValue {
* `<table:named-expression>` — 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<string | undefined> {
const out: Array<string | undefined> = []
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
Expand Down Expand Up @@ -687,6 +711,13 @@ function parseContentXml(
const cells = new Map<string, Cell>()
const tableRows = findChildren(table, "table-row")

// LibreOffice puts a column's format on the column rather than on its
// cells: `<table:table-column table:default-cell-style-name="ce1"/>`,
// 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

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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({
Expand All @@ -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
}
}

Expand Down
89 changes: 89 additions & 0 deletions test/ods-column-default-style.test.ts
Original file line number Diff line number Diff line change
@@ -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.
// `<table:table-column table:default-cell-style-name="ce1"/>` 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()
})
})
1 change: 1 addition & 0 deletions tsconfig.cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
Loading