From c1795c8171a16cd9874d4c20f4c0751bd10646ff Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 10 Sep 2026 14:11:31 +0100 Subject: [PATCH 1/2] feat(odf.js): read and write embedded objects in odp and odg shapes readDrawFrameContent gains the draw:object branch (checked first, ahead of every other content, for the identical ObjectReplacements-preview reason the odt and ods readers already order it that way), opted into per format so ods's own cell-anchored resolution keeps its shape; the shape writer admits a lone embedded-object block through the identical writeEmbeddedObject machinery an odt body embedding uses, refusing one found alongside other content by name. The root barrel now exports odfIndexWrapperTag and writeOdfIndexWrapper so documents.js's odt editor can open real index-wrapper regions (the follow-up commit). --- packages/odf.js/README.md | 2 +- packages/odf.js/src/index.ts | 6 ++ packages/odf.js/src/typed/draw/shapes.ts | 56 +++++++++++++-- .../odf.js/src/typed/draw/write-shapes.ts | 39 ++++++++--- .../src/typed/odp/write-round-trip.test.ts | 70 +++++++++++++++++++ 5 files changed, 160 insertions(+), 13 deletions(-) diff --git a/packages/odf.js/README.md b/packages/odf.js/README.md index 84788d8c2..41b79c7f4 100644 --- a/packages/odf.js/README.md +++ b/packages/odf.js/README.md @@ -68,7 +68,7 @@ Under active development. Built and shipped: - **OpenOffice.org 1.x writing** (`writeSxw`/`writeSxwContent`, `writeSxc`/`writeSxcContent`, `writeSxi`/`writeSxiContent`, and `writeSxd`/`writeSxdContent`, 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`, built on `writeOdg`/`writeOdgContent`, through one wrapper pair each rather than a second writer. Every genuine document format this package reads on the OpenOffice.org 1.x side it now writes as well, including the `.stw`/`.stc`/`.sti`/`.std` template variants of all four (a `template: true` write option) — see [Reading and writing an OpenOffice.org 1.x document](#reading-and-writing-an-openofficeorg-1x-document). - **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. A field and a bookmark anchor (entirely within one paragraph) and a division/index wrapper (bracketing whole blocks) round-trip too ([ExaDev/documents.js#969](https://github.com/ExaDev/documents.js/issues/969)); a note/annotation/tracked-change anchor, a bookmark/tracked-change/comment range spanning several blocks, an `office:forms` control, and an embedded object are refused by name rather than silently dropped. See [Writing a document](#writing-a-document) and [Fidelity constructs](#fidelity-constructs). - **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 (ExaDev/documents.js#1075 gave `readOdsContent` a real reader for the last of these; `writeOdsContent` still has no inverse) 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 — including a nested list or a further nested table inside one of its cells — 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`, and the `.odg` writer below reuses it unchanged. A shape's own text carries its run-level construct extents (fields, bookmarks, notes, comments, tracked changes) through the identical machinery an odt body paragraph uses, definitions table and all ([ExaDev/documents.js#969](https://github.com/ExaDev/documents.js/issues/969)); the shape content ODF genuinely has no spelling for (a heading, a page break, an embedded object, a table or image mixed with other shape content) is refused by name; a slide's own residue (transitions/animations/sound) is dropped, the same deliberate exception `writeOdt` makes for a paragraph's own residue — the package-level table restores, for the same reason it does there. 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. +- **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 — including a nested list or a further nested table inside one of its cells — 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`, and the `.odg` writer below reuses it unchanged. A shape's own text carries its run-level construct extents (fields, bookmarks, notes, comments, tracked changes) through the identical machinery an odt body paragraph uses, definitions table and all ([ExaDev/documents.js#969](https://github.com/ExaDev/documents.js/issues/969)), and a shape whose sole content is an embedded object round-trips through the identical `writeEmbeddedObject`/`readDrawObjectReference` machinery an odt body embedding uses ([ExaDev/documents.js#1190](https://github.com/ExaDev/documents.js/issues/1190)); the shape content ODF genuinely has no spelling for (a heading, a page break, an embedded object mixed with other shape content, a table or image mixed with other shape content) is refused by name; a slide's own residue (transitions/animations/sound) is dropped, the same deliberate exception `writeOdt` makes for a paragraph's own residue — the package-level table restores, for the same reason it does there. 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. - **The odg writer, at the same two levels** — `writeOdg`/`writeOdgContent`, the genuine inverse of `readOdg`/`readOdgContent`, and the one writer here with content the others have no vocabulary for: a drawing page's **vector primitives**. Rectangles, ellipses, lines, and free-form paths write as real `draw:rect`/`draw:ellipse`/`draw:line`/`draw:path` elements, with fill and stroke interned as graphic-family automatic styles and a path's own subpaths serialised into genuine `svg:d` path data against an `svg:viewBox` sized to its own frame. A page's text-in-a-frame shapes go through the same `writeDrawShapes` the odp writer uses, and per-page geometry through the same `style:master-page`/`style:page-layout` pair, so a drawing costs no second copy of either. A dotted or double stroke, a non-positive stroke width, and a path with no subpaths or a zero-extent frame are refused by name — ODF has no spelling for the first two, and this package's own reader discards the last two outright. See [Writing a document](#writing-a-document) and [LibreOffice verification (`writeOdg`)](#libreoffice-verification-writeodg). Not yet built: a write path for the odt fidelity constructs `writeOdt` does not yet resolve (notes, annotations, tracked changes, a block-scope construct range spanning several blocks, and forms — see [Fidelity constructs](#fidelity-constructs)), the ods/odp/odg fidelity constructs, a `.ods` cell's own `number:*` data-style (`readOdsContent` does not read one back yet, so there is nothing to write against), live-view editors, and the `.odb` database-table-export subsystem. A general-purpose SQL query engine for rendering a Report against its data is **deliberately not attempted** — building even a bounded SQL engine means reimplementing HSQLDB's/Firebird's query semantics, a materially different undertaking from decoding their file formats, with unreviewed licensing questions. Gated on the requesting engineer's explicit sign-off. diff --git a/packages/odf.js/src/index.ts b/packages/odf.js/src/index.ts index 99e8ed981..65c2ff543 100644 --- a/packages/odf.js/src/index.ts +++ b/packages/odf.js/src/index.ts @@ -335,6 +335,12 @@ export { writeEmbeddedObjectPackage, } from "./typed/draw/embedded-write"; +// The index-wrapper element builders (typed/shared/constructs.ts): the write-side inverse of the odt reader's index-wrapper recovery, exported so documents.js's odt editor can open a real text:table-of-content-style region for an index contentControl construct pair (ExaDev/documents.js#1190). +export { + odfIndexWrapperTag, + writeOdfIndexWrapper, +} from "./typed/shared/constructs"; + export { readOdm } from "./typed/odm/read"; // The master-document WRITER, the inverse of readOdm: one top-level text:section per chapter, each carrying its external-file reference exactly as the reader's own real-LibreOffice verification transcribed it. diff --git a/packages/odf.js/src/typed/draw/shapes.ts b/packages/odf.js/src/typed/draw/shapes.ts index b764df1e5..cda69e4f6 100644 --- a/packages/odf.js/src/typed/draw/shapes.ts +++ b/packages/odf.js/src/typed/draw/shapes.ts @@ -23,6 +23,11 @@ import { attrValue, childrenWithTag, elementsWithTag } from "../../xml/query"; import { decodeXmlText } from "../../xml/entities"; import { base64ToBytes } from "../../util/base64"; import { sniffImageFormat } from "../../image/sniff"; +import { + readDrawObjectReference, + readEmbeddedObjectDocument, +} from "./embedded"; +import type { OdfResidueFormat } from "../shared/constructs"; import { findNamedStylePartElement, resolveStyleElementChain, @@ -190,7 +195,7 @@ export function readDrawImageBlock( return block; } -// A draw:frame's content is exactly one of table:table, draw:text-box, or draw:image (verified against real LibreOffice output) -- table:table is checked FIRST because a real saved presentation table frame also carries a sibling draw:image (an .svm fallback preview LibreOffice writes for consumers that can't render a real table), which must not be mistaken for the frame's own image content. +// A draw:frame's content is exactly one of table:table, draw:text-box, draw:image, or draw:object (an embedded sub-document, read only when the caller opts in through embeddedFormat -- verified against real LibreOffice output) -- an embedded draw:object is checked FIRST (a real embedding frame also carries a sibling draw:image, the ObjectReplacements/ preview, which must not be mistaken for the frame's own image content), then table:table (a real saved presentation table frame carries a sibling .svm fallback preview for the identical reason). // // ODP LIST MEMBERSHIP -- minted numId, not the numId-less { level } shape: document-schema.js 3.3.0 made ContentListMembership.numId optional precisely so a reader whose source carries NO list identity could emit the honest minimal { level } (ooxml.js's pptx reader, whose a:pPr/@lvl is a bare depth attribute on the paragraph with no list element behind it -- a fabricated numId there would be a lie in the data). A slide text box is not that case: draw:text-box's own content model is exactly (text:p | text:list)*, and its text:list elements are the IDENTICAL structural containers the odt reader walks in office:text -- a slide can carry two of them (two bullet bodies in one text box, or one list in each of two frames), and a consumer grouping list paragraphs apart (rendering separate