Skip to content

Repository files navigation

documents.js

GitHub npm Release CI

Bidirectional docx/pptx/odt/odp/ods/odg ⇄ PDF conversion, a resolver-driven odm (ODF master document) → PDF conversion for multi-chapter documents, six further cross-format bridges (odt⇄docx, odp⇄pptx, ods⇄xlsx) that bypass PDF entirely, a read-and-write live-view editor for docx/pptx/odt/odp/ods/odg content, and a fully hand-written PDF codec, built on ooxml.js and odf.js.

documents.js depends on ooxml.js for lossless docx/pptx/xlsx ⇄ JSON handling and extends it in two directions ooxml.js deliberately does not cover: full PDF support (parsing arbitrary real-world PDFs and generating new ones), and a read-and-write manipulation API for docx/pptx content — ooxml.js's own typed readers (readDocx/readPptx) are one-way and explicitly forbid write-back. PDF reading, writing, and the docx⇄PDF/pptx⇄PDF conversion pipeline are entirely hand-written: no external PDF library (pdf-lib, pdfjs-dist, mupdf, or any other) is a dependency. The one exception is fflate for raw DEFLATE/zlib compression underneath PDF's FlateDecode filter and PNG's IDAT chunks — the same dependency ooxml.js itself already relies on for ZIP handling.

Why

Converting docx/pptx to PDF and back is usually solved by wrapping a mature third-party PDF library. This package takes the opposite approach: every layer of the PDF format — the object model, the cross-reference table, the content-stream operators, standard-font metrics, the parser's cross-reference/object-stream resolution and content-stream interpreter — is hand-written against the ISO 32000-1 specification. That is a genuinely large undertaking (the PDF codec is comparable in size to the rest of the package combined), and it comes with an honest trade-off spelled out in Fidelity below: this is not, and does not attempt to be, as robust against adversarial or badly malformed real-world PDFs as a library with 15+ years of hardening. What it buys instead is a dependency-free, fully auditable PDF implementation with no supply-chain surface beyond ooxml.js and fflate.

The read-and-write editor exists because ooxml.js's own typed readers are a deliberate one-way, lossy projection — reading is fine, but there is no way to add a paragraph, style a run, or insert an image and get a valid docx/pptx back out. documents.js's editors are live views directly over the XmlElement objects inside a decoded Package: a mutation edits that tree in place, and everything you don't touch round-trips byte-faithful, because it never stopped being the original XML.

Getting started

Requires Node.js >=20 and pnpm 11.6.0 (pinned via packageManager in package.json).

pnpm install

Install as a dependency in another project:

pnpm add documents.js
# or
npm install documents.js

Usage

The twelve round-trip ergonomic conversions (docx/pptx/odt/odp/ods/odg ⇄ PDF, all now round-trip both ways):

import { docxToPdf, odgToPdf, odpToPdf, odsToPdf, odtToPdf, pdfToDocx, pdfToOdg, pdfToOdp, pdfToOds, pdfToOdt, pptxToPdf, pdfToPptx } from 'documents.js';

const pdfBytes = docxToPdf(docxBytes);
const docxBytes2 = pdfToDocx(pdfBytes);

const pdfFromSlides = pptxToPdf(pptxBytes);
const pptxBytes2 = pdfToPptx(pdfFromSlides);

const pdfFromOdt = odtToPdf(odtBytes);
const odtBytes2 = pdfToOdt(pdfFromOdt);

const pdfFromOdp = odpToPdf(odpBytes);
const odpBytes2 = pdfToOdp(pdfFromOdp);

const pdfFromOdg = odgToPdf(odgBytes);
const odgBytes2 = pdfToOdg(pdfFromOdg);

const pdfFromOds = odsToPdf(odsBytes);
const odsBytes2 = pdfToOds(pdfFromOds); // recovers what was printed, not what was entered -- see Fidelity

Each accepts an optional signal (AbortSignal) and either a onSubstitution callback (docx/pptx/odt/odp/ods/odg → PDF, called once per character not representable in a standard-14 font) or a sink (PDF → docx/pptx/odt/odp/ods/odg, called once per recoverable parse diagnostic).

Six further conversions bypass PDF entirely: odtToDocx/docxToOdt, odpToPptx/pptxToOdp, and odsToXlsx/xlsxToOds each compose a direct readXContentbuildYPackage pivot copy, since both sides of each pair already read into and build from the identical ContentDocument variant — no layout engine, no font measurement, and no geometry-based reconstruction in between. See Fidelity for what that means in practice.

import { odtToDocx, docxToOdt } from 'documents.js';

const docxBytes = odtToDocx(odtBytes);
const odtBytes2 = docxToOdt(docxBytes);

Each takes an optional { signal } — there is no onSubstitution/sink option here, since there is no font substitution or PDF-parse degradation to report; a wrong-kind ContentDocument throws outright rather than becoming a diagnostic.

The same conversions behind a swappable port, for a caller that wants to inject a different implementation later without changing call sites:

import { createLocalDocumentConverter } from 'documents.js';

const converter = createLocalDocumentConverter();
const { document, diagnostics } = await converter.convert(
  { source: { format: 'docx', bytes: docxBytes }, targetFormat: 'pdf' },
  { signal: new AbortController().signal },
);

DocumentFormat includes xlsx alongside docx/pptx/odt/odp/ods/odg/pdf — not because xlsx has a PDF conversion of its own, but because createLocalDocumentConverter's { source, targetFormat } contract already generalises past "targetFormat always means pdf": odtdocx, docxodt, odppptx, pptxodp, odsxlsx, and xlsxods are six further entries in the same conversions list, routed to the six bridge functions above with an empty diagnostics array.

Reading and editing docx/pptx content directly, without going through PDF at all:

import { openDocx, createDocx } from 'documents.js';

const editor = openDocx(existingDocxBytes);
const paragraph = editor.body.appendParagraph({ alignment: 'center' });
const run = paragraph.appendRun({ text: 'Hello' });
run.bold = true;
run.color = { r: 1, g: 0, b: 0 };
const bytes = editor.toBytes();

// or start from nothing:
const fresh = createDocx();
fresh.body.appendParagraph().appendRun({ text: 'New document' });

openPptx/createPptx and PptxSlide/PptxShape are the pptx equivalent (slide.addTextBox, slide.addImage, shape.setParagraphs for multi-paragraph styled text).

openOdt/createOdt and OdtParagraph/OdtRun/OdtTable/OdtList are the odt equivalent, built on ODF's own style-name-referencing model (run.bold = true interns or reuses a named style:style in office:automatic-styles, rather than writing an inline attribute — see Conventions below). openOdp/createOdp and OdpSlide/OdpShape are the odp equivalent of PptxSlide/PptxShape (slide.addTextBox, slide.addImage, slide.notes), and reuse OdtParagraph/OdtRun/OdtList directly for a shape's own text content — a draw:frame's draw:text-box holds the identical text:p/text:span model office:text does, interned into the same content.xml style registry:

import { createOdp } from 'documents.js';

const editor = createOdp();
const slide = editor.addSlide();
const title = slide.addTextBox({ frame: { xPt: 40, yPt: 30, widthPt: 640, heightPt: 80 }, text: 'Title' });
title.rotationDeg = 15; // OdpShape has a genuine draw:transform rotation setter, unlike PptxShape's own documented gap below
const bullets = slide.addTextBox({ frame: { xPt: 40, yPt: 130, widthPt: 300, heightPt: 200 }, text: '' });
bullets.paragraphs()[0].remove();
bullets.addList().addItem().appendParagraph({ text: 'A real bulleted text:list' });
slide.notes = 'Speaker notes for this slide';
const bytes = editor.toBytes();

createOds/openOds and OdsEditor/OdsSheet/OdsCell are the spreadsheet equivalent — cell addressing has no docx/pptx analogue at all, so this is the one editor family built from scratch rather than reusing OdtParagraph/OdtRun. Setting a cell far from the origin does not materialise every cell in between: the underlying table:number-columns-repeated/table:number-rows-repeated runs are split in place at exactly the target position, the same repeat-compression convention odf.js's own reader already reads. OdsSheet.printSettings is a genuine getter/setter too (src/edit/ods/print-settings.ts) — a set mints a fresh style:page-layout/style:master-page/style:style[family="table"] chain and repoints the sheet at it, rather than mutating whatever it was pointing at before, matching this package's own append-only style-editing convention throughout.

import { createOds } from 'documents.js';

const editor = createOds();
const sheet = editor.addSheet('Sheet1');
sheet.printSettings = { pageSize: { widthPt: 595, heightPt: 842 }, margins: { topPt: 20, rightPt: 20, bottomPt: 20, leftPt: 20 }, gridlines: true, headers: true, pageOrder: 'downThenOver' };
sheet.cell(0, 0).value = { kind: 'string', value: 'Total' }; // 0-based (row, column) -- there is no A1-string overload
sheet.cell(0, 1).value = { kind: 'currency', value: 42.5, currency: 'USD' };
sheet.cell(500, 50).value = { kind: 'boolean', value: true }; // does not materialise 500x50 empty cells
const bytes = editor.toBytes();

