Skip to content
This repository was archived by the owner on Aug 20, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 76 additions & 11 deletions README.md

Large diffs are not rendered by default.

16 changes: 11 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down
54 changes: 54 additions & 0 deletions src/test-support/document-package.ts
Original file line number Diff line number Diff line change
@@ -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<K extends DocumentPackage['kind']> = Extract<DocumentPackage, { kind: K }>;

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;
}
6 changes: 3 additions & 3 deletions src/typed/draw/embedded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<draw:object xlink:href="./Object 1" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/>`, 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.
Expand All @@ -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':
Expand Down
6 changes: 3 additions & 3 deletions src/typed/draw/shapes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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<ContentShape>[] = [];
const vectorsOut: PaintOrdered<ContentVector>[] = [];
Expand Down
Loading
Loading