Skip to content

feat(odf.js): add a real .ods writer (writeOdsContent/writeOds) - #889

Merged
Mearman merged 4 commits into
mainfrom
feat/odf-ods-writer
Sep 3, 2026
Merged

feat(odf.js): add a real .ods writer (writeOdsContent/writeOds)#889
Mearman merged 4 commits into
mainfrom
feat/odf-ods-writer

Conversation

@Mearman

@Mearman Mearman commented Sep 3, 2026

Copy link
Copy Markdown
Member

Summary

Builds writeOdsContent/writeOds in odf.js -- the genuine inverse of readOdsContent/readOds -- at the same two levels the existing writeOdt/writeOdtContent writer established. This is the direct prerequisite for .sxc (OpenOffice.org 1.x spreadsheet) write support, the same way the .odt writer was the prerequisite for the .sxw writer.

Progresses #815, #821, #85. Not a full resolution of any of them -- this is one spreadsheet-format writer, not full read+write parity for the ecosystem-wide gaps those issues track.

What it covers

  • Every ContentCellValue kind readOdsContent can actually produce (number, percentage, currency, boolean, date, time, string, empty) writes back with the correct office:value-type and its own value attribute -- preferring a number's exactValue for the office:value literal when present.
  • Column widths, row heights, hidden rows/columns, and merged ranges (table:number-columns/-rows-spanned + compressed table:covered-table-cell runs).
  • Cell-anchored images (draw:frame inside the anchor cell).
  • Print settings: page geometry, gridlines/headers, page order, scale/fit-to-page, print range, repeated header rows/columns, manual page breaks -- via a real master page + page layout each sheet's own table-family style names.
  • Multiple sheets, cell background/borders/alignment/vertical-alignment, verbatim formulas, inline run formatting (bold/italic/color/hyperlinks), and manually broken multi-line cells.
  • writeOds flattens a DocumentTree through document-schema.js and delegates to writeOdsContent, mirroring writeOdt.

Honest scope gaps

  • Embedded objects, data-validation rules, and conditional-formatting rules are refused by name for every sheet. readOdsContent has no write-side counterpart for any of the three today: no embedded-sub-document package writer exists anywhere in this package's typed layer yet (the .odt writer refuses all embedded objects too, for the same reason), and the reader itself never populates either rule array. Writing one would either drop it silently or misrepresent it.
  • A cell's numberFormatCode is not written as a number:* data-style/style:data-style-name reference. readOdsContent does not populate that field for any cell today -- there is no genuine inverse to write against or verify. Every cell still writes back with the correct office:value-type regardless, which is the fact that actually round-trips.
  • A 'time' cell's ISO wall-clock value is converted to a real ODF xsd:duration for office:time-value (there is no other valid ODF spelling), but readOdsContent does not perform the inverse conversion yet -- a narrow, pre-existing, unrelated reader gap this writer's own correctness cannot depend on being fixed. Documented in the module's own top-of-file note rather than worked around with non-conformant XML.
  • Sheet-level residue (sheet.source) is dropped on write, the one deliberate exception this shares with the .odt writer's own stance.

Test plan

  • write.test.ts pins the actual XML shapes a real ODF consumer expects (value-type attributes, merged-range shape, gap-cell compression, image frame, style:master-page-name placement).
  • write-round-trip.test.ts states and verifies the round-trip law (normaliseOdsContent(readOdsContent(writeOdsContent(document))) === normaliseOdsContent(document)) across every cell kind, formulas, formatting, merges, multi-sheet, column/row sizing, images, print settings, and every named refusal -- plus a real LibreOffice-produced fixture.
  • pnpm exec turbo run _lint _typecheck _test _test:workers --filter=odf.js -- all green.
  • Full-workspace pnpm exec turbo run _lint _typecheck sanity pass.
  • pnpm exec turbo run _test:smoke --filter=odf.js.
  • README updated (Status + "Writing a document" section).

…stants for reuse

formatBorderEdge/BORDER_EDGE_KEYS/BORDER_EDGE_ATTRS (typed/shared/table.ts)
and DEFAULT_COLUMN_WIDTH_PT/DEFAULT_ROW_HEIGHT_PT (typed/ods/read.ts) are
needed by the forthcoming ods writer, which writes the identical XSL-FO
border shorthand ContentSheetCell.borders uses and states its own
round-trip law against the reader's own column/row default-stamping
constants rather than restating the same magic numbers a second time.
…e ods reader