createOdg/openOdg and OdgEditor/OdgPage are the drawing equivalent — a page-level container (draw:page), extended with the vector-primitive setters a drawing carries that a presentation typically doesn't. OdgPage.addTextBox/.addImage return real OdpShape instances (draw:frame's content model is byte-for-byte identical between odp and odg — see Architecture); addRect/addEllipse/addLine/addPath return OdgBoxVector/OdgLineVector/OdgPathVector, writing real draw:rect/draw:ellipse/draw:line/draw:path elements. A vector's own paint order is purely document order — the same convention real LibreOffice output already uses, so an earlier add* call paints behind a later one, with no draw:z-index attribute ever written.

import { createOdg } from 'documents.js';

const editor = createOdg();
const page = editor.addPage();
page.addRect({ frame: { xPt: 20, yPt: 20, widthPt: 100, heightPt: 60 }, fill: { r: 1, g: 0.5, b: 0 } });
page.addEllipse({ frame: { xPt: 140, yPt: 20, widthPt: 100, heightPt: 60 }, stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 } });
page.addPath({
  frame: { xPt: 20, yPt: 100, widthPt: 80, heightPt: 80 },
  subpaths: [{ start: { xPt: 0, yPt: 80 }, closed: true, segments: [{ kind: 'line', to: { xPt: 60, yPt: 80 } }, { kind: 'cubic', control1: { xPt: 80, yPt: 80 }, control2: { xPt: 80, yPt: 0 }, to: { xPt: 40, yPt: 0 } }] }],
  fill: { r: 1, g: 1, b: 0 },
}); // a genuine Bezier curve -- writes a real svg:d/svg:viewBox pair, not a polygon approximation
page.addTextBox({ frame: { xPt: 20, yPt: 200, widthPt: 300, heightPt: 30 }, text: 'A label on top' });
const bytes = editor.toBytes();

buildOdsPackage bridges a spreadsheet ContentDocument (either one from readOdsContent, or a best-effort one from reconstructSpreadsheet) to a fresh package built entirely through the same primitives — pdfToOds's own package-building half, mirroring buildOdtPackage/buildOdpPackage's role for pdfToOdt/pdfToOdp. buildOdgPackage bridges a drawing ContentDocument (either one from readOdgContent, or a best-effort one from reconstructDrawing) to a fresh package built entirely through the same primitives — pdfToOdg's own package-building half.

Reading and writing PDF bytes directly, without going through docx/pptx:

import { readPdf, writePdf } from 'documents.js';

const layout = readPdf(pdfBytes); // -> LayoutDocument: pages of positioned text/image/rect/link items
const bytes = writePdf(layout);

The same seven round trips (PDF ⇄ LayoutDocument, docx ⇄ PDF, pptx ⇄ PDF, odt ⇄ PDF, odp ⇄ PDF, ods ⇄ PDF, odg ⇄ PDF) are each also available as a schema-validated z.codec() pair, mirroring ooxml.js's own packageCodecz.decode/z.encode validate both the raw bytes (against the magic-byte schemas below) and the parsed value (against LayoutDocumentSchema) on every call, catching a malformed value that a bare function call wouldn't. This is the no-extra-options form: readPdf/writePdf/docxToPdf/etc. remain the entry points for cancellation (signal), diagnostics (sink), or substitution reporting (onSubstitution), none of which fit z.codec()'s fixed decode(input)/encode(output) signature.

import { z } from 'zod';
import { docxPdfCodec, pdfCodec, pptxPdfCodec } from 'documents.js';

const layout = z.decode(pdfCodec, pdfBytes); // throws a ZodError if pdfBytes has no %PDF- header
const pdfBytes2 = z.encode(pdfCodec, layout);

const pdfFromDocx = z.decode(docxPdfCodec, docxBytes);
const docxBack = z.encode(docxPdfCodec, pdfFromDocx);

The six PDF-bypassing bridges above get the same treatment: odtDocxCodec, odpPptxCodec, and odsXlsxCodec (odt bytes ⇄ docx bytes, odp bytes ⇄ pptx bytes, ods bytes ⇄ xlsx bytes) — the no-options form again, odtToDocx/docxToOdt/etc. remain the entry points for signal.

readDocxContent/readPptxContent/readOdtContent/readOdpContent/readOdsContent/readOdgContent (docx/pptx/odt/odp/ods/odg → ContentDocument), convertWordprocessingToLayout/convertPresentationToLayout/convertSpreadsheetToLayout/convertDrawingToLayout (ContentDocumentLayoutDocument), and reconstructWordprocessing/reconstructPresentation/reconstructSpreadsheet/reconstructDrawing (LayoutDocumentContentDocument) are each exported individually too, for a caller that wants one stage of the pipeline without the rest. readDocxContent and readOdtContent both produce the identical wordprocessing-variant ContentDocument shape from two completely unrelated package formats (OOXML and ODF), which is what lets odtToPdf feed convertWordprocessingToLayout without a single line of that engine changing; readPptxContent and readOdpContent do the same for the presentation variant and convertPresentationToLayout. readOdgContent/convertDrawingToLayout has no OOXML-side counterpart at all (no drawing-equivalent OOXML format this package reads); readOdsContent/convertSpreadsheetToLayout now does have one on the read side — ooxml.js's own readXlsxContent — but only for the PDF-bypassing odsToXlsx/xlsxToOds bridge below, not for the PDF pivot: xlsx has no PDF conversion of its own, so convertSpreadsheetToLayout still has no xlsx-layout counterpart to reuse or be reused by. Both convertSpreadsheetToLayout and convertDrawingToLayout are genuinely new layout algorithms, since a spreadsheet's addressed-grid-with-print-settings semantics and a drawing's vector-primitive vocabulary (rect/ellipse/line/path) have no flow/pagination or direct-placement analogue; convertDrawingToLayout does still reuse convertPresentationToLayout's own shape-conversion logic (convertShape, exported from src/layout/slides.ts) verbatim for whatever text/image/table content a drawing page also carries. reconstructDrawing is reconstructWordprocessing/reconstructPresentation's drawing-side counterpart, but does no baseline/paragraph clustering at all — a drawing has no semantic structure to recover, only a near-1:1 LayoutItemContentVector/ContentShape mapping to make, in the same paint order the items were recovered in. reconstructSpreadsheet is a genuinely different geometry-recovery problem from either: a real gridline lattice on the page (drawn by a printed sheet with gridlines enabled) is used DIRECTLY as cell boundaries when one is detected; absent one, text is clustered into a 2D grid from geometry alone. It recovers what was printed, not what was entered — every cell comes back a bare string, never re-parsed into a number/date/boolean or claimed as a formula (see Fidelity).

One further conversion, odmToPdf, is shaped differently from every conversion above: a .odm (ODF master document, a "book" of chapters) never carries its own chapters' content — each text:section is a bare external reference to a standalone .odt file, confirmed against real LibreOffice output (see Gotchas below) — so producing a PDF needs a caller-supplied resolveSubDocument callback to hand back each chapter's own bytes given that section's href. Every chapter's own ContentSection[] is concatenated in text:section document order into one combined document, with an explicit page break marking each chapter boundary, and fed through the same convertWordprocessingToLayout engine every wordprocessing-variant conversion above already uses unmodified:

import { readFileSync } from 'node:fs';
import { odmToPdf, OdmUnresolvedSectionError } from 'documents.js';

const chapterBytes = new Map([
  ['../chapter1.odt', new Uint8Array(readFileSync('chapter1.odt'))],
  ['../chapter2.odt', new Uint8Array(readFileSync('chapter2.odt'))],
]);

try {
  const pdfBytes = odmToPdf(odmBytes, {
    resolveSubDocument: (href) => chapterBytes.get(href),
  });
} catch (error) {
  if (error instanceof OdmUnresolvedSectionError) {
    console.error('missing chapters:', error.hrefs); // every unresolved href, not just the first
  }
}

odmToPdf is not one of the twelve round-trip conversions or the six bridges above, has no z.codec() pair, and is not wired into the DocumentConverter port below — see Gotchas for why.

Architecture

