A hand-written, dependency-minimal codec for the OpenDocument Format (ODF — OASIS/ISO 26300):
.odt/.ods/.odp/.odg/.odf/.odb/.odmand their template variants, built on Zod 4 codecs.
odf.js is the ODF sibling of ooxml.js, mirroring its architecture as closely as the two, structurally unrelated formats allow: a lossless ZIP-of-XML core that round-trips any package byte-for-content-faithful, with ergonomic typed readers layered on top for convenient access. Unlike OOXML — a ZIP of parts with a relationship-file (.rels) mechanism and an extension-defaults-plus-overrides [Content_Types].xml — ODF has no relationships at all (inter-part references are direct paths/IRIs) and an exhaustive META-INF/manifest.xml that enumerates every part explicitly. Where OOXML runs carry formatting directly as attributes, ODF has no inline/direct formatting whatsoever: every formatting difference, however small, must be a named "automatic style" — so odf.js owns a style-interning subsystem (src/styles/) with no equivalent anywhere in ooxml.js.
This package does not depend on ooxml.js, even though the two do near-identical jobs for their respective formats: ooxml.js is a package signed, SBOM-attested, and branded exclusively around ECMA-376/OOXML, so depending on it here would be a permanently wrong signal for an OASIS-standard codec, and would force a breaking ooxml.js release every time an ODF-only fix needed the shared primitive layer. Instead, odf.js duplicates the small (~400-line) generic ZIP/XML/Package layer as its own code — kept deliberately structurally identical (plain, unmarked shapes, no branding) so TypeScript's structural typing makes the two packages' Package/XmlNode/XmlElement values freely interchangeable wherever a shared consumer (like documents.js) needs to treat them uniformly, without either package formally depending on the other.
Both packages do depend on document-schema.js, the genuinely shared canonical schema for ContentDocument/LayoutDocument — the semantic content model (paragraphs, runs, tables, shapes, slides) both an ODF and an OOXML reader ultimately produce. odf.js's typed readers return the real, imported ContentSection/ContentSlide/etc. types from that package, not a structurally-similar lookalike, so a downstream consumer (documents.js) can run an .odt through the exact same layout/pagination engine it already uses for .docx, unmodified.
graph TD
schema("document-schema.js")
ooxml("ooxml.js")
odf("odf.js")
pdfcodec("pdf-codec")
mdcodec("markdown-codec")
documents("documents.js")
cli("document-cli")
schema --> ooxml
schema --> odf
schema --> pdfcodec
schema --> mdcodec
schema --> documents
ooxml --> documents
odf --> documents
pdfcodec --> documents
mdcodec --> documents
documents --> cli
odf --> cli
click schema "https://github.com/ExaDev/document-schema.js" "document-schema.js"
click ooxml "https://github.com/ExaDev/ooxml.js" "ooxml.js"
click odf "https://github.com/ExaDev/odf.js" "odf.js"
click pdfcodec "https://github.com/ExaDev/pdf-codec" "pdf-codec"
click mdcodec "https://github.com/ExaDev/markdown-codec" "markdown-codec"
click documents "https://github.com/ExaDev/documents.js" "documents.js"
click cli "https://github.com/ExaDev/document-cli" "document-cli"
style odf fill:#f9a825,stroke:#333,stroke-width:3px
This package is under active development. What's built and shipped:
- Lossless core — generic ZIP-of-XML primitives (
Package/XmlNode/XmlElement, XML parse/build, zip/unzip, base64, thepackageCodec/xmlCodecz.codec()pairs) with zero ODF-specific knowledge. - Namespaces, media types, mimetype, manifest (
src/ns.ts,src/media-type.ts,src/mimetype.ts,src/manifest.ts) — full read and write, includingMETA-INF/manifest.xml's exhaustive per-part enumeration and the mimetype part's mandatory first-entry/stored/uncompressed byte layout, verified against real LibreOffice-produced output. - Style interning (
src/styles/) —StyleRegistry: adopts a part's existing automatic styles on construction, finds-or-mints onintern(), fingerprints on canonical serialized properties plus parent style name (neverJSON.stringify), and is collision-checked across all four style containers a document can have. - Shared typed primitives (
src/typed/shared/) — ODF length-unit parsing (cm/mm/in/pt/pc/px), A1-style spreadsheet cell-reference computation with repeat-count cursor advancement, colour/geometry/master-page-size parsing intodocument-schema.js's own types, ODF'stext:s/text:tab/text:line-breakwhitespace-run decoding, the read-side style cascade (style:default-style→ parent chain → the referenced style — one layer shorter than OOXML's, since ODF has no separate direct-formatting layer on top), the sharedtext:p→ContentParagraph/ContentRunandtable:table→ContentTablereaders (readOdfParagraph/readOdfTable) every typed reader below builds on, thedraw:transform/draw:ggroup-flattening geometry resolver, ansvg:d/draw:pointsvector-path grammar parser, andmeta.xmlreading. - Typed readers —
readOdt(wordprocessing),readOdp(presentation slides:draw:frame/draw:gtext/image/table content),readOdg(drawing pages: every vector primitive —draw:rect/draw:ellipse/draw:circle/draw:line/draw:path/draw:polygon/draw:polyline, plus a recogniseddraw:custom-shapepreset subset — in realdraw:z-index-aware paint order), andreadOds(spreadsheets: geometry- and print-settings-rich, everyoffice:value-typevariant with its own OpenFormula string, plus cell- and page-anchoreddraw:frameimages and embedded ODF sub-documents) each resolve aPackageintodocument-schema.js's ownContentSection/ContentSlide/ContentDrawPage/ContentSheetshapes. readOdfFormularesolves a standalone or embedded.odfformula'scontent.xml— bare MathML with nooffice:document-contentwrapper, confirmed against real LibreOffice output — into its raw MathML nodes plus, when present, the formula's own native StarMath annotation string, in this reader's own bespokeOdfFormulaDocumentshape.readOdfFormulaDocumentwraps that same result into a realdocument-schema.jsContentDocumentof kind'formula'—document-schema.js2.0.0 added aMathMlNode/ContentFormulapivot shape for exactly this, a structural mirror of this package's ownXmlNodethatreadOdfFormula's real output assigns to with zero cast.readOdmresolves a.odmmaster document's owncontent.xmlinto an ordered list of chapter references ({ name, href, filterName? }, one per top-level linkedtext:section) without opening the external.odtfiles those references point at. A master document's chapters are genuinely external files by ODF design, not embedded package sub-documents — confirmed against real LibreOffice output, which never caches a chapter's own content inside the master document itself (seesrc/typed/odm/read.ts's own top-of-file note).readOdbInventoryresolves a.odbdatabase front-end package into connection info, its table names, its query definitions (name, realdb:commandSQL text, anddb:escape-processingwhen declared), and its forms/reports as{ name, href }pairs. Confirmed against real LibreOffice output thatoffice:databaselives directly in the package's ordinarycontent.xml(no separatedatabase/connection.xmlpart, contrary to the OASIS schema's own chapter layout suggesting one), that queries are declared inline (db:queries/db:query, no manifest part of their own), and that a live engine's own tables have no ODF-level manifest listing at all. A form's/report's sub-document directory is named after an opaque persistent name (forms/Obj11), not after the form/report — the user-visible name lives only incontent.xml's owndb:forms/db:reportsregistry, which is where this reader takes both the name and the href from. Seesrc/typed/odb/read.ts's own top-of-file note for the full findings.readOdbFormandreadOdbReportopen one of those sub-documents and extract its static structure, executing nothing. A form sub-document is a complete, ordinary ODF text document (readOdtreads it unmodified throughsubDocumentPackage, a synthetic sub-Packageview over the sub-document's own directory) plus anoffice:text/office:formscontrol tree: per form, itsform:command/form:command-typebinding, every control's real element tag, UNO control implementation andform:data-fieldbinding, and any genuinely nested sub-form with its own independent binding. A report sub-document uses therpt:Report Builder vocabulary: therpt:command/rpt:command-typedata binding, the band stack (report header, page header, detail, page footer, report footer), the recursive group tree with each group's own expression/sort/page-break attributes and header/footer bands, every band's bound fields (field:[COLUMN], unwrapped into a real column name) and computed expressions (rpt:SUM([AMOUNT]), left verbatim), and the report's ownrpt:functiondeclarations. Both are grounded in a real, LibreOffice-generated fixture rather than in the schema — which is what caught the two shapes an assumed reading gets wrong: the detail band is nested inside the innermost group, not a sibling of the other bands, and a group's key is a formula (rpt:HASCHANGED("REGION")), not a bare column name. Seesrc/typed/odb/form.ts's andsrc/typed/odb/report.ts's own top-of-file notes.
Not yet built: live-view editors and the .odb database-table-export subsystem. This section will be replaced with real usage examples once those land — see the Architecture section below for the intended shape, and this repository's own commit history/releases for current progress.
A general-purpose SQL query engine for actually rendering a Report against its data — as opposed to reading the report's own static rpt:command/band/group definition, which readOdbReport already does — has been deliberately not attempted, not merely left unstarted. readOdbInventory already extracts a query's or report's real db:command/rpt:command SQL text verbatim, and a rough survey against the one real fixture this package has (src/typed/odb/fixtures/form-and-report.odb) suggests the underlying query text itself would often fall inside a plausible bounded SQL subset (single-table SELECT/WHERE/ORDER BY, no JOINs or subqueries) — but that survey is a sample size of one report and should not be read as a real coverage figure. Building even a bounded engine against it means reimplementing a slice of HSQLDB's/Firebird's own query semantics, which is a materially different undertaking from decoding their file formats (already done safely elsewhere in this package's .odb support) and raises its own licensing questions that have not yet been reviewed. This is gated on the requesting engineer's own explicit sign-off on that licensing posture, given after reading the relevant engine source — not given yet — before any such engine is built.
Requires Node.js >=20 and pnpm 11.6.0 (pinned via packageManager in package.json).
pnpm installInstall as a dependency in another project:
pnpm add odf.js
# or
npm install odf.jsThe lossless core — the only public surface stable enough to document with real examples right now:
import { decodePackage, encodePackage } from 'odf.js';
// .odt / .ods / .odp bytes -> faithful JSON Package
const pkg = decodePackage(new Uint8Array(await file.arrayBuffer()));
// ...inspect pkg.parts...
// Package -> bytes (content-identical, mimetype-first/stored, manifest untouched)
const bytes = encodePackage(pkg);Manifest and mimetype, ODF's own package-identity mechanism (no relationships, unlike OOXML):
import { readManifest, syncManifest, setDocumentMediaType, readMimetype } from 'odf.js';
const manifest = readManifest(pkg); // { entries: [{ fullPath, mediaType }, ...] }
setDocumentMediaType(pkg, 'application/vnd.oasis.opendocument.text'); // updates mimetype + manifest root entry atomically
syncManifest(pkg); // rebuilds manifest.xml to exactly match pkg's current parts
readMimetype(pkg); // 'application/vnd.oasis.opendocument.text'Every module is also importable directly by its own subpath, without going through the barrel — useful for a caller that wants one narrow piece of the package (e.g. a bundler doing tree-shaking, or a script that only needs the length-unit parser) without pulling in the rest:
import { parseOdfLength } from 'odf.js/typed/shared/units';
parseOdfLength('2.5cm'); // 70.86614173228347Any src/**/*.ts module (excluding tests and internal test-support/ fixtures) resolves this way, at its path relative to src/ — src/manifest.ts as odf.js/manifest, src/typed/odt/read.ts as odf.js/typed/odt/read, and so on.
Layered from a lossless core outward, mirroring ooxml.js's own structure:
src/model/—Package/XmlNode/XmlElementand friends: a duplicate-by-design copy ofooxml.js's equivalent, kept structurally identical (see Why noooxml.jsdependency above).src/xml/—parse.ts/build.ts(XML string ⇄XmlNode[]forest viafast-xml-parser),fragment.ts/entities.ts(production element/text-node construction and entity encoding —odf.jswritesmanifest.xmlitself, unlikeooxml.js's read-only stance on OPC relationships, so this needs to be real writing code, not test-only scaffolding),query.ts(shared tree-query helpers).src/image/—sniffImageFormat(ImageFormat): a minimal PNG/JPEG magic-byte format sniffer with no ODF knowledge of its own, consumed bysrc/manifest.ts(choosing a binary part's manifest media type) andsrc/typed/draw/shapes.ts(resolving adraw:image's ownContentImageBlock.format).src/zip.ts— takes ordered[path, entry]tuples, not aRecord, specifically so ODF's mimetype-first/stored/uncompressed requirement doesn't depend onRecord/Object.keysinsertion order surviving a Zod round trip.src/package-io/—write.tshoists amimetypepart first (stored) andMETA-INF/manifest.xmlsecond, if present, before everything else in existing order — the one deliberate behavioural difference fromooxml.js's own writer, and never fabricates either part as a side effect.src/manifest.ts— unlikeooxml.js(which only ever reads OPC relationships, leaving writing todocuments.js),odf.jsowns manifest read and write, since the manifest is ODF's one mandatory part and its correctness is exhaustive.src/styles/—properties.ts(the property-bag shape + real ODF attribute parsing),serialize.ts(canonical, deterministic property-bag → XML attributes),registry.ts(StyleRegistry, ODF's mandatory style-interning layer, no OOXML equivalent),span.ts(character-range wrapping into a formattabletext:span, correctly splittingtext:s/text:tabelements that straddle a boundary).src/typed/shared/— the ODF-specific typed primitives every future format reader builds on:units.ts,a1.ts,color.ts/geometry.ts(parsing intodocument-schema.js's own types, never redefining them),style.ts(a thin re-export — ODF's style-properties concern is fully covered bystyles/properties.tsand the cascade below),text.ts(whitespace-run decoding),cascade.ts(the read-side style-resolution walk),paragraph.ts/table.ts(readOdfParagraph/readOdfTable, the sharedtext:p→ContentParagraph/ContentRunandtable:table→ContentTablereadersreadOdt,readOds, andtyped/draw/shapes.tsall call),transform.ts(thedraw:transform/draw:ggroup-flattening geometry resolver),masterpage.ts(master-page → page-layout page-size/print-settings resolution, shared by adraw:page's own size and a spreadsheet's print settings),path.ts(ansvg:d/draw:pointsvector-path grammar parser),metadata.ts(meta.xmlreading).src/typed/odt/,src/typed/odp/,src/typed/odg/,src/typed/ods/— the builtreadOdt/readOdp/readOdg/readOdsreaders;src/typed/draw/— thedraw:frame/draw:g/vector-primitive shape vocabularyreadOdpandreadOdgboth share (shapes.ts), plusembedded.ts'sreadDrawObjectReference, thedraw:objectembedded-sub-document counterpartreadOdsresolves a sheet's anchored OLE objects through;src/typed/formula/,src/typed/odm/—readOdfFormula/readOdfFormulaDocument(raw MathML, and thedocument-schema.jsContentDocument'formula'-kind pivot built on top of it) andreadOdm(a.odmmaster document's own external chapter references — name/href/filter-name per linkedtext:section, never the linked content itself);src/typed/odb/—readOdbInventory(a.odbpackage's own connection info, table/query/form/report registry, never the database engine's own storage), plusreadOdbForm/readOdbReport(static, execution-free structure extraction from a form's or report's own ODF sub-document) andsubDocumentPackage(the synthetic sub-Packageview they read those sub-documents through).
See the top of this README — the short version: ooxml.js's branding and signed SBOM make it the wrong dependency for an OASIS-standard package regardless of how much low-level code the two could share; document-schema.js is the neutral package both actually depend on for the parts that are genuinely, permanently identical (the semantic content vocabulary), while the ZIP-of-XML primitive layer stays duplicated on purpose.
- Zod-first schema/type/guard, matching
ooxml.js/document-schema.js: every model type is inferred from its Zod schema, never hand-written. - Recursive types use a hand-written structural guard, not
z.lazy— the samez.lazy-collapses-to-unknownissueooxml.js'sXmlNodeanddocument-schema.js'sContentBlockalready work around. - No type assertions anywhere —
assertionStyle: 'never',noInlineConfig: true, matching both sibling packages exactly. - Ground truth over memory for every ODF spec fact. Namespace URIs, media types, style-property attribute names, and
meta.xmlelement names are all verified against either the live OASIS ODF specification or real files produced by an installed LibreOffice, never assumed from pattern-matching an OOXML analogue or a remembered convention — several confirmed traps exist specifically because the "obvious" guess is wrong (see Gotchas).
-
Several ODF namespace URIs are not what you'd guess from the prefix.
draw:is...xmlns:drawing:1.0, not...draw:1.0;number:is...xmlns:datastyle:1.0, not...number:1.0;fo:/svg:/smil:are OASIS's own*-compatible:1.0URIs, not the real W3C namespaces those prefixes suggest. Seesrc/ns.ts's inline comments for the full, verified table. -
.odb's real media type isapplication/vnd.oasis.opendocument.base, not...database— a common stale/wrong value found in some third-party documentation. -
ODF's
dc:creatoris not "the author." It records whoever most recently saved the document (Dublin Core's own definition); the original author ismeta:initial-creator.typed/shared/metadata.tsmapsLayoutMetadata.authortometa:initial-creator, matching the byline roleooxml.js's ownDocumentMetadata.authorplays for OOXML. -
meta:keywordappears once per keyword, unlike OOXML's single comma-separatedcp:keywordselement. -
table:number-columns-repeated/table:number-rows-repeatedmust be cursor-advanced, never materialized. A real spreadsheet has trailing cells/rows with repeat counts over a million;typed/shared/a1.ts's cursor advances in O(1) without allocating that many objects — tested against a real repeat count taken from a genuine LibreOffice template. -
ODF cells carry no explicit cell-reference attribute at all (unlike xlsx's
r="B7") —typed/shared/a1.tscomputes A1-style references from a running column/row cursor as a reader walks cells in document order. -
A rotated
draw:rect/draw:ellipse/draw:path/draw:custom-shapevector primitive now reads its ownrotationDeg, not just its unrotated bounding frame.resolveVectorGeometry(typed/draw/shapes.ts) replaces the old frame-onlyresolveVectorFrame, reusing the exact sameresolveOdfShapeGeometry/composeOdfGroupTransformmachinerydraw:framealready resolved rotation through, including composing an enclosingdraw:g's own rotation the same way it already did for a frame.ContentVectorSchema's rect/ellipse/path variants already carried arotationDegfield before this change — it was being discarded on the read side, not missing from the schema. -
Every
ContentShape/ContentVectora drawing or presentation reader produces is now stamped with its own resolvedpaintOrder, not merely sorted by it and discarded.walkDrawPageContent(odg) andwalkDrawShapes(odp) both compute a real z-index per element — an explicitdraw:z-indexwhen present, otherwise a monotonic document-encounter counter — purely to order their own output arrays; that value is now attached to each produced value via the same single counter threaded across the whole walk. Becauseshapesandvectorsare stamped from the identical counter, a caller can recover their true relative paint order across the two independently-ordered arrays by comparingpaintOrderdirectly — closing the cross-array ordering gapdocuments.js's own README previously described as unrecoverable, even thoughContentDrawPageSchemaitself still keeps the two arrays with no shared ordering field of its own. -
svg:fill-rule(nonzero/evenodd) anddraw:stroke'ssolid/dashenumeration are now read and mapped ontoContentVector's path-variantfillRuleandContentStroke.style. Both mappings are confirmed against the OASIS schema reference rather than only against real-world producer output —typed/draw/shapes.ts's own comment notes that a live-LibreOffice re-verification ofsvg:fill-rulespecifically was blocked by the same headless-soffice-hang constraintdocuments.js's own README documents. A genuinely dotted (as opposed to dashed) stroke pattern, and a"double"stroke style ODF's own vector-stroke model has no concept of at all, both remain unread — real, permanent boundaries rather than oversights (see that file's own comment for why each). The original test coverage forsvg:fill-ruleonly ever exercised a single-loopsvg:d, which cannot actually distinguish the two rules — there is nothing forevenodd's alternating parity to differ fromnonzero's winding count with only one contour.shapes.test.tsnow also covers a genuine two-subpath "letter O" donut shape (an outer square and an inner square hole, both wound in the same rotational direction), the real-world case the attribute exists for:nonzerowould fill the hole solid (winding number 2, still non-zero) whileevenoddcorrectly punches it (parity toggles to 0 inside), and the test confirms both subpaths, theirclosedflags, and the inner subpath's scaled points are read correctly alongsidefillRuleitself. -
readOdsand the sharedreadTableCell(odt/odp table cells) now resolvefo:background-color, thefo:border/fo:border-(left|right|top|bottom)shorthand-and-per-edge-override chain,style:vertical-align, and a cell style's ownstyle:paragraph-propertiesfo:text-alignfrom the real ODF style cascade, populatingContentSheetCell/ContentTableCell'sbackground/borders(plusContentSheetCell's ownalignment/verticalAlignment) rather than leaving them unpopulated.readCellStyleDecoration(typed/shared/table.ts) is the one fold both callers share:readOdsresolves it over the full root-to-targetstyle:parent-style-namechain (resolveStyleElementChain, confirmed against this package's ownkitchen-sink.odsfixture's realce1..ce5→Defaultchain),readTableCellover the single style elementfindStyleElementalready resolves. An explicitfo:border-*override of"none"/"hidden"genuinely clears an inherited edge rather than merely leaving it unmentioned. -
readOdsnow reads drawings anchored to a sheet, populatingContentSheet.images(previously hardcoded[]) andContentSheet.embeddedObjects(previously never set), through the sametyped/draw/shapes.tsprimitivesreadOdp/readOdgalready use. ODF has exactly two spreadsheet anchoring conventions, both confirmed against real LibreOffice 26.2 output (src/typed/ods/fixtures/sheet-anchors.ods, built via a Java UNO client and never hand-edited): a cell-anchoreddraw:frameis a direct child of thetable:table-cellit is anchored to, withsvg:x/svg:ymeasured from that cell's own top-left corner; a page-anchored one sits in atable:shapeselement (a child oftable:tablepreceding its column definitions) with absolute sheet coordinates. The anchor cell reference is not read from any attribute at all — ODF cells have none (see the cursor gotchas above) — it is the same runningTableCursorpositionContentSheetCell.row/columnalready come from; a page-anchored image is reported against cell(0, 0), whose own top-left is the sheet origin, so its absolute offsets carry through exactly rather than being approximated. Adraw:ggroup is walked through, composing its owndraw:transformviareadDrawFrame's existinggroupFunctionsparameter. What a sheet cannot carry is anythingContentSheetSchemahas nowhere to put — a floating text box or table frame (noshapesarray), a bare vector primitive (novectorsarray), and an embedded chart object (ContentEmbeddedObjectKindhas nochartmember to map one onto) — each skipped rather than mapped onto an approximation of a different kind. An embedded formula object is no longer among them; see the bullet below. -
readDrawObjectReference(typed/draw/embedded.ts) is the new shareddraw:objectcounterpart toshapes.ts's existingdraw:imagehandling, resolving a frame's embedded-object reference into the sub-Packageit names (viasubDocumentPackage) plus theContentEmbeddedObjectKindthat sub-document actually is, read from its owncontent.xml(theoffice:bodycontent child, or — for a formula — a bare MathML root) rather than from the manifest's declared media type — one signal, so the reported kind and the reader that produced the content agree by construction. It deliberately stops short of callingreadOdt/readOds/readOdp/readOdgitself: doing so would importreadOds, which imports this module. A real embedded-object frame also carries adraw:imagepreview of its own object (anObjectReplacements/GDI metafile), sodraw:objectmust be checked before a frame's image content — the same ordering, for the same reason, thatreadDrawFrameContentalready applies to a table frame's preview image. -
An embedded LibreOffice Math object anchored to a spreadsheet cell now reads as a real
ContentEmbeddedObjectofobjectKind: 'formula', carrying a genuineContentDocumentformula payload rather than being skipped. A formula sub-document is the one embedded kind with nooffice:bodyat all — itscontent.xmlroot is the MathML root — soreadDrawObjectReferencefalls back totyped/formula/read.ts's ownfindMathRoot(reused, not restated) whenever theoffice:bodypath resolves nothing, andreadOdsdispatches the resulting reference toreadOdfFormulaDocument, the same function the standalone.odfpath already uses.document-schema.js2.2.0 is what makes this representable at all: itsContentDocumentunion carries a real'formula'variant, and itsContentEmbeddedObjectgained the sameanchorRow/anchorColumn/offsetXPt/offsetYPtquartetContentSheetImagealready had — so an embedded object's anchor cell is now recorded exactly as an anchored image's is, for every embedded kind, not just formulas. Confirmed against real, unmodified LibreOffice 26.2 output (src/typed/ods/fixtures/sheet-formula.ods, a Calc sheet built via a Java UNO client with acom.sun.star.drawing.OLE2Shapecarrying Math's own CLSID and a real StarMathFormulaproperty, anchored to cell C4, never hand-edited): the outer manifest declaresObject 1/asapplication/vnd.oasis.opendocument.formula, that sub-document ships nometa.xmlof its own, and itscontent.xmlis the same bare<math>root with a default MathMLxmlnstyped/formula/read.tsalready documents for a standalone.odf. -
A
draw:frame's alternative text (svg:title, falling back tosvg:desc) is now read intoContentImageBlock.altTextfor every format, not just spreadsheets. Both are plain-text child elements ofdraw:frameitself, not attributes — confirmed against real LibreOffice output, where a Calc image's UNOTitle/Descriptionproperties round-trip as<svg:title>/<svg:desc>siblings of the frame's owndraw:image. -
readOdfFormulaDocumentnow wraps a formula's raw MathML into a realdocument-schema.jsContentDocumentof kind'formula', alongside (not instead of) this package's own pre-existing bespokeOdfFormulaDocumentshape fromreadOdfFormula, which is unchanged.document-schema.js2.0.0 added theMathMlNode/ContentFormulapivot specifically for this — a structural mirror of this package's ownXmlNodethatreadOdfFormula's existing return value assigns into with zero cast. -
readOdbInventory'squeriesnow carry each query's realdb:commandSQL text (plusdb:escape-processingwhen declared) instead of only a bare name.OdbInventory.queriesis nowOdbQueryInfo[]({ name, command, escapeProcessing? }), a breaking rename from the previousstring[]— a query's owndb:commandis already inline incontent.xml, unlike a form/report/table's real content, which lives in a separate sub-document or database engine this reader never opens, so the "content lives elsewhere, only names are read" rule that correctly applies to those does not hold for a query.db:commandis entity-decoded before being returned, matching every other typed reader's convention. -
.odbForm/Report structure extraction is real and working, grounded in a genuine LibreOffice-generated fixture, not blocked.readOdbForm/readOdbReportopen a form's or report's own sub-document (viasubDocumentPackage, a synthetic sub-Packageview over its directory) and extract its complete static structure — command bindings, control trees, nested sub-forms, the band/group hierarchy, bound and computed expressions — executing nothing. See the Architecture section above andsrc/typed/odb/form.ts/report.ts's own top-of-file notes for the two real shapes this fixture caught that an assumed reading would have got wrong. A SQL/rpt:rendering engine to actually execute a query or evaluate a report's own totals against real data is deliberately not attempted, not merely unstarted — that is a materially different, larger undertaking (reimplementing a slice of HSQLDB's/Firebird's own query semantics) with its own unreviewed licensing question, gated on the requesting engineer's explicit sign-off after reading the relevant engine source. See the bounded-SQL-subset assessment immediately below for exactly how far the reading side alone gets you. -
A bounded-SQL-subset assessment for Report rendering exists for exactly one real report, and it should be read that way.
src/typed/odb/fixtures/form-and-report.odb's own "SalesByRegion" report is bound (rpt:command-type="query") to a saved query whose real SQL text isSELECT "SALES"."REGION", "SALES"."QUARTER", "SALES"."CUSTOMER", "SALES"."AMOUNT" FROM "SALES" WHERE "SALES"."AMOUNT" >= 100 ORDER BY "SALES"."REGION" ASC, "SALES"."QUARTER" ASC, "SALES"."AMOUNT" DESC— single-table, a simple comparisonWHERE, a multi-columnORDER BY, noJOIN, no subquery, and (in the SQL itself) noGROUP BYor aggregate function at all. That one query sits entirely inside the bounded subset described above. The fixture's bound form is even simpler: its top-levelform:formbinds directly to the bare table nameSALES(form:command-type="table"), equivalent to an unconditionalSELECT * FROM "SALES". On this single data point, 100% of the real.odbcommand bindings seen so far (one query, one table binding, one repeat use of the same query from a nested sub-form) fall inside the bounded subset — but a sample of one report from one fixture says essentially nothing about the real-world distribution of.odbfiles in the wild, and should not be quoted as a coverage percentage beyond "the one file we have." A more consequential finding sits alongside the SQL text itself: even a fully bounded SQL engine would not be sufficient to render this one report. The report's own grouping breaks (rpt:HASCHANGED("REGION"),rpt:HASCHANGED("LEFT_QUARTER")), its prefix-character grouping function (rpt:LEFT([QUARTER];2)), and its running per-group/per-report totals (rpt:SUM([AMOUNT])) are all evaluated by Report Builder's ownrpt:formula language over the plain, ungrouped, ordered row stream the SQL query returns — LibreOffice does not express any of that as SQLGROUP BY/aggregate syntax at all. Rendering even this one simple report therefore needs a bounded SQL engine and a separaterpt:formula evaluator (HASCHANGED,LEFT,SUM, and whatever else real reports use) — two genuinely different pieces of engine-semantics reimplementation, not one.
.github/workflows/ci.yml runs commitlint, lint, typecheck, the unit suite, and the smoke test on every push and pull request. On a push to main where those all pass, release.config.ts drives semantic-release: commit history since the last tag decides the version bump, CHANGELOG.md and package.json are committed back to main, a GitHub Release is cut, and the package publishes to npmjs.org — via npm's OIDC trusted publishing, so no NPM_TOKEN exists anywhere in the pipeline. A further job republishes the same build under the scoped @exadev/odf.js alias to GitHub Packages, and another signs an SPDX SBOM and build-provenance attestation against the exact release tarball.
Commits follow Conventional Commits (feat:, fix:, test:, chore:, …), enforced by commitlint via a husky commit-msg hook and a CI commitlint job. A husky pre-commit hook runs lint-staged (eslint --fix on staged *.ts files) and pre-push runs the test suite. There is a single main branch and no open pull request workflow established so far.
- ooxml.js — the sibling package doing the equivalent lossless-codec job for OOXML (docx/pptx/xlsx). Architecturally mirrored, deliberately not depended on — see Why no
ooxml.jsdependency. - document-schema.js — the canonical
ContentDocument/LayoutDocumentschema pivot both this package andooxml.jsdepend on. - documents.js — the downstream consumer, already built on this package's typed readers: its own
readOdtContent/readOdpContent/readOdsContent/readOdgContentare thin adapters overreadOdt/readOdp/readOds/readOdg, feeding both ODF ⇄ PDF conversion (odtToPdf/odpToPdf/odsToPdf/odgToPdfand their inverses) and independently-built live-view ODF editors (OdtEditor/OdpEditor/OdsEditor/OdgEditor).
MIT