Builds a real .ods writer at both levels the typed layer already uses for
odt: writeOdsContent takes the flat ContentDocument readOdsContent
returns, and writeOds flattens a DocumentTree through document-schema.js
before delegating to it.

Every cell value kind readOdsContent can produce (number, percentage,
currency, boolean, date, time, string, empty) writes back with the
correct office:value-type and its own value attribute, preferring a
number's exactValue for the office:value literal when present. A
'dateTime' or 'error' cell is refused by name, since readOdsContent's own
office:value-type switch can never produce either kind for an .ods
document, so there is no genuine inverse to write against. A 'time'
cell's ISO wall-clock value is converted to a real ODF xsd:duration
(office:time-value has no other valid spelling), which readOdsContent
does not yet convert back on its own -- a narrow, pre-existing, unrelated
reader gap this writer's own correctness cannot depend on being fixed,
documented in the module's own top-of-file note rather than worked
around with non-conformant XML.

Column widths, row heights, hidden rows/columns, and merged ranges all
round-trip: a sparse columns/rows array densifies to one entry per
position across the sheet's own used range (ODF's table:table-column/-row
model is purely positional), and merges write table:number-columns/-rows-
spanned on the anchor cell with the covered grid compressed into
table:covered-table-cell runs. Cell-anchored images write a draw:frame
directly inside their own table:table-cell -- the one representation that
already covers readOdsContent's own page-anchored convention too, since
the two are numerically indistinguishable at the sheet origin. Print
settings (page geometry, gridlines/headers, page order, scale/fit-to-
page, print range, repeated header rows/columns, manual page breaks) mint
a real master page and page layout the sheet's own table-family style
names, mirroring exactly how readOdsContent resolves them back.

Multiple sheets, sheet names, cell background/borders/alignment/vertical-
alignment, formulas carried verbatim, and inline run formatting
(including a hyperlink and a manually broken multi-line cell) all
round-trip too.

Embedded objects, data-validation rules, and conditional-formatting rules
are refused by name for every sheet: readOdsContent has no write-side
counterpart for any of the three today (no embedded-sub-document package
writer exists anywhere in this package's typed layer yet, and the reader
itself never populates either rule array), so writing one would either
drop it silently or misrepresent it. Sheet-level residue is dropped, the
one deliberate exception shared with the odt writer's own stance.

normaliseOdsContent states the canonical form a written-and-reread
document equals, including every forced normalisation readOdsContent's
own established behaviour imposes rather than this writer's own choice:
a value-less, formula-less, text-less cell vanishes entirely (the
reader's own trailing-empty-cell skip runs before any of its other
attributes are considered), a bare newline run becomes a genuine
paragraph split rather than a text:line-break, and images reorder into
row-major anchor-position document order.
write.test.ts pins the actual ODF a real consumer expects (value-type
attributes, the merged-range covered-cell shape, gap-cell compression,
a cell-anchored image frame, and where style:master-page-name genuinely
lives) -- facts a round trip through this package's own reader cannot
prove, since a writer and reader that agreed on the same wrong spelling
would round-trip perfectly and open nowhere.

write-round-trip.test.ts states the law itself:
normaliseOdsContent(readOdsContent(writeOdsContent(document))) equals
normaliseOdsContent(document), for every document the writer accepts.
Covers every cell value kind, exactValue's own non-round-tripping
precision, formulas, inline run formatting and hyperlinks, multi-line
cells, cell decoration, merged ranges, multiple sheets, column/row
sizing and hidden state, cell-anchored images (including one placed at
the sheet origin), print settings (scale, fit-to-page, print range,
repeated header rows/columns, manual breaks), every refusal this writer
makes by name, and a real LibreOffice-produced fixture.
Adds the ods writer to the Status section's built-and-shipped list and
to the "Writing a document" section alongside writeOdt, naming exactly
what round-trips (every cell value kind readOdsContent can produce,
column/row sizing, merges, decoration, formulas, cell-anchored images,
print settings, multiple sheets) and what is refused by name and why
(embedded objects, data-validation rules, conditional-formatting rules,
and a cell's own numberFormatCode, none of which readOdsContent has a
write-side counterpart for today). Also corrects the Fidelity constructs
section's own now-stale claim that the typed layer has no content writer
at all -- writeOdt already refuses every fidelity construct it reads by
name, which is the real asymmetry, not an absent writer.

Adds writeOds/writeOdsContent/normaliseOdsContent to the smoke suite's
expected export list, matching the odt writer's own entries.
@Mearman
Mearman marked this pull request as ready for review September 3, 2026 17:27
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review Completed 2026-09-03T17:35:35.367653Z 2710603 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant