diff --git a/README.md b/README.md index ce0d628ab..59a502820 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Each converts one document format to and from the shared schema, built on `docum | Package | Formats | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`ooxml.js`](packages/ooxml.js/README.md) | OOXML packages (docx, pptx, xlsx) to and from JSON. | -| [`odf.js`](packages/odf.js/README.md) | OpenDocument packages (odt, ods, odp) to and from JSON, plus read support for the pre-OASIS OpenOffice.org 1.x documents ODF was based on (sxw, sxc, sxi, sxd) and a write path for sxw. | +| [`odf.js`](packages/odf.js/README.md) | OpenDocument packages (odt, ods, odp) to and from JSON, plus read support for the pre-OASIS OpenOffice.org 1.x documents ODF was based on (sxw, sxc, sxi, sxd) and write paths for sxw and sxc. | | [`markdown-codec`](packages/markdown-codec/README.md) | CommonMark+GFM to and from the shared content schema. | | [`pdf-codec`](packages/pdf-codec/README.md) | Parses arbitrary real-world PDFs and generates new ones, also depending on `byte-codec`. | | [`epub-codec`](packages/epub-codec/README.md) | Flowable EPUB 2/3 to and from the shared content schema; writes EPUB 3 only. | diff --git a/packages/odf.js/README.md b/packages/odf.js/README.md index d98a49dd9..e42cc36ca 100644 --- a/packages/odf.js/README.md +++ b/packages/odf.js/README.md @@ -2,7 +2,7 @@ [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/documents.js/tree/main/packages/odf.js) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/odf.js) [![npm version](https://img.shields.io/npm/v/odf.js)](https://www.npmjs.com/package/odf.js) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/documents.js/ci.yml?branch=main)](https://github.com/ExaDev/documents.js/actions) -> A hand-written, dependency-minimal codec for the OpenDocument Format (ODF — OASIS/ISO 26300): `.odt`/`.ods`/`.odp`/`.odg`/`.odf`/`.odb`/`.odm` and their template variants, built on [Zod 4](https://zod.dev) codecs — plus read support for the pre-OASIS OpenOffice.org 1.x / StarOffice 6-7 documents ODF was based on (`.sxw`/`.sxc`/`.sxi`/`.sxd`), with a real `.sxw` writer alongside it. +> A hand-written, dependency-minimal codec for the OpenDocument Format (ODF — OASIS/ISO 26300): `.odt`/`.ods`/`.odp`/`.odg`/`.odf`/`.odb`/`.odm` and their template variants, built on [Zod 4](https://zod.dev) codecs — plus read support for the pre-OASIS OpenOffice.org 1.x / StarOffice 6-7 documents ODF was based on (`.sxw`/`.sxc`/`.sxi`/`.sxd`), with real `.sxw` and `.sxc` writers alongside it. `odf.js` is the ODF sibling of [`ooxml.js`](../ooxml.js/README.md), mirroring its architecture: a lossless ZIP-of-XML core that round-trips any package byte-for-content-faithful, with typed readers layered on top. Two ODF-specific differences shape the design: ODF has no relationship mechanism (inter-part references are direct paths, with an exhaustive `META-INF/manifest.xml`), and ODF has no inline/direct formatting — every formatting difference must be a named "automatic style," so `odf.js` owns a style-interning subsystem (`src/styles/`) with no OOXML equivalent. @@ -65,7 +65,7 @@ Under active development. Built and shipped: - **`readOdbInventory`** — resolves a `.odb` into connection info, table names, query definitions (`{ name, command, escapeProcessing? }` with real SQL text), and form/report `{ name, href }` pairs. A sub-document directory is named after an opaque _persistent_ name (`forms/Obj11`), not the user-visible name. - **`readOdbForm`/`readOdbReport`** — extract one sub-document's _static structure_, executing nothing: a form's control tree and data bindings, or a report's band stack, recursive group tree, bound fields, and computed expressions. - **OpenOffice.org 1.x / StarOffice 6-7 reading** (`readSxw`/`readSxc`/`readSxi`/`readSxd` and their `*Content` siblings, plus `transformOoo1Package` and `isOoo1Package`) — the pre-OASIS ancestor ODF 1.0 was based on, read through the ODF readers above rather than beside them. See [Reading and writing an OpenOffice.org 1.x document](#reading-and-writing-an-openofficeorg-1x-document). -- **OpenOffice.org 1.x writing** (`writeSxw`/`writeSxwContent`, plus `transformToOoo1Package`, the read-side transform's own inverse) — `.sxw` only, built on `writeOdt`/`writeOdtContent`: `.sxc`/`.sxi`/`.sxd` have no writer yet. `.sxc` now has a real `writeOds`/`writeOdsContent` to build one on (the same relationship `.sxw` already has to `writeOdt`/`writeOdtContent`); `.sxi`/`.sxd` still have no `writeOdp`/`writeOdg` underneath them. +- **OpenOffice.org 1.x writing** (`writeSxw`/`writeSxwContent` and `writeSxc`/`writeSxcContent`, plus `transformToOoo1Package`, the read-side transform's own inverse) — `.sxw`, built on `writeOdt`/`writeOdtContent`, and `.sxc`, built on `writeOds`/`writeOdsContent`. `.sxi`/`.sxd` still have no writer, since this package's typed layer has no `writeOdp`/`writeOdg` underneath them yet. - **The odt writer, at the same two levels** — `writeOdt` takes the `DocumentTree` `readOdt` returns and `writeOdtContent` the flat `ContentDocument` `readOdtContent` returns, and both produce a real `.odt` `Package` (`encodePackage` turns it into bytes). Paragraphs, headings, runs with character formatting and hyperlinks, whitespace, lists, tables, images, explicit page breaks, per-section page geometry, and `meta.xml` all round-trip; the fidelity constructs and embedded objects are refused by name rather than silently dropped. See [Writing a document](#writing-a-document). - **The ods writer, at the same two levels** — `writeOds`/`writeOdsContent`, the genuine inverse of `readOds`/`readOdsContent`. Every `office:value-type` a cell can carry (float/percentage/currency/boolean/date/time/string, plus a value-less cell), column widths, row heights, hidden rows/columns, merged ranges, cell background/borders/alignment/vertical-alignment, verbatim formulas, cell-anchored images, and print settings (page geometry, gridlines/headers, page order, scale/fit-to-page, print range, repeated header rows/columns, manual page breaks) all round-trip. Embedded objects, data-validation rules, and conditional-formatting rules are refused by name — `readOdsContent` has no write-side counterpart for any of the three yet. See [Writing a document](#writing-a-document). @@ -249,20 +249,29 @@ None of these is a second reader. Each is `readOdt`/`readOds`/`readOdp`/`readOdg `transformOoo1Package` is exported for a caller that wants the transformed `Package` rather than a read of it, and returns anything that is not an OpenOffice.org 1.x package unchanged; `isOoo1Package` is the same detection on its own, decided by the namespace URIs the package's parts declare rather than by a file extension or a manifest media type. `OOO1_NAMESPACES`, `OOO1_MEDIA_TYPES`, `ooo1MediaTypeForExtension` and `odfMediaTypeForOoo1MediaType` expose the format's own namespace and media-type tables. -`.sxw` has a real writer, built the same way the reader is — as a transform either side of the ODF writer, not a second writer of its own: +`.sxw` and `.sxc` each have a real writer, built the same way the reader is — as a transform either side of the ODF writer, not a second writer of its own: ```ts -import { writeSxw, writeSxwContent, encodePackage } from "odf.js"; +import { + writeSxw, + writeSxwContent, + writeSxc, + writeSxcContent, + encodePackage, +} from "odf.js"; const pkg = writeSxw(document); // a wordprocessing DocumentTree -> a real .sxw Package const bytes = encodePackage(pkg); // Package -> bytes const pkgFromContent = writeSxwContent(contentDocument); // the flat ContentDocument level, same shape writeOdtContent returns + +const sxcPkg = writeSxc(spreadsheetTree); // a spreadsheet DocumentTree -> a real .sxc Package +const sxcPkgFromContent = writeSxcContent(spreadsheetContentDocument); // the flat ContentDocument level, same shape writeOdsContent returns ``` -`writeSxw`/`writeSxwContent` call `writeOdt`/`writeOdtContent` to build a real ODF `.odt` `Package`, then run it through `transformToOoo1Package` — `transformOoo1Package`'s own inverse, reversing every rename and restructure the read-side transform documents (namespace URIs, the `office:class` genre wrap/unwrap, the `style:properties` typed-family split/merge, the `draw:frame` wrap/unwrap, the renamed elements and attributes, the `"inch"`/`"in"` unit spelling, and the package-level mimetype/manifest handling) against the same LibreOffice transformer source and OpenOffice.org DTD the forward direction is grounded against. The result genuinely declares OpenOffice.org 1.x namespace URIs, carries no `mimetype` part, and reads back correctly through the ordinary `readSxw`/`readSxwContent` — `readSxw(writeSxw(document))` recovers `document` up to the exact same canonical form `normaliseOdtContent` already states for `writeOdt`, since `writeSxwContent` is `writeOdtContent`'s own output run one transform further. What `writeOdt` refuses (the fidelity constructs — fields, bookmarks, notes, annotations, tracked changes, divisions, index wrappers, forms — and embedded objects), `writeSxw` refuses too, for the same reason: a document that silently lost semantic content would be worse than one this writer declined to produce at all. +`writeSxw`/`writeSxwContent` call `writeOdt`/`writeOdtContent` to build a real ODF `.odt` `Package`; `writeSxc`/`writeSxcContent` call `writeOds`/`writeOdsContent` to build a real ODF `.ods` `Package` the identical way. Both then run their package through `transformToOoo1Package` — `transformOoo1Package`'s own inverse, reversing every rename and restructure the read-side transform documents (namespace URIs, the `office:class` genre wrap/unwrap, the `style:properties` typed-family split/merge, the `draw:frame` wrap/unwrap, the renamed elements and attributes including a cell's `office:value-*` family becoming `table:value-*`, the `"inch"`/`"in"` unit spelling, and the package-level mimetype/manifest handling) against the same LibreOffice transformer source and OpenOffice.org DTD the forward direction is grounded against. Since `transformToOoo1Package` is itself generic across every ODF media type rather than `.odt`-specific, wiring `.sxc` up to it needed no changes to the transform at all — only a second pair of writer entry points wrapping `writeOds`/`writeOdsContent` the way `writeSxw`/`writeSxwContent` already wrap `writeOdt`/`writeOdtContent`. The result genuinely declares OpenOffice.org 1.x namespace URIs, carries no `mimetype` part, and reads back correctly through the ordinary readers — `readSxw(writeSxw(document))` recovers `document` up to the exact same canonical form `normaliseOdtContent` already states for `writeOdt`, and `readSxc(writeSxc(document))` recovers `document` up to the canonical form `normaliseOdsContent` already states for `writeOds`, since each `*Content` writer here is its ODF counterpart's own output run one transform further. What `writeOdt`/`writeOds` refuse (the odt fidelity constructs — fields, bookmarks, notes, annotations, tracked changes, divisions, index wrappers, forms; the ods embedded objects, data-validation rules, and conditional-formatting rules), `writeSxw`/`writeSxc` refuse too, for the same reason: a document that silently lost semantic content would be worse than one this writer declined to produce at all. -`.sxc`/`.sxi`/`.sxd` have no writer yet — this package's typed layer has no `writeOds`/`writeOdp`/`writeOdg` for one to be built on; only `.odt`/`.sxw` do. See [What differs between the two vocabularies](#what-differs-between-the-two-vocabularies) for what the transform covers, and its own module comment (`src/ooo1/transform.ts`) for the full list, including the reverse direction's own note (`transformToOoo1Package`) on the package-wide context (a document's `office:class`, a list's ordered/bullet kind) the reverse needs that the forward direction never did. +`.sxi`/`.sxd` still have no writer — this package's typed layer has no `writeOdp`/`writeOdg` for one to be built on. See [What differs between the two vocabularies](#what-differs-between-the-two-vocabularies) for what the transform covers, and its own module comment (`src/ooo1/transform.ts`) for the full list, including the reverse direction's own note (`transformToOoo1Package`) on the package-wide context (a document's `office:class`, a list's ordered/bullet kind) the reverse needs that the forward direction never did. ### What differs between the two vocabularies @@ -304,7 +313,7 @@ Layered from a lossless core outward, mirroring `ooxml.js`: - **`src/typed/draw/`** — the shared `draw:frame`/`draw:g`/vector shape vocabulary and `readDrawImageBlock` (`shapes.ts`), plus `embedded.ts` (`readDrawObjectReference`, `readEmbeddedObjectDocument`, `readOdfChartContent` — the shared embedded-object reference resolver and the central kind→reader dispatch table). - **`src/typed/formula/`, `odm/`** — `readOdfFormula`/`readOdfFormulaContent`/`readOdfFormulaMathMl` and `readOdm`. - **`src/typed/odb/`** — `readOdbInventory`, `readOdbForm`/`readOdbReport`, `resolveOdbComponent`, `subDocumentPackage`. -- **`src/ooo1/`** — the OpenOffice.org 1.x variant reader and writer: `ns.ts` (the pre-OASIS namespace and `application/vnd.sun.xml.*` media-type tables plus package detection, in both directions), `properties.ts` (the `style:properties` split, and `mergeStyleProperties`, its own inverse), `transform.ts` (the whole package rewrite, `transformOoo1Package` and its inverse `transformToOoo1Package`), `read.ts` (`readSxw`/`readSxc`/`readSxi`/`readSxd`), `write.ts` (`writeSxw`/`writeSxwContent`). Sits _beside_ `typed/`, not inside it: it adds no reader or writer of its own for the ODF content model, it feeds `writeOdt`'s output into `transformToOoo1Package` and the ODF readers' input through `transformOoo1Package`. +- **`src/ooo1/`** — the OpenOffice.org 1.x variant reader and writer: `ns.ts` (the pre-OASIS namespace and `application/vnd.sun.xml.*` media-type tables plus package detection, in both directions), `properties.ts` (the `style:properties` split, and `mergeStyleProperties`, its own inverse), `transform.ts` (the whole package rewrite, `transformOoo1Package` and its inverse `transformToOoo1Package`), `read.ts` (`readSxw`/`readSxc`/`readSxi`/`readSxd`), `write.ts` (`writeSxw`/`writeSxwContent`/`writeSxc`/`writeSxcContent`). Sits _beside_ `typed/`, not inside it: it adds no reader or writer of its own for the ODF content model, it feeds `writeOdt`'s/`writeOds`'s own output into `transformToOoo1Package` and the ODF readers' input through `transformOoo1Package`. ## Conventions diff --git a/packages/odf.js/src/index.ts b/packages/odf.js/src/index.ts index 55470cf16..831b03062 100644 --- a/packages/odf.js/src/index.ts +++ b/packages/odf.js/src/index.ts @@ -313,7 +313,7 @@ export { } from "./typed/shared/forms"; export type { OdbFormDefinition, OdbFormControl } from "./typed/shared/forms"; -// --- OpenOffice.org 1.x / StarOffice 6-7, the pre-OASIS ancestor ODF 1.0 was based on. Read-only, and read through the ODF readers above rather than beside them: transformOoo1Package rewrites a .sxw/.sxc/.sxi/.sxd package into the ODF shape those readers already understand, so every construct they know how to read works on an OpenOffice.org 1.x document too. See src/ooo1/transform.ts for what actually differs between the two vocabularies. --- +// --- OpenOffice.org 1.x / StarOffice 6-7, the pre-OASIS ancestor ODF 1.0 was based on. Read through the ODF readers above rather than beside them: transformOoo1Package rewrites a .sxw/.sxc/.sxi/.sxd package into the ODF shape those readers already understand, so every construct they know how to read works on an OpenOffice.org 1.x document too. .sxw and .sxc also write, the same way: transformToOoo1Package rewrites a real ODF Package (writeOdt's/writeOds's own output) into genuine OpenOffice.org 1.x XML; .sxi/.sxd remain read-only, since this package's typed layer has no writeOdp/writeOdg yet. See src/ooo1/transform.ts for what actually differs between the two vocabularies. --- export { OOO1_NAMESPACES, OOO1_MEDIA_TYPES, @@ -338,8 +338,13 @@ export { readSxdContent, } from "./ooo1/read"; -// The .sxw writer -- the OpenOffice.org 1.x / StarOffice 6-7 counterpart to writeOdt/writeOdtContent above, built on them: writeOdt/writeOdtContent produce a real ODF Package, and transformToOoo1Package (this format's own inverse of transformOoo1Package, the same module the readers above run) rewrites it into genuine OpenOffice.org 1.x XML. See src/ooo1/write.ts for the full scope statement -- .sxc/.sxi/.sxd have no writer yet, since this package's typed layer has no writeOds/writeOdp/writeOdg for one to be built on. -export { writeSxw, writeSxwContent } from "./ooo1/write"; +// The .sxw and .sxc writers -- the OpenOffice.org 1.x / StarOffice 6-7 counterparts to writeOdt/writeOdtContent and writeOds/writeOdsContent above, built on them: those produce a real ODF Package, and transformToOoo1Package (this format's own inverse of transformOoo1Package, the same module the readers above run) rewrites it into genuine OpenOffice.org 1.x XML. See src/ooo1/write.ts for the full scope statement -- .sxi/.sxd have no writer yet, since this package's typed layer has no writeOdp/writeOdg for one to be built on. +export { + writeSxw, + writeSxwContent, + writeSxc, + writeSxcContent, +} from "./ooo1/write"; export { readOdbReport } from "./typed/odb/report"; export type { diff --git a/packages/odf.js/src/ooo1/read.ts b/packages/odf.js/src/ooo1/read.ts index 7cc2d1a61..7c6fa4780 100644 --- a/packages/odf.js/src/ooo1/read.ts +++ b/packages/odf.js/src/ooo1/read.ts @@ -17,7 +17,7 @@ import { readOdg, readOdgContent, type OdgDocument } from "../typed/odg/read"; // // Each format has the same two levels its ODF counterpart has: the bare name returns document-schema.js's DocumentTree, and the *Content sibling beneath it returns the flat ContentDocument-level shape. See this package's README for the distinction. // -// What these readers do NOT do is write. This package's typed layer is read-only for ODF too, so there is no ODF content writer for an inverse transform to target; adding one is a much larger piece of work than this reader, and it belongs to ODF first rather than to its predecessor. +// What these readers do NOT do is write. Their own writing counterpart lives in write.ts (writeSxw/writeSxwContent, writeSxc/writeSxcContent), built on transform.ts's own inverse direction and this package's ODF-native writers (writeOdt, writeOds) -- .sxi/.sxd have no writer yet, since odf.js has no writeOdp/writeOdg for their own inverse transform to target. // A .sxw or .stw (OpenOffice.org 1.x Writer) package as a flat OdtDocument. export function readSxwContent( diff --git a/packages/odf.js/src/ooo1/transform.ts b/packages/odf.js/src/ooo1/transform.ts index 623108c4c..3beceb762 100644 --- a/packages/odf.js/src/ooo1/transform.ts +++ b/packages/odf.js/src/ooo1/transform.ts @@ -686,7 +686,7 @@ export function transformOoo1Package(pkg: Package): Package { // ===================================================================================================================== // THE REVERSE DIRECTION: an ODF-shaped Package -> genuine OpenOffice.org 1.x XML. // -// The exact inverse of every rule above, reversing each rename/restructure by name against the same LibreOffice transformer source (xmloff/source/transform/OOo2Oasis.cxx, StyleOOoTContext.cxx) and OpenOffice.org DTD the forward direction is grounded against -- transformToOoo1Package is what typed/odt/write.ts's writeOdt produces run backwards through this module, and ../write.ts's writeSxw is what actually calls it. As with the forward direction's own module comment, this deliberately does NOT claim to be a general ODF-to-OpenOffice.org converter: it targets the shape this package's own typed writers (writeOdt today) produce, not arbitrary real-world ODF. Every rule below is still a genuine, unconditional structural inverse of its forward counterpart, not a special case carved out for writeOdt's own output alone -- the narrowing is in what a real writer here can ever HAND it (no fidelity constructs, no embedded objects, no chart/presentation-only constructs), not in how faithfully each rule itself is reversed. +// The exact inverse of every rule above, reversing each rename/restructure by name against the same LibreOffice transformer source (xmloff/source/transform/OOo2Oasis.cxx, StyleOOoTContext.cxx) and OpenOffice.org DTD the forward direction is grounded against -- transformToOoo1Package is what typed/odt/write.ts's writeOdt or typed/ods/write.ts's writeOds produces run backwards through this module, and ../write.ts's writeSxw/writeSxc are what actually call it, one per ODF-native writer this package has. As with the forward direction's own module comment, this deliberately does NOT claim to be a general ODF-to-OpenOffice.org converter: it targets the shape this package's own typed writers (writeOdt, writeOds today) produce, not arbitrary real-world ODF. Every rule below is still a genuine, unconditional structural inverse of its forward counterpart, not a special case carved out for one writer's own output alone -- the narrowing is in what a real writer here can ever HAND it (no fidelity constructs, no embedded objects, no chart/presentation-only constructs), not in how faithfully each rule itself is reversed, and nothing in this module is odt- or ods-specific -- it is media-type-agnostic, which is exactly what let writeSxc reuse it with no changes of its own. // // Three rules need something the forward direction never did: PACKAGE-WIDE context. // - The document's own office:class (buildBody's inverse) is derivable only from content.xml's own office:body -- @@ -1212,7 +1212,7 @@ function rewriteManifestToOoo1( }); } -// An ODF-shaped Package (the output of writeOdt/writeOdtContent, or a future writeOds/writeOdp/writeOdg) into genuine OpenOffice.org 1.x XML: the whole of this direction's own support for a real .sxw/.sxc/.sxi/.sxd writer, and the counterpart ../write.ts's writeSxw actually calls. A package whose "mimetype" part names an ODF media type with no OpenOffice.org 1.x predecessor (or one with no "mimetype" part at all -- already OpenOffice.org 1.x-shaped, or not a document this module can identify) is returned exactly as given, mirroring transformOoo1Package's own "not applicable, leave alone" stance on the read side. +// An ODF-shaped Package (the output of writeOdt/writeOdtContent, writeOds/writeOdsContent, or a future writeOdp/writeOdg) into genuine OpenOffice.org 1.x XML: the whole of this direction's own support for a real .sxw/.sxc/.sxi/.sxd writer, and the counterpart ../write.ts's writeSxw and writeSxc actually call -- .sxi/.sxd have no writer yet, since odf.js has no writeOdp/writeOdg for their own inverse transform to target. A package whose "mimetype" part names an ODF media type with no OpenOffice.org 1.x predecessor (or one with no "mimetype" part at all -- already OpenOffice.org 1.x-shaped, or not a document this module can identify) is returned exactly as given, mirroring transformOoo1Package's own "not applicable, leave alone" stance on the read side. export function transformToOoo1Package(pkg: Package): Package { const odfMediaType = readMimetype(pkg); if (odfMediaType === undefined) { diff --git a/packages/odf.js/src/ooo1/write.test.ts b/packages/odf.js/src/ooo1/write.test.ts index cfce39a68..e91e9e907 100644 --- a/packages/odf.js/src/ooo1/write.test.ts +++ b/packages/odf.js/src/ooo1/write.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import type { ContentBlock, ContentDocument } from "document-schema.js"; +import type { + ContentBlock, + ContentDocument, + ContentSheet, + ContentSheetCell, +} from "document-schema.js"; import { PAGE_SIZE_A4, PAGE_SIZE_LETTER, @@ -8,12 +13,18 @@ import { } from "document-schema.js"; import type { Package } from "../model/package"; import { decodePackage, encodePackage } from "../codec"; -import { rootElement, attrValue, findChildElement } from "../xml/query"; +import { + rootElement, + attrValue, + childrenWithTag, + findChildElement, +} from "../xml/query"; import { readMimetype } from "../mimetype"; import { normaliseOdtContent } from "../typed/odt/write"; -import { readSxw, readSxwContent } from "./read"; +import { normaliseOdsContent } from "../typed/ods/write"; +import { readSxw, readSxwContent, readSxc, readSxcContent } from "./read"; import { isOoo1Package } from "./ns"; -import { writeSxw, writeSxwContent } from "./write"; +import { writeSxw, writeSxwContent, writeSxc, writeSxcContent } from "./write"; // The write side's correctness suite for .sxw, mirroring typed/odt/write-round-trip.test.ts's own law: a document written by writeSxwContent and read back through the EXISTING readSxwContent reader (readOdtContent run over transformOoo1Package's own forward transform -- unmodified by anything in this PR) reproduces the document it was given, up to the exact same canonical form normaliseOdtContent already states for the plain .odt writer. That reuse is deliberate, not a shortcut: writeSxwContent is writeOdtContent's own output run through transformToOoo1Package and back through transformOoo1Package on the way in, so the two writers share one correctness law by construction, and a normalisation gap in one is a normalisation gap in both. // @@ -431,3 +442,532 @@ describe("writeSxwContent produces genuine OpenOffice.org 1.x XML, not merely so ).toBeUndefined(); }); }); + +// --- .sxc: the OpenOffice.org 1.x Calc writer ------------------------------------------------------------------------- +// +// The same two-part discipline as the .sxw suite above: THE LAW below is the round-trip correctness proof (normaliseOdsContent(readSxcContent(writeSxcContent(document))) equals normaliseOdsContent(document), mirroring typed/ods/write-round-trip.test.ts's own law exactly, run through one more transform each way), and the "genuine OpenOffice.org 1.x XML" describe block that follows it makes the same second, independent kind of assertion the .sxw suite makes above: that the package writeSxcContent produces actually LOOKS like OpenOffice.org 1.x XML -- declares its own namespace URIs, carries no "mimetype" part, keeps a cell's value on table:value-type/table:value rather than ODF's office:value-type/office:value, splits nothing into ODF's typed style:table-cell-properties, wraps nothing in a draw:frame -- rather than happening to round-trip only because transformOoo1Package's own catch-all passthrough tolerates whatever shape it was handed. + +const SHEET_MARGINS = { topPt: 36, rightPt: 36, bottomPt: 36, leftPt: 36 }; + +const DEFAULT_SHEET_PRINT_SETTINGS = { + pageSize: PAGE_SIZE_A4, + margins: SHEET_MARGINS, + gridlines: false, + headers: false, + pageOrder: "downThenOver" as const, +}; + +type SpreadsheetDocument = Extract; + +function sheetOf( + name: string, + cells: ContentSheetCell[], + overrides: Partial = {}, +): ContentSheet { + return { + name, + cells, + columns: [], + rows: [], + images: [], + printSettings: DEFAULT_SHEET_PRINT_SETTINGS, + ...overrides, + }; +} + +function sheetDocumentOf(sheets: ContentSheet[]): SpreadsheetDocument { + return { kind: "spreadsheet", metadata: {}, sheets }; +} + +function sheetRoundTrip(document: ContentDocument): SpreadsheetDocument { + const pkg = decodePackage(encodePackage(writeSxcContent(document))); + const { metadata, sheets } = readSxcContent(pkg); + return { kind: "spreadsheet", metadata, sheets }; +} + +function expectSheetRoundTrip(document: ContentDocument): void { + expect(normaliseOdsContent(sheetRoundTrip(document))).toEqual( + normaliseOdsContent(document), + ); +} + +describe("the sxc round-trip law", () => { + it("round-trips every cell value kind readOdsContent can produce", () => { + const cells: ContentSheetCell[] = [ + { + row: 0, + column: 0, + value: { kind: "number", value: 42.5 }, + displayText: "42.5", + }, + { + row: 0, + column: 1, + value: { kind: "percentage", value: 0.5 }, + displayText: "50%", + }, + { + row: 0, + column: 2, + value: { kind: "currency", value: 9.99, currency: "GBP" }, + displayText: "£9.99", + }, + { + row: 1, + column: 0, + value: { kind: "boolean", value: true }, + displayText: "TRUE", + }, + { + row: 1, + column: 1, + value: { kind: "date", value: "2026-07-30" }, + displayText: "2026-07-30", + }, + { + row: 1, + column: 2, + value: { kind: "string", value: "hello" }, + displayText: "hello", + }, + ]; + expectSheetRoundTrip(sheetDocumentOf([sheetOf("Sheet1", cells)])); + }); + + it("round-trips a formula cell's verbatim formula text alongside its cached value", () => { + const cells: ContentSheetCell[] = [ + { + row: 0, + column: 0, + value: { kind: "number", value: 3 }, + displayText: "3", + }, + { + row: 0, + column: 1, + value: { kind: "number", value: 4 }, + displayText: "4", + }, + { + row: 0, + column: 2, + value: { kind: "number", value: 7 }, + displayText: "7", + formula: "of:=[.A1]+[.B1]", + }, + ]; + expectSheetRoundTrip(sheetDocumentOf([sheetOf("Sheet1", cells)])); + }); + + it("round-trips cell runs with inline formatting and a hyperlink", () => { + const cells: ContentSheetCell[] = [ + { + row: 0, + column: 0, + value: { kind: "string", value: "Bold and a link" }, + displayText: "Bold and a link", + runs: [ + { text: "Bold", bold: true }, + { text: " and " }, + { text: "a link", hyperlink: "https://example.invalid/?a=1&b=2" }, + ], + }, + ]; + expectSheetRoundTrip(sheetDocumentOf([sheetOf("Sheet1", cells)])); + }); + + it("round-trips cell background, borders, alignment, and vertical alignment", () => { + const cells: ContentSheetCell[] = [ + { + row: 0, + column: 0, + value: { kind: "string", value: "Decorated" }, + displayText: "Decorated", + background: { r: 1, g: 1, b: 0.6 }, + borders: { + left: { color: { r: 0, g: 0, b: 0 }, widthPt: 1, style: "solid" }, + right: { color: { r: 0, g: 0, b: 0 }, widthPt: 1, style: "dashed" }, + top: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 }, + bottom: { color: { r: 0, g: 0, b: 0 }, widthPt: 1, style: "double" }, + }, + alignment: "center", + verticalAlignment: "middle", + }, + ]; + expectSheetRoundTrip(sheetDocumentOf([sheetOf("Sheet1", cells)])); + }); + + it("round-trips a merged range, with the anchor cell's own colSpan/rowSpan and the covered cells dropped", () => { + const cells: ContentSheetCell[] = [ + { + row: 0, + column: 0, + value: { kind: "string", value: "Merged" }, + displayText: "Merged", + colSpan: 2, + rowSpan: 2, + }, + { + row: 0, + column: 2, + value: { kind: "string", value: "Neighbour" }, + displayText: "Neighbour", + }, + ]; + expectSheetRoundTrip(sheetDocumentOf([sheetOf("Sheet1", cells)])); + }); + + it("round-trips multiple sheets with distinct names", () => { + const document = sheetDocumentOf([ + sheetOf("First", [ + { + row: 0, + column: 0, + value: { kind: "number", value: 1 }, + displayText: "1", + }, + ]), + sheetOf("Second", [ + { + row: 0, + column: 0, + value: { kind: "number", value: 2 }, + displayText: "2", + }, + ]), + ]); + expectSheetRoundTrip(document); + }); + + it("round-trips column widths and hidden columns", () => { + const document = sheetDocumentOf([ + sheetOf( + "Sheet1", + [ + { + row: 0, + column: 3, + value: { kind: "number", value: 1 }, + displayText: "1", + }, + ], + { + columns: [ + { index: 0, widthPt: 100 }, + { index: 1, hidden: true }, + ], + }, + ), + ]); + expectSheetRoundTrip(document); + }); + + it("round-trips row heights and hidden rows", () => { + const document = sheetDocumentOf([ + sheetOf( + "Sheet1", + [ + { + row: 3, + column: 0, + value: { kind: "number", value: 1 }, + displayText: "1", + }, + ], + { + rows: [ + { index: 0, heightPt: 30 }, + { index: 1, hidden: true }, + ], + }, + ), + ]); + expectSheetRoundTrip(document); + }); + + it("round-trips a cell-anchored image, including alt text", () => { + const document = sheetDocumentOf([ + sheetOf("Sheet1", [], { + images: [ + { + kind: "image", + format: "png", + base64: PNG_BASE64, + widthPt: 40, + heightPt: 40, + anchorRow: 2, + anchorColumn: 1, + offsetXPt: 5, + offsetYPt: 3, + altText: "A test image", + }, + ], + }), + ]); + expectSheetRoundTrip(document); + }); + + it("round-trips print settings: gridlines, headers, page order, scale, and print range", () => { + const document = sheetDocumentOf([ + sheetOf( + "Sheet1", + [ + { + row: 5, + column: 5, + value: { kind: "number", value: 1 }, + displayText: "1", + }, + ], + { + printSettings: { + pageSize: PAGE_SIZE_A4, + margins: SHEET_MARGINS, + gridlines: true, + headers: true, + pageOrder: "overThenDown", + scalePercent: 75, + printRange: { + startRow: 0, + startColumn: 0, + endRow: 5, + endColumn: 5, + }, + }, + }, + ), + ]); + expectSheetRoundTrip(document); + }); + + it("round-trips repeated header rows/columns and manual page breaks", () => { + const document = sheetDocumentOf([ + sheetOf( + "Sheet1", + [ + { + row: 4, + column: 4, + value: { kind: "number", value: 1 }, + displayText: "1", + }, + ], + { + printSettings: { + ...DEFAULT_SHEET_PRINT_SETTINGS, + repeatRows: { start: 0, end: 1 }, + repeatColumns: { start: 0, end: 1 }, + manualBreaks: { rows: [2], columns: [1] }, + }, + }, + ), + ]); + expectSheetRoundTrip(document); + }); + + it("holds through the tree form as well as the flat one", () => { + const document = sheetDocumentOf([ + sheetOf("Sheet1", [ + { + row: 0, + column: 0, + value: { kind: "string", value: "Tree form" }, + displayText: "Tree form", + }, + ]), + ]); + const tree = assembleTree(document); + const pkg = decodePackage(encodePackage(writeSxc(tree))); + expect(normaliseOdsContent(flattenTree(readSxc(pkg)))).toEqual( + normaliseOdsContent(document), + ); + expect(readSxc(pkg).kind).toBe("spreadsheet"); + }); +}); + +describe("writeSxcContent produces genuine OpenOffice.org 1.x XML, not merely something transformOoo1Package tolerates", () => { + function sheetContentRootOf(pkg: Package): { + readonly pkg: Package; + readonly root: ReturnType; + } { + const content = pkg.parts["content.xml"]; + if (content?.kind !== "xml") { + throw new Error("content.xml did not survive as an XML part"); + } + return { pkg, root: rootElement(content.nodes) }; + } + + function firstAnchorCell(pkg: Package): ReturnType { + const { root } = sheetContentRootOf(pkg); + const body = + root === undefined + ? undefined + : findChildElement(root.children, "office:body"); + const table = + body === undefined + ? undefined + : findChildElement(body.children, "table:table"); + const row = + table === undefined + ? undefined + : findChildElement(table.children, "table:table-row"); + return row === undefined + ? undefined + : findChildElement(row.children, "table:table-cell"); + } + + it("carries no mimetype part at all", () => { + const pkg = writeSxcContent(sheetDocumentOf([sheetOf("Sheet1", [])])); + expect(pkg.parts.mimetype).toBeUndefined(); + expect(readMimetype(pkg)).toBeUndefined(); + }); + + it("is itself detected as an OpenOffice.org 1.x package", () => { + const pkg = writeSxcContent(sheetDocumentOf([sheetOf("Sheet1", [])])); + expect(isOoo1Package(pkg)).toBe(true); + }); + + it("declares the OpenOffice.org 1.x namespace URIs and office:class='spreadsheet'", () => { + const { root } = sheetContentRootOf( + writeSxcContent(sheetDocumentOf([sheetOf("Sheet1", [])])), + ); + if (root === undefined) { + throw new Error("content.xml has no root element"); + } + expect(attrValue(root, "xmlns:office")).toBe( + "http://openoffice.org/2000/office", + ); + expect(attrValue(root, "xmlns:table")).toBe( + "http://openoffice.org/2000/table", + ); + expect(attrValue(root, "office:class")).toBe("spreadsheet"); + }); + + it("puts office:body's content directly inside it, with no office:spreadsheet genre wrapper", () => { + const { root } = sheetContentRootOf( + writeSxcContent(sheetDocumentOf([sheetOf("Sheet1", [])])), + ); + const body = + root === undefined + ? undefined + : findChildElement(root.children, "office:body"); + if (body === undefined) { + throw new Error("content.xml has no office:body"); + } + expect( + findChildElement(body.children, "office:spreadsheet"), + ).toBeUndefined(); + expect(findChildElement(body.children, "table:table")).toBeDefined(); + }); + + it("writes a cell's value as table:value-type/table:value, not office:value-type/office:value", () => { + const pkg = writeSxcContent( + sheetDocumentOf([ + sheetOf("Sheet1", [ + { + row: 0, + column: 0, + value: { kind: "number", value: 42 }, + displayText: "42", + }, + ]), + ]), + ); + const cell = firstAnchorCell(pkg); + if (cell === undefined) { + throw new Error("no anchor table:table-cell found"); + } + expect(attrValue(cell, "table:value-type")).toBe("float"); + expect(attrValue(cell, "table:value")).toBe("42"); + expect(attrValue(cell, "office:value-type")).toBeUndefined(); + expect(attrValue(cell, "office:value")).toBeUndefined(); + }); + + it("writes a cell background as one bare style:properties, not a typed style:table-cell-properties", () => { + const pkg = writeSxcContent( + sheetDocumentOf([ + sheetOf("Sheet1", [ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + background: { r: 1, g: 0, b: 0 }, + }, + ]), + ]), + ); + const { root } = sheetContentRootOf(pkg); + if (root === undefined) { + throw new Error("content.xml has no root element"); + } + const automaticStyles = findChildElement( + root.children, + "office:automatic-styles", + ); + if (automaticStyles === undefined) { + throw new Error("content.xml has no office:automatic-styles"); + } + const cellStyle = childrenWithTag(automaticStyles, "style:style").find( + (styleElement) => + attrValue(styleElement, "style:family") === "table-cell" && + findChildElement(styleElement.children, "style:properties") !== + undefined, + ); + if (cellStyle === undefined) { + throw new Error( + "no table-cell style:style with a style:properties child was minted", + ); + } + expect( + findChildElement(cellStyle.children, "style:table-cell-properties"), + ).toBeUndefined(); + }); + + it("writes a cell-anchored image as a bare draw:image, not a draw:frame wrapping one", () => { + const pkg = writeSxcContent( + sheetDocumentOf([ + sheetOf("Sheet1", [], { + images: [ + { + kind: "image", + format: "png", + base64: PNG_BASE64, + widthPt: 10, + heightPt: 10, + anchorRow: 0, + anchorColumn: 0, + offsetXPt: 0, + offsetYPt: 0, + }, + ], + }), + ]), + ); + const cell = firstAnchorCell(pkg); + if (cell === undefined) { + throw new Error("no anchor table:table-cell found"); + } + expect(findChildElement(cell.children, "draw:frame")).toBeUndefined(); + expect(findChildElement(cell.children, "draw:image")).toBeDefined(); + }); + + it("gives each sheet its own distinct table:name", () => { + const pkg = writeSxcContent( + sheetDocumentOf([sheetOf("First", []), sheetOf("Second", [])]), + ); + const { root } = sheetContentRootOf(pkg); + const body = + root === undefined + ? undefined + : findChildElement(root.children, "office:body"); + if (body === undefined) { + throw new Error("content.xml has no office:body"); + } + const tables = childrenWithTag(body, "table:table"); + expect(tables.map((table) => attrValue(table, "table:name"))).toEqual([ + "First", + "Second", + ]); + }); +}); diff --git a/packages/odf.js/src/ooo1/write.ts b/packages/odf.js/src/ooo1/write.ts index eea270143..89e460c4c 100644 --- a/packages/odf.js/src/ooo1/write.ts +++ b/packages/odf.js/src/ooo1/write.ts @@ -6,12 +6,17 @@ import { writeOdtContent, type OdtWriteOptions, } from "../typed/odt/write"; +import { + writeOds, + writeOdsContent, + type OdsWriteOptions, +} from "../typed/ods/write"; -// The OpenOffice.org 1.x / StarOffice 6-7 writers: .sxw (Writer), built from typed/odt/write.ts's own writeOdt/ writeOdtContent -- a real ODF Package -- and then rewritten into genuine OpenOffice.org 1.x XML by transformToOoo1Package, this format's own inverse of ./transform.ts's transformOoo1Package (the SAME module the readers on the other side of this package run: readSxw is readOdt(transformOoo1Package(pkg)), so writeSxw's own round-trip law is readSxw(writeSxw(document)) reading back the document it was given, up to the exact same normalisation writeOdt's own normaliseOdtContent already states -- see write-round-trip.test.ts in this directory). +// The OpenOffice.org 1.x / StarOffice 6-7 writers: .sxw (Writer), built from typed/odt/write.ts's own writeOdt/ writeOdtContent, and .sxc (Calc), built from typed/ods/write.ts's own writeOds/writeOdsContent -- in both cases a real ODF Package, rewritten into genuine OpenOffice.org 1.x XML by transformToOoo1Package, this format's own inverse of ./transform.ts's transformOoo1Package (the SAME module the readers on the other side of this package run: readSxw is readOdt(transformOoo1Package(pkg)), readSxc is readOds(transformOoo1Package(pkg)), so each writer's own round-trip law is readSxw(writeSxw(document))/readSxc(writeSxc(document)) reading back the document it was given, up to the exact same normalisation writeOdt's/writeOds's own normaliseOdtContent/normaliseOdsContent already states -- see write.test.ts in this directory). // -// Each is its ODF counterpart's output run through one more transform, not a second writer: every construct writeOdt/writeOdtContent can write (paragraphs, headings, runs with character formatting and hyperlinks, whitespace, lists, tables, images, explicit page breaks, per-section page geometry, and meta.xml) therefore writes to .sxw too, and a fix to the ODF writer fixes both formats at once -- the same one-fix-both-formats property transformOoo1Package already gives the READ side. What writeOdt refuses (the fidelity constructs -- fields, bookmarks, notes, annotations, tracked changes, divisions, index wrappers, forms -- and embedded objects) this writer refuses too, for the identical reason stated there: a document that silently lost semantic content would be worse than one this writer declined to produce at all. +// Each is its ODF counterpart's output run through one more transform, not a second writer: every construct writeOdt/writeOdtContent can write (paragraphs, headings, runs with character formatting and hyperlinks, whitespace, lists, tables, images, explicit page breaks, per-section page geometry, and meta.xml) therefore writes to .sxw too, and every construct writeOds/writeOdsContent can write (every office:value-type, column widths/row heights, hidden rows/columns, merged ranges, cell background/borders/alignment, verbatim formulas, cell-anchored images, print settings, and multiple sheets) therefore writes to .sxc too -- the same one-fix-both-formats property transformOoo1Package already gives the READ side, now true of the write side for each pair that has an ODF writer underneath it. What writeOdt/writeOds refuse (the odt fidelity constructs -- fields, bookmarks, notes, annotations, tracked changes, divisions, index wrappers, forms; the ods embedded objects, data-validation rules, and conditional-formatting rules) these writers refuse too, for the identical reason stated in each: a document that silently lost semantic content would be worse than one this writer declined to produce at all. // -// .sxc/.sxi/.sxd (Calc/Impress/Draw) have no writer yet, because odf.js's own typed layer has no writeOds/writeOdp/ writeOdg to build one on -- see this package's README (Status) for what remains. +// .sxi/.sxd (Impress/Draw) have no writer yet, because odf.js's own typed layer has no writeOdp/writeOdg to build one on -- see this package's README (Status) for what remains. // A wordprocessing DocumentTree as a real .sxw Package. export function writeSxw( @@ -28,3 +33,19 @@ export function writeSxwContent( ): Package { return transformToOoo1Package(writeOdtContent(document, options)); } + +// A spreadsheet DocumentTree as a real .sxc Package, exactly mirroring writeSxw's own relationship to writeOdt above. +export function writeSxc( + document: DocumentTree, + options: OdsWriteOptions = {}, +): Package { + return transformToOoo1Package(writeOds(document, options)); +} + +// A spreadsheet ContentDocument as a real .sxc Package, the flat-level sibling of writeSxc above -- exactly the same split writeOds/writeOdsContent themselves carry, mirrored one direction further out. +export function writeSxcContent( + document: ContentDocument, + options: OdsWriteOptions = {}, +): Package { + return transformToOoo1Package(writeOdsContent(document, options)); +}