diff --git a/README.md b/README.md index 4598bb8e..f63b4f9e 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 write paths for sxw and sxc. | +| [`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, sxc, and sxi. | | [`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 6f21c4b7..9b522a48 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 real `.sxw` and `.sxc` writers 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`, `.sxc`, and `.sxi` 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` 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 of their own — `.sxi` now has `writeOdp` to invert `transformToOoo1Package` against (a follow-up, not built here), and `.sxd` still needs a `writeOdg` underneath it first. +- **OpenOffice.org 1.x writing** (`writeSxw`/`writeSxwContent`, `writeSxc`/`writeSxcContent`, and `writeSxi`/`writeSxiContent`, plus `transformToOoo1Package`, the read-side transform's own inverse) — `.sxw`, built on `writeOdt`/`writeOdtContent`; `.sxc`, built on `writeOds`/`writeOdsContent`; `.sxi`, built on `writeOdp`/`writeOdpContent`. `.sxd` still has no writer of its own — it needs a `writeOdg` underneath it first. - **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). - **The odp writer, at the same two levels** — `writeOdp`/`writeOdpContent`, the genuine inverse of `readOdp`/`readOdpContent`. A slide's shapes (positioned text boxes with formatted runs and lists, a rotated shape's `draw:transform`, a shape carrying a table or an image as its sole content, per-shape text insets), per-slide page geometry, and speaker notes all round-trip. Shape writing itself (`typed/draw/write-shapes.ts`) is factored out as the shared mirror of the read side's own `typed/draw/shapes.ts`, ready for a future `.odg` writer to reuse. The fidelity constructs a shape's own text cannot carry (a heading, a run-level construct extent, a page break, an embedded object, a table or image mixed with other shape content) are refused by name; a slide's own residue (transitions/animations/sound) is dropped, the same deliberate exception `writeOdt` makes. See [Writing a document](#writing-a-document) and this package's own [LibreOffice verification](#libreoffice-verification-writeodp) section for what was checked against a real, independent ODF implementation, including the two gaps that verification found and closed. @@ -175,7 +175,7 @@ const bytes = encodePackage(pkg); // Package -> bytes const pkgFromContent = writeOdpContent(contentDocument); // the flat ContentDocument level, same shape readOdpContent returns ``` -A presentation is a sequence of slides, each a positioned bag of shapes rather than flowed blocks — `writeOdp` writes one `style:master-page`/`style:page-layout` pair per slide (a presentation genuinely allows different slides to reference different page geometry, unlike OOXML's single document-level `p:sldSz`) and one `draw:page` per slide, its shapes written by `typed/draw/write-shapes.ts`'s `writeDrawShapes` — the shape writer this package factored out as the shared mirror of the read side's own `typed/draw/shapes.ts`, so a future `.odg` writer reuses it rather than reimplementing shape geometry, insets, and text/table/image content from scratch. A shape's own `frame`/`rotationDeg` write as plain `svg:x`/`svg:y`/`svg:width`/`svg:height` when unrotated, or `svg:width`/`svg:height` plus a `draw:transform="rotate(...) translate(...)"` when rotated — the exact algebraic inverse of the reader's own `resolveOdfShapeGeometry`, exact up to ordinary floating-point rounding on a real round trip. A shape's own text (formatted runs, alignment, spacing, indentation, bullet/ordered lists nested per level) writes as a `draw:text-box`; a shape whose sole block is a table or an image writes that content directly as the frame's own `table:table`/`draw:image`, since a real `draw:frame` can hold exactly one of the three, never a mix — a combination ODF has no spelling for is refused **by name**, the same fidelity-construct stance `writeOdt` takes, and so is a heading or a page break inside a shape's own text (a `draw:text-box` has no `text:h` reading path and no page concept at all). A shape's own `paintOrder` writes as `draw:z-index`, the one spelling ODF has for a stacking order independent of document position, and the one the reader already resolves; a `paintOrder` ODF's own `xsd:nonNegativeInteger` cannot spell (a negative or fractional one) writes no attribute rather than a rounded approximation that would reorder it past a sibling. Speaker notes write as `presentation:notes`, one `text:p` per line. `flattenTree(readOdp(writeOdp(document)))` reproduces `document` up to the normalisation `normaliseOdpContent` states explicitly — including the one fact ODF forces rather than this writer choosing it: an image's own `widthPt`/`heightPt` become its enclosing shape's own frame size, since a `draw:image` has no size of its own at all inside a `draw:frame`. A shape's `fontScale`/`lineSpacingReduction` are dropped and say so: they are DrawingML's own `a:normAutofit` percentages — the shrink factor PowerPoint _computed_ and stored — and ODF's own autofit vocabulary is a mode flag with no computed factor anywhere, so a pptx → odp conversion loses autofit shrink state rather than having it approximated into something the format never said. A slide's own residue (transition/animation/sound facts) is dropped, the same deliberate exception `writeOdt` makes. `.odg`/`.sxi` are not covered — see [Status](#status). +A presentation is a sequence of slides, each a positioned bag of shapes rather than flowed blocks — `writeOdp` writes one `style:master-page`/`style:page-layout` pair per slide (a presentation genuinely allows different slides to reference different page geometry, unlike OOXML's single document-level `p:sldSz`) and one `draw:page` per slide, its shapes written by `typed/draw/write-shapes.ts`'s `writeDrawShapes` — the shape writer this package factored out as the shared mirror of the read side's own `typed/draw/shapes.ts`, so a future `.odg` writer reuses it rather than reimplementing shape geometry, insets, and text/table/image content from scratch. A shape's own `frame`/`rotationDeg` write as plain `svg:x`/`svg:y`/`svg:width`/`svg:height` when unrotated, or `svg:width`/`svg:height` plus a `draw:transform="rotate(...) translate(...)"` when rotated — the exact algebraic inverse of the reader's own `resolveOdfShapeGeometry`, exact up to ordinary floating-point rounding on a real round trip. A shape's own text (formatted runs, alignment, spacing, indentation, bullet/ordered lists nested per level) writes as a `draw:text-box`; a shape whose sole block is a table or an image writes that content directly as the frame's own `table:table`/`draw:image`, since a real `draw:frame` can hold exactly one of the three, never a mix — a combination ODF has no spelling for is refused **by name**, the same fidelity-construct stance `writeOdt` takes, and so is a heading or a page break inside a shape's own text (a `draw:text-box` has no `text:h` reading path and no page concept at all). A shape's own `paintOrder` writes as `draw:z-index`, the one spelling ODF has for a stacking order independent of document position, and the one the reader already resolves; a `paintOrder` ODF's own `xsd:nonNegativeInteger` cannot spell (a negative or fractional one) writes no attribute rather than a rounded approximation that would reorder it past a sibling. Speaker notes write as `presentation:notes`, one `text:p` per line. `flattenTree(readOdp(writeOdp(document)))` reproduces `document` up to the normalisation `normaliseOdpContent` states explicitly — including the one fact ODF forces rather than this writer choosing it: an image's own `widthPt`/`heightPt` become its enclosing shape's own frame size, since a `draw:image` has no size of its own at all inside a `draw:frame`. A shape's `fontScale`/`lineSpacingReduction` are dropped and say so: they are DrawingML's own `a:normAutofit` percentages — the shrink factor PowerPoint _computed_ and stored — and ODF's own autofit vocabulary is a mode flag with no computed factor anywhere, so a pptx → odp conversion loses autofit shrink state rather than having it approximated into something the format never said. A slide's own residue (transition/animation/sound facts) is dropped, the same deliberate exception `writeOdt` makes. `.odg` is not covered — see [Status](#status). `.sxi` is: `writeSxi`/`writeSxiContent` wrap this writer exactly the way `writeSxw`/`writeSxc` wrap `writeOdt`/`writeOds` — see [Reading and writing an OpenOffice.org 1.x document](#reading-and-writing-an-openofficeorg-1x-document). #### LibreOffice verification (`writeOdp`) @@ -284,7 +284,7 @@ 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` 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: +`.sxw`, `.sxc`, and `.sxi` 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 { @@ -292,6 +292,8 @@ import { writeSxwContent, writeSxc, writeSxcContent, + writeSxi, + writeSxiContent, encodePackage, } from "odf.js"; @@ -302,11 +304,16 @@ const pkgFromContent = writeSxwContent(contentDocument); // the flat ContentDocu const sxcPkg = writeSxc(spreadsheetTree); // a spreadsheet DocumentTree -> a real .sxc Package const sxcPkgFromContent = writeSxcContent(spreadsheetContentDocument); // the flat ContentDocument level, same shape writeOdsContent returns + +const sxiPkg = writeSxi(presentationTree); // a presentation DocumentTree -> a real .sxi Package +const sxiPkgFromContent = writeSxiContent(presentationContentDocument); // the flat ContentDocument level, same shape writeOdpContent returns ``` -`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. +`writeSxw`/`writeSxwContent` call `writeOdt`/`writeOdtContent` to build a real ODF `.odt` `Package`; `writeSxc`/`writeSxcContent` call `writeOds`/`writeOdsContent` to build a real ODF `.ods` `Package`; `writeSxi`/`writeSxiContent` call `writeOdp`/`writeOdpContent` to build a real ODF `.odp` `Package` — all three the identical way. Each then runs its 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` and `.sxi` up to it needed no changes to the transform at all — only one more pair of writer entry points each time, wrapping `writeOds`/`writeOdsContent` and `writeOdp`/`writeOdpContent` 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`, `readSxc(writeSxc(document))` recovers `document` up to the canonical form `normaliseOdsContent` already states for `writeOds`, and `readSxi(writeSxi(document))` recovers `document` up to the canonical form `normaliseOdpContent` already states for `writeOdp`, since each `*Content` writer here is its ODF counterpart's own output run one transform further. What `writeOdt`/`writeOds`/`writeOdp` 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; the odp fidelity constructs a shape's own text cannot carry), `writeSxw`/`writeSxc`/`writeSxi` 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. + +Verified against real LibreOffice 26.2.5.2 the same way, not just the round-trip law: a `.sxi` built with `writeSxiContent` (a title, a rotated shape, a table, an image, and multi-line speaker notes) converts cleanly with `soffice --headless --convert-to fodp`/`--convert-to pdf` (both exit `0`, LibreOffice identifies the input as a genuine Impress document), every authored string survives verbatim in the flat XML, and the rendered PDF confirms the content lays out correctly with the speaker notes absent from the slide itself. -`.sxi`/`.sxd` still have no writer. `writeOdp` now exists for a `writeSxi`/`writeSxiContent` pair to wrap the same way `writeSxw`/`writeSxc` wrap `writeOdt`/`writeOds` — tracked as its own follow-up rather than built here. `.sxd` still needs a `writeOdg` underneath it first. 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. +`.sxd` still has no writer — it needs a `writeOdg` underneath it first, tracked as its own follow-up rather than built here. 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 @@ -348,7 +355,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), plus the write-side mirror of `shapes.ts` (`write-shapes.ts`: `writeDrawFrame`/`writeDrawShapes`, shared between `writeOdp` and a future `.odg` writer). - **`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`/`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`. +- **`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`/`writeSxi`/`writeSxiContent`). 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/`writeOdp`'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 791e8ba2..802b7802 100644 --- a/packages/odf.js/src/index.ts +++ b/packages/odf.js/src/index.ts @@ -351,12 +351,14 @@ export { readSxdContent, } from "./ooo1/read"; -// 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. +// The .sxw, .sxc, and .sxi writers -- the OpenOffice.org 1.x / StarOffice 6-7 counterparts to writeOdt/writeOdtContent, writeOds/writeOdsContent, and writeOdp/writeOdpContent 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 -- .sxd has no writer yet, since this package's typed layer has no writeOdg for one to be built on. export { writeSxw, writeSxwContent, writeSxc, writeSxcContent, + writeSxi, + writeSxiContent, } from "./ooo1/write"; export { readOdbReport } from "./typed/odb/report"; diff --git a/packages/odf.js/src/ooo1/write.test.ts b/packages/odf.js/src/ooo1/write.test.ts index e91e9e90..f338f20a 100644 --- a/packages/odf.js/src/ooo1/write.test.ts +++ b/packages/odf.js/src/ooo1/write.test.ts @@ -4,10 +4,13 @@ import type { ContentDocument, ContentSheet, ContentSheetCell, + ContentShape, + ContentSlide, } from "document-schema.js"; import { PAGE_SIZE_A4, PAGE_SIZE_LETTER, + SLIDE_SIZE_WIDESCREEN, assembleTree, flattenTree, } from "document-schema.js"; @@ -22,9 +25,24 @@ import { import { readMimetype } from "../mimetype"; import { normaliseOdtContent } from "../typed/odt/write"; import { normaliseOdsContent } from "../typed/ods/write"; -import { readSxw, readSxwContent, readSxc, readSxcContent } from "./read"; +import { normaliseOdpContent } from "../typed/odp/write"; +import { + readSxw, + readSxwContent, + readSxc, + readSxcContent, + readSxi, + readSxiContent, +} from "./read"; import { isOoo1Package } from "./ns"; -import { writeSxw, writeSxwContent, writeSxc, writeSxcContent } from "./write"; +import { + writeSxw, + writeSxwContent, + writeSxc, + writeSxcContent, + writeSxi, + writeSxiContent, +} 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. // @@ -207,6 +225,8 @@ describe("the sxw round-trip law", () => { it("holds through the tree form as well as the flat one", () => { const tree = assembleTree(KITCHEN_SINK); const pkg = decodePackage(encodePackage(writeSxw(tree))); + // The round trip alone cannot distinguish genuine OpenOffice.org 1.x output from writeOdt's own plain ODF passed straight through: transformOoo1Package returns anything it does not detect as OpenOffice.org 1.x unchanged, so a writeSxw that silently skipped transformToOoo1Package would still round-trip correctly here (identity composed with identity). isOoo1Package is the assertion that actually catches that -- see the "genuine OpenOffice.org 1.x XML" describe block below for the same check on writeSxwContent's own output. + expect(isOoo1Package(pkg)).toBe(true); expect(normaliseOdtContent(flattenTree(readSxw(pkg)))).toEqual( normaliseOdtContent(KITCHEN_SINK), ); @@ -778,6 +798,8 @@ describe("the sxc round-trip law", () => { ]); const tree = assembleTree(document); const pkg = decodePackage(encodePackage(writeSxc(tree))); + // See the sxw suite's own identical note above: the round trip alone cannot distinguish genuine OpenOffice.org 1.x output from writeOds's own plain ODF passed straight through. + expect(isOoo1Package(pkg)).toBe(true); expect(normaliseOdsContent(flattenTree(readSxc(pkg)))).toEqual( normaliseOdsContent(document), ); @@ -971,3 +993,245 @@ describe("writeSxcContent produces genuine OpenOffice.org 1.x XML, not merely so ]); }); }); + +// The same two-part discipline as the .sxw/.sxc suites above: THE LAW below is the round-trip correctness proof (normaliseOdpContent(readSxiContent(writeSxiContent(document))) equals normaliseOdpContent(document), mirroring typed/odp/write-round-trip.test.ts's own law exactly, run through one more transform each way, including that suite's own rotated-shape tolerance exception), and the "genuine OpenOffice.org 1.x XML" describe block that follows makes the same second, independent assertion the .sxw/.sxc suites make: that writeSxiContent's own output actually LOOKS like OpenOffice.org 1.x XML -- declares its own namespace URIs, carries no "mimetype" part, puts office:body's content directly inside it with no office:presentation genre wrapper, and writes a shape as a bare draw:text-box rather than ODF's draw:frame-wrapped one -- rather than happening to round-trip only because transformOoo1Package's own catch-all passthrough tolerates whatever shape it was handed. + +// A 1x1 PNG, genuinely decodable (sniffImageFormat reads real magic bytes) -- the same fixture typed/odp/write-round-trip.test.ts's own suite uses. +const SXI_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +type PresentationDocument = Extract; + +function shapeOf( + overrides: Partial = {}, + blocks: ContentShape["blocks"] = [ + { kind: "paragraph", runs: [{ text: "Body" }] }, + ], +): ContentShape { + return { + frame: { xPt: 36, yPt: 48, widthPt: 400, heightPt: 120 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks, + ...overrides, + }; +} + +function slideOf(shapes: ContentShape[], notes = ""): ContentSlide { + return { size: SLIDE_SIZE_WIDESCREEN, shapes, notes }; +} + +function presentationDocumentOf(slides: ContentSlide[]): PresentationDocument { + return { kind: "presentation", metadata: {}, slides }; +} + +function presentationRoundTrip( + document: ContentDocument, +): PresentationDocument { + const pkg = decodePackage(encodePackage(writeSxiContent(document))); + const { metadata, slides } = readSxiContent(pkg); + return { kind: "presentation", metadata, slides }; +} + +function expectPresentationRoundTrip(document: ContentDocument): void { + expect(normaliseOdpContent(presentationRoundTrip(document))).toEqual( + normaliseOdpContent(document), + ); +} + +describe("the sxi round-trip law", () => { + it("round-trips a slide with formatted text, a table, an image, and speaker notes", () => { + expectPresentationRoundTrip( + presentationDocumentOf([ + slideOf( + [ + shapeOf({}, [ + { + kind: "paragraph", + alignment: "center", + runs: [{ text: "Title", bold: true }], + }, + ]), + shapeOf( + { frame: { xPt: 300, yPt: 300, widthPt: 200, heightPt: 100 } }, + [ + { + kind: "table", + columnWidthsPt: [80, 80], + rows: [ + { + cells: [ + { + blocks: [ + { kind: "paragraph", runs: [{ text: "A" }] }, + ], + }, + { + blocks: [ + { kind: "paragraph", runs: [{ text: "B" }] }, + ], + }, + ], + }, + ], + }, + ], + ), + shapeOf( + { frame: { xPt: 100, yPt: 400, widthPt: 96, heightPt: 96 } }, + [ + { + kind: "image", + format: "png", + base64: SXI_PNG_BASE64, + widthPt: 96, + heightPt: 96, + }, + ], + ), + ], + "Speaker notes line one\nline two", + ), + ]), + ); + }); + + it("round-trips multiple slides, each with its own page size", () => { + expectPresentationRoundTrip({ + kind: "presentation", + metadata: { title: "Sxi doc" }, + slides: [ + { size: SLIDE_SIZE_WIDESCREEN, shapes: [shapeOf()], notes: "" }, + { size: PAGE_SIZE_A4, shapes: [shapeOf()], notes: "" }, + ], + }); + }); + + // A rotated shape's own frame/rotationDeg is an exact algebraic inverse (typed/draw/write-shapes.ts's own frameGeometryAttrs) verified with a numeric tolerance rather than the blanket expectPresentationRoundTrip helper above, exactly mirroring typed/odp/write-round-trip.test.ts's own identical exception (two independent trig evaluations on either side of a real round trip -- here run through transformToOoo1Package/transformOoo1Package on top of writeOdp/readOdp -- are not guaranteed bit-identical). + it("round-trips a rotated shape's geometry within floating-point tolerance", () => { + const written = presentationRoundTrip( + presentationDocumentOf([ + slideOf([ + shapeOf({ + frame: { xPt: 60, yPt: 200, widthPt: 200, heightPt: 80 }, + rotationDeg: 30, + }), + ]), + ]), + ); + const writtenShape = written.slides[0]!.shapes[0]!; + expect(writtenShape.rotationDeg).toBeCloseTo(30, 9); + expect(writtenShape.frame.xPt).toBeCloseTo(60, 6); + expect(writtenShape.frame.yPt).toBeCloseTo(200, 6); + expect(writtenShape.frame.widthPt).toBeCloseTo(200, 6); + expect(writtenShape.frame.heightPt).toBeCloseTo(80, 6); + }); + + it("holds through the tree form as well as the flat one", () => { + const document = presentationDocumentOf([slideOf([shapeOf()])]); + const tree = assembleTree(document); + const pkg = decodePackage(encodePackage(writeSxi(tree))); + // See the sxw suite's own identical note above: the round trip alone cannot distinguish genuine OpenOffice.org 1.x output from writeOdp's own plain ODF passed straight through. + expect(isOoo1Package(pkg)).toBe(true); + expect(normaliseOdpContent(flattenTree(readSxi(pkg)))).toEqual( + normaliseOdpContent(document), + ); + }); +}); + +describe("writeSxiContent produces genuine OpenOffice.org 1.x XML, not merely something transformOoo1Package tolerates", () => { + function presentationContentRootOf(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) }; + } + + it("carries no mimetype part at all", () => { + const pkg = writeSxiContent(presentationDocumentOf([slideOf([shapeOf()])])); + expect(pkg.parts.mimetype).toBeUndefined(); + expect(readMimetype(pkg)).toBeUndefined(); + }); + + it("is itself detected as an OpenOffice.org 1.x package", () => { + const pkg = writeSxiContent(presentationDocumentOf([slideOf([shapeOf()])])); + expect(isOoo1Package(pkg)).toBe(true); + }); + + it("declares the OpenOffice.org 1.x namespace URIs and office:class='presentation'", () => { + const { root } = presentationContentRootOf( + writeSxiContent(presentationDocumentOf([slideOf([shapeOf()])])), + ); + 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:presentation")).toBe( + "http://openoffice.org/2000/presentation", + ); + expect(attrValue(root, "office:class")).toBe("presentation"); + }); + + it("puts office:body's content directly inside it, with no office:presentation genre wrapper", () => { + const { root } = presentationContentRootOf( + writeSxiContent(presentationDocumentOf([slideOf([shapeOf()])])), + ); + 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:presentation"), + ).toBeUndefined(); + expect(findChildElement(body.children, "draw:page")).toBeDefined(); + }); + + it("writes a shape as a bare draw:text-box, not ODF's draw:frame-wrapped one", () => { + const pkg = writeSxiContent(presentationDocumentOf([slideOf([shapeOf()])])); + const { root } = presentationContentRootOf(pkg); + const body = + root === undefined + ? undefined + : findChildElement(root.children, "office:body"); + const page = + body === undefined + ? undefined + : findChildElement(body.children, "draw:page"); + if (page === undefined) { + throw new Error("content.xml has no draw:page"); + } + expect(findChildElement(page.children, "draw:frame")).toBeUndefined(); + expect(findChildElement(page.children, "draw:text-box")).toBeDefined(); + }); + + it("gives each slide's speaker notes as presentation:notes with no xmlns undeclared-prefix defect", () => { + const pkg = writeSxiContent( + presentationDocumentOf([slideOf([shapeOf()], "Notes text")]), + ); + const { root } = presentationContentRootOf(pkg); + const body = + root === undefined + ? undefined + : findChildElement(root.children, "office:body"); + const page = + body === undefined + ? undefined + : findChildElement(body.children, "draw:page"); + const notes = + page === undefined + ? undefined + : findChildElement(page.children, "presentation:notes"); + expect(notes).toBeDefined(); + }); +}); diff --git a/packages/odf.js/src/ooo1/write.ts b/packages/odf.js/src/ooo1/write.ts index 89e460c4..58b0ae8e 100644 --- a/packages/odf.js/src/ooo1/write.ts +++ b/packages/odf.js/src/ooo1/write.ts @@ -11,12 +11,17 @@ import { writeOdsContent, type OdsWriteOptions, } from "../typed/ods/write"; +import { + writeOdp, + writeOdpContent, + type OdpWriteOptions, +} from "../typed/odp/write"; -// 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). +// The OpenOffice.org 1.x / StarOffice 6-7 writers: .sxw (Writer), built from typed/odt/write.ts's own writeOdt/writeOdtContent; .sxc (Calc), built from typed/ods/write.ts's own writeOds/writeOdsContent; .sxi (Impress), built from typed/odp/write.ts's own writeOdp/writeOdpContent -- in all three 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)), readSxi is readOdp(transformOoo1Package(pkg)), so each writer's own round-trip law is readSxw(writeSxw(document))/readSxc(writeSxc(document))/readSxi(writeSxi(document)) reading back the document it was given, up to the exact same normalisation writeOdt's/writeOds's/writeOdp's own normaliseOdtContent/normaliseOdsContent/normaliseOdpContent 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 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. +// 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; 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; every construct writeOdp/writeOdpContent can write (a slide's positioned shapes with formatted text/lists, a rotated shape's draw:transform, a shape carrying a table or an image as its sole content, per-shape text insets, per-slide page geometry, speaker notes, and a shape's own draw:z-index paint order) therefore writes to .sxi 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/writeOdp 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; the odp fidelity constructs a shape's own text cannot carry) 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. // -// .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. +// .sxd (Draw) has no writer yet, because odf.js's own typed layer has no 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( @@ -49,3 +54,19 @@ export function writeSxcContent( ): Package { return transformToOoo1Package(writeOdsContent(document, options)); } + +// A presentation DocumentTree as a real .sxi Package, exactly mirroring writeSxw's/writeSxc's own relationship to writeOdt/writeOds above. +export function writeSxi( + document: DocumentTree, + options: OdpWriteOptions = {}, +): Package { + return transformToOoo1Package(writeOdp(document, options)); +} + +// A presentation ContentDocument as a real .sxi Package, the flat-level sibling of writeSxi above -- exactly the same split writeOdp/writeOdpContent themselves carry, mirrored one direction further out. +export function writeSxiContent( + document: ContentDocument, + options: OdpWriteOptions = {}, +): Package { + return transformToOoo1Package(writeOdpContent(document, options)); +}