diff --git a/README.md b/README.md index 3eca830..bed7348 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ `odf.js` is the ODF sibling of [`ooxml.js`](https://github.com/ExaDev/ooxml.js), 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. -**This package does not depend on `ooxml.js`.** `ooxml.js`'s branding and SBOM are scoped to ECMA-376/OOXML; depending on it would be the wrong signal for an OASIS-standard codec and would force a breaking `ooxml.js` release for every ODF-only fix. The small generic ZIP/XML/`Package` layer is duplicated, kept structurally identical so TypeScript's structural typing makes both packages' values interchangeable for a shared consumer like `documents.js`. Both depend on [`document-schema.js`](https://github.com/ExaDev/document-schema.js) for the genuinely identical `ContentDocument`/`LayoutDocument` content model. +**This package does not depend on `ooxml.js`.** `ooxml.js`'s branding and SBOM are scoped to ECMA-376/OOXML; depending on it would be the wrong signal for an OASIS-standard codec and would force a breaking `ooxml.js` release for every ODF-only fix. The small generic ZIP/XML/`Package` layer is duplicated, kept structurally identical so TypeScript's structural typing makes both packages' values interchangeable for a shared consumer like `documents.js`. Both depend on [`document-schema.js`](https://github.com/ExaDev/document-schema.js) for the genuinely identical `ContentDocument`/`DocumentPackage` content model. ```mermaid graph TD @@ -58,8 +58,9 @@ Under active development. Built and shipped: - **Namespaces, media types, mimetype, manifest** (`src/ns.ts`, `src/media-type.ts`, `src/mimetype.ts`, `src/manifest.ts`) — full read and write, including `META-INF/manifest.xml` and the mimetype part's mandatory first-entry/stored/uncompressed layout. - **Style interning** (`src/styles/`) — `StyleRegistry` adopts existing automatic styles, finds-or-mints on `intern()`, fingerprints on canonical serialized properties (never `JSON.stringify`), collision-checked across all four style containers. - **Shared typed primitives** (`src/typed/shared/`) — unit parsing, A1 cell-reference computation with repeat-count cursor advancement, colour/geometry/master-page parsing into `document-schema.js` types, whitespace-run decoding, the read-side style cascade, shared `readOdfParagraph`/`readOdfTable`, the `draw:transform`/`draw:g` group-flattening geometry resolver, an `svg:d`/`draw:points` path parser, and `meta.xml` reading. -- **Typed readers** — `readOdt` (wordprocessing), `readOdp` (presentation), `readOdg` (drawing: vector primitives in `draw:z-index`-aware paint order), and `readOds` (spreadsheet: every `office:value-type`, cell/page-anchored images, and embedded sub-documents) each resolve a `Package` into `document-schema.js`'s own `ContentSection`/`ContentSlide`/`ContentDrawPage`/`ContentSheet` shapes. -- **`readOdfFormula`** — resolves a standalone/embedded `.odf` formula's bare-MathML `content.xml` into raw MathML nodes plus a StarMath annotation. **`readOdfFormulaDocument`** wraps that into a real `'formula'`-kind `ContentDocument`. +- **Typed readers, at two levels** — `readOdt`/`readOdp`/`readOdg`/`readOds`/`readOdfFormula` resolve a `Package` into a `document-schema.js` **`DocumentPackage`**: the single hierarchical artefact, with a minted styles table. Beneath each sits its `*Content` sibling (`readOdtContent`, `readOdpContent`, `readOdgContent`, `readOdsContent`, `readOdfFormulaContent`) producing the flat `ContentDocument`-level shape instead. See [Reading a document](#reading-a-document). +- **What each reader actually covers** — wordprocessing (`readOdt`), presentation (`readOdp`), drawing (`readOdg`: vector primitives in `draw:z-index`-aware paint order), spreadsheet (`readOds`: every `office:value-type`, cell/page-anchored images, and embedded sub-documents), each expressed in `document-schema.js`'s own `ContentSection`/`ContentSlide`/`ContentDrawPage`/`ContentSheet` vocabulary. +- **`readOdfFormulaMathMl`** — resolves a standalone/embedded `.odf` formula's bare-MathML `content.xml` into raw MathML nodes plus a StarMath annotation, with no pivot shaping at all. **`readOdfFormulaContent`** wraps that into a real `'formula'`-kind `ContentDocument`, and **`readOdfFormula`** into a `DocumentPackage`. - **`readOdm`** — resolves a `.odm` master document into an ordered list of chapter references (`{ name, href, filterName? }`); chapters are genuinely external `.odt` files by ODF design, never cached. - **`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. @@ -96,7 +97,69 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`. ## Usage -The lossless core — the only public surface stable enough to document with real examples right now: +### Reading a document + +A typed reader takes a `Package` (bytes go through `decodePackage`/`parsePackage` first) and returns a [`DocumentPackage`](https://github.com/ExaDev/document-schema.js#the-package-tree) — `document-schema.js`'s single hierarchical artefact, where structure, layout, and content are fused in one tree and a `styles` table has already been minted over it: + +```ts +import { decodePackage, readOdt } from 'odf.js'; + +const pkg = decodePackage(new Uint8Array(await file.arrayBuffer())); +const document = readOdt(pkg); + +document.kind; // 'wordprocessing' +document.metadata; // title, author, keywords, ... from meta.xml +document.children; // one section group per ContentSection, headings and lists grouped inside it +document.styles; // the minted styles table the tree's `style` refs name +``` + +One reader per format, each returning the `DocumentPackage` arm its format produces: + +| Format | Reader | Package kind | +| --- | --- | --- | +| `.odt` | `readOdt` | `wordprocessing` | +| `.odp` | `readOdp` | `presentation` | +| `.ods` | `readOds` | `spreadsheet` | +| `.odg` | `readOdg` | `drawing` | +| `.odf` | `readOdfFormula` | `formula` | + +Each is assembled through `document-schema.js`'s own `assemblePackage`, so odf.js's packages are built exactly the way every other package construction site in this family builds one. No `pages` array is populated and no node carries `frames`: a reader runs before any layout pass, and rendered page geometry is a layout engine's to report, never a reader's to invent. + +### The flat `ContentDocument` level + +Beneath each package-native reader sits the flat reader it is built on, unchanged in behaviour and exported under a `*Content` name. Reach for these when you work in `document-schema.js`'s flat codec-exchange form — as `documents.js`'s own conversion pipeline does — rather than in the tree: + +```ts +import { readOdsContent, readOdfFormulaMathMl } from 'odf.js'; + +const { metadata, sheets } = readOdsContent(pkg); // the flat ContentSheet[] shape, no tree, no styles table +const { mathml, starMath } = readOdfFormulaMathMl(formulaPkg); // rawest of all: MathML nodes and the StarMath annotation +``` + +`readOdtContent`/`readOdpContent`/`readOdgContent`/`readOdsContent` return `{ metadata, sections | slides | pages | sheets }`; `readOdfFormulaContent` returns a whole `'formula'`-kind `ContentDocument`; `readOdfFormulaMathMl` returns the raw MathML with no pivot shaping at all. A package-native reader calls its own `*Content` sibling and reshapes that result, so the two levels are one read and can never disagree about what the file says. + +Crossing between the levels is `document-schema.js`'s job, not this package's: `flattenPackage(readOdt(pkg))` reproduces exactly what `readOdtContent(pkg)` returns, wrapped in its `ContentDocument` envelope. That equality is pinned per format against real fixture bytes in this package's own test suites. + +### The primary names moved — migrating from 4.x + +Every bare `readOdX` name now belongs to the package-native reader. Callers of the old flat functions rename; nothing about those functions' behaviour changed: + +| 4.x | 5.0 | Returns | +| --- | --- | --- | +| `readOdt` | `readOdtContent` | `OdtDocument` | +| `readOdp` | `readOdpContent` | `OdpDocument` | +| `readOdg` | `readOdgContent` | `OdgDocument` | +| `readOds` | `readOdsContent` | `OdsDocument` | +| `readOdfFormulaDocument` | `readOdfFormulaContent` | `ContentDocument` | +| `readOdfFormula` | `readOdfFormulaMathMl` | `OdfFormulaDocument` | + +The rename is a compile error at every call site, never a silent behaviour change: each new bare name returns a `DocumentPackage`, which is assignable to none of the old return types. + +`readOdm`, `readOdbInventory`, `readOdbForm`, and `readOdbReport` are untouched, and none gains a package-native form. `readOdm`'s chapters are external file references and `readOdbInventory`/`readOdbReport` describe structure rather than content, so none of those three has a `ContentDocument` to decompose. `readOdbForm` is the exception that proves the rule rather than a fourth case of it: a form's sub-document is a complete, ordinary ODF text document, so `readOdbForm` does call `readOdtContent` on it and does return an `OdtDocument` — but that document is one component nested inside the form's own control-tree result, not the function's own top-level return value, so there is no `DocumentPackage`-native `readOdbForm` to add without changing what the function returns altogether. + +### The lossless core + +The ZIP-of-XML layer every reader above is built on: ```ts import { decodePackage, encodePackage } from 'odf.js'; @@ -121,6 +184,8 @@ syncManifest(pkg); // rebuilds manifest.xml to exactly match pkg's current parts readMimetype(pkg); // 'application/vnd.oasis.opendocument.text' ``` +### Direct module imports + Every module is also importable directly by its own subpath, without going through the barrel: ```ts @@ -143,9 +208,9 @@ Layered from a lossless core outward, mirroring `ooxml.js`: - **`src/manifest.ts`** — full manifest read/write; the manifest is ODF's one mandatory part, unlike `ooxml.js`'s read-only OPC-relationship stance. - **`src/styles/`** — `properties.ts`/`serialize.ts` (canonical property-bag ↔ XML attributes), `registry.ts` (`StyleRegistry`, the mandatory style-interning layer), `span.ts` (character-range `text:span` wrapping). - **`src/typed/shared/`** — ODF-specific typed primitives every reader builds on (units, A1 cursors, colour/geometry, whitespace runs, style cascade, shared paragraph/table readers, transform/path parsing, metadata). -- **`src/typed/odt/`, `odp/`, `odg/`, `ods/`** — the `readOdt`/`readOdp`/`readOdg`/`readOds` readers. +- **`src/typed/odt/`, `odp/`, `odg/`, `ods/`** — one module per format, each carrying both levels of its reader: the package-native `readOdt`/`readOdp`/`readOdg`/`readOds` and the flat `readOdtContent`/`readOdpContent`/`readOdgContent`/`readOdsContent` it is built on. - **`src/typed/draw/`** — the shared `draw:frame`/`draw:g`/vector shape vocabulary (`shapes.ts`), plus `embedded.ts` (`readDrawObjectReference`, `readDrawImageBlock`). -- **`src/typed/formula/`, `odm/`** — `readOdfFormula`/`readOdfFormulaDocument` and `readOdm`. +- **`src/typed/formula/`, `odm/`** — `readOdfFormula`/`readOdfFormulaContent`/`readOdfFormulaMathMl` and `readOdm`. - **`src/typed/odb/`** — `readOdbInventory`, `readOdbForm`/`readOdbReport`, `resolveOdbComponent`, `subDocumentPackage`. ## Conventions @@ -166,10 +231,10 @@ Layered from a lossless core outward, mirroring `ooxml.js`: - **A rotated `draw:rect`/`ellipse`/`path`/`custom-shape` reads its own `rotationDeg`** via the same `resolveOdfShapeGeometry` machinery `draw:frame` uses, composing any enclosing `draw:g` rotation. - **Every `ContentShape`/`ContentVector` carries a resolved `paintOrder`** so true relative paint order survives across the independently-ordered `shapes`/`vectors` arrays. - **`svg:fill-rule` and `draw:stroke` map onto `ContentVector.fillRule`/`ContentStroke.style`.** A dotted pattern and `"double"` stroke have no ODF vector-stroke counterpart and remain unread. -- **`readOds`/`readTableCell` resolve cell `background`/`borders`/`alignment`/`verticalAlignment` from the real style cascade.** An explicit `fo:border-*` of `"none"`/`"hidden"` clears an inherited edge. -- **`readOds` reads sheet-anchored drawings** — cell-anchored `draw:frame`s (coordinates relative to the cell) and page-anchored ones (in `table:shapes`). A sheet cannot carry a floating text box, bare vector, or embedded chart; each is skipped. +- **`readOdsContent`/`readTableCell` resolve cell `background`/`borders`/`alignment`/`verticalAlignment` from the real style cascade.** An explicit `fo:border-*` of `"none"`/`"hidden"` clears an inherited edge. +- **`readOdsContent` reads sheet-anchored drawings** — cell-anchored `draw:frame`s (coordinates relative to the cell) and page-anchored ones (in `table:shapes`). A sheet cannot carry a floating text box, bare vector, or embedded chart; each is skipped. - **`readDrawObjectReference` resolves a frame's embedded sub-document kind from its own `content.xml`, not the manifest.** A `draw:object` must be checked *before* the frame's preview image, since an embedded-object frame also carries a preview `draw:image`. -- **An embedded Math object in a spreadsheet cell reads as `objectKind: 'formula'`** — its `content.xml` root *is* the MathML root, so `readDrawObjectReference` falls back to `findMathRoot` and dispatches to `readOdfFormulaDocument`. +- **An embedded Math object in a spreadsheet cell reads as `objectKind: 'formula'`** — its `content.xml` root *is* the MathML root, so `readDrawObjectReference` falls back to `findMathRoot` and dispatches to `readOdfFormulaContent`. - **A `draw:frame`'s alternative text (`svg:title`, falling back to `svg:desc`) reads into `ContentImageBlock.altText`.** - **`readOdbInventory`'s `queries` carry real `db:command` SQL text**, not just names — a breaking rename from `string[]` to `OdbQueryInfo[]`. - **`.odb` Form/Report structure extraction is real** (`readOdbForm`/`readOdbReport`), grounded in a genuine fixture. **A SQL/`rpt:` rendering engine to execute a query or evaluate report totals is deliberately not attempted** — see the Status section. Even a fully bounded SQL engine would not suffice to render a report: grouping breaks (`rpt:HASCHANGED`), prefix functions (`rpt:LEFT`), and running totals (`rpt:SUM`) are evaluated by Report Builder's own `rpt:` formula language, not by SQL. @@ -185,8 +250,8 @@ Conventional Commits (`feat:`, `fix:`, `test:`, `chore:`, …), enforced by comm ## References - [ooxml.js](https://github.com/ExaDev/ooxml.js) — the OOXML sibling; architecturally mirrored, deliberately not depended on. -- [document-schema.js](https://github.com/ExaDev/document-schema.js) — the canonical `ContentDocument`/`LayoutDocument` schema both packages depend on. -- [documents.js](https://github.com/ExaDev/documents.js) — the downstream consumer; its `readOdtContent`/`readOdpContent`/`readOdsContent`/`readOdgContent` are thin adapters over this package's readers. +- [document-schema.js](https://github.com/ExaDev/document-schema.js) — the canonical `ContentDocument`/`DocumentPackage` schema both packages depend on, and the home of the `assemblePackage`/`flattenPackage`/`decompose`/`factorStyles` transform between the two encodings. +- [documents.js](https://github.com/ExaDev/documents.js) — the downstream consumer; its own `readOdtContent`/`readOdpContent`/`readOdsContent`/`readOdgContent` adapters wrap this package's flat `*Content` readers into `ContentDocument`s, adding the odt/odp formula, image, and vector detection passes those readers deliberately leave out. ## License diff --git a/src/index.ts b/src/index.ts index 733419f..8399064 100644 --- a/src/index.ts +++ b/src/index.ts @@ -92,6 +92,9 @@ export { parsePageSize, parseMargins, parseBox, parseLinePoints } from './typed/ export { type Alignment, AlignmentSchema } from 'document-schema.js'; +// The type every primary reader below returns, re-exported so a consumer can name it without reaching past odf.js for a second dependency -- the same reason AlignmentSchema is re-exported above. The value-level surface it belongs to (DocumentPackageSchema, assemblePackage, flattenPackage, decompose, factorStyles) deliberately stays where it is defined: this package constructs packages, it does not own the vocabulary, and re-exporting the transform would put a second import path on functions whose home is document-schema.js. +export type { DocumentPackage } from 'document-schema.js'; + export { getOdfSpaceCount, measureOdfNodeLength, sumOdfNodeLength, decodeOdfText } from './typed/shared/text'; export { resolveStyle, resolveStyleElementChain, findStyleElement } from './typed/shared/cascade'; @@ -127,19 +130,22 @@ export type { DrawPageContent } from './typed/draw/shapes'; export { readDrawObjectReference } from './typed/draw/embedded'; export type { EmbeddedDrawObject, EmbeddedDocumentKind } from './typed/draw/embedded'; -export { readOdp } from './typed/odp/read'; +// --- The typed readers, each at two levels. readOdt/readOdp/readOdg/readOds/readOdfFormula are the PRIMARY entry points and return document-schema.js's DocumentPackage -- the single hierarchical artefact (kind, metadata, tables, and a `children` tree of one group per top-level container), assembled via that package's own assemblePackage so the styles table is minted exactly as it is at every other package construction site in this family. The *Content functions beneath them are the same readers' flat, ContentDocument-level output ({ metadata, sections|slides|pages|sheets }, or a whole ContentDocument for the formula case), unchanged in behaviour and still the right call for a consumer that works in the flat pivot -- documents.js's own conversion pipeline reads at this level today. Each pair is one read, not two: the package-native function calls its own *Content sibling and reshapes the result, so the two can never disagree about what the file says. +// +// The *Content names belong to the flat reader beneath each package-native function -- see the README's migration table for the full old-to-new name mapping. readOdfFormulaMathMl is the rawest reader in the formula ladder, the MathML-plus-StarMath reader with no pivot shaping at all, unchanged in behaviour: a caller typing "readOdfFormula" wants the format's primary reader, not its rawest one, which is why the bare name belongs to the package-native function instead. --- +export { readOdp, readOdpContent } from './typed/odp/read'; export type { OdpDocument } from './typed/odp/read'; -export { readOdt } from './typed/odt/read'; +export { readOdt, readOdtContent } from './typed/odt/read'; export type { OdtDocument } from './typed/odt/read'; -export { readOdg } from './typed/odg/read'; +export { readOdg, readOdgContent } from './typed/odg/read'; export type { OdgDocument } from './typed/odg/read'; -export { readOds } from './typed/ods/read'; +export { readOds, readOdsContent } from './typed/ods/read'; export type { OdsDocument } from './typed/ods/read'; -export { readOdfFormula, readOdfFormulaDocument } from './typed/formula/read'; +export { readOdfFormula, readOdfFormulaContent, readOdfFormulaMathMl } from './typed/formula/read'; export type { OdfFormulaDocument } from './typed/formula/read'; export { readOdm } from './typed/odm/read'; diff --git a/src/test-support/document-package.ts b/src/test-support/document-package.ts new file mode 100644 index 0000000..cf9f999 --- /dev/null +++ b/src/test-support/document-package.ts @@ -0,0 +1,54 @@ +import { expect } from 'vitest'; +import type { ContentDocument, DocumentPackage } from 'document-schema.js'; +import { DocumentPackageSchema, factorStyles, flattenPackage } from 'document-schema.js'; + +// The shared round-trip harness each package-native reader's own suite runs over its format's real fixture, so the five readers are held to one contract stated once rather than five paraphrases of it. Never imported by src/index.ts and never reaches dist/ -- test-only, matching test-support/zip.ts's own convention. +// +// Every assertion here is a real property of the value, never a no-throw smoke check: +// +// 1. SCHEMA VALIDITY, at the envelope and at every group node, though not at a leaf. DocumentPackageSchema.parse walks the tree through document-schema.js's own isPackageGroup/isPackageLeaf guards, so comparing the parsed value back against the input proves the package satisfies that structure. A group descriptor is `.strict()` and isGroupWrapper restricts a group node's own keys to node/style/children, so a stray key on a GROUP node is rejected outright and the parse throws. A leaf is validated by a z.custom guard, which returns the input unchanged on success rather than a stripped, reparsed value -- a stray key on a LEAF is neither rejected nor stripped, so this comparison cannot catch one there. +// 2. THE ROUND TRIP ITSELF. flattenPackage(assemblePackage(content)) reproduces `content` exactly -- law (i) of the package boundary, and the strongest round trip odf.js can state today: this package has readers and no writers, so bytes -> package -> bytes has no second half to run. What IS provable is that nothing is lost or invented crossing the boundary in the direction that does exist: real ODF bytes -> Package -> the flat ContentDocument the *Content reader produces -> the tree -> back to a ContentDocument that must deep-equal the flat one, refs resolved, groups dissolved, document order intact. +// 3. MINTING IDEMPOTENCE. factorStyles re-factors an already-assembled package and must mint the identical table -- law (iii). Run over real fixture content rather than synthetic property tuples, this is what catches a styles table whose ids or strips depend on anything but the content itself. +export function assertPackageRoundTrip(pkg: DocumentPackage, content: ContentDocument): void { + expect(DocumentPackageSchema.parse(pkg)).toEqual(pkg); + expect(flattenPackage(pkg)).toEqual(content); + expect(factorStyles(pkg)).toEqual(pkg); +} + +// One narrower per package kind, so a suite reaching into a package's own tree (children, group nodes, refs) works against the arm its reader actually returns rather than the whole five-arm union. Each is a plain discriminated-union guard -- the throw IS the "this reader returns this kind" assertion, and narrowing by comparison is what keeps every caller free of type assertions, which this package bans outright. Written out one per kind rather than as one kind-parameterised helper on purpose: TypeScript narrows a union against a LITERAL discriminant, not against a generic type parameter, so the generic form would only typecheck behind exactly the assertion this avoids. +type PackageOfKind = Extract; + +export function wordprocessingPackage(pkg: DocumentPackage): PackageOfKind<'wordprocessing'> { + if (pkg.kind !== 'wordprocessing') { + throw new Error(`expected a wordprocessing package, got ${pkg.kind}`); + } + return pkg; +} + +export function presentationPackage(pkg: DocumentPackage): PackageOfKind<'presentation'> { + if (pkg.kind !== 'presentation') { + throw new Error(`expected a presentation package, got ${pkg.kind}`); + } + return pkg; +} + +export function spreadsheetPackage(pkg: DocumentPackage): PackageOfKind<'spreadsheet'> { + if (pkg.kind !== 'spreadsheet') { + throw new Error(`expected a spreadsheet package, got ${pkg.kind}`); + } + return pkg; +} + +export function drawingPackage(pkg: DocumentPackage): PackageOfKind<'drawing'> { + if (pkg.kind !== 'drawing') { + throw new Error(`expected a drawing package, got ${pkg.kind}`); + } + return pkg; +} + +export function formulaPackage(pkg: DocumentPackage): PackageOfKind<'formula'> { + if (pkg.kind !== 'formula') { + throw new Error(`expected a formula package, got ${pkg.kind}`); + } + return pkg; +} diff --git a/src/typed/draw/embedded.ts b/src/typed/draw/embedded.ts index 0c51d22..325f983 100644 --- a/src/typed/draw/embedded.ts +++ b/src/typed/draw/embedded.ts @@ -5,9 +5,9 @@ import { attrValue, childrenWithTag, findChildElement, rootElement } from '../.. import { findMathRoot } from '../formula/read'; import { subDocumentPackage } from '../odb/subdocument'; -// A draw:frame's own EMBEDDED OBJECT reference (draw:object) resolved into the sub-Package it points at, plus the ContentEmbeddedObjectKind that sub-document actually is -- the draw: counterpart to shapes.ts's readDrawImageBlock (draw:image), kept in its own module because the two answer genuinely different questions: an image resolves to a binary part this package decodes itself, while an object resolves to a whole nested ODF DOCUMENT only a typed reader (readOdt/readOds/readOdp/readOdg) can turn into content. +// A draw:frame's own EMBEDDED OBJECT reference (draw:object) resolved into the sub-Package it points at, plus the ContentEmbeddedObjectKind that sub-document actually is -- the draw: counterpart to shapes.ts's readDrawImageBlock (draw:image), kept in its own module because the two answer genuinely different questions: an image resolves to a binary part this package decodes itself, while an object resolves to a whole nested ODF DOCUMENT only a typed reader (readOdtContent/readOdsContent/readOdpContent/readOdgContent) can turn into content. // -// WHY THIS MODULE DELIBERATELY DOES NOT CALL THOSE READERS ITSELF: it would have to import readOds, which imports this module -- a genuine import cycle, and one that would grow a new edge every time another format learns to read embedded objects. Resolving the reference (which sub-package, which kind) needs no reader at all, so the split falls exactly where the dependency does: this module answers "what is embedded and where are its parts", and the calling format reader dispatches its own kind -> readX call from that. +// WHY THIS MODULE DELIBERATELY DOES NOT CALL THOSE READERS ITSELF: it would have to import readOdsContent, which imports this module -- a genuine import cycle, and one that would grow a new edge every time another format learns to read embedded objects. Resolving the reference (which sub-package, which kind) needs no reader at all, so the split falls exactly where the dependency does: this module answers "what is embedded and where are its parts", and the calling format reader dispatches its own kind -> readX call from that. // // CONFIRMED against real, unmodified LibreOffice 26.2 output (src/typed/ods/fixtures/sheet-anchors.ods -- a real Calc sheet built through the same UNO calls the Calc UI itself uses, with a LibreOffice Draw document inserted as an OLE object anchored to a cell, then saved and unzipped directly): // - The reference is ``, a direct child of draw:frame -- a package-relative DIRECTORY path with a "./" prefix and NO trailing "/content.xml", so the href is exactly the prefix subDocumentPackage (typed/odb/subdocument.ts) already re-keys a sub-document's parts against, once that "./" is stripped. @@ -32,7 +32,7 @@ export interface EmbeddedDrawObject { const CONTENT_PART = 'content.xml'; -// office:body's single content child identifies the document kind, exactly as it does for a top-level package (readOdt looks for office:text, readOds for office:spreadsheet, and so on) -- a switch rather than a lookup table so each mapping narrows to its own literal type with no assertion. 'formula' is genuinely reachable through this function's own return type but never returned BY it: an embedded formula has no office:body element to have a content child at all, so it is resolved from the MathML root instead (see subDocumentKind below). +// office:body's single content child identifies the document kind, exactly as it does for a top-level package (readOdtContent looks for office:text, readOdsContent for office:spreadsheet, and so on) -- a switch rather than a lookup table so each mapping narrows to its own literal type with no assertion. 'formula' is genuinely reachable through this function's own return type but never returned BY it: an embedded formula has no office:body element to have a content child at all, so it is resolved from the MathML root instead (see subDocumentKind below). function embeddedKindFor(bodyChildTag: string): EmbeddedDocumentKind | undefined { switch (bodyChildTag) { case 'office:text': diff --git a/src/typed/draw/shapes.ts b/src/typed/draw/shapes.ts index 0b39499..9caac38 100644 --- a/src/typed/draw/shapes.ts +++ b/src/typed/draw/shapes.ts @@ -144,7 +144,7 @@ function readOwnTransformFunctions(element: XmlElement): OdfTransformFunction[] // // `indexState` reuses the EXACT SAME paintOrderKey/DocumentIndexState machinery walkDrawPageContent (odg, further down this file) uses -- ContentShapeSchema carries the identical optional `paintOrder` field ContentSlideSchema's own shapes already declare, so a presentation shape gets the same real, spec-aware (draw:z-index-honouring, falling back to document-encounter order) paint-order value an odg drawing's shapes get, even though odp's own output array is never reordered by it (matching this walker's own pre-existing document-order-only behaviour -- only the STAMPED VALUE is new, not a new sort). Defaults to a fresh counter so every existing external call site (a single top-level call per slide, with no indexState argument) keeps working unchanged; recursion into a nested draw:g threads the SAME state onward so the counter stays monotonic across the whole slide, matching walkDrawPageContent's own threading discipline exactly. // -// `listIdState` threads the text-box list numId counter (see readDrawFrameContent's own ODP LIST MEMBERSHIP note) through every frame of the walk, with the same fresh-counter default and the same recursive threading discipline as indexState -- odp passes one document-wide state (see readOdp) so a list's identity is unique across the whole presentation, never reset per slide or per group. +// `listIdState` threads the text-box list numId counter (see readDrawFrameContent's own ODP LIST MEMBERSHIP note) through every frame of the walk, with the same fresh-counter default and the same recursive threading discipline as indexState -- odp passes one document-wide state (see readOdpContent) so a list's identity is unique across the whole presentation, never reset per slide or per group. export function walkDrawShapes(children: readonly XmlNode[], groupFunctions: readonly OdfTransformFunction[], pkg: Package, out: ContentShape[], indexState: DocumentIndexState = { next: 0 }, listIdState: OdfListIdState = { next: 1 }): void { for (const node of children) { if (node.type !== 'element') { @@ -322,7 +322,7 @@ function readCustomShapeVector(element: XmlElement, groupFunctions: readonly Odf return { kind: type === 'ellipse' ? 'ellipse' : 'rect', frame: geometry.frame, rotationDeg: geometry.rotationDeg, fill, stroke }; } -// The fallback for an UNRECOGNISED draw:custom-shape preset (or one with no draw:enhanced-geometry/draw:type at all): produce text-only content -- a plain ContentShape carrying whatever real text:p runs the shape has, read through the same readOdfParagraph call readDrawFrameContent's own draw:text-box case uses (though without its list-membership walk -- an odg path, where a text:list's own text:p children are still FOUND by this deep search and read as plain paragraphs) -- rather than a vector primitive this reader cannot correctly derive without evaluating draw:enhanced-path's own formula language (see RECOGNIZED_CUSTOM_SHAPE_PRESETS' own note). A custom-shape's text:p children sit DIRECTLY under draw:custom-shape itself (confirmed against real LibreOffice output -- unlike draw:frame's own draw:text-box wrapper), so elementsWithTag is used here as a deep search that also finds a text:list's own text:p children should a custom shape carry one, reading them as plain paragraphs. An unrecognised preset with NO real text content at all (every run empty, matching this reader's own hand-built fixtures, which never populate a placeholder shape's own text) has nothing worth preserving and is skipped entirely -- this IS the "diagnostic-worthy note" this task's brief asks for: this comment IS that note, since neither this reader nor readOdg below has a diagnostics sink to report it through (matching readOdp/readOdt's own established "no diagnostics channel" posture elsewhere in this package). +// The fallback for an UNRECOGNISED draw:custom-shape preset (or one with no draw:enhanced-geometry/draw:type at all): produce text-only content -- a plain ContentShape carrying whatever real text:p runs the shape has, read through the same readOdfParagraph call readDrawFrameContent's own draw:text-box case uses (though without its list-membership walk -- an odg path, where a text:list's own text:p children are still FOUND by this deep search and read as plain paragraphs) -- rather than a vector primitive this reader cannot correctly derive without evaluating draw:enhanced-path's own formula language (see RECOGNIZED_CUSTOM_SHAPE_PRESETS' own note). A custom-shape's text:p children sit DIRECTLY under draw:custom-shape itself (confirmed against real LibreOffice output -- unlike draw:frame's own draw:text-box wrapper), so elementsWithTag is used here as a deep search that also finds a text:list's own text:p children should a custom shape carry one, reading them as plain paragraphs. An unrecognised preset with NO real text content at all (every run empty, matching this reader's own hand-built fixtures, which never populate a placeholder shape's own text) has nothing worth preserving and is skipped entirely -- this IS the "diagnostic-worthy note" this task's brief asks for: this comment IS that note, since neither this reader nor readOdgContent below has a diagnostics sink to report it through (matching readOdpContent/readOdtContent's own established "no diagnostics channel" posture elsewhere in this package). function readCustomShapeAsTextShape(element: XmlElement, groupFunctions: readonly OdfTransformFunction[], pkg: Package): ContentShape | undefined { const paragraphs = elementsWithTag(element.children, 'text:p').map((p) => readOdfParagraph(p, pkg)); const hasText = paragraphs.some((paragraph) => paragraph.runs.some((run) => run.text.length > 0)); @@ -444,7 +444,7 @@ export interface DrawPageContent { readonly vectors: ContentVector[]; } -// The odg-facing entry point: resolves a draw:page's own children (typically office:drawing's draw:page, but equally valid for a presentation draw:page that happens to contain vector primitives directly -- draw:page's own content model does not differ between office:drawing and office:presentation, see readOdg's own top-of-file note) into paint-ordered shapes/vectors, ready to place directly into a ContentDrawPageSchema value. +// The odg-facing entry point: resolves a draw:page's own children (typically office:drawing's draw:page, but equally valid for a presentation draw:page that happens to contain vector primitives directly -- draw:page's own content model does not differ between office:drawing and office:presentation, see readOdgContent's own top-of-file note) into paint-ordered shapes/vectors, ready to place directly into a ContentDrawPageSchema value. export function readDrawPageContent(children: readonly XmlNode[], pkg: Package): DrawPageContent { const shapesOut: PaintOrdered[] = []; const vectorsOut: PaintOrdered[] = []; diff --git a/src/typed/formula/read.test.ts b/src/typed/formula/read.test.ts index 9d257f1..7fedea7 100644 --- a/src/typed/formula/read.test.ts +++ b/src/typed/formula/read.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'; import type { Package } from '../../model/package'; import type { XmlElement } from '../../model/node'; import { el, txt } from '../../xml/fragment'; -import { readOdfFormula, readOdfFormulaDocument } from './read'; +import { assertPackageRoundTrip, formulaPackage } from '../../test-support/document-package'; +import { readOdfFormula, readOdfFormulaContent, readOdfFormulaMathMl } from './read'; // The math root below is copied, element-for-element, from a GENUINE LibreOffice 26.2 .odf's own content.xml -- built via a headless UNO Basic macro (private:factory/smath, Formula set to "f(x) = {x^2} over {2} + sqrt {x}", saved with the "math8" filter) and inspected directly after unzipping the result. It is deliberately NOT hand-simplified: the real fence/stretchy/form attributes on the parenthesis elements, the nested wrapping, and the exact / shape are all real LibreOffice output, confirming both (a) a bare "math" root tag with a DEFAULT xmlns (not a "math:" prefix -- see read.ts's own top-of-file note) and (b) a real StarMath annotation nested two levels down (). function realFormulaMathRoot(): XmlElement { @@ -38,23 +39,23 @@ function realFormulaPackage(): Package { }; } -describe('readOdfFormula', () => { +describe('readOdfFormulaMathMl', () => { it('throws when the package has no content.xml part at all', () => { - expect(() => readOdfFormula({ parts: {} })).toThrow(/content\.xml/); + expect(() => readOdfFormulaMathMl({ parts: {} })).toThrow(/content\.xml/); }); it('throws when content.xml is not an XML part', () => { const pkg: Package = { parts: { 'content.xml': { kind: 'binary', base64: '' } } }; - expect(() => readOdfFormula(pkg)).toThrow(/content\.xml/); + expect(() => readOdfFormulaMathMl(pkg)).toThrow(/content\.xml/); }); it('throws when content.xml has no MathML root anywhere', () => { const pkg: Package = { parts: { 'content.xml': { kind: 'xml', nodes: [el('office:document-content', {}, [el('office:body')])] } } }; - expect(() => readOdfFormula(pkg)).toThrow(/MathML/); + expect(() => readOdfFormulaMathMl(pkg)).toThrow(/MathML/); }); it('reads a genuine LibreOffice-produced formula\'s mathml as the bare "math" root\'s own children, preserving nested fraction/superscript/sqrt structure', () => { - const { mathml } = readOdfFormula(realFormulaPackage()); + const { mathml } = readOdfFormulaMathMl(realFormulaPackage()); expect(mathml).toHaveLength(1); const [semantics] = mathml; if (semantics?.type !== 'element' || semantics.tag !== 'semantics') { @@ -68,23 +69,23 @@ describe('readOdfFormula', () => { }); it('reads the real StarMath annotation text from the standard MathML element', () => { - const { starMath } = readOdfFormula(realFormulaPackage()); + const { starMath } = readOdfFormulaMathMl(realFormulaPackage()); expect(starMath).toBe('f(x) = {x^2} over {2} + sqrt {x}'); }); it('reads metadata via meta.xml, identically to every other odf.js typed reader', () => { - const { metadata } = readOdfFormula(realFormulaPackage()); + const { metadata } = readOdfFormulaMathMl(realFormulaPackage()); expect(metadata.title).toBe('Pythagoras'); }); it('returns empty metadata for a package with no meta.xml at all', () => { const pkg: Package = { parts: { 'content.xml': { kind: 'xml', nodes: [realFormulaMathRoot()] } } }; - expect(readOdfFormula(pkg).metadata).toEqual({}); + expect(readOdfFormulaMathMl(pkg).metadata).toEqual({}); }); it('leaves starMath undefined for plain presentation MathML with no semantics/annotation wrapper -- e.g. hand-authored or third-party-produced content.xml, never genuine LibreOffice-Math output (see read.ts\'s own top-of-file note)', () => { const pkg: Package = { parts: { 'content.xml': { kind: 'xml', nodes: [el('math', { xmlns: 'http://www.w3.org/1998/Math/MathML' }, [el('mi', {}, [txt('x')])])] } } }; - const result = readOdfFormula(pkg); + const result = readOdfFormulaMathMl(pkg); expect(result.starMath).toBeUndefined(); expect('starMath' in result).toBe(false); expect(result.mathml).toEqual([el('mi', {}, [txt('x')])]); @@ -99,7 +100,7 @@ describe('readOdfFormula', () => { }, }, }; - expect(readOdfFormula(pkg).starMath).toBeUndefined(); + expect(readOdfFormulaMathMl(pkg).starMath).toBeUndefined(); }); it('ignores an whose encoding is not StarMath (e.g. a LaTeX annotation)', () => { @@ -115,46 +116,71 @@ describe('readOdfFormula', () => { }, }, }; - expect(readOdfFormula(pkg).starMath).toBeUndefined(); + expect(readOdfFormulaMathMl(pkg).starMath).toBeUndefined(); }); // Real LibreOffice output -- both a standalone .odf and a Math object embedded inside a real .odt (verified via a headless UNO macro embedding a TextEmbeddedObject with Math's own CLSID) -- never wraps content.xml's math root in office:document-content; see read.ts's own top-of-file note. The two cases below are therefore purely defensive per this reader's own design brief, not verified against any real producer's output. it('defensively finds a literal "math:math"-prefixed root at content.xml\'s own top level (never observed in real output, but matches ns.ts\'s own math: prefix convention)', () => { const pkg: Package = { parts: { 'content.xml': { kind: 'xml', nodes: [el('math:math', { 'xmlns:math': 'http://www.w3.org/1998/Math/MathML' }, [el('math:mi', {}, [txt('x')])])] } } }; - expect(readOdfFormula(pkg).mathml).toEqual([el('math:mi', {}, [txt('x')])]); + expect(readOdfFormulaMathMl(pkg).mathml).toEqual([el('math:mi', {}, [txt('x')])]); }); it('defensively finds a bare "math" root nested inside the standard office:document-content wrapper', () => { const mathRoot = el('math', { xmlns: 'http://www.w3.org/1998/Math/MathML' }, [el('mi', {}, [txt('y')])]); const pkg: Package = { parts: { 'content.xml': { kind: 'xml', nodes: [el('office:document-content', {}, [el('office:body', {}, [mathRoot])])] } } }; - expect(readOdfFormula(pkg).mathml).toEqual([el('mi', {}, [txt('y')])]); + expect(readOdfFormulaMathMl(pkg).mathml).toEqual([el('mi', {}, [txt('y')])]); }); }); -describe('readOdfFormulaDocument', () => { - it('wraps a genuine LibreOffice-produced formula into a real ContentDocument of kind \'formula\', carrying the identical mathml/starMath/metadata readOdfFormula itself reads', () => { - const document = readOdfFormulaDocument(realFormulaPackage()); +describe('readOdfFormulaContent', () => { + it('wraps a genuine LibreOffice-produced formula into a real ContentDocument of kind \'formula\', carrying the identical mathml/starMath/metadata readOdfFormulaMathMl itself reads', () => { + const document = readOdfFormulaContent(realFormulaPackage()); expect(document.kind).toBe('formula'); if (document.kind !== 'formula') { throw new Error('expected a formula-kind ContentDocument'); } expect(document.metadata.title).toBe('Pythagoras'); expect(document.formula.starMath).toBe('f(x) = {x^2} over {2} + sqrt {x}'); - expect(document.formula.mathml).toEqual(readOdfFormula(realFormulaPackage()).mathml); + expect(document.formula.mathml).toEqual(readOdfFormulaMathMl(realFormulaPackage()).mathml); }); - it('omits starMath from the formula field when readOdfFormula itself found none', () => { + it('omits starMath from the formula field when readOdfFormulaMathMl itself found none', () => { const pkg: Package = { parts: { 'content.xml': { kind: 'xml', nodes: [el('math', { xmlns: 'http://www.w3.org/1998/Math/MathML' }, [el('mi', {}, [txt('x')])])] } } }; - const document = readOdfFormulaDocument(pkg); + const document = readOdfFormulaContent(pkg); if (document.kind !== 'formula') { throw new Error('expected a formula-kind ContentDocument'); } expect('starMath' in document.formula).toBe(false); }); - it('throws the identical readOdfFormula error for a package with no MathML root, since it is built directly on readOdfFormula', () => { + it('throws the identical readOdfFormulaMathMl error for a package with no MathML root, since it is built directly on readOdfFormulaMathMl', () => { const pkg: Package = { parts: { 'content.xml': { kind: 'xml', nodes: [el('office:document-content', {}, [el('office:body')])] } } }; - expect(() => readOdfFormulaDocument(pkg)).toThrow(/MathML/); + expect(() => readOdfFormulaContent(pkg)).toThrow(/MathML/); + }); +}); + +describe('readOdfFormula: the package-native reader over the same fixture', () => { + it('assembles the real LibreOffice formula package into a formula-kind DocumentPackage that flattens back exactly', () => { + const pkg = realFormulaPackage(); + const content = readOdfFormulaContent(pkg); + const documentPackage = readOdfFormula(pkg); + + expect(documentPackage.kind).toBe('formula'); + expect(documentPackage.metadata).toEqual(content.metadata); + assertPackageRoundTrip(documentPackage, content); + }); + + it('carries the ContentFormula leaf itself as the package\'s single child, with no styles table to mint', () => { + const documentPackage = formulaPackage(readOdfFormula(realFormulaPackage())); + // A formula has no container structure to group: the tree's one child IS the leaf, and with no wrappers and no paragraphs anywhere the minting pass necessarily produces nothing. + expect(documentPackage.children).toHaveLength(1); + expect(documentPackage.children[0]?.starMath).toBe('f(x) = {x^2} over {2} + sqrt {x}'); + expect(documentPackage.styles).toBeUndefined(); + }); + + it('throws the identical readOdfFormulaMathMl error for a package with no MathML root', () => { + const pkg: Package = { parts: { 'content.xml': { kind: 'xml', nodes: [el('office:document-content', {}, [el('office:body')])] } } }; + expect(() => readOdfFormula(pkg)).toThrow(/MathML/); }); }); diff --git a/src/typed/formula/read.ts b/src/typed/formula/read.ts index c774627..3359a42 100644 --- a/src/typed/formula/read.ts +++ b/src/typed/formula/read.ts @@ -1,4 +1,5 @@ -import type { ContentDocument, LayoutMetadata } from 'document-schema.js'; +import type { ContentDocument, DocumentPackage, LayoutMetadata } from 'document-schema.js'; +import { assemblePackage } from 'document-schema.js'; import type { XmlElement, XmlNode } from '../../model/node'; import type { Package } from '../../model/package'; import { attrValue, elementsWithTag, rootElement } from '../../xml/query'; @@ -74,15 +75,15 @@ function findStarMathAnnotation(mathRoot: XmlElement): string | undefined { return undefined; } -// Package -> OdfFormulaDocument. Throws only when content.xml itself, or a MathML root within it (see findMathRoot), is missing -- a genuinely unusable package, mirroring every other odf.js typed reader's own "missing required structural element" throw convention. `mathml` is the MathML root's own children (its real content -- typically a single element wrapping the presentation MathML plus any s, per real LibreOffice output; occasionally, for hand-authored presentation-only MathML with no wrapper, the presentation elements directly), returned as the raw, lossless XmlNode[] this reader read them as -- see readOdfFormulaDocument below for the document-schema.js-pivot-shaped alternative built on top of this same result. -export function readOdfFormula(pkg: Package): OdfFormulaDocument { +// Package -> OdfFormulaDocument. Throws only when content.xml itself, or a MathML root within it (see findMathRoot), is missing -- a genuinely unusable package, mirroring every other odf.js typed reader's own "missing required structural element" throw convention. `mathml` is the MathML root's own children (its real content -- typically a single element wrapping the presentation MathML plus any s, per real LibreOffice output; occasionally, for hand-authored presentation-only MathML with no wrapper, the presentation elements directly), returned as the raw, lossless XmlNode[] this reader read them as -- see readOdfFormulaContent below for the document-schema.js-pivot-shaped alternative built on top of this same result. +export function readOdfFormulaMathMl(pkg: Package): OdfFormulaDocument { const contentPart = pkg.parts[CONTENT_PART]; if (contentPart?.kind !== 'xml') { - throw new Error(`readOdfFormula: package has no ${CONTENT_PART} part`); + throw new Error(`readOdfFormulaMathMl: package has no ${CONTENT_PART} part`); } const mathRoot = findMathRoot(contentPart.nodes); if (mathRoot === undefined) { - throw new Error(`readOdfFormula: ${CONTENT_PART} has no MathML root element`); + throw new Error(`readOdfFormulaMathMl: ${CONTENT_PART} has no MathML root element`); } const metadata = readOdfMetadata(pkg); @@ -91,11 +92,11 @@ export function readOdfFormula(pkg: Package): OdfFormulaDocument { return starMath === undefined ? { mathml: mathRoot.children, metadata } : { starMath, mathml: mathRoot.children, metadata }; } -// Package -> a real document-schema.js ContentDocument of kind 'formula'. Built directly on readOdfFormula's own result -- same throw behaviour, same metadata, same raw mathml/starMath -- just reshaped into the ContentDocumentSchema 'formula' variant document-schema.js 2.0.0 now defines, for a caller that wants the shared pivot type rather than this reader's own bespoke OdfFormulaDocument shape. readOdfFormula itself is unchanged and remains the right call for a caller that wants the raw, lossless data with no pivot-schema shaping at all. +// Package -> a real document-schema.js ContentDocument of kind 'formula'. Built directly on readOdfFormulaMathMl's own result -- same throw behaviour, same metadata, same raw mathml/starMath -- just reshaped into the ContentDocumentSchema 'formula' variant document-schema.js 2.0.0 now defines, for a caller that wants the shared pivot type rather than this reader's own bespoke OdfFormulaDocument shape. readOdfFormulaMathMl itself is unchanged and remains the right call for a caller that wants the raw, lossless data with no pivot-schema shaping at all. // // `mathml` here is odf.js's own XmlNode[] (this package's local, hand-written recursive element type); the object literal below assigns it straight into ContentFormula's own `mathml: MathMlNode[]` field, checked structurally against this function's own `ContentDocument` return type, with NO cast anywhere. XmlNode and MathMlNode are independently-defined structural mirrors of each other (see this module's own top-of-file note and src/interop.test.ts-style guards elsewhere in this family), not a shared class or branded type, so this return statement compiling unmodified is itself the live proof that document-schema.js's MathMlNode transcription is a genuine structural supertype of XmlNode. -export function readOdfFormulaDocument(pkg: Package): ContentDocument { - const { mathml, starMath, metadata } = readOdfFormula(pkg); +export function readOdfFormulaContent(pkg: Package): ContentDocument { + const { mathml, starMath, metadata } = readOdfFormulaMathMl(pkg); return { kind: 'formula', @@ -103,3 +104,10 @@ export function readOdfFormulaDocument(pkg: Package): ContentDocument { formula: starMath === undefined ? { mathml } : { mathml, starMath }, }; } + +// Package -> DocumentPackage: this module's PRIMARY entry point, the formula mirror of readOdtContent/readOdt (see src/typed/odt/read.ts's own note on why assemblePackage rather than bare decompose, and why no `pages` argument). A formula package's single child is the ContentFormula leaf itself -- there is no container structure to group and therefore nothing for the minting pass to factor, so assemblePackage's styles table is necessarily absent here; the call still routes through it rather than hand-building the envelope, so every reader in this package constructs its package exactly one way. +// +// readOdfFormula is this module's bare, primary entry point. The ladder beneath it matches every other format's: readOdfFormulaContent for the flat ContentDocument pivot, readOdfFormulaMathMl for the raw MathML nodes plus StarMath annotation with no pivot shaping at all -- a caller reaches for the level it actually needs rather than assuming the bare name is the only one on offer. +export function readOdfFormula(pkg: Package): DocumentPackage { + return assemblePackage(readOdfFormulaContent(pkg)); +} diff --git a/src/typed/odb/form.test.ts b/src/typed/odb/form.test.ts index 8814c81..2b1db35 100644 --- a/src/typed/odb/form.test.ts +++ b/src/typed/odb/form.test.ts @@ -24,7 +24,7 @@ describe('readOdbForm: form-and-report.odb (real LibreOffice output)', () => { expect(form.href).toBe('forms/Obj11'); }); - it('reads the sub-document as the ordinary ODF text document it genuinely is, through readOdt unmodified', () => { + it('reads the sub-document as the ordinary ODF text document it genuinely is, through readOdtContent unmodified', () => { expect(form.document.sections).toHaveLength(1); expect(form.document.metadata).toBeDefined(); }); diff --git a/src/typed/odb/form.ts b/src/typed/odb/form.ts index d65c4d2..2df6c91 100644 --- a/src/typed/odb/form.ts +++ b/src/typed/odb/form.ts @@ -2,7 +2,7 @@ import type { XmlElement } from '../../model/node'; import type { Package } from '../../model/package'; import { findChildElement, attrValue, rootElement } from '../../xml/query'; import { decodeXmlText } from '../../xml/entities'; -import { readOdt, type OdtDocument } from '../odt/read'; +import { readOdtContent, type OdtDocument } from '../odt/read'; import { resolveOdbComponent } from './read'; import { subDocumentPackage } from './subdocument'; @@ -10,7 +10,7 @@ import { subDocumentPackage } from './subdocument'; // // EMPIRICALLY CONFIRMED against real, unmodified LibreOffice 26.2 output (src/typed/odb/fixtures/form-and-report.odb -- see typed/odb/read.ts's own top-of-file note for how that fixture was generated and cross-verified), not assumed: // -// 1. A form sub-document is a COMPLETE, ordinary ODF TEXT document. Its own directory holds content.xml/styles.xml/settings.xml (plus a manifest.rdf), its manifest:media-type is "application/vnd.oasis.opendocument.text", and its content.xml root is the usual office:document-content/office:body/office:text. readOdt therefore reads it unmodified through a synthetic sub-Package (see subdocument.ts) -- no form-specific text reader needed, and the paragraphs/tables a form's designer laid out around its controls come back exactly as they would from a standalone .odt. +// 1. A form sub-document is a COMPLETE, ordinary ODF TEXT document. Its own directory holds content.xml/styles.xml/settings.xml (plus a manifest.rdf), its manifest:media-type is "application/vnd.oasis.opendocument.text", and its content.xml root is the usual office:document-content/office:body/office:text. readOdtContent therefore reads it unmodified through a synthetic sub-Package (see subdocument.ts) -- no form-specific text reader needed, and the paragraphs/tables a form's designer laid out around its controls come back exactly as they would from a standalone .odt. // 2. The control tree hangs off office:text/office:forms, NOT off the drawing layer. office:forms holds one form:form per top-level form; a control is a form: ELEMENT (form:text, form:formatted-text, form:listbox, form:fixed-text, form:checkbox, ...) whose own form:data-field names the bound column. The drawing layer separately carries a draw:control element per control, referencing the control by its form:id -- that is the control's own GEOMETRY (position/size/anchor), which no reader here resolves today: readBlocks (typed/odt/read.ts) has no draw:control branch, and the ods shape walker skips the element explicitly (see typed/ods/read.ts's collectAnchoredFrames note), so control geometry is dropped entirely rather than re-derived here. // 3. A form:form can NEST another form:form (a real Base sub-form, bound to its own command -- the fixture's own "HighValueSubForm" is a genuine nested form:form bound to a QUERY while its parent is bound to a TABLE). Sub-forms are consequently modelled as their own recursive OdbFormDefinition list rather than flattened into the parent's controls. // 4. form:properties (an untyped bag of form:property elements carrying UNO property values LibreOffice round-trips for its own benefit -- PropertyChangeNotificationEnabled, DefaultControl, ObjIDinMSO, ...) appears on the form and on most controls. It is deliberately never read: none of it is form STRUCTURE, and surfacing a producer-specific property bag would invite callers to depend on LibreOffice internals. @@ -173,7 +173,7 @@ export function readOdbForm(pkg: Package, formName: string): OdbForm { return { name: component.name, href: component.href, - document: readOdt(subPackage), + document: readOdtContent(subPackage), forms: readFormDefinitions(rootElement(contentPart.nodes)), }; } diff --git a/src/typed/odb/read.test.ts b/src/typed/odb/read.test.ts index ecebc7d..c626afa 100644 --- a/src/typed/odb/read.test.ts +++ b/src/typed/odb/read.test.ts @@ -8,7 +8,7 @@ import { el, txt } from '../../xml/fragment'; import { parsePackage } from '../../package-io/read'; import { readOdbInventory, resolveOdbComponent } from './read'; -// This suite reads TWO real, unmodified LibreOffice 26.2-generated .odb fixtures for its genuine-producer-shape assertions, mirroring readOdt's and readOdm's own established convention: src/typed/odb/fixtures/embedded-firebird.odb (an embedded-Firebird database document with two live SQL tables and one real query, and deliberately no forms or reports), and src/typed/odb/fixtures/form-and-report.odb (the same engine, plus a real bound form and a real Report Builder report -- see read.ts's own top-of-file note for how it was generated and for the two findings about real form/report registration it produced). A handful of synthetic, hand-built packages (via el/txt) cover shapes neither real fixture exercises -- an external connection, the two defensive db:database-description variants (never empirically observed), and the db:component-collection grouping and malformed-component paths. +// This suite reads TWO real, unmodified LibreOffice 26.2-generated .odb fixtures for its genuine-producer-shape assertions, mirroring readOdtContent's and readOdm's own established convention: src/typed/odb/fixtures/embedded-firebird.odb (an embedded-Firebird database document with two live SQL tables and one real query, and deliberately no forms or reports), and src/typed/odb/fixtures/form-and-report.odb (the same engine, plus a real bound form and a real Report Builder report -- see read.ts's own top-of-file note for how it was generated and for the two findings about real form/report registration it produced). A handful of synthetic, hand-built packages (via el/txt) cover shapes neither real fixture exercises -- an external connection, the two defensive db:database-description variants (never empirically observed), and the db:component-collection grouping and malformed-component paths. const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), 'fixtures'); diff --git a/src/typed/odb/read.ts b/src/typed/odb/read.ts index 2369cfc..b06ac32 100644 --- a/src/typed/odb/read.ts +++ b/src/typed/odb/read.ts @@ -3,7 +3,7 @@ import type { Package } from '../../model/package'; import { rootElement, findChildElement, childrenWithTag, attrValue } from '../../xml/query'; import { decodeXmlText } from '../../xml/entities'; -// Package -> OdbInventory: connection info plus the NAMES of forms/queries/reports/tables in a .odb (application/vnd.oasis.opendocument.base) database front-end package -- never their content, and never the embedded/external database engine's own binary or script storage. This is deliberately NOT a typed content reader in the sense readOdt/readOdp/readOds/readOdg are: a .odb's real "content" -- table rows, query result sets, form/report layout and logic -- lives either inside a real database engine (HSQLDB, Firebird, or an external server) this reader does not and will not parse, or inside each form's/report's own genuine ODF sub-document (a real, separately-readable office:document-content the existing readOdt-style machinery could open on its own merit, but which this reader deliberately never opens, matching the "never their content" mandate). HSQLDB/Firebird binary parsing is separate, later work in the documents.js repo, not here. +// Package -> OdbInventory: connection info plus the NAMES of forms/queries/reports/tables in a .odb (application/vnd.oasis.opendocument.base) database front-end package -- never their content, and never the embedded/external database engine's own binary or script storage. This is deliberately NOT a typed content reader in the sense readOdtContent/readOdpContent/readOdsContent/readOdgContent are: a .odb's real "content" -- table rows, query result sets, form/report layout and logic -- lives either inside a real database engine (HSQLDB, Firebird, or an external server) this reader does not and will not parse, or inside each form's/report's own genuine ODF sub-document (a real, separately-readable office:document-content the existing readOdtContent-style machinery could open on its own merit, but which this reader deliberately never opens, matching the "never their content" mandate). HSQLDB/Firebird binary parsing is separate, later work in the documents.js repo, not here. // // EMPIRICALLY CONFIRMED against real, unmodified LibreOffice 26.2 output, not assumed: a headless UNO Basic macro (mirroring the same technique src/typed/odm/read.ts's own top-of-file note describes) created a real embedded-Firebird .odb via com.sun.star.sdb.DatabaseContext.createInstance() with URL "sdbc:embedded:firebird", two real tables via a live SQL connection (CREATE TABLE "Customers"/"Orders"), and one real query via the data source's own QueryDefinitions container -- then stored it and unzipped the result directly (src/typed/odb/fixtures/embedded-firebird.odb, checked in alongside this reader, never hand-edited afterwards). Three real findings from that inspection, all load-bearing for this reader's own design, and all genuine corrections to this reader's own design-phase assumptions (which, per the OASIS ODF 1.3 schema's own "Database Front-end Document" chapter table of contents, expected a separate database/connection.xml part): // @@ -221,7 +221,7 @@ export function resolveOdbComponent(pkg: Package, kind: 'form' | 'report', name: return match; } -// Package -> OdbInventory. Throws only when content.xml itself, or its own office:body/office:database element, is missing -- a genuinely unusable package, mirroring every other odf.js typed reader's own "missing required structural element" throw convention (see e.g. readOdt, readOdm). Everything else -- no connection data, no queries, no forms/reports/tables -- degrades to undefined/an empty array rather than throwing, matching this reader's own general "malformed-but-salvageable input degrades gracefully" posture; readOdbInventory has no diagnostics channel to report a partial read through, the same shape readOdt/readOdm/readOdfFormula already establish. +// Package -> OdbInventory. Throws only when content.xml itself, or its own office:body/office:database element, is missing -- a genuinely unusable package, mirroring every other odf.js typed reader's own "missing required structural element" throw convention (see e.g. readOdtContent, readOdm). Everything else -- no connection data, no queries, no forms/reports/tables -- degrades to undefined/an empty array rather than throwing, matching this reader's own general "malformed-but-salvageable input degrades gracefully" posture; readOdbInventory has no diagnostics channel to report a partial read through, the same shape readOdtContent/readOdm/readOdfFormulaMathMl already establish. export function readOdbInventory(pkg: Package): OdbInventory { const contentPart = pkg.parts[CONTENT_PART]; if (contentPart?.kind !== 'xml') { diff --git a/src/typed/odb/subdocument.test.ts b/src/typed/odb/subdocument.test.ts index d6b85ed..af8d608 100644 --- a/src/typed/odb/subdocument.test.ts +++ b/src/typed/odb/subdocument.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; import type { Package } from '../../model/package'; import { el } from '../../xml/fragment'; import { parsePackage } from '../../package-io/read'; -import { readOdt } from '../odt/read'; +import { readOdtContent } from '../odt/read'; import { subDocumentPackage } from './subdocument'; const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), 'fixtures'); @@ -16,11 +16,11 @@ function loadFixture(name: string): Package { } describe('subDocumentPackage', () => { - it('re-keys a real .odb form sub-document into a Package readOdt accepts unmodified', () => { + it('re-keys a real .odb form sub-document into a Package readOdtContent accepts unmodified', () => { const sub = subDocumentPackage(loadFixture('form-and-report.odb'), 'forms/Obj11'); // "Configurations2/" is a genuine zero-length zip DIRECTORY entry real LibreOffice writes into a form sub-document, surfaced as a part like any other -- re-keyed here rather than filtered out, since deciding what is "really" a part is the package reader's own concern, not this helper's. expect(Object.keys(sub.parts).sort()).toEqual(['Configurations2/', 'content.xml', 'manifest.rdf', 'settings.xml', 'styles.xml']); - expect(readOdt(sub).sections).toHaveLength(1); + expect(readOdtContent(sub).sections).toHaveLength(1); }); it('accepts a prefix with or without a trailing slash, producing the identical result', () => { diff --git a/src/typed/odb/subdocument.ts b/src/typed/odb/subdocument.ts index 0b84c6b..182dd62 100644 --- a/src/typed/odb/subdocument.ts +++ b/src/typed/odb/subdocument.ts @@ -1,6 +1,6 @@ import type { Package } from '../../model/package'; -// A synthetic sub-Package view over an embedded ODF sub-document's own directory inside a larger package. An embedded sub-document (a .odb's forms// or reports// directory; a .odt's "Object 1/" embedded Math object) is a COMPLETE, self-contained ODF document whose parts happen to be stored under a path prefix rather than at the package root -- content.xml, styles.xml, settings.xml and meta.xml all sit at "/content.xml" and friends, exactly as they would at the root of a standalone file. Re-keying those parts relative to the prefix therefore produces a genuine Package that every existing typed reader in this package (readOdt, readOds, readOdp, readOdg, readOdfFormula) accepts unmodified, with no sub-document-aware variant of any of them needed. +// A synthetic sub-Package view over an embedded ODF sub-document's own directory inside a larger package. An embedded sub-document (a .odb's forms// or reports// directory; a .odt's "Object 1/" embedded Math object) is a COMPLETE, self-contained ODF document whose parts happen to be stored under a path prefix rather than at the package root -- content.xml, styles.xml, settings.xml and meta.xml all sit at "/content.xml" and friends, exactly as they would at the root of a standalone file. Re-keying those parts relative to the prefix therefore produces a genuine Package that every existing typed reader in this package (readOdtContent, readOdsContent, readOdpContent, readOdgContent, readOdfFormulaMathMl) accepts unmodified, with no sub-document-aware variant of any of them needed. // // This is deliberately a plain re-keying of the SAME Part values (not a deep copy): a Part is treated as immutable by every reader here, and copying a large binary part's base64 for no reason would be pure waste. Nothing outside the prefix is carried over -- notably NOT the outer package's own META-INF/manifest.xml, which describes the OUTER package and would be actively misleading inside the sub-package (its entries are all outer-package-relative paths). A sub-document that ships its own manifest under "/META-INF/manifest.xml" gets it re-keyed like any other part; real LibreOffice .odb output does not write one (a real form sub-document directory holds content.xml, styles.xml, settings.xml, manifest.rdf and an empty Configurations2/ entry, and nothing else -- see typed/odb/form.ts's own top-of-file note). diff --git a/src/typed/odg/read.test.ts b/src/typed/odg/read.test.ts index fde0a03..fbd085d 100644 --- a/src/typed/odg/read.test.ts +++ b/src/typed/odg/read.test.ts @@ -2,9 +2,10 @@ import { describe, expect, it } from 'vitest'; import { PAGE_SIZE_A4 } from 'document-schema.js'; import type { Package } from '../../model/package'; import { el, txt } from '../../xml/fragment'; -import { readOdg } from './read'; +import { assertPackageRoundTrip, drawingPackage } from '../../test-support/document-package'; +import { readOdg, readOdgContent } from './read'; -// A full, real-shape .odg fixture assembled from XML shapes verified against genuine LibreOffice 26.2 output (a StarBasic macro run headlessly against the LibreOffice UNO API to construct actual draw:rect/ellipse/line/path/custom-shape geometry, then the resulting content.xml inspected directly -- NOT hand-authored guesses; see typed/shared/path.ts's own top-of-file note for the full verification method), matching this package's other typed-reader tests' established convention of building packages programmatically from ground-truth-verified shapes rather than loading a committed binary fixture (mirroring readOdp's own read.test.ts). +// A full, real-shape .odg fixture assembled from XML shapes verified against genuine LibreOffice 26.2 output (a StarBasic macro run headlessly against the LibreOffice UNO API to construct actual draw:rect/ellipse/line/path/custom-shape geometry, then the resulting content.xml inspected directly -- NOT hand-authored guesses; see typed/shared/path.ts's own top-of-file note for the full verification method), matching this package's other typed-reader tests' established convention of building packages programmatically from ground-truth-verified shapes rather than loading a committed binary fixture (mirroring readOdpContent's own read.test.ts). function stylesXml(): Package['parts'][string] { return { @@ -80,14 +81,14 @@ function buildFixturePackage(): Package { return { parts: { 'content.xml': contentXml, 'styles.xml': stylesXml(), 'meta.xml': metaXml } }; } -describe('readOdg', () => { +describe('readOdgContent', () => { it('reads draw:page elements in native document order (no p:sldIdLst-style indirection, matching odp)', () => { - const { pages } = readOdg(buildFixturePackage()); + const { pages } = readOdgContent(buildFixturePackage()); expect(pages).toHaveLength(2); }); - it('resolves page size from the master-page -> page-layout chain, identically to readOdp', () => { - const { pages } = readOdg(buildFixturePackage()); + it('resolves page size from the master-page -> page-layout chain, identically to readOdpContent', () => { + const { pages } = readOdgContent(buildFixturePackage()); expect(pages[0]?.size.widthPt).toBeCloseTo((21 * 72) / 2.54, 6); expect(pages[0]?.size.heightPt).toBeCloseTo((29.7 * 72) / 2.54, 6); }); @@ -95,23 +96,23 @@ describe('readOdg', () => { it('falls back to A4 (LibreOffice Draw\'s own real default page size) when the master-page/page-layout chain does not resolve', () => { const pkg = buildFixturePackage(); delete pkg.parts['styles.xml']; - const { pages } = readOdg(pkg); + const { pages } = readOdgContent(pkg); expect(pages[0]?.size).toEqual(PAGE_SIZE_A4); }); it('reads every recognised vector primitive kind onto the first page\'s own vectors array', () => { - const { pages } = readOdg(buildFixturePackage()); + const { pages } = readOdgContent(buildFixturePackage()); const kinds = pages[0]?.vectors.map((v) => v.kind); expect(kinds).toEqual(['ellipse', 'line', 'path', 'path', 'rect', 'ellipse', 'rect']); }); it('salvages the unrecognised custom-shape preset ("smiley") as nothing at all (no real text content in this fixture) rather than a vector', () => { - const { pages } = readOdg(buildFixturePackage()); + const { pages } = readOdgContent(buildFixturePackage()); expect(pages[0]?.shapes).toEqual([]); }); it('reads the closed curve\'s real svg:d geometry correctly end to end (viewBox-scaled cubic segment)', () => { - const { pages } = readOdg(buildFixturePackage()); + const { pages } = readOdgContent(buildFixturePackage()); const path = pages[0]?.vectors.find((v) => v.kind === 'path' && v.subpaths[0]?.closed === true); if (path?.kind !== 'path') { throw new Error('expected the closed curve path vector'); @@ -121,7 +122,7 @@ describe('readOdg', () => { }); it('reads the polygon\'s draw:points geometry as a closed straight-line path', () => { - const { pages } = readOdg(buildFixturePackage()); + const { pages } = readOdgContent(buildFixturePackage()); const polygon = pages[0]?.vectors.find((v) => v.kind === 'path' && v.subpaths[0]?.closed === true && v.subpaths[0]?.segments.length === 3); if (polygon?.kind !== 'path') { throw new Error('expected the polygon path vector'); @@ -130,30 +131,77 @@ describe('readOdg', () => { }); it('reads a recognised custom-shape preset\'s fill from its own graphic-family style', () => { - const { pages } = readOdg(buildFixturePackage()); + const { pages } = readOdgContent(buildFixturePackage()); const rectPreset = pages[0]?.vectors.find((v) => v.kind === 'rect'); expect(rectPreset?.fill).toEqual({ r: 0, g: 0.5019607843137255, b: 1 }); }); it('reads a second page\'s draw:frame text content via the SAME shared shape-walking logic odp uses', () => { - const { pages } = readOdg(buildFixturePackage()); + const { pages } = readOdgContent(buildFixturePackage()); expect(pages[1]?.shapes).toHaveLength(1); expect(pages[1]?.shapes[0]?.blocks[0]).toMatchObject({ kind: 'paragraph', runs: [{ text: 'Page two text' }] }); }); it('reads document metadata via meta.xml', () => { - const { metadata } = readOdg(buildFixturePackage()); + const { metadata } = readOdgContent(buildFixturePackage()); expect(metadata.title).toBe('My Drawing'); }); it('reads an empty pages array for a package with no office:drawing at all', () => { const pkg: Package = { parts: { 'content.xml': { kind: 'xml', nodes: [el('office:document-content', {}, [el('office:body')])] } } }; - expect(readOdg(pkg).pages).toEqual([]); + expect(readOdgContent(pkg).pages).toEqual([]); }); it('reads an empty pages array and empty metadata for a package with no content.xml at all', () => { - const result = readOdg({ parts: {} }); + const result = readOdgContent({ parts: {} }); expect(result.pages).toEqual([]); expect(result.metadata).toEqual({}); }); }); + +describe('readOdg: the package-native reader over the same fixture', () => { + it('assembles the fixture into a drawing package whose tree flattens back to readOdgContent output exactly', () => { + const pkg = buildFixturePackage(); + const content = readOdgContent(pkg); + const documentPackage = readOdg(pkg); + + expect(documentPackage.kind).toBe('drawing'); + expect(documentPackage.metadata).toEqual(content.metadata); + // One draw-page group per authored ContentDrawPage. These are the document's OWN pages, not the package envelope's rendered `pages` array -- which stays absent, since no layout pass has run. + expect(documentPackage.children).toHaveLength(content.pages.length); + expect(documentPackage.pages).toBeUndefined(); + assertPackageRoundTrip(documentPackage, { kind: 'drawing', ...content }); + }); + + it('carries each page\'s size on its group node, its shapes as groups, and its vector primitives as the leaves after them', () => { + const pkg = buildFixturePackage(); + const content = readOdgContent(pkg); + const documentPackage = drawingPackage(readOdg(pkg)); + const firstPage = documentPackage.children[0]; + const firstContentPage = content.pages[0]; + if (firstPage === undefined || firstContentPage === undefined) { + throw new Error('expected at least one draw page'); + } + expect(firstPage.node.kind).toBe('drawPage'); + expect(firstPage.node.size).toEqual(firstContentPage.size); + // A shape is a container and becomes its own group; a vector is a textless primitive with no inner structure and stays a leaf, after every shape group -- so the page's children are the two arrays concatenated in that order. + expect(firstPage.children).toHaveLength(firstContentPage.shapes.length + firstContentPage.vectors.length); + expect(firstPage.children.slice(firstContentPage.shapes.length)).toEqual(firstContentPage.vectors); + expect(firstContentPage.vectors.length).toBeGreaterThan(0); + + // The first page alone has zero shapes, so the assertions above would hold even if shape-group assembly were dropped entirely -- the second page is the fixture's only page with a shape on it, so the "shapes as groups" half of this test's own name is actually exercised here. + const secondPage = documentPackage.children[1]; + const secondContentPage = content.pages[1]; + if (secondPage === undefined || secondContentPage === undefined) { + throw new Error('expected a second draw page'); + } + expect(secondContentPage.shapes.length).toBeGreaterThan(0); + expect(secondContentPage.vectors).toHaveLength(0); + expect(secondPage.children).toHaveLength(secondContentPage.shapes.length); + const [shapeGroup] = secondPage.children; + if (shapeGroup === undefined || !('children' in shapeGroup)) { + throw new Error("expected the second page's shape to decompose into its own group node, not a bare leaf"); + } + expect(shapeGroup.children.length).toBeGreaterThan(0); + }); +}); diff --git a/src/typed/odg/read.ts b/src/typed/odg/read.ts index cf490a5..6a6e2ba 100644 --- a/src/typed/odg/read.ts +++ b/src/typed/odg/read.ts @@ -1,5 +1,5 @@ -import type { ContentDrawPage, LayoutMetadata } from 'document-schema.js'; -import { PAGE_SIZE_A4 } from 'document-schema.js'; +import type { ContentDrawPage, DocumentPackage, LayoutMetadata } from 'document-schema.js'; +import { assemblePackage, PAGE_SIZE_A4 } from 'document-schema.js'; import type { XmlElement } from '../../model/node'; import type { Package } from '../../model/package'; import { childrenWithTag, findChildElement, rootElement } from '../../xml/query'; @@ -9,12 +9,12 @@ import { readDrawPageContent } from '../draw/shapes'; // Resolves a Package into { metadata, pages }: office:drawing's own draw:page content model was verified structurally IDENTICAL to office:presentation's, against both the OASIS ODF schema (draw:page's own attribute/content-model definition is a single, format-agnostic schema fragment shared by both office:body children, not two separate definitions) and a real .odg file built via the LibreOffice UNO API -- document order is native here exactly like odp (a draw:page's own position among its office:drawing siblings IS page order, with nothing resolution-worthy above it), and the SAME draw:frame/draw:g/vector-primitive content-walking logic (readDrawPageContent, typed/draw/shapes.ts) and the SAME master-page -> page-layout size-resolution chain (resolveDrawPageSize, typed/shared/masterpage.ts) apply unchanged. // -// FACTORING DECISION: what genuinely differs between odp and odg is not draw:page's own content model, but everything AROUND it -- odg has no presentation:notes concept and no slide-specific wrapper to build, while (per shapes.ts's own vector-primitive additions this module now also draws on) a drawing commonly carries bare vector-primitive shapes directly under draw:page/draw:g, something a presentation's own draw:page can equally carry per the schema but rarely does in real Impress output. Given that, the SHARED page-size chain and the SHARED content walk were each factored out into typed/shared/masterpage.ts and typed/draw/shapes.ts respectively (both now used verbatim by readOdp too -- see masterpage.ts's own top-of-file note), while this module's own thin readPage/readOdg wrapper is kept separate from readOdp's own readSlide/readOdp wrapper: forcing the two format-specific wrappers into one shared function would mean threading an odp-only "does this format have notes" branch through odg's own call sites for no shared benefit, since neither wrapper is more than a few lines of format-specific glue over already-shared machinery -- matching odt/read.ts's own precedent for when duplicating a small amount of glue beats an awkward shared abstraction. +// FACTORING DECISION: what genuinely differs between odp and odg is not draw:page's own content model, but everything AROUND it -- odg has no presentation:notes concept and no slide-specific wrapper to build, while (per shapes.ts's own vector-primitive additions this module now also draws on) a drawing commonly carries bare vector-primitive shapes directly under draw:page/draw:g, something a presentation's own draw:page can equally carry per the schema but rarely does in real Impress output. Given that, the SHARED page-size chain and the SHARED content walk were each factored out into typed/shared/masterpage.ts and typed/draw/shapes.ts respectively (both now used verbatim by readOdpContent too -- see masterpage.ts's own top-of-file note), while this module's own thin readPage/readOdgContent wrapper is kept separate from readOdpContent's own readSlide/readOdpContent wrapper: forcing the two format-specific wrappers into one shared function would mean threading an odp-only "does this format have notes" branch through odg's own call sites for no shared benefit, since neither wrapper is more than a few lines of format-specific glue over already-shared machinery -- matching odt/read.ts's own precedent for when duplicating a small amount of glue beats an awkward shared abstraction. const CONTENT_PART = 'content.xml'; function readPage(page: XmlElement, pkg: Package): ContentDrawPage { - // LibreOffice Draw's own out-of-the-box default page size for a freshly created, unmodified .odg (confirmed directly against a real Draw document's own style:page-layout-properties: 21cm x 29.7cm portrait, i.e. A4) -- used only when a page's own master-page/page-layout chain doesn't resolve. Deliberately A4-based, matching readOdt's own fallback choice and reasoning (each reader's own fallback should reflect the format it actually reads) rather than reusing readOdp's own SLIDE_SIZE_WIDESCREEN, which is Impress's default, not Draw's. + // LibreOffice Draw's own out-of-the-box default page size for a freshly created, unmodified .odg (confirmed directly against a real Draw document's own style:page-layout-properties: 21cm x 29.7cm portrait, i.e. A4) -- used only when a page's own master-page/page-layout chain doesn't resolve. Deliberately A4-based, matching readOdtContent's own fallback choice and reasoning (each reader's own fallback should reflect the format it actually reads) rather than reusing readOdpContent's own SLIDE_SIZE_WIDESCREEN, which is Impress's default, not Draw's. const size = resolveDrawPageSize(page, pkg) ?? PAGE_SIZE_A4; const { shapes, vectors } = readDrawPageContent(page.children, pkg); return { size, shapes, vectors }; @@ -25,7 +25,7 @@ export interface OdgDocument { pages: ContentDrawPage[]; } -export function readOdg(pkg: Package): OdgDocument { +export function readOdgContent(pkg: Package): OdgDocument { const contentPart = pkg.parts[CONTENT_PART]; const root = contentPart?.kind === 'xml' ? rootElement(contentPart.nodes) : undefined; const body = root === undefined ? undefined : findChildElement(root.children, 'office:body'); @@ -37,3 +37,9 @@ export function readOdg(pkg: Package): OdgDocument { pages: pages.map((page) => readPage(page, pkg)), }; } + +// Package -> DocumentPackage: this module's PRIMARY entry point, the drawing mirror of readOdtContent/readOdt (see src/typed/odt/read.ts's own note on why assemblePackage rather than bare decompose, and why no `pages` argument -- ContentDrawPage's own `pages` here are the DOCUMENT's authored draw pages, an entirely different thing from the package envelope's rendered-page-size array). readOdgContent above is unchanged and remains the flat, ContentDocument-level reader. +export function readOdg(pkg: Package): DocumentPackage { + const { metadata, pages } = readOdgContent(pkg); + return assemblePackage({ kind: 'drawing', metadata, pages }); +} diff --git a/src/typed/odm/read.test.ts b/src/typed/odm/read.test.ts index 1525556..c31c0bd 100644 --- a/src/typed/odm/read.test.ts +++ b/src/typed/odm/read.test.ts @@ -7,7 +7,7 @@ import { el, txt } from '../../xml/fragment'; import { parsePackage } from '../../package-io/read'; import { readOdm } from './read'; -// This suite reads a real, unmodified LibreOffice 26.2-generated .odm fixture (src/typed/odm/fixtures/two-chapters.odm, built via a headless UNO Basic macro -- see read.ts's own top-of-file note for the exact UNO calls -- never hand-edited afterwards) for the genuine-producer-shape assertions, mirroring readOdt's and readOds's own established convention. Its two linked chapters (fixtures/chapter1.odt, fixtures/chapter2.odt) are checked in alongside it for realism -- a genuine master document is meaningless without its sibling files on disk -- though readOdm itself never opens them; it only ever reads the master document's own content.xml. A handful of narrow scope-boundary/error-path tests at the end use small, synthetic, hand-built packages instead (via el/txt), for shapes no genuine master document produced by this verification ever exercises (a non-master text:section with no text:section-source, a malformed section missing a required attribute) or that plain ODF cannot produce at all (a missing content.xml). +// This suite reads a real, unmodified LibreOffice 26.2-generated .odm fixture (src/typed/odm/fixtures/two-chapters.odm, built via a headless UNO Basic macro -- see read.ts's own top-of-file note for the exact UNO calls -- never hand-edited afterwards) for the genuine-producer-shape assertions, mirroring readOdtContent's and readOdsContent's own established convention. Its two linked chapters (fixtures/chapter1.odt, fixtures/chapter2.odt) are checked in alongside it for realism -- a genuine master document is meaningless without its sibling files on disk -- though readOdm itself never opens them; it only ever reads the master document's own content.xml. A handful of narrow scope-boundary/error-path tests at the end use small, synthetic, hand-built packages instead (via el/txt), for shapes no genuine master document produced by this verification ever exercises (a non-master text:section with no text:section-source, a malformed section missing a required attribute) or that plain ODF cannot produce at all (a missing content.xml). const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), 'fixtures'); diff --git a/src/typed/odm/read.ts b/src/typed/odm/read.ts index fa350b8..b36295d 100644 --- a/src/typed/odm/read.ts +++ b/src/typed/odm/read.ts @@ -10,7 +10,7 @@ import { attrValue, findChildElement, rootElement } from '../../xml/query'; // 2. A chapter's own REAL content (its heading text, its body paragraphs -- everything actually authored in chapter1.odt/chapter2.odt) is NEVER cached inside the master document's own text:section. Proven two ways: (a) content.xml has no trace of either chapter's own text ("Chapter One: Introduction", "This is the first chapter's own body text...", etc.) anywhere, even though setting FileLink DID synchronously pull that text into the LIVE in-memory document (oText.getString() on the freshly-linked master document returns both chapters' full text, confirmed via macro logging) -- LibreOffice resolves and displays linked content at edit/view time but deliberately does not persist it to content.xml on save; (b) META-INF/manifest.xml lists no entry at all for either chapter file, confirming they are genuinely external to the package, exactly as this reader's own background brief stated, now independently reconfirmed from the manifest side as well as the content.xml side. // 3. A top-level text:section CAN still have non-empty XML children in real output -- but what actually appeared there is NOT chapter content of any kind. The FIRST section linked into a fresh master document (and only the first -- confirmed by re-running with a single-section document, where the identical structure attaches to that document's own sole section) picks up ten empty, text-less `` elements, one per outline level 1-10, inserted immediately after text:section-source. These carry no text nodes and match neither chapter's real heading ("Chapter One: Introduction" / "Chapter Two: Findings") -- they are LibreOffice's own internal chapter-numbering-continuity bookkeeping (seeding the outline-numbering counter's carry-over state at the point continuous numbering first crosses a linked-section boundary), not a cached copy of anything an author wrote. This is exactly why `inlineContent` below is populated NEVER, not "when text:section has children": the one real case that DOES produce children is precisely the case where surfacing them would be actively misleading, not helpful. See readSection's own note. // -// SCOPE: only TOP-LEVEL text:section elements (direct children of office:text) are read -- matching a master document's own real shape, where every chapter section sits directly under office:text with no further nesting in any file actually produced for this verification. A text:section with no text:section-source child (ODF's generic non-master-document section, e.g. for multi-column layout) is silently skipped, exactly as odt's own readOdt transparently unwraps a plain text:section rather than treating it as a chapter. A text:section that has a text:section-source child but is missing its own required text:name, or whose text:section-source is missing its own required xlink:href, is likewise skipped (degrade-with-diagnostic-free-skip, matching this codebase's general "malformed but salvageable" posture) rather than failing the whole document read over one malformed chapter reference -- per the OASIS schema both attributes are required, so a real producer's output should never actually hit this path; it exists purely so one malformed section doesn't take down every other genuinely readable chapter in the same file. `href` is returned completely verbatim, exactly as the producer wrote it (here, a relative "../chapter1.odt" -- see this module's own note above on why the ".." appears even though both files share one directory: package-relative addressing treats content.xml's own base URI as the PACKAGE FILE itself, so a sibling-directory reference needs the extra level to escape it) -- this reader never attempts to resolve it against a filesystem or fetch the linked file's own content. +// SCOPE: only TOP-LEVEL text:section elements (direct children of office:text) are read -- matching a master document's own real shape, where every chapter section sits directly under office:text with no further nesting in any file actually produced for this verification. A text:section with no text:section-source child (ODF's generic non-master-document section, e.g. for multi-column layout) is silently skipped, exactly as odt's own readOdtContent transparently unwraps a plain text:section rather than treating it as a chapter. A text:section that has a text:section-source child but is missing its own required text:name, or whose text:section-source is missing its own required xlink:href, is likewise skipped (degrade-with-diagnostic-free-skip, matching this codebase's general "malformed but salvageable" posture) rather than failing the whole document read over one malformed chapter reference -- per the OASIS schema both attributes are required, so a real producer's output should never actually hit this path; it exists purely so one malformed section doesn't take down every other genuinely readable chapter in the same file. `href` is returned completely verbatim, exactly as the producer wrote it (here, a relative "../chapter1.odt" -- see this module's own note above on why the ".." appears even though both files share one directory: package-relative addressing treats content.xml's own base URI as the PACKAGE FILE itself, so a sibling-directory reference needs the extra level to escape it) -- this reader never attempts to resolve it against a filesystem or fetch the linked file's own content. export interface OdmSection { name: string; @@ -41,7 +41,7 @@ function readSection(element: XmlElement): OdmSection | undefined { return filterName === undefined ? { name, href } : { name, href, filterName }; } -// Package -> OdmDocument. Throws only when content.xml itself, or its own office:body/office:text element, is missing -- a genuinely unusable package, mirroring every other odf.js typed reader's own "missing required structural element" throw convention (see e.g. readOdt). +// Package -> OdmDocument. Throws only when content.xml itself, or its own office:body/office:text element, is missing -- a genuinely unusable package, mirroring every other odf.js typed reader's own "missing required structural element" throw convention (see e.g. readOdtContent). export function readOdm(pkg: Package): OdmDocument { const contentPart = pkg.parts[CONTENT_PART]; if (contentPart?.kind !== 'xml') { diff --git a/src/typed/odp/read.test.ts b/src/typed/odp/read.test.ts index bf8ed8c..93a2bdd 100644 --- a/src/typed/odp/read.test.ts +++ b/src/typed/odp/read.test.ts @@ -3,7 +3,8 @@ import type { ContentListMembership, ContentParagraph, ContentSlide } from 'docu import type { Package } from '../../model/package'; import { el, txt } from '../../xml/fragment'; import { bytesToBase64 } from '../../util/base64'; -import { readOdp } from './read'; +import { assertPackageRoundTrip, presentationPackage } from '../../test-support/document-package'; +import { readOdp, readOdpContent } from './read'; // A full, real-shape .odp fixture assembled from XML shapes verified against genuine LibreOffice 26.2 output (soffice --headless --convert-to odp on hand-built .fodp source, and an odp -> odp round trip to confirm LibreOffice's OWN writer's exact serialization -- see this repository's own commit history for the verification method): multiple draw:page elements in native document order, a rotated text frame, a grouped pair of shapes, an image, a table, and speaker notes, matching this package's other typed-reader tests' established convention of building packages programmatically from ground-truth-verified shapes rather than loading a committed binary fixture (mirroring ooxml.js's own src/typed/pptx/read.test.ts). @@ -114,9 +115,9 @@ function paragraphsWithText(slides: readonly ContentSlide[], shapeName: string, return paragraphs; } -describe('readOdp: text:list content inside slide text frames', () => { +describe('readOdpContent: text:list content inside slide text frames', () => { it('reads a nested text:list as one numId across both depths, with level read off the actual text:list-in-text:list-item nesting and document order preserved across listed and unlisted paragraphs', () => { - const paragraphs = paragraphsWithText(readOdp(buildListFixturePackage()).slides, 'Body', ['Intro', 'Alpha', 'Beta', 'Beta.1', 'Beta.2', 'Gamma', 'Delta']); + const paragraphs = paragraphsWithText(readOdpContent(buildListFixturePackage()).slides, 'Body', ['Intro', 'Alpha', 'Beta', 'Beta.1', 'Beta.2', 'Gamma', 'Delta']); const [, alpha, beta, beta1, beta2, gamma] = paragraphs; expect([alpha?.list?.level, beta?.list?.level, beta1?.list?.level, beta2?.list?.level, gamma?.list?.level]).toEqual([0, 0, 1, 1, 0]); const numId = alpha?.list?.numId; @@ -125,7 +126,7 @@ describe('readOdp: text:list content inside slide text frames', () => { }); it('mints a distinct numId per top-level text:list encounter -- a sibling list in the same text box and a list in a different frame never share an identity', () => { - const { slides } = readOdp(buildListFixturePackage()); + const { slides } = readOdpContent(buildListFixturePackage()); const body = paragraphsWithText(slides, 'Body', ['Intro', 'Alpha', 'Beta', 'Beta.1', 'Beta.2', 'Gamma', 'Delta']); const aside = paragraphsWithText(slides, 'Aside', ['Epsilon']); const firstListId = body[1]?.list?.numId; @@ -135,27 +136,27 @@ describe('readOdp: text:list content inside slide text frames', () => { }); it('leaves list undefined on paragraphs outside any text:list, including one sharing a text box with a list', () => { - const { slides } = readOdp(buildListFixturePackage()); + const { slides } = readOdpContent(buildListFixturePackage()); expect(paragraphsWithText(slides, 'Body', ['Intro', 'Alpha', 'Beta', 'Beta.1', 'Beta.2', 'Gamma', 'Delta'])[0]?.list).toBeUndefined(); // The main fixture's title frame proves the same for a text box that never carried a list at all. - expect(readOdp(buildFixturePackage()).slides[0]?.shapes.find((s) => s.name === 'Title')?.blocks[0]).not.toHaveProperty('list'); + expect(readOdpContent(buildFixturePackage()).slides[0]?.shapes.find((s) => s.name === 'Title')?.blocks[0]).not.toHaveProperty('list'); }); it('resolves the ordered-vs-bullet kind prefix from the referenced text:list-style, and leaves an unstyled list unprefixed -- the same shared numId convention the odt reader mints', () => { - const paragraphs = paragraphsWithText(readOdp(buildListFixturePackage()).slides, 'Body', ['Intro', 'Alpha', 'Beta', 'Beta.1', 'Beta.2', 'Gamma', 'Delta']); + const paragraphs = paragraphsWithText(readOdpContent(buildListFixturePackage()).slides, 'Body', ['Intro', 'Alpha', 'Beta', 'Beta.1', 'Beta.2', 'Gamma', 'Delta']); expect(paragraphs[1]?.list).toEqual({ numId: 'bullet:list1', level: 0 } satisfies ContentListMembership); expect(paragraphs[6]?.list).toEqual({ numId: 'list2', level: 0 } satisfies ContentListMembership); }); }); -describe('readOdp', () => { +describe('readOdpContent', () => { it('reads slides in native document order (draw:page order, no p:sldIdLst-style indirection to resolve)', () => { - const { slides } = readOdp(buildFixturePackage()); + const { slides } = readOdpContent(buildFixturePackage()); expect(slides).toHaveLength(2); }); it('resolves slide size from the master-page -> page-layout chain (draw:master-page-name -> style:master-page -> style:page-layout-name -> style:page-layout-properties)', () => { - const { slides } = readOdp(buildFixturePackage()); + const { slides } = readOdpContent(buildFixturePackage()); expect(slides[0]?.size).toEqual({ widthPt: 720, heightPt: 540 }); expect(slides[1]?.size).toEqual({ widthPt: 720, heightPt: 540 }); }); @@ -163,12 +164,12 @@ describe('readOdp', () => { it('falls back to document-schema.js\'s own SLIDE_SIZE_WIDESCREEN when the master-page/page-layout chain does not resolve', () => { const pkg = buildFixturePackage(); delete pkg.parts['styles.xml']; - const { slides } = readOdp(pkg); + const { slides } = readOdpContent(pkg); expect(slides[0]?.size.widthPt).toBeGreaterThan(0); }); it('reads a rotated shape\'s real text content and its pixel-verified geometry (see transform.test.ts for the render-based derivation)', () => { - const { slides } = readOdp(buildFixturePackage()); + const { slides } = readOdpContent(buildFixturePackage()); const title = slides[0]?.shapes.find((s) => s.name === 'Title'); expect(title).toBeDefined(); expect(title?.blocks[0]).toMatchObject({ kind: 'paragraph', runs: [{ text: 'Slide One Title' }] }); @@ -176,29 +177,29 @@ describe('readOdp', () => { }); it('flattens a grouped pair of shapes into the slide\'s own flat shape list, in document order', () => { - const { slides } = readOdp(buildFixturePackage()); + const { slides } = readOdpContent(buildFixturePackage()); const names = slides[0]?.shapes.map((s) => s.name); expect(names).toEqual(['Title', 'A', 'B']); }); it('extracts speaker notes text, joining multiple text:p lines with a newline', () => { - const { slides } = readOdp(buildFixturePackage()); + const { slides } = readOdpContent(buildFixturePackage()); expect(slides[0]?.notes).toBe('First line of notes.\nSecond line.'); }); it('reads an empty string for notes when a slide carries no presentation:notes at all', () => { - const { slides } = readOdp(buildFixturePackage()); + const { slides } = readOdpContent(buildFixturePackage()); expect(slides[1]?.notes).toBe(''); }); it('reads an image shape\'s referenced media part on the slide with no notes', () => { - const { slides } = readOdp(buildFixturePackage()); + const { slides } = readOdpContent(buildFixturePackage()); const imageShape = slides[1]?.shapes.find((s) => s.blocks[0]?.kind === 'image'); expect(imageShape?.blocks[0]).toMatchObject({ kind: 'image', format: 'png', widthPt: 60, heightPt: 60 }); }); it('reads a table shape with a spanned header row and a covered cell', () => { - const { slides } = readOdp(buildFixturePackage()); + const { slides } = readOdpContent(buildFixturePackage()); const tableShape = slides[1]?.shapes.find((s) => s.blocks[0]?.kind === 'table'); const table = tableShape?.blocks[0]; if (table?.kind !== 'table') { @@ -210,18 +211,52 @@ describe('readOdp', () => { }); it('reads document metadata via meta.xml', () => { - const { metadata } = readOdp(buildFixturePackage()); + const { metadata } = readOdpContent(buildFixturePackage()); expect(metadata.title).toBe('My Presentation'); }); it('reads an empty slides array for a package with no office:presentation at all', () => { const pkg: Package = { parts: { 'content.xml': { kind: 'xml', nodes: [el('office:document-content', {}, [el('office:body')])] } } }; - expect(readOdp(pkg).slides).toEqual([]); + expect(readOdpContent(pkg).slides).toEqual([]); }); it('reads an empty slides array and empty metadata for a package with no content.xml at all', () => { - const result = readOdp({ parts: {} }); + const result = readOdpContent({ parts: {} }); expect(result.slides).toEqual([]); expect(result.metadata).toEqual({}); }); }); + +describe('readOdp: the package-native reader over the same fixture', () => { + it('assembles the fixture into a presentation package whose tree flattens back to readOdpContent output exactly', () => { + const pkg = buildFixturePackage(); + const content = readOdpContent(pkg); + const documentPackage = readOdp(pkg); + + expect(documentPackage.kind).toBe('presentation'); + expect(documentPackage.metadata).toEqual(content.metadata); + // One slide group per ContentSlide, each holding its own shapes as groups -- never one slide's paragraphs flattened across its shapes. + expect(documentPackage.children).toHaveLength(content.slides.length); + assertPackageRoundTrip(documentPackage, { kind: 'presentation', ...content }); + }); + + it('keeps each slide\'s shapes as their own groups, with the slide descriptor carrying size and notes', () => { + const documentPackage = presentationPackage(readOdp(buildFixturePackage())); + const content = readOdpContent(buildFixturePackage()); + const firstSlide = documentPackage.children[0]; + const firstContentSlide = content.slides[0]; + if (firstSlide === undefined || firstContentSlide === undefined) { + throw new Error('expected at least one slide'); + } + expect(firstSlide.node.kind).toBe('slide'); + expect(firstSlide.node.size).toEqual(firstContentSlide.size); + expect(firstSlide.node.notes).toBe(firstContentSlide.notes); + expect(firstSlide.children).toHaveLength(firstContentSlide.shapes.length); + }); + + it('round-trips the list fixture, whose slide text frames carry real text:list nesting', () => { + const pkg = buildListFixturePackage(); + const content = readOdpContent(pkg); + assertPackageRoundTrip(readOdp(pkg), { kind: 'presentation', ...content }); + }); +}); diff --git a/src/typed/odp/read.ts b/src/typed/odp/read.ts index 38f677a..b7a445b 100644 --- a/src/typed/odp/read.ts +++ b/src/typed/odp/read.ts @@ -1,5 +1,5 @@ -import type { ContentShape, ContentSlide, LayoutMetadata, PageSize } from 'document-schema.js'; -import { SLIDE_SIZE_WIDESCREEN } from 'document-schema.js'; +import type { ContentShape, ContentSlide, DocumentPackage, LayoutMetadata, PageSize } from 'document-schema.js'; +import { assemblePackage, SLIDE_SIZE_WIDESCREEN } from 'document-schema.js'; import type { XmlElement } from '../../model/node'; import type { Package } from '../../model/package'; import { childrenWithTag, elementsWithTag, findChildElement, rootElement } from '../../xml/query'; @@ -27,7 +27,7 @@ function readSlideNotes(page: XmlElement): string { return elementsWithTag(notes.children, 'text:p').map(decodeOdfText).join('\n'); } -// `listIdState` mints the numId identity for every text:list found inside a slide text frame (draw:frame > draw:text-box), threaded by walkDrawShapes through the whole shape walk and owned by readOdp below at DOCUMENT scope -- one counter across every slide, so two lists on different slides get different identities exactly as two lists in different parts of one odt body do (see typed/shared/list.ts's own top-of-file note for the numId convention and typed/draw/shapes.ts's readDrawFrameContent for why odp mints rather than emitting the numId-less { level } shape). +// `listIdState` mints the numId identity for every text:list found inside a slide text frame (draw:frame > draw:text-box), threaded by walkDrawShapes through the whole shape walk and owned by readOdpContent below at DOCUMENT scope -- one counter across every slide, so two lists on different slides get different identities exactly as two lists in different parts of one odt body do (see typed/shared/list.ts's own top-of-file note for the numId convention and typed/draw/shapes.ts's readDrawFrameContent for why odp mints rather than emitting the numId-less { level } shape). function readSlide(page: XmlElement, pkg: Package, listIdState: OdfListIdState): ContentSlide { const shapes: ContentShape[] = []; walkDrawShapes(page.children, [], pkg, shapes, { next: 0 }, listIdState); @@ -39,7 +39,7 @@ export interface OdpDocument { slides: ContentSlide[]; } -export function readOdp(pkg: Package): OdpDocument { +export function readOdpContent(pkg: Package): OdpDocument { const contentPart = pkg.parts[CONTENT_PART]; const root = contentPart?.kind === 'xml' ? rootElement(contentPart.nodes) : undefined; const body = root === undefined ? undefined : findChildElement(root.children, 'office:body'); @@ -52,3 +52,9 @@ export function readOdp(pkg: Package): OdpDocument { slides: pages.map((page) => readSlide(page, pkg, listIdState)), }; } + +// Package -> DocumentPackage: this module's PRIMARY entry point, the presentation mirror of readOdtContent/readOdt (see src/typed/odt/read.ts's own note on why assemblePackage rather than bare decompose, and why no `pages` argument). readOdpContent above is unchanged and remains the flat, ContentDocument-level reader. +export function readOdp(pkg: Package): DocumentPackage { + const { metadata, slides } = readOdpContent(pkg); + return assemblePackage({ kind: 'presentation', metadata, slides }); +} diff --git a/src/typed/ods/read.test.ts b/src/typed/ods/read.test.ts index b0f579a..1e41915 100644 --- a/src/typed/ods/read.test.ts +++ b/src/typed/ods/read.test.ts @@ -9,9 +9,10 @@ import { el, txt } from '../../xml/fragment'; import { bytesToBase64 } from '../../util/base64'; import { parsePackage } from '../../package-io/read'; import { parseOdfLength } from '../shared/units'; -import { readOds } from './read'; +import { assertPackageRoundTrip, spreadsheetPackage } from '../../test-support/document-package'; +import { readOds, readOdsContent } from './read'; -// This suite reads real, unmodified LibreOffice 26.2-generated .ods fixtures (src/typed/ods/fixtures/*.ods, built via a headless UNO Basic macro driving the SAME UNO calls the Calc UI itself uses -- Format > Columns > Width, Format > Rows > Height, Format > Print Areas, Format > Page Style's Sheet tab -- never hand-edited afterwards) rather than programmatically reconstructing the expected XML shapes, mirroring readOdt's own established convention: this reader's own design brief is explicit that print-settings attribute names and the repeat-row/repeat-column mechanism must each be proven against genuine producer output, not just this package's own idea of what that output looks like. A handful of narrow scope-boundary/hazard-proof tests at the end use small, synthetic, hand-built packages instead (via el/txt), since a genuinely million-row repeat isn't something worth shipping as a binary fixture when the exact real repeat count is already established (typed/shared/a1.test.ts, citing a real LibreOffice-shipped .ots template). +// This suite reads real, unmodified LibreOffice 26.2-generated .ods fixtures (src/typed/ods/fixtures/*.ods, built via a headless UNO Basic macro driving the SAME UNO calls the Calc UI itself uses -- Format > Columns > Width, Format > Rows > Height, Format > Print Areas, Format > Page Style's Sheet tab -- never hand-edited afterwards) rather than programmatically reconstructing the expected XML shapes, mirroring readOdtContent's own established convention: this reader's own design brief is explicit that print-settings attribute names and the repeat-row/repeat-column mechanism must each be proven against genuine producer output, not just this package's own idea of what that output looks like. A handful of narrow scope-boundary/hazard-proof tests at the end use small, synthetic, hand-built packages instead (via el/txt), since a genuinely million-row repeat isn't something worth shipping as a binary fixture when the exact real repeat count is already established (typed/shared/a1.test.ts, citing a real LibreOffice-shipped .ots template). const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), 'fixtures'); @@ -28,8 +29,8 @@ function knownLength(value: string): number { return parsed; } -describe('readOds: kitchen-sink.ods (real LibreOffice output)', () => { - const { metadata, sheets } = readOds(loadFixture('kitchen-sink.ods')); +describe('readOdsContent: kitchen-sink.ods (real LibreOffice output)', () => { + const { metadata, sheets } = readOdsContent(loadFixture('kitchen-sink.ods')); const data = sheets.find((sheet) => sheet.name === 'Data'); const summary = sheets.find((sheet) => sheet.name === 'Summary'); if (data === undefined || summary === undefined) { @@ -209,8 +210,8 @@ describe('readOds: kitchen-sink.ods (real LibreOffice output)', () => { }); }); -describe('readOds: minimal.ods (real LibreOffice output, default/unmodified sheet)', () => { - const { sheets } = readOds(loadFixture('minimal.ods')); +describe('readOdsContent: minimal.ods (real LibreOffice output, default/unmodified sheet)', () => { + const { sheets } = readOdsContent(loadFixture('minimal.ods')); const sheet = sheets[0]; if (sheet === undefined) { throw new Error('expected at least one sheet'); @@ -251,8 +252,8 @@ describe('readOds: minimal.ods (real LibreOffice output, default/unmodified shee // - an 8x8 PNG anchored TO CELL C5 (column index 2, row index 4), sized 3cm x 2cm, positioned 0.5cm/0.3cm past its anchor cell's own top-left, with a real UNO Title and Description set (svg:title/svg:desc); // - a LibreOffice Draw document embedded as an OLE object anchored TO CELL B8 (column index 1, row index 7), sized 4cm x 3cm, offset 0.2cm/0.1cm, containing one real orange rectangle; // - the same PNG anchored TO PAGE at an absolute 7cm/0.9cm, sized 1.5cm x 1cm. -describe('readOds: sheet-anchors.ods (real LibreOffice output -- anchored images and an embedded object)', () => { - const { sheets } = readOds(loadFixture('sheet-anchors.ods')); +describe('readOdsContent: sheet-anchors.ods (real LibreOffice output -- anchored images and an embedded object)', () => { + const { sheets } = readOdsContent(loadFixture('sheet-anchors.ods')); const sheet = sheets[0]; if (sheet === undefined) { throw new Error('expected at least one sheet'); @@ -335,14 +336,14 @@ describe('readOds: sheet-anchors.ods (real LibreOffice output -- anchored images }); it('leaves embeddedObjects undefined on a sheet that has none, rather than writing an empty array', () => { - expect(readOds(loadFixture('kitchen-sink.ods')).sheets[0]?.embeddedObjects).toBeUndefined(); - expect(readOds(loadFixture('kitchen-sink.ods')).sheets[0]?.images).toEqual([]); + expect(readOdsContent(loadFixture('kitchen-sink.ods')).sheets[0]?.embeddedObjects).toBeUndefined(); + expect(readOdsContent(loadFixture('kitchen-sink.ods')).sheets[0]?.images).toEqual([]); }); }); // sheet-formula.ods was built the same way as sheet-anchors.ods above (a Java UNO client against a headless LibreOffice 26.2, saved with the calc8 filter, never hand-edited afterwards): a one-sheet Calc document named "Formulas" carrying two ordinary cells and ONE real LibreOffice Math object -- a com.sun.star.drawing.OLE2Shape with Math's own CLSID 078B7ABA-54FC-457F-8551-6147E776A997, its Formula property set to the StarMath expression "f(x) = {x^2} over {2} + sqrt {x}", anchored TO CELL C4 (column index 2, row index 3) at a 0.4cm/0.2cm cell-relative offset. Its saved shape confirms, on a genuinely produced file, everything typed/draw/embedded.ts's formula path is built on: the frame is an ordinary draw:frame with a draw:object href of "./Object 1" plus the usual ObjectReplacements preview sibling, the outer manifest declares "Object 1/" as application/vnd.oasis.opendocument.formula, and that sub-document's own content.xml is a BARE root with no office:body (and, notably, no meta.xml part of its own at all). -describe('readOds: sheet-formula.ods (real LibreOffice output -- a Math object anchored to a cell)', () => { - const { sheets } = readOds(loadFixture('sheet-formula.ods')); +describe('readOdsContent: sheet-formula.ods (real LibreOffice output -- a Math object anchored to a cell)', () => { + const { sheets } = readOdsContent(loadFixture('sheet-formula.ods')); const sheet = sheets[0]; if (sheet === undefined) { throw new Error('expected at least one sheet'); @@ -355,7 +356,7 @@ describe('readOds: sheet-formula.ods (real LibreOffice output -- a Math object a expect(sheet.embeddedObjects?.[0]?.document.kind).toBe('formula'); }); - it('carries the formula\'s real MathML through, with its own StarMath annotation -- the same payload readOdfFormulaDocument produces for a standalone .odf', () => { + it('carries the formula\'s real MathML through, with its own StarMath annotation -- the same payload readOdfFormulaContent produces for a standalone .odf', () => { const document = sheet.embeddedObjects?.[0]?.document; if (document?.kind !== 'formula') { throw new Error('expected a formula ContentDocument'); @@ -396,7 +397,7 @@ describe('readOds: sheet-formula.ods (real LibreOffice output -- a Math object a }); }); -describe('readOds: anchored drawings (synthetic packages -- the scope boundaries and group flattening real LibreOffice output does not exercise)', () => { +describe('readOdsContent: anchored drawings (synthetic packages -- the scope boundaries and group flattening real LibreOffice output does not exercise)', () => { // Only the PNG magic-byte signature matters to sniffImageFormat -- the rest is arbitrary filler, matching typed/draw/shapes.test.ts's own convention. const pngBase64 = bytesToBase64(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0])); @@ -421,14 +422,14 @@ describe('readOds: anchored drawings (synthetic packages -- the scope boundaries el('table:table-cell', {}, [imageFrame(frameBox)]), ]); const table = el('table:table', { 'table:name': 'Sheet1' }, [el('table:table-row', { 'table:number-rows-repeated': '3' }, []), row]); - const { sheets } = readOds(drawingPackage(table)); + const { sheets } = readOdsContent(drawingPackage(table)); expect(sheets[0]?.images[0]).toMatchObject({ anchorRow: 3, anchorColumn: 5, offsetXPt: 10, offsetYPt: 20 }); }); it('walks through a draw:g group, composing the group\'s own draw:transform onto the frame exactly as walkDrawShapes does for a slide', () => { const group = el('draw:g', { 'draw:transform': 'translate(5pt 7pt)' }, [imageFrame(frameBox)]); const table = el('table:table', { 'table:name': 'Sheet1' }, [el('table:table-row', {}, [el('table:table-cell', {}, [group])])]); - const { sheets } = readOds(drawingPackage(table)); + const { sheets } = readOdsContent(drawingPackage(table)); expect(sheets[0]?.images[0]).toMatchObject({ anchorRow: 0, anchorColumn: 0, offsetXPt: 15, offsetYPt: 27 }); }); @@ -436,26 +437,26 @@ describe('readOds: anchored drawings (synthetic packages -- the scope boundaries const textBox = el('draw:frame', frameBox, [el('draw:text-box', {}, [el('text:p', {}, [txt('floating')])])]); const rect = el('draw:rect', frameBox); const table = el('table:table', { 'table:name': 'Sheet1' }, [el('table:table-row', {}, [el('table:table-cell', {}, [textBox, rect])])]); - const { sheets } = readOds(drawingPackage(table)); + const { sheets } = readOdsContent(drawingPackage(table)); expect(sheets[0]?.images).toEqual([]); expect(sheets[0]?.embeddedObjects).toBeUndefined(); }); it('skips a frame with no resolvable geometry at all, matching readDrawFrame\'s own documented inherited-positioning boundary', () => { const table = el('table:table', { 'table:name': 'Sheet1' }, [el('table:table-row', {}, [el('table:table-cell', {}, [imageFrame({})])])]); - expect(readOds(drawingPackage(table)).sheets[0]?.images).toEqual([]); + expect(readOdsContent(drawingPackage(table)).sheets[0]?.images).toEqual([]); }); it('reads an anchored image from a cell that also has real content, without disturbing that cell\'s own value', () => { const cell = el('table:table-cell', { 'office:value-type': 'string' }, [el('text:p', {}, [txt('has a picture')]), imageFrame(frameBox)]); const table = el('table:table', { 'table:name': 'Sheet1' }, [el('table:table-row', {}, [cell])]); - const { sheets } = readOds(drawingPackage(table)); + const { sheets } = readOdsContent(drawingPackage(table)); expect(sheets[0]?.cells[0]?.displayText).toBe('has a picture'); expect(sheets[0]?.images[0]).toMatchObject({ anchorRow: 0, anchorColumn: 0 }); }); }); -describe('readOds: repeat-count hazards (synthetic packages -- proving this reader never materializes a huge repeated run, real confirmed counts from typed/shared/a1.test.ts)', () => { +describe('readOdsContent: repeat-count hazards (synthetic packages -- proving this reader never materializes a huge repeated run, real confirmed counts from typed/shared/a1.test.ts)', () => { // A real LibreOffice-shipped .ots template's own trailing empty rows carry table:number-rows-repeated="1016575" (confirmed in typed/shared/a1.test.ts, from /Applications/LibreOffice.app/Contents/Resources/template/common/wizard/styles/*.ots) -- reused here verbatim rather than re-deriving a fresh huge fixture, since the real count is already established ground truth. const HUGE_ROW_REPEAT = 1016575; const HUGE_COLUMN_REPEAT = 1024; // a1.test.ts's own "real trailing-repeated-cell block" example. @@ -478,40 +479,40 @@ describe('readOds: repeat-count hazards (synthetic packages -- proving this read } it('does not allocate one ContentSheetCell per repeated empty position: a >1,000,000-row repeat block yields exactly one real cell', () => { - const { sheets } = readOds(buildHugeRepeatPackage()); + const { sheets } = readOdsContent(buildHugeRepeatPackage()); expect(sheets[0]?.cells).toHaveLength(1); expect(sheets[0]?.cells[0]).toMatchObject({ row: 0, column: 0, value: { kind: 'string', value: 'Header' }, displayText: 'Header' }); }); it('does not allocate one ContentSheetRow per repeated row: only the two real table:table-row XML elements are represented', () => { - const { sheets } = readOds(buildHugeRepeatPackage()); + const { sheets } = readOdsContent(buildHugeRepeatPackage()); expect(sheets[0]?.rows).toHaveLength(2); expect(sheets[0]?.rows[1]?.index).toBe(1); // the huge repeat block's own STARTING row index, not a materialized count. }); it('does not allocate one ContentSheetColumn per repeated column: a 1024-column repeat block yields exactly one column entry', () => { - const { sheets } = readOds(buildHugeRepeatPackage()); + const { sheets } = readOdsContent(buildHugeRepeatPackage()); expect(sheets[0]?.columns).toHaveLength(1); expect(sheets[0]?.columns[0]?.index).toBe(0); }); it('completes in well under a second, confirming no O(repeatCount) work happened at all', () => { const start = performance.now(); - readOds(buildHugeRepeatPackage()); + readOdsContent(buildHugeRepeatPackage()); expect(performance.now() - start).toBeLessThan(1000); }); }); -describe('readOds: error and fallback paths (synthetic packages -- not something real LibreOffice output can exercise)', () => { +describe('readOdsContent: error and fallback paths (synthetic packages -- not something real LibreOffice output can exercise)', () => { it('reads an empty sheets array for a package with no content.xml at all', () => { - const result = readOds({ parts: {} }); + const result = readOdsContent({ parts: {} }); expect(result.sheets).toEqual([]); expect(result.metadata).toEqual({}); }); it('reads an empty sheets array for a package with no office:spreadsheet at all', () => { const pkg: Package = { parts: { 'content.xml': { kind: 'xml', nodes: [el('office:document-content', {}, [el('office:body')])] } } }; - expect(readOds(pkg).sheets).toEqual([]); + expect(readOdsContent(pkg).sheets).toEqual([]); }); it('skips a table:table with no table:name at all, rather than fabricating one', () => { @@ -523,7 +524,7 @@ describe('readOds: error and fallback paths (synthetic packages -- not something }, }, }; - expect(readOds(pkg).sheets).toEqual([]); + expect(readOdsContent(pkg).sheets).toEqual([]); }); it('reads a table:table with no rows/columns at all as a sheet with empty arrays, not a throw', () => { @@ -535,13 +536,13 @@ describe('readOds: error and fallback paths (synthetic packages -- not something }, }, }; - const { sheets } = readOds(pkg); + const { sheets } = readOdsContent(pkg); expect(sheets).toHaveLength(1); expect(sheets[0]).toMatchObject({ name: 'Empty', cells: [], columns: [], rows: [] }); }); }); -describe('readOds: cell background/borders/alignment/verticalAlignment (synthetic packages -- the real cascade, including a genuine style:parent-style-name chain matching kitchen-sink.ods\'s own real ce1..ce5 -> "Default" -> table-cell family default-style shape)', () => { +describe('readOdsContent: cell background/borders/alignment/verticalAlignment (synthetic packages -- the real cascade, including a genuine style:parent-style-name chain matching kitchen-sink.ods\'s own real ce1..ce5 -> "Default" -> table-cell family default-style shape)', () => { interface TableCellStyleOptions { cellProperties?: Record; paragraphProperties?: Record; @@ -599,7 +600,7 @@ describe('readOds: cell background/borders/alignment/verticalAlignment (syntheti el('table:table-column', {}, []), el('table:table-row', {}, [stringCell('a')]), ]); - const { sheets } = readOds(sheetPackage([], table)); + const { sheets } = readOdsContent(sheetPackage([], table)); expect(sheets[0]?.columns[0]?.widthPt).toBe(64); expect(sheets[0]?.rows[0]?.heightPt).toBe(15); }); @@ -607,14 +608,14 @@ describe('readOds: cell background/borders/alignment/verticalAlignment (syntheti it('resolves fo:background-color from the cell\'s own table:style-name -> table-cell family style', () => { const ce1 = tableCellStyle('ce1', { cellProperties: { 'fo:background-color': '#ff0000' } }); const table = el('table:table', { 'table:name': 'Sheet1' }, [el('table:table-row', {}, [stringCell('red', { 'table:style-name': 'ce1' })])]); - const { sheets } = readOds(sheetPackage([ce1], table)); + const { sheets } = readOdsContent(sheetPackage([ce1], table)); expect(sheets[0]?.cells[0]?.background).toEqual({ r: 1, g: 0, b: 0 }); }); it('expands the fo:border shorthand onto all four edges', () => { const ce1 = tableCellStyle('ce1', { cellProperties: { 'fo:border': '0.5pt solid #0000ff' } }); const table = el('table:table', { 'table:name': 'Sheet1' }, [el('table:table-row', {}, [stringCell('bordered', { 'table:style-name': 'ce1' })])]); - const { sheets } = readOds(sheetPackage([ce1], table)); + const { sheets } = readOdsContent(sheetPackage([ce1], table)); const expectedEdge = { color: { r: 0, g: 0, b: 1 }, widthPt: 0.5, style: 'solid' }; expect(sheets[0]?.cells[0]?.borders).toEqual({ left: expectedEdge, right: expectedEdge, top: expectedEdge, bottom: expectedEdge }); }); @@ -622,7 +623,7 @@ describe('readOds: cell background/borders/alignment/verticalAlignment (syntheti it('lets a per-edge fo:border-top override just that one edge', () => { const ce1 = tableCellStyle('ce1', { cellProperties: { 'fo:border': '1pt solid #000000', 'fo:border-top': '2pt dotted #ffff00' } }); const table = el('table:table', { 'table:name': 'Sheet1' }, [el('table:table-row', {}, [stringCell('bordered', { 'table:style-name': 'ce1' })])]); - const { sheets } = readOds(sheetPackage([ce1], table)); + const { sheets } = readOdsContent(sheetPackage([ce1], table)); const borders = sheets[0]?.cells[0]?.borders; expect(borders?.top).toEqual({ color: { r: 1, g: 1, b: 0 }, widthPt: 2, style: 'dotted' }); expect(borders?.bottom).toEqual({ color: { r: 0, g: 0, b: 0 }, widthPt: 1, style: 'solid' }); @@ -632,7 +633,7 @@ describe('readOds: cell background/borders/alignment/verticalAlignment (syntheti const parent = tableCellStyle('Parent', { cellProperties: { 'fo:border': '1pt solid #000000' } }); const child = tableCellStyle('ce1', { cellProperties: { 'fo:border-bottom': '1pt none #000000' }, parentStyleName: 'Parent' }); const table = el('table:table', { 'table:name': 'Sheet1' }, [el('table:table-row', {}, [stringCell('partial', { 'table:style-name': 'ce1' })])]); - const { sheets } = readOds(sheetPackage([parent, child], table)); + const { sheets } = readOdsContent(sheetPackage([parent, child], table)); const borders = sheets[0]?.cells[0]?.borders; expect(borders?.bottom).toBeUndefined(); expect(borders?.top).toEqual({ color: { r: 0, g: 0, b: 0 }, widthPt: 1, style: 'solid' }); @@ -641,28 +642,28 @@ describe('readOds: cell background/borders/alignment/verticalAlignment (syntheti it('reads style:vertical-align from the cell\'s own table-cell-properties', () => { const ce1 = tableCellStyle('ce1', { cellProperties: { 'style:vertical-align': 'middle' } }); const table = el('table:table', { 'table:name': 'Sheet1' }, [el('table:table-row', {}, [stringCell('centred', { 'table:style-name': 'ce1' })])]); - const { sheets } = readOds(sheetPackage([ce1], table)); + const { sheets } = readOdsContent(sheetPackage([ce1], table)); expect(sheets[0]?.cells[0]?.verticalAlignment).toBe('middle'); }); it('leaves verticalAlignment undefined for style:vertical-align="automatic" (no matching enum member), rather than guessing', () => { const ce1 = tableCellStyle('ce1', { cellProperties: { 'style:vertical-align': 'automatic' } }); const table = el('table:table', { 'table:name': 'Sheet1' }, [el('table:table-row', {}, [stringCell('auto', { 'table:style-name': 'ce1' })])]); - const { sheets } = readOds(sheetPackage([ce1], table)); + const { sheets } = readOdsContent(sheetPackage([ce1], table)); expect(sheets[0]?.cells[0]?.verticalAlignment).toBeUndefined(); }); it('reads fo:text-align from the cell style\'s own style:paragraph-properties as an alignment override', () => { const ce1 = tableCellStyle('ce1', { paragraphProperties: { 'fo:text-align': 'center' } }); const table = el('table:table', { 'table:name': 'Sheet1' }, [el('table:table-row', {}, [stringCell('centred', { 'table:style-name': 'ce1' })])]); - const { sheets } = readOds(sheetPackage([ce1], table)); + const { sheets } = readOdsContent(sheetPackage([ce1], table)); expect(sheets[0]?.cells[0]?.alignment).toBe('center'); }); it('leaves alignment undefined for a cell whose style sets no fo:text-align at all -- the value-kind default stays in effect elsewhere, this reader never fabricates one', () => { const ce1 = tableCellStyle('ce1', { cellProperties: { 'fo:background-color': '#ff0000' } }); const table = el('table:table', { 'table:name': 'Sheet1' }, [el('table:table-row', {}, [stringCell('red', { 'table:style-name': 'ce1' })])]); - const { sheets } = readOds(sheetPackage([ce1], table)); + const { sheets } = readOdsContent(sheetPackage([ce1], table)); expect(sheets[0]?.cells[0]?.alignment).toBeUndefined(); }); @@ -670,7 +671,7 @@ describe('readOds: cell background/borders/alignment/verticalAlignment (syntheti const defaultStyle = tableCellDefaultStyle({ cellProperties: { 'fo:background-color': '#00ff00' } }); const ce1 = tableCellStyle('ce1', { cellProperties: { 'style:vertical-align': 'top' } }); // no background of its own const table = el('table:table', { 'table:name': 'Sheet1' }, [el('table:table-row', {}, [stringCell('inherited', { 'table:style-name': 'ce1' })])]); - const { sheets } = readOds(sheetPackage([defaultStyle, ce1], table)); + const { sheets } = readOdsContent(sheetPackage([defaultStyle, ce1], table)); expect(sheets[0]?.cells[0]?.background).toEqual({ r: 0, g: 1, b: 0 }); expect(sheets[0]?.cells[0]?.verticalAlignment).toBe('top'); }); @@ -681,13 +682,13 @@ describe('readOds: cell background/borders/alignment/verticalAlignment (syntheti el('style:table-cell-properties', { 'fo:background-color': '#0000ff' }), ]); const table = el('table:table', { 'table:name': 'Sheet1' }, [el('table:table-row', {}, [stringCell('overridden', { 'table:style-name': 'ce1' })])]); - const { sheets } = readOds(sheetPackage([parent, ce1], table)); + const { sheets } = readOdsContent(sheetPackage([parent, ce1], table)); expect(sheets[0]?.cells[0]?.background).toEqual({ r: 0, g: 0, b: 1 }); }); it('leaves background/borders/alignment/verticalAlignment all undefined for a cell with no table:style-name at all', () => { const table = el('table:table', { 'table:name': 'Sheet1' }, [el('table:table-row', {}, [stringCell('plain')])]); - const { sheets } = readOds(sheetPackage([], table)); + const { sheets } = readOdsContent(sheetPackage([], table)); const cell = sheets[0]?.cells[0]; expect(cell?.background).toBeUndefined(); expect(cell?.borders).toBeUndefined(); @@ -695,3 +696,62 @@ describe('readOds: cell background/borders/alignment/verticalAlignment (syntheti expect(cell?.verticalAlignment).toBeUndefined(); }); }); + +describe('readOds: the package-native reader over the same real fixtures', () => { + it('assembles kitchen-sink.ods into a spreadsheet package whose tree flattens back to readOdsContent output exactly', () => { + const pkg = loadFixture('kitchen-sink.ods'); + const content = readOdsContent(pkg); + const documentPackage = readOds(pkg); + + expect(documentPackage.kind).toBe('spreadsheet'); + expect(documentPackage.metadata).toEqual(content.metadata); + expect(documentPackage.children).toHaveLength(content.sheets.length); + assertPackageRoundTrip(documentPackage, { kind: 'spreadsheet', ...content }); + }); + + it('keeps a sheet\'s grid and print settings on its group node, since a sheet holds addressable data rather than block flow', () => { + const pkg = loadFixture('kitchen-sink.ods'); + const content = readOdsContent(pkg); + const documentPackage = spreadsheetPackage(readOds(pkg)); + const firstSheet = documentPackage.children[0]; + const firstContentSheet = content.sheets[0]; + if (firstSheet === undefined || firstContentSheet === undefined) { + throw new Error('expected at least one sheet'); + } + expect(firstSheet.node.kind).toBe('sheet'); + expect(firstSheet.node.name).toBe(firstContentSheet.name); + expect(firstSheet.node.cells).toEqual(firstContentSheet.cells); + expect(firstSheet.node.printSettings).toEqual(firstContentSheet.printSettings); + // A sheet group's extent holds no paragraphs at all, so the minting pass has nothing to factor and never stamps a ref on one. + expect(firstSheet.style).toBeUndefined(); + }); + + it('carries a sheet\'s anchored images and embedded sub-documents as its group\'s children', () => { + const pkg = loadFixture('sheet-anchors.ods'); + const content = readOdsContent(pkg); + const documentPackage = spreadsheetPackage(readOds(pkg)); + const sheet = documentPackage.children[0]; + const contentSheet = content.sheets[0]; + if (sheet === undefined || contentSheet === undefined) { + throw new Error('expected at least one sheet'); + } + const images = contentSheet.images ?? []; + const embedded = contentSheet.embeddedObjects ?? []; + expect(images.length + embedded.length).toBeGreaterThan(0); + // Images first, then embedded objects -- the fixed order flatten's own partition reverses. + expect(sheet.children).toEqual([...images, ...embedded]); + assertPackageRoundTrip(documentPackage, { kind: 'spreadsheet', ...content }); + }); + + it('round-trips sheet-formula.ods, whose embedded Math object stays one intact leaf carrying its own formula document', () => { + const pkg = loadFixture('sheet-formula.ods'); + const content = readOdsContent(pkg); + assertPackageRoundTrip(readOds(pkg), { kind: 'spreadsheet', ...content }); + }); + + it('assembles minimal.ods into a package that round-trips identically', () => { + const pkg = loadFixture('minimal.ods'); + const content = readOdsContent(pkg); + assertPackageRoundTrip(readOds(pkg), { kind: 'spreadsheet', ...content }); + }); +}); diff --git a/src/typed/ods/read.ts b/src/typed/ods/read.ts index 726194f..0a50f86 100644 --- a/src/typed/ods/read.ts +++ b/src/typed/ods/read.ts @@ -11,10 +11,11 @@ import type { ContentSheetPrintSettings, ContentSheetRepeatRange, ContentSheetRow, + DocumentPackage, LayoutMetadata, Margins, } from 'document-schema.js'; -import { PAGE_SIZE_A4 } from 'document-schema.js'; +import { assemblePackage, PAGE_SIZE_A4 } from 'document-schema.js'; import type { XmlElement, XmlNode } from '../../model/node'; import type { Package } from '../../model/package'; import { attrValue, childrenWithTag, findChildElement, rootElement } from '../../xml/query'; @@ -31,10 +32,10 @@ import { parseOdfTransform } from '../shared/transform'; import { readDrawFrame } from '../draw/shapes'; import type { EmbeddedDrawObject } from '../draw/embedded'; import { readDrawObjectReference } from '../draw/embedded'; -import { readOdfFormulaDocument } from '../formula/read'; -import { readOdg } from '../odg/read'; -import { readOdp } from '../odp/read'; -import { readOdt } from '../odt/read'; +import { readOdfFormulaContent } from '../formula/read'; +import { readOdgContent } from '../odg/read'; +import { readOdpContent } from '../odp/read'; +import { readOdtContent } from '../odt/read'; // Package -> OdsDocument: a spreadsheet reader deliberately built GEOMETRY- and PRINT-SETTINGS-rich rather than a minimal cell-values-only reader (a real requirement from this reader's own design brief, not optional polish) -- real column widths/row heights, hidden rows/columns, merged ranges, every office:value-type variant with its own OpenFormula string carried verbatim, and a genuinely populated ContentSheetPrintSettings (page geometry, print range, scale/fit-to-page, repeat rows/columns, gridlines/headers, page order, manual breaks). Every ODF attribute name and structural shape below was confirmed against real LibreOffice 26.2 output (a headless UNO Basic macro building a real .ods with every one of these features actually configured through the same UNO calls the Calc UI itself uses -- Format > Columns > Width, Format > Rows > Height, Format > Print Areas, Format > Page Style's Sheet tab, a real merged range, a real cross-sheet SUM formula, every value-type including a GBP currency cell and a genuine #DIV/0! formula error -- then the resulting content.xml/styles.xml inspected directly), not assumed from memory or from xlsx's own different mechanisms. See this module's own inline notes at each surprising point (table:table-header-rows/columns as the REAL repeat-row/column mechanism, NOT a named range; style:master-page-name living on the table:table's own style:style[family="table"], NOT on table:table itself; the UNO API's own PageScale-vs-ScaleToPagesX/Y mutual-exclusivity quirk that shaped nothing in the READER but is worth knowing when re-deriving a fixture) for the exact evidence. // @@ -45,7 +46,7 @@ import { readOdt } from '../odt/read'; // 1. ANCHORED TO A CELL: the draw:frame is a DIRECT CHILD OF THE table:table-cell it is anchored to, and its svg:x/svg:y are offsets from THAT CELL'S own top-left corner (verified numerically: a shape positioned 0.5cm/0.3cm beyond its anchor cell's origin serialises as svg:x="0.5cm" svg:y="0.3cm", regardless of where that cell sits on the sheet). The anchor cell reference is therefore not read from any attribute at all -- ODF cells carry no address attribute (see the repeat-count note below) -- it IS the running TableCursor position this reader already computes for every cell, exactly the same way ContentSheetCell.row/column are resolved. // 2. ANCHORED TO THE PAGE: the draw:frame sits inside a table:shapes element, a child of table:table itself appearing BEFORE its column definitions, and its svg:x/svg:y are absolute from the sheet's own origin. ContentSheetImage has no "page-anchored" variant, so such an image is reported at anchorRow/anchorColumn 0 with its absolute offsets carried through unchanged -- not an approximation: cell (0,0)'s own top-left IS the sheet origin, so the two coordinate systems coincide exactly there. // -// A draw:g group is walked through (its own draw:transform composed onto each child via readDrawFrame's existing groupFunctions parameter, exactly as walkDrawShapes does for a slide), so a grouped anchored image is still found. What a sheet CANNOT carry is anything ContentSheetSchema has nowhere to put: a floating text box or a table frame (ContentSheet has no `shapes` array at all, unlike ContentSlide/ContentDrawPage), a bare vector primitive (no `vectors` array either -- the same scope boundary walkDrawShapes already documents for presentations), and an embedded CHART object (see typed/draw/embedded.ts's own SCOPE note: ContentEmbeddedObjectKind has no 'chart' member to map one onto). Each is skipped rather than mapped onto an approximation of a different kind. An embedded FORMULA object -- a real LibreOffice Math OLE object anchored to a cell -- is no longer in that skipped list: document-schema.js 2.2.0's ContentDocument union carries a genuine 'formula' variant, so readDrawObjectReference resolves one and readEmbeddedObjectDocument hands it to readOdfFormulaDocument like any other embedded kind. +// A draw:g group is walked through (its own draw:transform composed onto each child via readDrawFrame's existing groupFunctions parameter, exactly as walkDrawShapes does for a slide), so a grouped anchored image is still found. What a sheet CANNOT carry is anything ContentSheetSchema has nowhere to put: a floating text box or a table frame (ContentSheet has no `shapes` array at all, unlike ContentSlide/ContentDrawPage), a bare vector primitive (no `vectors` array either -- the same scope boundary walkDrawShapes already documents for presentations), and an embedded CHART object (see typed/draw/embedded.ts's own SCOPE note: ContentEmbeddedObjectKind has no 'chart' member to map one onto). Each is skipped rather than mapped onto an approximation of a different kind. An embedded FORMULA object -- a real LibreOffice Math OLE object anchored to a cell -- is no longer in that skipped list: document-schema.js 2.2.0's ContentDocument union carries a genuine 'formula' variant, so readDrawObjectReference resolves one and readEmbeddedObjectDocument hands it to readOdfFormulaContent like any other embedded kind. // // SCOPE: table:print-ranges is a space-separated list of cell-range-address strings per the OASIS spec, but ContentSheetPrintSettingsSchema's own `printRange` models only ONE range -- a document defining more than one non-contiguous print range has every range after the first silently ignored (a documented, narrow scope boundary, not a silent one). @@ -54,12 +55,12 @@ const CONTENT_PART = 'content.xml'; function parseKnownOdfLength(value: string): number { const parsed = parseOdfLength(value); if (parsed === undefined) { - throw new Error(`readOds: internal error -- "${value}" is not a valid ODF length literal`); + throw new Error(`readOdsContent: internal error -- "${value}" is not a valid ODF length literal`); } return parsed; } -// LibreOffice Calc's own real out-of-the-box default page geometry for an untouched page style (confirmed directly via the UNO API's own PageStyle.Width/Height/*Margin properties on a freshly created, unmodified Calc document -- 21.001cm x 29.7cm, 2cm margins on every side -- even though a truly untouched style:page-layout-properties element omits fo:page-width/height/margin-* from the SAVED XML entirely, per real LibreOffice output). Numerically identical to readOdt's own default page size/margins choice (PAGE_SIZE_A4 + 2cm), which is not a coincidence: Calc and Writer share the same locale-driven default page geometry, and both readers' own fallback should reflect the real, confirmed default rather than an assumed one. +// LibreOffice Calc's own real out-of-the-box default page geometry for an untouched page style (confirmed directly via the UNO API's own PageStyle.Width/Height/*Margin properties on a freshly created, unmodified Calc document -- 21.001cm x 29.7cm, 2cm margins on every side -- even though a truly untouched style:page-layout-properties element omits fo:page-width/height/margin-* from the SAVED XML entirely, per real LibreOffice output). Numerically identical to readOdtContent's own default page size/margins choice (PAGE_SIZE_A4 + 2cm), which is not a coincidence: Calc and Writer share the same locale-driven default page geometry, and both readers' own fallback should reflect the real, confirmed default rather than an assumed one. const DEFAULT_MARGIN_PT = parseKnownOdfLength('2cm'); const DEFAULT_MARGINS: Margins = { topPt: DEFAULT_MARGIN_PT, rightPt: DEFAULT_MARGIN_PT, bottomPt: DEFAULT_MARGIN_PT, leftPt: DEFAULT_MARGIN_PT }; @@ -107,7 +108,7 @@ function readRowLayout(rowElement: XmlElement, pkg: Package): { heightPt: number return { heightPt, manualBreak }; } -// A cell's own rendered text, read via paragraph.ts's existing run-reading logic (readOdfParagraph) rather than a bare text-node walk, so bold/italic/colour/etc. on the cell's own text:span runs survive into ContentSheetCell.runs -- and displayText is derived from those SAME runs, never computed separately, so the two can never disagree. Multiple text:p children (a manually line-broken cell, Alt+Enter in Calc) are joined with a synthetic newline run between them, mirroring how readOdp's own readSlideNotes joins multiple text:p lines with '\n'. +// A cell's own rendered text, read via paragraph.ts's existing run-reading logic (readOdfParagraph) rather than a bare text-node walk, so bold/italic/colour/etc. on the cell's own text:span runs survive into ContentSheetCell.runs -- and displayText is derived from those SAME runs, never computed separately, so the two can never disagree. Multiple text:p children (a manually line-broken cell, Alt+Enter in Calc) are joined with a synthetic newline run between them, mirroring how readOdpContent's own readSlideNotes joins multiple text:p lines with '\n'. function readCellText(cellElement: XmlElement, pkg: Package): { runs: ContentRun[]; displayText: string } { const paragraphs = childrenWithTag(cellElement, 'text:p'); const runs: ContentRun[] = []; @@ -220,28 +221,28 @@ interface TableWalkResult { manualBreakColumns: number[]; } -// An embedded sub-document -> the ContentDocument variant its own typed reader produces. This is the kind -> reader dispatch typed/draw/embedded.ts deliberately leaves to its caller (see that module's own note on the import cycle it would otherwise create): readOds is one of the four readers dispatched to, so a spreadsheet embedded inside a spreadsheet is plain self-recursion here, needing no indirection at all. +// An embedded sub-document -> the ContentDocument variant its own typed reader produces. This is the kind -> reader dispatch typed/draw/embedded.ts deliberately leaves to its caller (see that module's own note on the import cycle it would otherwise create): readOdsContent is one of the four readers dispatched to, so a spreadsheet embedded inside a spreadsheet is plain self-recursion here, needing no indirection at all. function readEmbeddedObjectDocument(reference: EmbeddedDrawObject): ContentDocument { switch (reference.objectKind) { case 'wordprocessing': { - const { metadata, sections } = readOdt(reference.package); + const { metadata, sections } = readOdtContent(reference.package); return { kind: 'wordprocessing', metadata, sections }; } case 'presentation': { - const { metadata, slides } = readOdp(reference.package); + const { metadata, slides } = readOdpContent(reference.package); return { kind: 'presentation', metadata, slides }; } case 'drawing': { - const { metadata, pages } = readOdg(reference.package); + const { metadata, pages } = readOdgContent(reference.package); return { kind: 'drawing', metadata, pages }; } case 'spreadsheet': { - const { metadata, sheets } = readOds(reference.package); + const { metadata, sheets } = readOdsContent(reference.package); return { kind: 'spreadsheet', metadata, sheets }; } case 'formula': - // The one embedded kind whose own reader already returns a finished ContentDocument (readOdfFormulaDocument), because a formula document has no per-format {metadata, sections/slides/pages/sheets} shape to re-wrap -- its whole content IS the MathML. - return readOdfFormulaDocument(reference.package); + // The one embedded kind whose own reader already returns a finished ContentDocument (readOdfFormulaContent), because a formula document has no per-format {metadata, sections/slides/pages/sheets} shape to re-wrap -- its whole content IS the MathML. + return readOdfFormulaContent(reference.package); } } @@ -523,7 +524,7 @@ function readSheet(tableElement: XmlElement, pkg: Package): ContentSheet | undef return sheet; } -export function readOds(pkg: Package): OdsDocument { +export function readOdsContent(pkg: Package): OdsDocument { const contentPart = pkg.parts[CONTENT_PART]; const root = contentPart?.kind === 'xml' ? rootElement(contentPart.nodes) : undefined; const body = root === undefined ? undefined : findChildElement(root.children, 'office:body'); @@ -540,3 +541,9 @@ export function readOds(pkg: Package): OdsDocument { return { metadata: readOdfMetadata(pkg), sheets }; } + +// Package -> DocumentPackage: this module's PRIMARY entry point, the spreadsheet mirror of readOdtContent/readOdt (see src/typed/odt/read.ts's own note on why assemblePackage rather than bare decompose, and why no `pages` argument). readOdsContent above is unchanged and remains the flat, ContentDocument-level reader. +export function readOds(pkg: Package): DocumentPackage { + const { metadata, sheets } = readOdsContent(pkg); + return assemblePackage({ kind: 'spreadsheet', metadata, sheets }); +} diff --git a/src/typed/odt/read.test.ts b/src/typed/odt/read.test.ts index ba337b2..822683f 100644 --- a/src/typed/odt/read.test.ts +++ b/src/typed/odt/read.test.ts @@ -8,7 +8,8 @@ import { PAGE_SIZE_A4 } from 'document-schema.js'; import { el, txt } from '../../xml/fragment'; import { parsePackage } from '../../package-io/read'; import { parseOdfLength } from '../shared/units'; -import { readOdt } from './read'; +import { assertPackageRoundTrip, wordprocessingPackage } from '../../test-support/document-package'; +import { readOdt, readOdtContent } from './read'; // This suite reads real, unmodified LibreOffice 26.2-generated .odt fixtures (src/typed/odt/fixtures/*.odt, built via a headless UNO Basic macro -- see this repository's own commit history for the exact macro -- never hand-edited afterwards) rather than programmatically reconstructing the expected XML shapes: the task this reader was built against is explicit that whitespace preservation, list nesting, and merged-cell handling must each be proven against genuine producer output, not just this package's own idea of what that output looks like. A handful of narrow error/fallback-path tests at the end use small, synthetic, hand-built packages instead (via el/txt, matching this package's other typed-reader tests), since those specific paths -- a missing content.xml, a missing office:text -- are not something any real LibreOffice document can ever actually produce. @@ -49,9 +50,9 @@ function asTable(block: ContentBlock | undefined): ContentTable { return block; } -describe('readOdt: kitchen-sink.odt (real LibreOffice output)', () => { +describe('readOdtContent: kitchen-sink.odt (real LibreOffice output)', () => { const kitchenSink = loadFixture('kitchen-sink.odt'); - const { metadata, sections } = readOdt(kitchenSink); + const { metadata, sections } = readOdtContent(kitchenSink); const section = sections[0]; if (section === undefined) { throw new Error('expected at least one section'); @@ -201,9 +202,9 @@ describe('readOdt: kitchen-sink.odt (real LibreOffice output)', () => { }); }); -describe('readOdt: minimal.odt (real LibreOffice output, default/unmodified page style)', () => { +describe('readOdtContent: minimal.odt (real LibreOffice output, default/unmodified page style)', () => { const minimal = loadFixture('minimal.odt'); - const { metadata, sections } = readOdt(minimal); + const { metadata, sections } = readOdtContent(minimal); const section = sections[0]; if (section === undefined) { throw new Error('expected at least one section'); @@ -232,14 +233,14 @@ describe('readOdt: minimal.odt (real LibreOffice output, default/unmodified page }); }); -describe('readOdt: error and fallback paths (synthetic packages -- not something real LibreOffice output can exercise)', () => { +describe('readOdtContent: error and fallback paths (synthetic packages -- not something real LibreOffice output can exercise)', () => { it('throws when the package has no content.xml part at all', () => { - expect(() => readOdt({ parts: {} })).toThrow(/content\.xml/); + expect(() => readOdtContent({ parts: {} })).toThrow(/content\.xml/); }); it('throws when content.xml has no office:body/office:text element', () => { const pkg: Package = { parts: { 'content.xml': { kind: 'xml', nodes: [el('office:document-content', {}, [el('office:body')])] } } }; - expect(() => readOdt(pkg)).toThrow(/office:text/); + expect(() => readOdtContent(pkg)).toThrow(/office:text/); }); it('falls back to document-schema.js\'s own PAGE_SIZE_A4/2cm-margin defaults when styles.xml is missing entirely', () => { @@ -248,7 +249,7 @@ describe('readOdt: error and fallback paths (synthetic packages -- not something 'content.xml': { kind: 'xml', nodes: [el('office:document-content', {}, [el('office:body', {}, [el('office:text', {}, [el('text:p', {}, [txt('hello')])])])])] }, }, }; - const { sections } = readOdt(pkg); + const { sections } = readOdtContent(pkg); expect(sections[0]?.pageSize).toEqual(PAGE_SIZE_A4); expect(sections[0]?.margins.topPt).toBeCloseTo(knownLength('2cm'), 5); }); @@ -257,7 +258,64 @@ describe('readOdt: error and fallback paths (synthetic packages -- not something const pkg: Package = { parts: { 'content.xml': { kind: 'xml', nodes: [el('office:document-content', {}, [el('office:body', {}, [el('office:text')])])] } }, }; - const { sections } = readOdt(pkg); + const { sections } = readOdtContent(pkg); expect(sections[0]?.blocks).toEqual([]); }); }); + +describe('readOdt: the package-native reader over the same real fixtures', () => { + it('assembles kitchen-sink.odt into a wordprocessing package whose tree flattens back to readOdtContent output exactly', () => { + const pkg = loadFixture('kitchen-sink.odt'); + const content = readOdtContent(pkg); + const documentPackage = readOdt(pkg); + + expect(documentPackage.kind).toBe('wordprocessing'); + expect(documentPackage.metadata).toEqual(content.metadata); + // One section group per ContentSection -- the tree's mandatory top-level grouping, not a flattening of the section's own blocks. + expect(documentPackage.children).toHaveLength(content.sections.length); + assertPackageRoundTrip(documentPackage, { kind: 'wordprocessing', ...content }); + }); + + it('groups this fixture\'s headings into real heading groups carrying their following blocks, rather than a flat block list', () => { + const documentPackage = wordprocessingPackage(readOdt(loadFixture('kitchen-sink.odt'))); + const section = documentPackage.children[0]; + if (section === undefined) { + throw new Error('expected one section group'); + } + // Every top-level child of this fixture's section is a heading group (its body paragraphs and its table sit INSIDE the heading they follow), which is precisely the structure the flat ContentSection.blocks list cannot express. + for (const child of section.children) { + if (!('node' in child) || !('kind' in child.node) || child.node.kind !== 'paragraph') { + throw new Error('expected every top-level section child to be a heading group'); + } + expect(child.node.headingLevel).toBeGreaterThanOrEqual(1); + expect(child.children.length).toBeGreaterThan(0); + } + expect(section.children.length).toBeGreaterThan(1); + }); + + it('mints a real styles table over the repeated run properties this fixture actually carries', () => { + const documentPackage = wordprocessingPackage(readOdt(loadFixture('kitchen-sink.odt'))); + // Not an assertion that some fixed entry exists: the fixture's own repeated property tuples are what mint, so the check is that minting HAPPENED and that every ref in the tree names an entry the table defines. + const styles = documentPackage.styles; + expect(styles).toBeDefined(); + expect(Object.keys(styles ?? {}).length).toBeGreaterThan(0); + for (const section of documentPackage.children) { + for (const child of section.children) { + if ('node' in child && child.style !== undefined) { + expect(styles?.[child.style]).toBeDefined(); + } + } + } + }); + + it('assembles minimal.odt into a package that round-trips identically', () => { + const pkg = loadFixture('minimal.odt'); + const content = readOdtContent(pkg); + assertPackageRoundTrip(readOdt(pkg), { kind: 'wordprocessing', ...content }); + }); + + it('throws from the package-native reader exactly as the content reader does, on a package with no content.xml', () => { + const pkg: Package = { parts: {} }; + expect(() => readOdt(pkg)).toThrow('readOdtContent: package has no content.xml part'); + }); +}); diff --git a/src/typed/odt/read.ts b/src/typed/odt/read.ts index 4e26e05..b60c7b4 100644 --- a/src/typed/odt/read.ts +++ b/src/typed/odt/read.ts @@ -1,5 +1,5 @@ -import type { ContentBlock, ContentParagraph, ContentSection, LayoutMetadata, Margins, PageSize } from 'document-schema.js'; -import { PAGE_SIZE_A4 } from 'document-schema.js'; +import type { ContentBlock, ContentParagraph, ContentSection, DocumentPackage, LayoutMetadata, Margins, PageSize } from 'document-schema.js'; +import { assemblePackage, PAGE_SIZE_A4 } from 'document-schema.js'; import type { Package } from '../../model/package'; import type { XmlElement, XmlNode } from '../../model/node'; import { rootElement, findChildElement, childrenWithTag, attrValue } from '../../xml/query'; @@ -27,7 +27,7 @@ const CONTENT_PART = 'content.xml'; const STYLES_PART = 'styles.xml'; const AUTOMATIC_STYLE_PARTS = [CONTENT_PART, STYLES_PART] as const; -// text:outline-level's ODF schema default when the attribute is absent is 1 (OASIS ODF 1.2 part 1); an unparseable or non-positive value degrades to the same default rather than throwing, matching this reader's general "malformed-but-salvageable input degrades gracefully" posture (readOdt itself has no diagnostics channel to report it through). +// text:outline-level's ODF schema default when the attribute is absent is 1 (OASIS ODF 1.2 part 1); an unparseable or non-positive value degrades to the same default rather than throwing, matching this reader's general "malformed-but-salvageable input degrades gracefully" posture (readOdtContent itself has no diagnostics channel to report it through). function readOutlineLevel(headingElement: XmlElement): number { const raw = attrValue(headingElement, 'text:outline-level'); if (raw === undefined) { @@ -73,7 +73,7 @@ function readBlocks(nodes: readonly XmlNode[], pkg: Package, listIdState: OdfLis function parseKnownOdfLength(value: string): number { const parsed = parseOdfLength(value); if (parsed === undefined) { - throw new Error(`readOdt: internal error -- "${value}" is not a valid ODF length literal`); + throw new Error(`readOdtContent: internal error -- "${value}" is not a valid ODF length literal`); } return parsed; } @@ -81,7 +81,7 @@ function parseKnownOdfLength(value: string): number { const DEFAULT_MARGIN_PT = parseKnownOdfLength('2cm'); const DEFAULT_MARGINS: Margins = { topPt: DEFAULT_MARGIN_PT, rightPt: DEFAULT_MARGIN_PT, bottomPt: DEFAULT_MARGIN_PT, leftPt: DEFAULT_MARGIN_PT }; -// A style:page-layout can live in either part's own office:automatic-styles (verified against real LibreOffice output) -- mirroring readOdp's own findPageLayoutElement (typed/odp/read.ts), which searches both content.xml and styles.xml for the identical reason (and cascade.ts's own collectStyles, which does the same for style:style/style:default-style). Duplicated here in full, deliberately, rather than importing readOdp's own private helper: this reader's own "first master page in document order" master-page selection differs enough from readOdp's own per-slide draw:master-page-name lookup that sharing just the page-layout half would leave the master-page half split across two modules for no real gain -- and readOdp's own findPageLayoutElement was never exported for reuse in the first place. +// A style:page-layout can live in either part's own office:automatic-styles (verified against real LibreOffice output) -- mirroring readOdpContent's own findPageLayoutElement (typed/odp/read.ts), which searches both content.xml and styles.xml for the identical reason (and cascade.ts's own collectStyles, which does the same for style:style/style:default-style). Duplicated here in full, deliberately, rather than importing readOdpContent's own private helper: this reader's own "first master page in document order" master-page selection differs enough from readOdpContent's own per-slide draw:master-page-name lookup that sharing just the page-layout half would leave the master-page half split across two modules for no real gain -- and readOdpContent's own findPageLayoutElement was never exported for reuse in the first place. function findPageLayoutElement(pkg: Package, pageLayoutName: string | undefined): XmlElement | undefined { if (pageLayoutName === undefined) { return undefined; @@ -106,7 +106,7 @@ function findPageLayoutElement(pkg: Package, pageLayoutName: string | undefined) // Reads the FIRST style:master-page (styles.xml's office:master-styles, in document order) and its associated style:page-layout into PageSize/Margins, via geometry.ts's own parsing helpers. A document with more than one master page (a mid-document page-style change, e.g. switching to a landscape layout partway through) has every master page AFTER the first silently ignored -- a deliberate, tracked scope gap, not an oversight: ODF's own multi-master-page mechanism doesn't correspond to anything ContentSection currently models (one ContentSection carries exactly one pageSize/margins pair for its own blocks), and building that mapping is genuinely separate, larger work from this reader's own current job of proving the single-section, single-page-layout path end to end. // -// ODF/LibreOffice's own out-of-the-box defaults for a freshly created, unmodified text document -- confirmed directly against a real Writer document's own style:page-layout-properties (21cm x 29.7cm page, 2cm margins on every side) -- used only when a package's styles.xml is missing, malformed, or has no master page/page layout this reader can resolve. Deliberately A4-based rather than reusing document-schema.js's own PAGE_SIZE_LETTER convention (which ooxml.js's docx reader falls back to): Word's real default is genuinely Letter-sized, but ODF/LibreOffice's real default is genuinely A4-sized, so each reader's own fallback should reflect the format it actually reads, not a single cross-format constant -- mirroring readOdp's own SLIDE_SIZE_WIDESCREEN fallback choice for the same reason. +// ODF/LibreOffice's own out-of-the-box defaults for a freshly created, unmodified text document -- confirmed directly against a real Writer document's own style:page-layout-properties (21cm x 29.7cm page, 2cm margins on every side) -- used only when a package's styles.xml is missing, malformed, or has no master page/page layout this reader can resolve. Deliberately A4-based rather than reusing document-schema.js's own PAGE_SIZE_LETTER convention (which ooxml.js's docx reader falls back to): Word's real default is genuinely Letter-sized, but ODF/LibreOffice's real default is genuinely A4-sized, so each reader's own fallback should reflect the format it actually reads, not a single cross-format constant -- mirroring readOdpContent's own SLIDE_SIZE_WIDESCREEN fallback choice for the same reason. function readFirstMasterPageGeometry(pkg: Package): { pageSize: PageSize; margins: Margins } { const stylesPart = pkg.parts[STYLES_PART]; const stylesRoot = stylesPart?.kind === 'xml' ? rootElement(stylesPart.nodes) : undefined; @@ -126,16 +126,16 @@ function readFirstMasterPageGeometry(pkg: Package): { pageSize: PageSize; margin } // Package -> OdtDocument. Throws only when content.xml itself, or its own office:body/office:text element, is missing -- a genuinely unusable package, mirroring exactly how ooxml.js's own readDocx throws when word/document.xml or its w:body is missing, rather than degrading gracefully the way a merely malformed or absent OPTIONAL part (meta.xml, styles.xml, an individual style reference) does throughout the rest of this reader. -export function readOdt(pkg: Package): OdtDocument { +export function readOdtContent(pkg: Package): OdtDocument { const contentPart = pkg.parts[CONTENT_PART]; if (contentPart?.kind !== 'xml') { - throw new Error(`readOdt: package has no ${CONTENT_PART} part`); + throw new Error(`readOdtContent: package has no ${CONTENT_PART} part`); } const contentRoot = rootElement(contentPart.nodes); const body = contentRoot === undefined ? undefined : findChildElement(contentRoot.children, 'office:body'); const textElement = body === undefined ? undefined : findChildElement(body.children, 'office:text'); if (textElement === undefined) { - throw new Error(`readOdt: ${CONTENT_PART} has no office:body/office:text element`); + throw new Error(`readOdtContent: ${CONTENT_PART} has no office:body/office:text element`); } const metadata = readOdfMetadata(pkg); @@ -145,3 +145,13 @@ export function readOdt(pkg: Package): OdtDocument { return { metadata, sections: [{ pageSize, margins, blocks }] }; } + +// Package -> DocumentPackage: this module's PRIMARY entry point, and the one a caller reaching for "read a .odt" should use. readOdtContent above stays exactly what it always was -- the flat, ContentDocument-level reader -- and this function is nothing more than its result spliced into the 'wordprocessing' ContentDocument envelope and handed to document-schema.js's own assemblePackage. +// +// assemblePackage rather than bare decompose, per that function's own doc comment ("the tree-form DocumentPackage every construction site reports"): decompose alone yields the `children` array for a caller composing its own package boundary, whereas a reader IS a construction site and owes its caller the whole package -- envelope spliced on, styles table minted over the result -- exactly as documents.js's own conversion pipeline already does at every package it builds. factorStyles is not called here either: assemblePackage already mints, and re-minting an already-minted package is a no-op by law (iii). +// +// No `pages` argument is passed, and none can be: `pages` carries each RENDERED page's own size, which only a layout pass can report. A reader runs strictly before any layout, so the package it returns is a content-only one -- its nodes carry no `frames` and its root carries no `pages`, which is the honest shape for a document nothing has laid out yet. +export function readOdt(pkg: Package): DocumentPackage { + const { metadata, sections } = readOdtContent(pkg); + return assemblePackage({ kind: 'wordprocessing', metadata, sections }); +} diff --git a/src/typed/shared/masterpage.ts b/src/typed/shared/masterpage.ts index c06ebde..fc2f65b 100644 --- a/src/typed/shared/masterpage.ts +++ b/src/typed/shared/masterpage.ts @@ -57,7 +57,7 @@ export function resolvePageLayoutProperties(pkg: Package, masterPageName: string return pageLayout === undefined ? undefined : childrenWithTag(pageLayout, 'style:page-layout-properties')[0]; } -// Resolves one draw:page's own size through the full chain, or undefined if any link doesn't resolve -- the caller supplies its own format-appropriate fallback (a presentation and a drawing document have genuinely different real-world defaults; see readOdp/readOdg's own DEFAULT_PAGE_SIZE constants) rather than this shared function baking one in. +// Resolves one draw:page's own size through the full chain, or undefined if any link doesn't resolve -- the caller supplies its own format-appropriate fallback (a presentation and a drawing document have genuinely different real-world defaults; see readOdpContent/readOdgContent's own DEFAULT_PAGE_SIZE constants) rather than this shared function baking one in. export function resolveDrawPageSize(page: XmlElement, pkg: Package): PageSize | undefined { const masterPageName = attrValue(page, 'draw:master-page-name'); const properties = resolvePageLayoutProperties(pkg, masterPageName); diff --git a/src/typed/shared/table.ts b/src/typed/shared/table.ts index e5f693d..d788446 100644 --- a/src/typed/shared/table.ts +++ b/src/typed/shared/table.ts @@ -130,7 +130,7 @@ export interface CellStyleDecoration { verticalAlignment?: 'top' | 'middle' | 'bottom'; } -// Folds a cell's own table-cell-family style chain into background/borders/alignment/verticalAlignment, later elements in `elements` always overriding an earlier one's value for whichever attribute they actually carry (the same fold cascade.ts's own resolveStyle applies for paragraph/run StyleProperties, just over a property vocabulary -- table-cell dimensional/decorative properties -- that module deliberately does not model). Deliberately generic over how many elements are passed and in what order they were resolved: readTableCell below passes a ONE-ELEMENT array from findStyleElement's single-level lookup (this file's own established "table-cell styles are standalone in practice" convention for odt/odp), while ods's readOds passes the FULL root-to-target array from cascade.ts's resolveStyleElementChain (real-world spreadsheet cell styles routinely DO chain via style:parent-style-name -- confirmed against this package's own kitchen-sink.ods fixture, where every cell style sets style:parent-style-name="Default") -- one fold, two callers, each supplying whatever chain its own family's real-world usage actually needs resolved. +// Folds a cell's own table-cell-family style chain into background/borders/alignment/verticalAlignment, later elements in `elements` always overriding an earlier one's value for whichever attribute they actually carry (the same fold cascade.ts's own resolveStyle applies for paragraph/run StyleProperties, just over a property vocabulary -- table-cell dimensional/decorative properties -- that module deliberately does not model). Deliberately generic over how many elements are passed and in what order they were resolved: readTableCell below passes a ONE-ELEMENT array from findStyleElement's single-level lookup (this file's own established "table-cell styles are standalone in practice" convention for odt/odp), while ods's readOdsContent passes the FULL root-to-target array from cascade.ts's resolveStyleElementChain (real-world spreadsheet cell styles routinely DO chain via style:parent-style-name -- confirmed against this package's own kitchen-sink.ods fixture, where every cell style sets style:parent-style-name="Default") -- one fold, two callers, each supplying whatever chain its own family's real-world usage actually needs resolved. export function readCellStyleDecoration(elements: readonly XmlElement[]): CellStyleDecoration { let background: Color | undefined; let alignment: Alignment | undefined; diff --git a/test/smoke.test.mjs b/test/smoke.test.mjs index 6c7c9ff..f54fa80 100644 --- a/test/smoke.test.mjs +++ b/test/smoke.test.mjs @@ -29,6 +29,18 @@ const FUNCTIONS = [ 'syncManifest', 'validateManifest', 'setDocumentMediaType', + // Both levels of every typed reader: the package-native primary and the flat *Content function beneath it. Listed here so a rename or a missing barrel export fails against the BUILT artifact, not only against src. + 'readOdt', + 'readOdtContent', + 'readOdp', + 'readOdpContent', + 'readOdg', + 'readOdgContent', + 'readOds', + 'readOdsContent', + 'readOdfFormula', + 'readOdfFormulaContent', + 'readOdfFormulaMathMl', ]; const OBJECTS = ['packageCodec', 'xmlCodec', 'ODF_NAMESPACES', 'ODF_MEDIA_TYPES']; diff --git a/test/workers/odf.test.ts b/test/workers/odf.test.ts index 84e0f75..c5feccb 100644 --- a/test/workers/odf.test.ts +++ b/test/workers/odf.test.ts @@ -1,27 +1,31 @@ +import { flattenPackage } from 'document-schema.js'; import { describe, expect, it } from 'vitest'; -import { decodePackage, readOdt, zipPackage, type ZipEntry } from '../../src'; +import { decodePackage, readOdt, readOdtContent, zipPackage, type ZipEntry } from '../../src'; + +// Proves odf.js's ODF package parsing and content reading execute inside a Cloudflare Workers isolate (workerd, via @cloudflare/vitest-pool-workers) with no Node-only APIs. The pipeline exercised -- zipPackage (fflate, pure JS), decodePackage (zip + manifest parse), readOdtContent (fast-xml-parser over content.xml, pure JS), and readOdt on top of it (document-schema.js's own assemblePackage, pure Zod/TS) -- is deliberately Node-free; if any path touched node:fs/Buffer/process the workerd isolate would throw instead of these passing. The package-native reader is covered here as well as the content-level one precisely because it pulls in a second package's transform code at runtime: whatever assemblePackage reaches for has to be Worker-safe too, and this is the check that says so rather than assumes it. The minimal .odt is built INLINE (no fs): an ODF package is a zip whose first entry must be the uncompressed "mimetype" part, followed by content.xml and META-INF/manifest.xml, mirroring the inline fixture shape src/round-trip.test.ts already uses. + +function minimalOdtBytes(): Uint8Array { + const encoder = new TextEncoder(); + const contentXml = encoder.encode( + '\nHello & world', + ); + const manifestXml = encoder.encode( + '\n', + ); + const entries: [string, ZipEntry][] = [ + ['mimetype', { bytes: encoder.encode('application/vnd.oasis.opendocument.text'), stored: true }], + ['content.xml', { bytes: contentXml }], + ['META-INF/manifest.xml', { bytes: manifestXml }], + ]; + return zipPackage(entries); +} -// Proves odf.js's ODF package parsing and content reading execute inside a Cloudflare Workers isolate (workerd, via @cloudflare/vitest-pool-workers) with no Node-only APIs. The pipeline exercised -- zipPackage (fflate, pure JS), decodePackage (zip + manifest parse), readOdt (fast-xml-parser over content.xml, pure JS) -- is deliberately Node-free; if any path touched node:fs/Buffer/process the workerd isolate would throw instead of these passing. The minimal .odt is built INLINE (no fs): an ODF package is a zip whose first entry must be the uncompressed "mimetype" part, followed by content.xml and META-INF/manifest.xml, mirroring the inline fixture shape src/round-trip.test.ts already uses. describe('odf.js under the Cloudflare Workers runtime', () => { it('decodes a minimal odt and reads its wordprocessing content (no Node fs, no Buffer)', () => { - const encoder = new TextEncoder(); - const mimetype = 'application/vnd.oasis.opendocument.text'; - const contentXml = encoder.encode( - '\nHello & world', - ); - const manifestXml = encoder.encode( - '\n', - ); - const entries: [string, ZipEntry][] = [ - ['mimetype', { bytes: encoder.encode(mimetype), stored: true }], - ['content.xml', { bytes: contentXml }], - ['META-INF/manifest.xml', { bytes: manifestXml }], - ]; - - const pkg = decodePackage(zipPackage(entries)); - const document = readOdt(pkg); + const pkg = decodePackage(minimalOdtBytes()); + const document = readOdtContent(pkg); - // readOdt returns the same { metadata, sections } shape documents.js's readOdtContent adapter wraps into a 'wordprocessing' ContentDocument. The single text:p round-trips as one paragraph block whose run text is the XML-decoded content. + // readOdtContent returns the flat { metadata, sections } shape. The single text:p round-trips as one paragraph block whose run text is the XML-decoded content. expect(document.sections).toHaveLength(1); const blocks = document.sections[0]?.blocks ?? []; expect(blocks).toHaveLength(1); @@ -31,4 +35,14 @@ describe('odf.js under the Cloudflare Workers runtime', () => { expect(paragraph.runs.map((run) => run.text).join('')).toBe('Hello & world'); } }); + + it('assembles the same odt into a DocumentPackage that flattens back to the content reader\'s output', () => { + const pkg = decodePackage(minimalOdtBytes()); + const content = readOdtContent(pkg); + const documentPackage = readOdt(pkg); + + expect(documentPackage.kind).toBe('wordprocessing'); + expect(documentPackage.children).toHaveLength(1); + expect(flattenPackage(documentPackage)).toEqual({ kind: 'wordprocessing', ...content }); + }); });