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-content-model, 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.
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-content-model'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), 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) each resolve aPackageintodocument-content-model'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. There is nodocument-content-modelpivot type for MathML, so this reader's own output type is its own.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 plus the names of its forms/queries/reports/tables — never their content, and never the embedded/external database engine's own binary or script storage. 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 named 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 — only forms/reports genuinely are separate manifest sub-document parts (forms/<Name>/content.xml,reports/<Name>/content.xml). Seesrc/typed/odb/read.ts's own top-of-file note for the full findings.
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.
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'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/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-content-model'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),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;src/typed/formula/,src/typed/odm/—readOdfFormula(raw MathML, nodocument-content-modelpivot) 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 plus form/query/report/table names, never their content or the database engine's own storage).
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-content-model 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-content-model: 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-content-model'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.
.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-content-model — the canonical
ContentDocument/LayoutDocumentschema pivot both this package andooxml.jsdepend on. - documents.js — the intended downstream consumer, adding ODF ⇄ PDF conversion and a live-view ODF editor once this package's typed readers land.
MIT