feat(odf.js): add a real .ods writer (writeOdsContent/writeOds) - #889
Merged
Conversation
…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
marked this pull request as ready for review
September 3, 2026 17:27
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
This was referenced Sep 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Builds
writeOdsContent/writeOdsinodf.js-- the genuine inverse ofreadOdsContent/readOds-- at the same two levels the existingwriteOdt/writeOdtContentwriter established. This is the direct prerequisite for.sxc(OpenOffice.org 1.x spreadsheet) write support, the same way the.odtwriter was the prerequisite for the.sxwwriter.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
ContentCellValuekindreadOdsContentcan actually produce (number, percentage, currency, boolean, date, time, string, empty) writes back with the correctoffice:value-typeand its own value attribute -- preferring a number'sexactValuefor theoffice:valueliteral when present.table:number-columns/-rows-spanned+ compressedtable:covered-table-cellruns).draw:frameinside the anchor cell).writeOdsflattens aDocumentTreethroughdocument-schema.jsand delegates towriteOdsContent, mirroringwriteOdt.Honest scope gaps
readOdsContenthas 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.odtwriter 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.numberFormatCodeis not written as anumber:*data-style/style:data-style-namereference.readOdsContentdoes 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 correctoffice:value-typeregardless, which is the fact that actually round-trips.xsd:durationforoffice:time-value(there is no other valid ODF spelling), butreadOdsContentdoes 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.source) is dropped on write, the one deliberate exception this shares with the.odtwriter's own stance.Test plan
write.test.tspins the actual XML shapes a real ODF consumer expects (value-type attributes, merged-range shape, gap-cell compression, image frame,style:master-page-nameplacement).write-round-trip.test.tsstates 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.pnpm exec turbo run _lint _typechecksanity pass.pnpm exec turbo run _test:smoke --filter=odf.js.