The package is layered from generic primitives outward to the two conversion directions:

  • src/model/ — thin, documents.js-specific additions on top of the sibling document-content-model package, which now owns the two pivot models themselves: LayoutDocument (the PDF-side pivot: pages of positioned text/image/rect/line/ellipse/path/link items, PDF-native coordinates and units — LayoutPath is a general vector path, one or more subpaths of line/cubic segments sharing one fill/fillRule/stroke, the item kind writePath, src/pdf/content-write.ts, turns into PDF m/l/c/h content-stream operators) and ContentDocument (the semantic pivot: a discriminated union of wordprocessing, presentation, spreadsheet, and drawing variants sharing paragraph/run/table/image building blocks, drawing's own ContentVector vocabulary — rect/ellipse/line/path — being the vector-primitive counterpart to the shared ContentShape) are both imported, not defined here — document-content-model exists specifically so ooxml.js, odf.js, and documents.js share one schema instead of each maintaining an independent, drift-prone copy. What remains local: bytes.ts (magic-byte-validated Uint8Array schemas for docx/pptx/PDF, plus Odt/Ods/Odp/OdgBytesSchema, which check the package's actual declared media type against odf.js's ODF_MEDIA_TYPES table rather than only the generic ZIP signature the OOXML schemas are limited to), units.ts (OOXML EMU/twip/point/half-point conversions), and geometry.ts/color.ts/style.ts, each now mostly a thin re-export of document-content-model's Box/Margins/PageSize/Color/Alignment/LayoutFont — the one genuinely PDF-specific piece each still adds locally is geometry.ts's flipY (the top-left/y-down ↔ bottom-left/y-up space conversion between OOXML/ODF and PDF coordinates); LayoutFont/DEFAULT_LAYOUT_FONT moved to document-content-model too (since LayoutText, part of the pivot, needs the field), leaving only the standard-14 font resolution logic that consumes it (src/pdf/fonts.ts/font-read.ts) as PDF-specific and local.
  • src/bytes/ and src/image/ — generic byte and image-container primitives with zero PDF or OOXML knowledge: a chunked byte writer, a backtracking byte reader, CRC32, and a hand-written PNG decoder/encoder (palette/gray/RGB/alpha, multi-IDAT files, all five scanline filters) plus JPEG marker scanning for dimensions only — JPEG's compressed bytes pass through completely unchanged in both directions. src/bytes/flate.ts is the only file that imports fflate, mirroring how ooxml.js's own src/zip.ts wraps it for ZIP handling.
  • src/xml/ and src/opc/ — parent-aware XML query/mutation and OPC package mechanics (relationship IDs, content-type entries, atomic media-part insertion) built over ooxml.js's Package/XmlNode, needed because ooxml.js's own XML nodes have no parent pointers and ooxml.js never writes new parts into an existing package.
  • src/edit/ — the read-and-write editable model: live-view classes (DocxEditor/DocxParagraph/DocxRun/DocxTable, PptxEditor/PptxSlide/PptxShape, OdtEditor/OdtParagraph/OdtRun/OdtTable/OdtList, OdpEditor/OdpSlide/OdpShape, OdsEditor/OdsSheet/OdsCell, OdgEditor/OdgPage/OdgBoxVector/OdgLineVector/OdgPathVector) wrapping the actual XmlElement objects inside a decoded Package, plus buildDocxPackage/buildPptxPackage/buildOdtPackage/buildOdpPackage/buildOdsPackage/buildOdgPackage bridging a ContentDocument to a fresh package built entirely through those same primitives — pdfToOdt/pdfToOdp/pdfToOds/pdfToOdg each call the matching one. src/edit/odp/* reuses src/edit/odt/*'s own paragraph/run/list/style-interning classes WHOLESALE rather than reimplementing them for presentations: a draw:frame's draw:text-box holds the identical text:p/text:span content model office:text does, interned into the identical content.xml office:automatic-styles registry (src/edit/odt/props.ts's applyStyleChange) — OdpShape.appendParagraph/.paragraphs()/.addList() return real OdtParagraph/OdtList instances, not odp-specific lookalikes. The genuinely new odp-specific work is draw:page/draw:frame mechanics (a slide is a draw:page, a shape's geometry is explicit svg:x/svg:y/svg:width/svg:height rather than pptx's placeholder-inheritance-heavy model) and rotation: OdpShape.rotationDeg is a genuine draw:transform setter built on odf.js's own applyOdfTransform/resolveOdfShapeGeometry (typed/shared/transform.ts) — the write-side inverse of the exact function odf.js's own reader uses — unlike PptxShape, which has no rotation setter yet (see Gotchas below). src/edit/ods/* has no docx/pptx/odt/odp analogue to reuse for its core concern (cell addressing) but still reuses src/edit/odt/*'s style interning and src/edit/odt/content.ts's populateParagraph for cell text content — src/edit/ods/address.ts is the write-side counterpart to odf.js's own read-side table:number-*-repeated-aware cursor: setting a distant cell's value splits the covering repeated run in place at that one position rather than materialising every cell in between, exactly mirroring the read-side hazard odf.js's own typed/shared/a1.ts already solved. src/edit/ods/print-settings.ts is the newest addition: OdsSheet.printSettings's own getter/setter, mining styles.xml's office:automatic-styles/office:master-styles directly (a part no other src/edit/ods/* module needed to touch before) rather than content.xml alone, reusing odf.js's own exported findStyleElement/resolvePageLayoutProperties/parsePageSize/parseMargins for the read half and src/edit/odt/automatic-styles.ts's nextStyleName (already generic over which office:automatic-styles element it scans) for the write half's own fresh-name minting. src/edit/odg/* reuses OdpShape/buildTextBoxFrame/insertImageFrameMedia WHOLESALE for draw:frame text/image content (a drawing page's draw:frame content model and geometry resolution — rotation included — are byte-for-byte identical to a presentation's, both resolved through odf.js's own shared readDrawFrame), so there is no separate OdgShape class at all; the genuinely new work is the vector-primitive classes (no rotation, a per-kind attribute vocabulary: svg:x/y/width/height for rect/ellipse/path, svg:x1/y1/x2/y2 for a line) and their own fill/stroke, which needed a small, self-contained graphic-family style writer (src/edit/odg/style.ts) since odf.js's own StyleRegistry recognises 'graphic' as a style family but its StylePropertiesSchema only ever models text/paragraph formatting — it has no fill/stroke fields and never emits a style:graphic-properties element. A path vector's own svg:d is generated by src/edit/odg/svg-path.ts, the write-side inverse of odf.js's own typed/shared/path.ts parser — always absolute, always space-separated commands, anchoring svg:viewBox at "0 0 {widthPt} {heightPt}" so the written numbers are the exact source ContentPathPoint values with no rescaling arithmetic either way (see Gotchas below for the cross-check against that exact parser).
  • src/pdf/ — the hand-written PDF codec, importing only model/bytes/image (no OOXML knowledge at all):
    • Write: objects.ts (the PdfObject discriminated union), afm-widths.ts/encoding.ts/winansi.ts/fonts.ts (standard-14 metrics, WinAnsi encoding, family resolution), measure.ts/text-layout.ts (greedy line-wrapping), matrix.ts, content-write.ts (LayoutItem[] → content-stream operators), write.ts (the full object graph, classic cross-reference table, trailer).
    • Read: lexer.ts/parse.ts (byte tokenizer and tokens → PdfObject), filters.ts/predictors.ts (Flate/LZW/ASCII85/ASCIIHex/RunLength, TIFF/PNG predictors), xref.ts/document.ts (classic and cross-reference-stream resolution, object streams, /Prev chains, linear-scan recovery, the page tree with attribute inheritance), content-read.ts/interpret.ts (the content-stream tokenizer and graphics/text state machine, including form-XObject recursion), cmap.ts/font-style.ts/font-read.ts (/ToUnicode CMaps, font-dictionary resolution), images-read.ts (Image XObjects → PNG/JPEG bytes), read.ts (readPdf, assembling all of the above into a LayoutDocument).
    • codec.tspdfCodec, a z.codec() pair over readPdf/writePdf (PDF bytes ⇄ LayoutDocument).
  • src/ooxml/ — resolves a Package into a ContentDocument: docx/read.ts and pptx/read.ts are now thin adapters over ooxml.js's own readDocx/readPptx, wrapping their { metadata, sections }/{ metadata, slides } result into ContentDocument's wordprocessing/presentation shape. The docx style cascade (docDefaults → named-style basedOn chains → paragraph-mark run properties → character styles → direct formatting), the pptx placeholder → layout → master → theme inheritance cascade, and DrawingML geometry/colour resolution all now live upstream in ooxml.js itself, not in this package.
  • src/odf/ — the ODF-side counterpart to src/ooxml/, resolving an odf.js Package into a ContentDocument: odt/read.ts's readOdtContent is a thin adapter over odf.js's own readOdt, wrapping its { metadata, sections } result into the identical wordprocessing shape readDocxContent produces — the concrete proof that odt and docx genuinely share one pivot and one layout engine. odp/read.ts's readOdpContent is the same adapter over odf.js's readOdp, wrapping { metadata, slides } into the identical presentation shape readPptxContent produces. ods/read.ts's readOdsContent wraps odf.js's readOds's { metadata, sheets } into the spreadsheet ContentDocument variant, and odg/read.ts's readOdgContent wraps odf.js's readOdg's { metadata, pages } into the drawing variant — odg still has no OOXML-side sibling adapter at all (no drawing-equivalent OOXML format this package reads); ods now does, ooxml.js's own readXlsxContent/buildXlsxPackage, consumed directly by src/convert/convert.ts's odsToXlsx/xlsxToOds bridge (see below) but deliberately not re-exported from this package's own public surface, mirroring the readDocx/readPptx non-re-export choice above. buildOdtPackage/buildOdpPackage/buildOdsPackage/buildOdgPackage (src/edit/{odt,odp,ods,odg}/content.ts) each bridge a ContentDocument back to a fresh package built on that format's own live-view editor, closing the PDF → odt/odp/ods/odg direction (pdfToOdt/pdfToOdp/pdfToOds/pdfToOdg each call the matching one) — see the pdfToOds gotcha below for buildOdsPackage's own printSettings-writing addition.
  • src/layout/ — the pure conversion algorithms, importing only model (no I/O): engine.ts (ContentDocument wordprocessing → LayoutDocument: flow, line-breaking, pagination — fed identically by docx- and odt-sourced content), slides.ts (ContentDocument presentation → LayoutDocument: direct EMU-to-point placement, no pagination needed — fed identically by pptx- and odp-sourced content; also exports convertShape, the single-ContentShape-to-LayoutItem[] conversion drawing.ts below reuses verbatim), sheets.ts (ContentDocument spreadsheet → LayoutDocument: resolve the print range, build cumulative column/row offsets skipping hidden ones, reserve header/repeat-row-column space, resolve an explicit or non-iterative fit-to-page scale, partition into column/row bands honouring manual breaks with the same "an oversized item gets its own band and overflows rather than looping" guarantee engine.ts's ensureRoom documents, emit pages in downThenOver/overThenDown order, then per page paint backgrounds/gridlines/headers/cell text with default alignment by value kind and ###/spill-then-truncate overflow handling — the first layout algorithm in this package that accepts an AbortSignal, since a 50k-cell sheet needs cancellation where a docx/pptx page count never did), drawing.ts (ContentDocument drawing → LayoutDocument: one ContentDrawPage per PDF page, direct placement like slides.ts, with one new emission path — a ContentVector rect/ellipse/line maps onto the pre-existing LayoutRect/LayoutEllipse/LayoutLine kinds, and a path vector's local, viewBox-relative subpath points are resolved through the vector's own frame offset then a single page-space flip into a LayoutPath value; vectors paint before shapes, a documented, bounded choice — see this module's own top-of-file note — since ContentDrawPageSchema keeps shapes and vectors as two independently paint-ordered arrays with no field recording their relative order when the two genuinely overlap), reconstruct.ts (LayoutDocumentContentDocument: reconstructWordprocessing/reconstructPresentation do baseline-proximity line clustering, then paragraph/text-block clustering from geometry — PDF has no semantic paragraph or shape structure to recover, only positioned glyphs; reconstructDrawing does no clustering at all, since a drawing has no such structure to infer in the first place — every LayoutItem maps close to 1:1 back onto a ContentVector rect/ellipse/line/path or a ContentShape, in the exact z-order it was painted, bucketed into ContentDrawPageSchema's own two independently-ordered shapes/vectors arrays the same way drawing.ts produced them; reconstructSpreadsheet tries a real gridline lattice first — scanning the page's LayoutLine/stroked-single-segment-LayoutPath items for enough parallel horizontal and vertical lines at consistent positions to call it a printed grid, using those line positions directly as cell boundaries when found — and falls back to text-position clustering otherwise, reusing this same module's clusterIntoLines for rows and a parallel recurring-x-position generalisation of clusterIntoParagraphs's own dominantLeftX for columns; every recovered cell is a bare string, column widths/row heights are genuinely measured from whichever geometry was used, and no print range/scale/repeat-rows/repeat-columns/manual-breaks are ever inferred).
  • src/convert/convert.ts (the twelve PDF-pivot round-trip ergonomic wrappers, a dedicated "Six cross-format bridges" section: odtToDocx/docxToOdt, odpToPptx/pptxToOdp, odsToXlsx/xlsxToOds, each a direct readXContentbuildYPackage composition bypassing PDF entirely — see Fidelity — and odmToPdf, the one further conversion shaped around a caller-supplied resolveSubDocument callback rather than being purely bytes-in/bytes-out, since a .odm master document's own chapters are external references odf.js's readOdm never inlines — see Gotchas), codec.ts (docxPdfCodec/pptxPdfCodec/odtPdfCodec/odpPdfCodec/odsPdfCodec/odgPdfCodec plus odtDocxCodec/odpPptxCodec/odsXlsxCodec, a z.codec() pair over each — odmToPdf has no codec of its own, for the same fixed-signature reason it has no port entry below), port.ts/local.ts (the swappable DocumentConverter contract and its synchronous local implementation, covering docx/pptx/odt/odp/ods/odgpdf, pdfdocx/pptx/odt/odp/ods/odg, and the six bridge pairs — DocumentFormat includes xlsx for exactly this reason, even though xlsx has no PDF conversion of its own; odm is deliberately not a DocumentFormat member, since odmToPdf is not wired into this port at all).

Dependency direction is strictly downward and checkable: model/bytes import nothing local; image imports bytes only; pdf imports model+bytes+image only; ooxml/* imports xml/model only (no PDF knowledge); odf/* imports model only (no PDF knowledge, no xml/*odf.js already owns its own XML query helpers); layout imports model only; convert composes everything else. No PdfObject/PdfDict/PdfStream type appears outside src/pdf/.

Build, test, and lint

pnpm build         # tsdown -> dist/ (ESM + CJS + .d.ts)
pnpm typecheck     # tsc --noEmit
pnpm lint          # eslint . --max-warnings 0
pnpm test          # vitest run --project unit
pnpm test:watch    # vitest --project unit
pnpm test:smoke    # rebuilds dist/, then verifies ESM/CJS parity, a real docxToPdf/pdfToDocx round trip, real odtToPdf/odpToPdf/odsToPdf/odgToPdf conversions (odgToPdf's own fixture carries a real curved path, proving writePath reaches the built dist/ bundle), a real createOdp/odpToPdf/pdfToOdp round trip, a real odsToPdf/pdfToOds round trip plus a separate createOds/printSettings/buildOdsPackage exercise, and a real createOdg/odgToPdf/pdfToOdg round trip (a curved path, a filled rect, and text, built entirely through the odg live-view editor, converted to PDF and reconstructed back to odg via reconstructDrawing), from the built CJS bundle
pnpm test:corpus   # optional real-world PDF conformance checks against a local, gitignored test/corpus/ (see Fidelity)

To run a single test file: pnpm vitest run src/path/to/file.test.ts.

Conventions

  • Zod-first schema/type/guard, matching ooxml.js: every model type is inferred from its Zod schema, never hand-written. ContentBlock (recursive, mirroring ooxml.js's own XmlNode treatment) uses a hand-written structural guard + z.custom, not z.lazy, which collapses to unknown for recursive element-children in the pinned Zod version.
  • z.codec() for every schema-to-schema round trip, matching ooxml.js's packageCodec/xmlCodec: pdfCodec (PDF bytes ⇄ LayoutDocument), docxPdfCodec/pptxPdfCodec/odtPdfCodec/odpPdfCodec/odsPdfCodec/odgPdfCodec (docx/pptx/odt/odp/ods/odg bytes ⇄ PDF bytes), and odtDocxCodec/odpPptxCodec/odsXlsxCodec (odt/odp/ods bytes ⇄ docx/pptx/xlsx bytes, the PDF-bypassing bridges) each wrap an already-independently-tested function pair, adding automatic two-way schema validation. These are deliberately the no-options form — readPdf/writePdf/docxToPdf/pdfToDocx/pptxToPdf/pdfToPptx/odtToPdf/pdfToOdt/odpToPdf/pdfToOdp/odsToPdf/pdfToOds/odgToPdf/pdfToOdg/odtToDocx/docxToOdt/odpToPptx/pptxToOdp/odsToXlsx/xlsxToOds remain the primary entry points wherever a caller needs an AbortSignal, a PdfDiagnosticSink, or an onSubstitution callback, since z.codec()'s fixed decode(input)/encode(output) signature has no room for side-channel options.
  • PdfObject has no Zod schema at all, deliberately: it never crosses a public boundary or round-trips through JSON, and is constructed exclusively by this package's own parser — validating it would just be validating our own output. It narrows natively on its own kind discriminant instead, the same reasoning ooxml.js applies when it picks a hand-written isXmlNode guard over z.lazy.
  • No type assertions anywhere. Every third-party or loosely-typed value is narrowed through a type guard or a Zod parse at the boundary.
  • Live views, not flatten-and-regenerate. src/edit/*'s editor classes hold a reference directly into the real Package/XmlElement objects; saving is encodePackage(pkg), nothing more. This is what makes "everything you didn't touch stays byte-faithful" a structural guarantee rather than a best effort.
  • A three-tier PDF-read failure policy, applied consistently across every src/pdf/* read module: throw a typed PdfParseError/PdfEncryptedError for a file that cannot be meaningfully processed at all; recover with a PdfDiagnostic (severity: 'warning') for something malformed but salvageable (a bad startxref, a wrong stream /Length); degrade with a diagnostic for an individual unsupported feature (an unimplemented filter, an unrecognised colour space) while the rest of the document still reads.
  • Conventional commits, enforced via commitlint + husky, matching ooxml.js.

Gotchas and quirks

  • ooxml.js's typed readers (readDocx/readPptx) are now the actual basis for conversionreadDocxContent/readPptxContent are thin wrappers around them, not an independent walk of word/document.xml/ppt/slides/slideN.xml. They are still deliberately not re-exported from this package's own public surface: readDocx/readPptx also carry comments/footnotes/headers/footers (docx) that ContentDocument doesn't model, so exposing both the wrapper and the thing it wraps would invite a caller to reach for the wrong one rather than genuinely offering two competing models.
  • The docx⇄PDF and pptx⇄PDF conversions are explicitly not round-trip-lossless — in deliberate contrast to ooxml.js's own packageCodec, which is byte/part-faithful by design. See Fidelity. The six cross-format bridges below (odtToDocx/docxToOdt, odpToPptx/pptxToOdp, odsToXlsx/xlsxToOds) are a genuinely different case — see the Fidelity section's own paragraph on them.
  • Building the six cross-format bridges surfaced two real, previously-undiscovered gaps in existing populateParagraph write paths, both now fixed. buildDocxPackage's populateParagraph (src/edit/docx/content.ts) never wrote a paragraph's own list membership back (ContentParagraph.list, docx's flat numId/level model) — only read, never written, since no existing caller had ever round-tripped a list-bearing paragraph through it. buildOdtPackage's populateParagraph (src/edit/odt/content.ts) never wrote a paragraph's own styleId back at all (readOdtContent/readOdfParagraph in odf.js reads it unconditionally from text:style-name, but nothing on the write side ever set that attribute). Both are now fixed: DocxParagraph.list is set unconditionally alongside styleId/alignment, matching that function's own existing pattern; OdtParagraph.styleId is set conditionally alongside alignment, matching odt's own local convention. buildOdtPackage additionally gained appendBlocks/appendListRun (src/edit/odt/content.ts) — ODF has no flat per-paragraph list property to set the way docx does, so a run of consecutive ContentParagraphs sharing list.numId is grouped and written as a real, potentially multi-level text:list/text:list-item tree via OdtList/OdtListItem, the structural inverse of odf.js's own list-reading (a fresh text:list per numId change, one level of nesting per list.level step, descending only one level at a time since ODF can only open a nested list from inside an existing item). Both gaps were invisible before this task specifically because nothing had previously round-tripped a list-bearing paragraph or a styled paragraph through docx ⇄ odt at all — the PDF-pivot conversions never exercised buildDocxPackage/buildOdtPackage on content read back from the OTHER format.
  • A table shape inside an odp slide does not survive odpToPptx. buildPptxPackage's appendShape (src/edit/pptx/content.ts) silently drops any non-paragraph block found inside a shape's own text-box loop — a scope choice whose own comment ("PDF-reconstructed shapes never mix kinds") assumed its only caller was the PDF-reconstruction path, where that is true. odpToPptx is a second, non-PDF-reconstructed caller for which it is not: a real odp draw:frame containing a table:table directly (not inside a text box) reads as a ContentShape with a 'table' block, and that block is silently dropped, leaving an empty pptx text box where the table was. Everything else on the same slide — a rotated shape, grouped shapes, an image, speaker notes — survives correctly (see src/convert/bridges.test.ts's own dedicated fidelity-gap test, which proves both halves against the existing minimalOdpBytes() fixture). A real, tracked, bounded gap, not a silent one: closing it means teaching buildPptxPackage/buildOdpPackage to write a real table into a slide shape, a materially larger feature than this bridge's own scope.
  • The ods ⇄ xlsx bridge inherits several real, format-boundary fidelity limits from ooxml.js's brand-new readXlsxContent/buildXlsxPackage, on top of its own pivot-copy design. xlsx has no percentage/currency cell type of its own (both are a plain numeric cell plus a number-format style neither this reader nor this writer interprets) — an ods percentage/currency cell survives the odsToXlsx hop with its numeric value intact but downgrades to a plain number kind, permanently (currency's own currency code is dropped outright). xlsx also has only one rare t="d" cell type covering BOTH date and time — an ods time cell survives as a date-kind cell carrying its original value string verbatim, but mislabelled; an ods date cell is unaffected (it was already the kind xlsx's own t="d" maps onto). A formula (table:formula/<f>) is carried completely verbatim in both directions — never parsed, translated, or evaluated by either this package's own reader or writer — but a REAL spreadsheet application does evaluate a workbook's own <f>/table:formula on open: confirmed against genuine LibreOffice 26.2, an ods formula authored in OpenFormula syntax (of:=[.B2]*2) becomes a formula ERROR (Err:510) when the bridged xlsx is opened in real Calc, even though the formula's own cached value is still present and correctly readable via readXlsxContent — going the other way is less fragile in practice only because a genuine xlsx formula (bare Excel A1 syntax, e.g. B2*2) happens to still parse under LibreOffice's own more lenient, backward-compatible ODF formula grammar, not because of anything this bridge does differently in either direction. Column widths survive the odsToXlsx hop within roughly a pixel of rounding tolerance (see src/convert/bridges.test.ts's own COLUMN_WIDTH_TOLERANCE_PT) but are then dropped entirely on the return xlsxToOds hop — not a character-width-unit rounding loss, but buildOdsPackage not writing ContentSheetColumn.widthPt at all, a pre-existing, already-documented gap in that file's own module comment, unrelated to and unfixed by this bridge. A boolean cell written by buildXlsxPackage renders as a raw 1/0 rather than TRUE/FALSE when opened in real Excel/Calc, since that writer's own genuinely-minimal xl/styles.xml (one default cell format, no boolean-specific number format) has nothing else to apply — the underlying { kind: 'boolean', value: true } is still read back correctly by readXlsxContent regardless; this is a real-application display gap, not a data-fidelity one. readXlsxContent's own cell.value.kind never produces 'error' from an odf.js-sourced document at all, for a structural reason rather than a bug: ODF's office:value-type enumeration has no error member, so OdsCell.value's own write-side choice for a kind: 'error' cell is to write it as a genuine, non-empty office:string-value carrying the error's own text — an xlsxToOdsodsToXlsx round trip of a genuine xlsx t="e" error cell therefore turns it into a plain string cell carrying the identical text; the message survives, the error semantic does not.
  • odpToPdf/pdfToOdp needed zero new layout code. readOdpContent (src/odf/odp/read.ts) produces the identical presentation ContentDocument shape readPptxContent does, so it feeds convertPresentationToLayout unmodified — including the existing hidden-annotation speaker-notes mechanism below, which carries odp's presentation:notes through to the PDF with no new notes-handling code at all; pdfToOdp reuses reconstructPresentation unmodified too, the same architectural bet pdfToOdt already proved for reconstructWordprocessing. The genuinely new work for the reverse direction was the live-view editor itself (src/edit/odp/*) — see Architecture above.
  • OdpShape.rotationDeg writes a real draw:transform, built on odf.js's own transform machinery. It is the write-side inverse of odf.js's resolveOdfShapeGeometry (typed/shared/transform.ts), built on that module's own exported applyOdfTransform rather than a hand-rolled rotation matrix, so it inherits that module's own empirically-verified rotate/translate composition order and sign convention by construction. Unlike PptxShape (see the colSpan/rowSpan gotcha below, which pptx still has and odp does not), buildOdpPackage writes a rotated shape's rotation back correctly — verified both by this package's own tests and by opening a fresh, editor-built .odp in actual LibreOffice.
  • src/pdf/interpret.ts tracks general vector paths, not just axis-aligned re rectangles. m/l/c/v/y/h (and re itself, per its own ISO 32000-1 definition as a 4-point rectangle subpath) accumulate real subpaths — CTM-transformed line/cubic segments, open or closed — and any paint operator (f/F/f*/S/s/B/B*/b/b*) emits a LayoutPath item when the path isn't reducible to the simple single-re-on-an-axis-aligned-CTM case (which still takes the original, unchanged LayoutRect fast path). Verified both by dedicated tests and by a genuine writePathwritePdfreadPdf round trip recovering the original LayoutPath value exactly. This is the shared infrastructure both pdfToOds and reconstructDrawing need; both now use it. A direct, practical consequence for pdfToOds: readPdf never reconstructs a 'line' kind item at all (see the reconstructDrawing gotcha below), so a gridline written by sheets.ts's own renderGridlines always comes back from a real PDF round trip as a generic, single-subpath, single-line-segment, stroke-only LayoutPathreconstructSpreadsheet's own gridline-lattice detection accepts both shapes (a genuine LayoutLine item and this stroked-single-segment LayoutPath shape) for exactly this reason.
  • pdfToOds recovers what was printed, not what was entered. reconstructSpreadsheet (src/layout/reconstruct.ts) tries a real gridline lattice first: it scans the page's LayoutLine/stroked-single-segment-LayoutPath items (see the interpret.ts gotcha above) for enough parallel horizontal and vertical lines at consistent positions to call it a printed grid (MIN_GRIDLINE_COUNT_PER_AXIS = 3 per axis, i.e. at least a 2×2 grid, and a span-consistency check that rejects a scatter of unrelated short strokes — a page border or a couple of decorative rules — as not a genuine lattice), and uses those line positions DIRECTLY as cell boundaries when found. Absent a lattice, it clusters text into a grid from geometry alone instead: rows reuse clusterIntoLines verbatim (a spreadsheet cell's own text is never wrapped across lines, so a text line already IS a row), and columns generalise clusterIntoParagraphs's own single dominantLeftX to several recurring x-position anchors, first merging directly-adjacent same-line fragments (splitLineByLargeGaps, the same >2em-gap signal reconstructPresentation's own block clustering uses) so a cell whose text arrived as several run-level-split LayoutText items isn't scattered across spurious columns. Column widths and row heights are genuinely measured from whichever geometry was used (drawn gridline gaps, or measured text/anchor extents), never invented. Every recovered cell is a bare string carrying only its own extracted displayTextnever re-parsed into a number/date/boolean, and never claimed as a formula, even when the text looks numeric or date-shaped; see Fidelity for the full framing. buildOdsPackage (src/edit/ods/content.ts) is pdfToOds's own package-building half, mirroring buildOdtPackage/buildOdpPackage/buildOdgPackage's role for pdfToOdt/pdfToOdp/pdfToOdg.
  • buildOdsPackage now writes printSettings for real, via a new OdsSheet.printSettings getter/setter (src/edit/ods/print-settings.ts) — discovered as a genuine blocker while building pdfToOds's own round-trip verification, not a pre-planned feature. OdsEditor/OdsSheet previously had no width/height/print-settings API at all, so buildOdsPackage silently dropped ContentSheetPrintSettings entirely; that made a reconstructed sheet's own recovered gridlines/headers/pageSize unverifiable by any real write-then-reread round trip, which is exactly what pdfToOds's own test needed to prove. The setter mints a fresh style:page-layout (styles.xml/office:automatic-styles) + style:master-page (styles.xml/office:master-styles) + style:style[family="table"] (content.xml/office:automatic-styles) triple and repoints the sheet's own table:style-name to it on every call, rather than mutating whatever it was pointing at before — the same append-only style-editing convention src/edit/odg/style.ts already documents. Scoped to the five fields ContentSheetPrintSettingsSchema always carries (pageSize/margins/gridlines/headers/pageOrder); printRange/scale/fitToPages/repeatRows/repeatColumns/manualBreaks (all optional, and never set by reconstructSpreadsheet) are still not read or written — resolving them needs the same table-wide repeated-column/row cursor tracking odf.js's own readTable does before ever calling its own readPrintSettings, a genuinely separate, larger undertaking than this getter/setter's own scope.
  • OdsSheet/OdsEditor still have no column-width or row-height setter at all — discovered the same way as the printSettings gap above, while building pdfToOds's own smoke-test coverage. OdsSheet.cell()'s own column/row-materialisation (address.ts) creates a real, explicit table:table-column/table:table-row element for any position a caller ever addresses, but never gives it a width/height style. This is a genuinely different failure shape from a column/row with NO element at all: sheets.ts's own resolveAxis only falls back to DEFAULT_COLUMN_WIDTH_PT/DEFAULT_ROW_HEIGHT_PT for an index with no ContentSheetColumn/ContentSheetRow entry whatsoever — an explicit-but-unstyled element reads back at widthPt/heightPt 0 (odf.js's own resolveColumnWidthPt/readRowLayout), and that explicit zero wins over the fallback. A sheet built purely through createOds()/OdsSheet.cell() therefore renders with a zero-size grid — real content needs an explicit column-width/row-height style, exactly as a real LibreOffice-authored file always has one (see src/test-support/ods.ts's own fixtures, which set one deliberately). buildOdsPackage (src/edit/ods/content.ts) documents this as a tracked, bounded gap alongside ContentSheetImage/embeddedObjects, mirroring buildOdtPackage's own identical image/colSpan-write gaps.
  • reconstructDrawing maps recovered geometry back onto ODF shapes near-1:1, with no clustering — but PDF's own content-stream operators still force several ContentVector kinds to collapse to a generic path on the way through. Every painted LayoutItem maps onto a ContentVector/ContentShape directly, in the exact z-order it was recovered — LayoutRectrect, LayoutEllipseellipse, LayoutLineline, LayoutPathpath, LayoutText/LayoutImageContentShape — a fundamentally more tractable problem than reconstructWordprocessing/reconstructPresentation's own paragraph/shape geometry clustering, since a drawing has no semantic structure to infer at all. The catch is upstream of reconstructDrawing itself, in what readPdf can even hand it: src/pdf/interpret.ts's LayoutRect fast path only fires for a fill-only rectangle under a non-rotated CTM (see the interpret.ts gotcha above), writeEllipse always emits an ellipse as four cubic Beziers with no PDF-level marker that it started life as an ellipse, and readPdf never reconstructs a 'line' kind item at all — so a stroked-and-filled rect, any ellipse, and any line each come back from a PDF as a generic LayoutPath, and reconstructDrawing correctly maps that to a ContentVector 'path', not the shape's original kind. Position, size, and fill/stroke colour still survive (within ordinary floating-point/string-formatting tolerance); only the vector's own discriminant kind narrows to whatever PDF's content-stream operators actually distinguish. A path vector's own reconstructed frame is a further, separate approximation: it is the tight bounding box of every recovered point, cubic control points included (a cubic curve is guaranteed to lie within their convex hull, so this never clips the curve) — which can legitimately be larger than whatever frame the original path's own author declared, if that frame didn't tightly bound its own control points to begin with (a real, valid ODF/SVG authoring pattern: a viewBox/frame is a declared coordinate window, not a guaranteed tight bounding box). A single original drawing text box that PDF's own greedy line-wrapper split across several lines does not reconstruct as one multi-line shape: reconstructDrawing maps each recovered LayoutText item to its own separate ContentShape (the same one-LayoutItem-to-one-shape rule every other kind follows), so a wrapped multi-line text box comes back as several small, independently-positioned text boxes, one per original line — confirmed visually against real LibreOffice (see the real-file verification note below); the full text content still survives, just redistributed. buildOdgPackage (src/edit/odg/content.ts) is pdfToOdg's own package-building half, mirroring buildOdtPackage/buildOdpPackage's role for pdfToOdt/pdfToOdp.
  • Two real, confirmed-against-actual-LibreOffice-rendering fill bugs were fixed as part of building reconstructDrawing/pdfToOdg, not by it. Both are pre-existing gaps in code that reconstructDrawing's own real-file verification exposed, not something the reconstruction algorithm itself introduced, and both apply to every .odg this package writes, not only a reconstructed one: (1) src/edit/odg/style.ts's graphicPropertyAttrs wrote draw:fill-color alone, with no accompanying draw:fill="solid" — real LibreOffice 26.2 fills a draw:rect/draw:ellipse that way fine, but silently renders a draw:path with the identical omission as unfilled, even with a fill colour declared. draw:fill="solid" is now written explicitly whenever a fill is set, for every vector kind. (2) writeEllipse (src/pdf/content-write.ts) never emitted a PDF closepath (h) operator, even though its four Bezier arcs already return exactly to their own starting point — PDF fill operators close every subpath implicitly regardless (ISO 32000-1 8.5.3.1), but readPdf's own general path tracking only marks a subpath closed: true when it actually sees an explicit h, so a PDF-round-tripped ellipse came back with closed: false, which correctly-behaving ODF/SVG consumers then refuse to fill even with draw:fill="solid" set. writeEllipse now emits h before its paint operator, drawing no additional ink (the path was already geometrically closed) but recording that closure explicitly.
  • A vector primitive's own fill/stroke needed a self-contained graphic-family style writer, not odf.js's own StyleRegistry. 'graphic' is a recognised StyleFamily member (odf.js's src/styles/registry.ts), but StylePropertiesSchema/buildStylePropertyElements (properties.ts/serialize.ts) only ever model text/paragraph formatting and never emit a style:graphic-properties element for any family — extending that shared package for one narrow, documents.js-local need (draw:fill(-color)/draw:stroke + svg:stroke-color/svg:stroke-width) would be scope creep into a foreign package for a two-attribute-group writer this package can express directly. src/edit/odg/style.ts is that writer: it still reuses odf.js's general append-only style-editing invariant (a setter always mints a fresh style:style and repoints draw:style-name, never mutates an existing entry — verified by the same assertAutomaticStylesOnlyAppended helper OdpEditor's own live-view fidelity test uses) and src/edit/odt/automatic-styles.ts's ensureAutomaticStyles/nextStyleName (the "find-or-create office:automatic-styles, mint the next unused name" logic every other hand-rolled style helper in this package already shares), rather than a third reimplementation of either.
  • A path vector's own svg:d is cross-checked against odf.js's real parser, not merely asserted to "look plausible". src/edit/odg/svg-path.ts's buildSvgPathData is the write-side inverse of odf.js's parseOdfPathData; OdgPathVector.subpaths re-derives its value by reparsing the actual written svg:viewBox/svg:d through that exact function (plus parseOdfViewBox/buildOdfSubpaths) on every read, rather than echoing back whatever ContentSubpath[] the caller originally passed to addPath — so every read is itself a live round-trip proof, and this module's own test suite additionally feeds buildSvgPathData's output straight into parseOdfPathData to confirm point-for-point recovery.
  • A newly added vector/shape's paint order is expressed purely as document order, with no draw:z-index ever written. This matches odf.js's own reader-side convention exactly (typed/draw/shapes.ts's paintOrderKey: honour an explicit draw:z-index when present, otherwise fall back to document order — and real LibreOffice output never emits one, it reorders elements instead), so OdgPage.addRect/addEllipse/addLine/addPath/addTextBox/addImage simply append to draw:page's own children in call order and nothing more is needed for a later add* call to paint in front of an earlier one.
  • LayoutPathSchema (document-content-model) has no quadratic or elliptical-arc segment kind, deliberately — not a scope gap that happens to be unfilled. writePath (src/pdf/content-write.ts) therefore has no quadratic-to-cubic elevation and no SVG-arc-to-cubic endpoint-to-centre parameterization anywhere in it: odf.js's own real-LibreOffice-output-verified svg:d parser (typed/shared/path.ts) recognises S/s/Q/q/T/t/A/a as command letters (so its own token stream stays in sync) but produces no segment for any of them — real LibreOffice output for rectangles, ellipses, freeform curves, and basic custom-shape presets never emits a quadratic or an arc in the first place, only M/L/H/V/C/Z. Building unused quadratic/arc conversion code against a segment kind that can never occur would be speculative, not root-cause work.
  • A drawing page's shapes and vectors paint in two independently-ordered arrays, with no field recording their relative order. ContentDrawPageSchema (document-content-model) keeps text/image/table content (shapes) and vector primitives (vectors) as two separate arrays, each correctly paint-ordered on its own by odf.js's own reader (honouring a real draw:z-index when present, falling back to document order otherwise) — but there is no shared ordering field between the two arrays at all, a real, tracked gap in the shared schema, not something convertDrawingToLayout can reconstruct after the fact. convertDrawingToLayout resolves it with one fixed, documented choice: every vector paints before every shape (vectors are the common "diagram" content in a real .odg; shapes are far more often text labels layered on top of them than the reverse). A page that genuinely interleaves the two mid-stack will not paint in true document z-order until the schema itself grows a shared field. reconstructDrawing resolves the identical gap in reverse the same way: it buckets each recovered LayoutItem into vectors or shapes by kind while walking the page once in overall paint order, so each array keeps its own items' relative order — which reproduces a convertDrawingToLayout-produced page's original paint order exactly (vectors-then-shapes, by construction), and is still the best either array's own shape is able to express for a LayoutDocument from any other producer.
  • A vector primitive's own rotation is never read at all. None of ContentVectorSchema's variants carry a rotation field, unlike ContentShapeSchemareadOdgContent's underlying odf.js reader deliberately discards a draw:rect/draw:ellipse/draw:custom-shape's own rotation, so it reads (and convertDrawingToLayout places) at its unrotated bounding frame. A real, tracked model limitation inherited from odf.js, not something this package's own layout code introduces.
  • ContentVector's path variant's fillRule is never populated by the reader — always undefined, which writePath treats as nonzero. odf.js's readDrawPathVector does not currently resolve an evenodd fill rule from real ODF output, so every path this pipeline reads paints with PDF's default nonzero winding rule. LayoutPathSchema/writePath fully support fillRule: 'evenodd' regardless — a caller constructing a LayoutPath (or a future ContentVector producer) directly can still set it; it just never arrives via odgToPdf today.
  • ContentSheetCellSchema (document-content-model) models no per-cell border or background, and no per-cell alignment override — unlike ContentTableCellSchema.background. sheets.ts's cell-background and cell-border z-order steps are consequently skipped entirely (no dead placeholder code), and cell text alignment always falls back to the value-kind default (numeric right, boolean/error centre, string left) since there is nothing to override it with. A tracked, documented gap, not a silent one.
  • PDF output uses the standard 14 fonts only — no font embedding. Helvetica/Times-Roman are genuinely metric-compatible substitutes for Arial/Times New Roman, but Word's actual current defaults (Calibri, Aptos) are not, so line wrapping and pagination will drift slightly from what Word itself would produce. Expect a faithful visual approximation, not a line-identical reproduction.
  • Reading arbitrary real-world PDFs is the single largest risk surface in this package, and the parser is honest about its design target: cleanly-generated output from mainstream producers (Word, PowerPoint, Chrome, LibreOffice, Acrobat), recovering from the malformations those producers and their downstream tooling actually create, and failing loudly and specifically on anything else — not matching a mature library's robustness against adversarial input.
  • Encrypted PDFs are unsupported. /Encrypt present in the trailer throws PdfEncryptedError, even for the common empty-user-password case.
  • CCITTFaxDecode/JBIG2Decode/JPXDecode PDF images are unsupported (scanned-fax and JPEG2000 formats) — the image is skipped with a diagnostic, the rest of the page still reads. JPEG images (DCTDecode) pass through completely losslessly in both directions; PNG-sourced images go through a real, narrowly-scoped hand-written codec.
  • PDF → docx/pptx reconstruction has no table or vector-shape recovery. A PDF has no semantic table structure to recover — a wide horizontal gap on a line becomes a tab character, not a reconstructed grid. General vector paths, curves, gradients, and shadings are not recovered either. This is a genuinely different scope boundary from pdfToOds's own grid recovery (see the pdfToOds gotcha above): there, the grid itself IS the deliverable, so a real gridline lattice or text-position clustering builds one deliberately, within its own honestly-scoped limits (a bare string per cell, never a typed value).
  • Table cell colSpan/rowSpan and pptx shape rotation are read from a ContentDocument but not yet written back by buildDocxPackage/buildOdtPackage/buildPptxPackage — a merged cell round-trips as an ordinary unmerged one, and a rotated pptx shape round-trips unrotated (buildOdpPackage does not share the rotation half of this gap — see the OdpShape.rotationDeg gotcha above). Both are bounded, tracked gaps (the cell's own text content and the shape's own position are still correct), not silent ones.
  • docx headers/footers, live PAGE/NUMPAGES field substitution, and inline images are not read by readDocxContent — a deliberate, tracked scope narrowing from the original design, not an oversight.
  • pptx speaker notes survive pptxToPdf/pdfToPptx, but not through any real PDF feature. PDF has no native concept of hidden presenter notes, so convertPresentationToLayout carries ContentSlide.notes as a hidden /Subtype /Text annotation on the page (the same construct Acrobat's own sticky-note tool uses, marked with the Hidden annotation flag so it never renders or prints), and reconstructPresentation reads it back via a /T marker that distinguishes this package's own notes annotation from a genuine third-party sticky note. This is a round-trip mechanism specific to this package's own writer/reader pair — a PDF produced by anything else will never carry it, and a PDF consumer other than this package's own readPdf will never see it as anything but an invisible, empty sticky note.
  • odmToPdf is the one conversion in this package that is not purely bytes-in/bytes-out. A .odm (ODF master document) never carries its own chapters' content — each text:section is a bare external reference (text:section-source's xlink:href + text:filter-name) to a standalone .odt file, confirmed against real, unmodified LibreOffice 26.2 output while building odf.js's own readOdm: a self-closing text:section-source with no xlink:show/xlink:type, no manifest entry for the linked part, and no chapter text anywhere in the master document's own content.xml. There is consequently no way for odmToPdf to read a chapter's content from the .odm bytes alone — it takes an options.resolveSubDocument callback, called once per section with that section's own href, to hand back the chapter's own .odt bytes. Every section left unresolved (no callback given, or the callback returns undefined for that href) is collected across the whole document before anything throws, and reported together in one OdmUnresolvedSectionError naming every unresolved href — not just whichever section the read loop happened to reach first. odmToPdf is consequently not one of the twelve round-trip conversions or six bridges above, and is deliberately not wired into the DocumentConverter port either: that port's convert(request, options) contract is a fixed single-bytes-in/bytes-out shape, and widening it with a resolver parameter for this one format would leak an odm-specific concern into every other conversion's own request shape — a caller wanting odmToPdf behind the port can wrap it in their own adapter. OdmSection.inlineContent (declared by odf.js's own readOdm for schema-completeness, covering a producer that caches a chapter's content inline rather than only linking it) is handled too, via the same readOdfParagraph/readOdfTable primitives odf.js's own readOdt calls internally — but the installed odf.js 1.9.0 never actually populates it for any real document readOdm was tested against, so this branch is exercised only by a directly-constructed OdmSection in this package's own test suite, not by any .odm fixture.
  • sourcePath traces a LayoutItem back to the ContentDocument node it came from, but only within one read+layout pass. ooxml.js's readDocx/readPptx stamp every ContentRun/ContentImageBlock/ContentTable/ContentShape with a positional path (sections[0].blocks[2].runs[1], slides[1].shapes[3].blocks[0]); convertWordprocessingToLayout/convertPresentationToLayout copy that same string onto whichever LayoutText/LayoutImage/LayoutLink/LayoutRect item(s) it produces, so a positioned PDF-side item can be traced back to its semantic origin. When line-wrapping splits one run's word across a run boundary, every resulting fragment gets its own run's path (not a shared or merged one); when a single run is emergency-split across several lines or pages, every resulting fragment keeps that same one run's path unchanged. A table cell's background LayoutRect is attributed to its containing table's own sourcePath, since ContentTableCell carries none of its own. This is not an edit-tracking or incremental-relayout mechanism — the path is only valid against the exact ContentDocument/Package it was assigned from in that one read; editing the document, re-reading it, or reordering its blocks invalidates every previously-captured path, and nothing here recomputes or diffs paths across two versions of a document.

Fidelity

docx/pptx/odt/odp/ods/odg → PDF is a genuine layout render: the docx/odt flow/pagination engine and the pptx/odp direct-placement engine both produce real positioned text, images, tables, and (for docx/odt) numbered/bulleted lists, styled through the full cascade (theme fonts/colours, basedOn chains, placeholder inheritance for docx/pptx; style:default-style/style:parent-style-name chains for odt/odp). odg renders its vector primitives (rect/ellipse/line/path, the last emitted as real PDF m/l/c/h content-stream operators, not a polygon approximation of any curve) and reuses the pptx/odp direct-placement engine's own shape conversion for whatever text it also carries. It is a faithful visual approximation, not a pixel- or line-identical reproduction of what Word/PowerPoint/Writer/Impress/Draw would themselves render — see the standard-14 font substitution gotcha above.

PDF → docx/pptx/odt/odp is necessarily a best-effort reconstruction from geometry: a PDF page is just positioned glyphs and images, with no semantic paragraph or shape structure to recover. Reading order, bold/italic/colour/font-size, and page/slide count are preserved; paragraph and text-block boundaries are inferred from baseline spacing and left-margin indentation, not recovered exactly.

PDF → odg (reconstructDrawing) is a best-effort reconstruction too, but for the opposite reason: not because a drawing's structure is hard to infer, but because a drawing has no semantic structure to infer at all, so there is no clustering step to get right or wrong in the first place. Every recovered LayoutItem maps close to 1:1 onto a ContentVector/ContentShape, in the exact order it was painted. What is genuinely lossy is upstream of reconstructDrawing, in what a PDF's own content-stream operators can even preserve: position, size, and fill/stroke colour survive within ordinary floating-point tolerance regardless of vector kind, but a filled-and-stroked rect, any ellipse, and any line each come back as a generic path vector rather than their original kind, since PDF has no native rect/ellipse/line primitive beyond one narrow fast-path case — see the reconstructDrawing gotcha above for the exact boundary. The one place this genuinely reorganises content rather than just approximating it: a single wrapped multi-line text box comes back as several separate single-line text boxes, one per line PDF's own line-wrapper produced, since reconstructDrawing maps one LayoutText item to one shape with no clustering — the text survives, its original grouping into one box does not. Verified against real LibreOffice 26.2, not merely against this package's own reader: a richly-varied .odg (overlapping rects, a filled-and-stroked ellipse, a stroked line, a filled-and-stroked Bezier curve, a wrapped text label) round-tripped through odgToPdf then pdfToOdg opens as a valid drawing with correct position, colour, and z-order throughout, the curve genuinely curved rather than polygon-approximated, and only the vector-kind-narrowing and text-splitting above visibly distinguishing it from the source.

PDF → ods (pdfToOds, reconstructSpreadsheet) recovers what was printed, not what was entered. This is a harder, categorically different limit than any other reconstruction direction above, not merely a looser version of the same one: docx/pptx/odp/odg reconstruction can at least recover real text, formatting, and (for odg) exact vector geometry from a PDF's own positioned glyphs and paths. A spreadsheet cell's own value — a formula, a real typed number, a date serial, a currency code — never exists anywhere in a rendered PDF at all; a PDF only ever carries the rendered string Calc or Excel chose to print for that cell. reconstructSpreadsheet is honest about this rather than pretending otherwise: every recovered cell is a bare { kind: 'string', value: displayText }, never re-parsed back into a number/date/boolean, and never claimed as a formula, even when the recovered text looks numeric or date-shaped ("42.5", "2024-01-15") — see the pdfToOds gotcha above for the full detection algorithm (a real gridline lattice used directly as cell boundaries when a printed sheet had gridlines enabled, text-position clustering otherwise). Column widths, row heights, and page size are genuinely measured from whichever geometry was used; no print range, scale, fit-to-page, repeat-rows/columns, or manual breaks are ever inferred, since a rendered page carries no trace of print intent, only what was visually printed. Verified against real LibreOffice 26.2, not merely against this package's own reader: a genuine, gridline-and-headers-enabled four-column, four-row employee-record .ods (mixed string/date/boolean-looking cell content) round-tripped through odsToPdf then pdfToOds opens as a valid spreadsheet with every cell's text recovered in its correct row/column position, via the gridline-lattice path specifically (confirmed by inspecting the recovered printSettings.gridlines), and every recovered cell an honest string.

Neither direction is round-trip-lossless, and no conversion is the exact inverse of its own reverse direction — pdfToDocx(docxToPdf(x)) will not reproduce x exactly, and neither will pdfToOdg(odgToPdf(x)) or pdfToOds(odsToPdf(x)); neither is intended to. This is a deliberate, permanent contrast with ooxml.js's own packageCodec, which genuinely is a lossless round trip. docxPdfCodec/pptxPdfCodec/odtPdfCodec/odpPdfCodec/odsPdfCodec/odgPdfCodec/pdfCodec share packageCodec's mechanism (z.codec(), schema-validated both ways) but not its guarantee — wrapping a lossy conversion in z.codec() validates the shape of what comes out, not its fidelity to what went in.

The six cross-format bridges (odtToDocx/docxToOdt, odpToPptx/pptxToOdp, odsToXlsx/xlsxToOds) are a categorically different case from every conversion above: they bypass the PDF pivot entirely, so the "not round-trip-lossless" caveat that applies to every PDF-pivot conversion in this section does not carry over to them. There is no layout engine (no flow, no line-wrapping, no pagination) and no geometry-based reconstruction (no baseline clustering, no gridline-lattice detection) anywhere in a bridge's own call path — each is nothing more than buildYPackage(readXContent(decodePackage(bytes))), composing the identical reader/builder pair the PDF-pivot conversions on either side of the bridge already use, because both formats in each pair read into and build from the exact same ContentDocument variant. Concretely, for odt ⇄ docx and odp ⇄ pptx: text, run styling (bold/italic/underline/colour/font/size), paragraph styleId, list membership and nesting level, table structure and cell content, and (for odp ⇄ pptx) speaker notes all survive completely — proven by src/convert/bridges.test.ts's own dedicated round-trip suite, exercised in both directions from both starting formats, and cross-checked by opening genuinely LibreOffice-produced source files and their bridged output in real LibreOffice (see that test file and this repo's own verification notes). The one confirmed gap is a table shape nested inside an odp/pptx slide shape, not the document/presentation structure itself — see the gotcha above. ods ⇄ xlsx preserves cell values, formulas (verbatim), and merged ranges completely, and column widths within a roughly one-pixel rounding tolerance on the odsToXlsx hop — but, being built on ooxml.js's brand-new readXlsxContent/buildXlsxPackage, carries several real, honestly-documented format-boundary limits of its own (percentage/currency downgrading to a plain number, time collapsing into date, xlsx column widths not surviving the xlsxToOds return hop, and a formula written in one dialect showing as a genuine formula error in a REAL spreadsheet application expecting the other) — see the ods ⇄ xlsx gotcha above for the full, specific list. None of this is layout drift or reconstruction guesswork; every gap listed is a genuine format-boundary limit (a cell type, a value kind, or a write-side omission that exists independently of this bridge), not an approximation introduced by the bridge itself.

Optional real-world corpus. test/corpus/ (gitignored, never committed) holds a pnpm test:corpus vitest project for manual conformance checking against real PDFs a hand-built fixture can't fully stand in for — a Word "Save as PDF", a PowerPoint "Save as PDF", a Chrome "Print to PDF", a LibreOffice export. It is not part of pnpm test and does not gate CI; drop files in locally before a significant parser change.

Release and publishing

.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.

Whether that release actually published a new version is detected by diffing package.json's version before and after the release step, not by trusting a third-party action's own detection. Two further jobs gate on that: one republishes the same build under the scoped @exadev/documents.js alias to GitHub Packages (which has no OIDC exchange of its own, so it authenticates with GITHUB_TOKEN instead), and one packs the release into its own directory, generates an SPDX SBOM (pnpm sbom), and signs both an SBOM and a build-provenance attestation against that exact tarball — verifiable independently of the registry, and still present if the package is later unpublished.

Contributing

Commits follow Conventional Commits (feat:, fix:, test:, chore:, …), enforced by commitlint (commitlint.config.ts) via a husky commit-msg hook and a CI commitlint job — semantic-release's version bump depends on these being well-formed, not just style. 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.

References

  • ooxml.js — the sibling package this depends on for all docx/pptx/xlsx ⇄ JSON handling and cascade-resolved typed reading, including its own readXlsxContent/buildXlsxPackage (a ContentDocument-shaped xlsx reader/writer pair), consumed directly by src/convert/convert.ts's odsToXlsx/xlsxToOds bridge but not re-exported from this package's own public surface.
  • document-content-model — the sibling package that owns ContentDocument/LayoutDocument themselves; both ooxml.js and documents.js import from it rather than each maintaining an independent copy.
  • odf.js — a sibling package doing the equivalent lossless-codec job for the OpenDocument Format (odt/ods/odp/odg/…), also built on document-content-model. A dependency of documents.js for: this package's Odt/Ods/Odp/OdgBytesSchema (src/model/bytes.ts), which validate against its ODF_MEDIA_TYPES table; src/interop.test.ts, a type-level guard that ooxml.js's and odf.js's raw XmlElement/XmlNode/Attribute/Package container types stay structurally compatible; src/odf/odt/read.ts's readOdtContent, a thin adapter over odf.js's own readOdt, feeding odtToPdf/pdfToOdt (src/convert/convert.ts); src/odf/odp/read.ts's readOdpContent, the same adapter over odf.js's readOdp, feeding odpToPdf/pdfToOdp; src/odf/ods/read.ts's readOdsContent, the same adapter over odf.js's readOds, feeding odsToPdf/pdfToOds, and reused directly by src/edit/ods/print-settings.ts's own readSheetPrintSettings (findStyleElement/resolvePageLayoutProperties/parsePageSize/parseMargins, the same style-chain-resolution primitives readOds's own readPrintSettings is built on); src/odf/odg/read.ts's readOdgContent, the same adapter over odf.js's readOdg — including its own typed/shared/path.ts, the real-LibreOffice-output-verified svg:d/draw:points parser this package's writePath content is ultimately sourced from, and which src/edit/odg/svg-path.ts's buildSvgPathData (the write-side inverse) also cross-checks its own output against directly — feeding odgToPdf/pdfToOdg (the latter re-reading a rebuilt package's own real geometry through this same readOdg, not just writing one); src/edit/odt/*'s StyleRegistry/resolveStyle (style interning), src/edit/odp/shape.ts's applyOdfTransform/resolveOdfShapeGeometry (rotation), and src/edit/odt/automatic-styles.ts's ensureAutomaticStyles/nextStyleName (reused by src/edit/odg/style.ts's own graphic-family style writer and src/edit/ods/print-settings.ts's own page-layout/master-page/table-style minting), all consumed directly rather than reimplemented. odt, odp, ods, and odg → ContentDocument reading and PDF conversion are now all integrated both ways.

License

MIT

About

Bidirectional docx/pptx <-> PDF conversion, a read-and-write live-view editor, and a hand-written PDF codec, built on ooxml.js.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages