Guides · SDK · Native and headless

The OpenDoc SDK

Create, read, edit, template, convert and render word-processing documents in code. One Rust engine sits behind every language, and each language gets the API its developers already know, so moving a program here is mostly a change of import. The engine lays the pages out itself, so the same call that writes a DOCX also writes a real-text PDF or a page image, and every open and every save reports what it could not carry.

Not published yet

Nothing here is on npm, PyPI, Maven Central or crates.io yet: publishing is a separate decision for the owner. Every install below builds from a checkout of this repository, and the commands are the ones the SDK's own CI jobs run.

Three products, one engine

OpenDoc ships as three products over the same engine, cut by what a host needs rather than switched by a mode at run time. A page the Viewer shows, a page the Editor shows and a page this SDK renders are the same page.

Product
Runs in
What it is for
@casualoffice/opendoc-viewer
A browser or Node (WebAssembly), or a server (native Rust)
Show a document exactly as laid out: page images, thumbnails, PDF and text for search. Read-only, with no editing code in it.
@casualoffice/opendoc-editor
A browser
The full editor inside your product, with review and co-editing. It has its own guide: Embed the editor.
@casualoffice/opendoc
Node and the browser (headless), Python, Java, Rust, C, and a command line
This page. Create, read, edit and convert documents in code, with no UI: open, change, then save, render or convert.

Install it

JavaScript and TypeScript. The npm name will be @casualoffice/opendoc, which is not published yet. It is the WebAssembly build of the engine, with every bundled font inside it, so a render in Node needs nothing from the machine:

packages/opendoc/README.md — build and pack it from a checkout
./packages/opendoc/build.sh                      # wasm-pack; needs Rust and wasm-pack
cd packages/opendoc && npm test && npm pack

Python. A wheel for Python 3.9 and later, built with maturin over PyO3:

bindings/python/README.md — build the wheel locally
python -m venv .venv && . .venv/bin/activate
pip install maturin pytest
cd bindings/python
maturin develop --release        # or: maturin build --release (wheel in target/wheels)
pytest tests

Java. JDK 22 or later, through the Foreign Function and Memory API, so there is no JNI glue and the jar has no dependencies. Run with --enable-native-access=ALL-UNNAMED:

bindings/java/README.md — build the jar and its native library
./bindings/java/build.sh     # cargo build -p opendoc-ffi --release, javac, jar, conformance test

Rust and the command line. The crate is opendoc in crates/opendoc, used as a path or git dependency until it is published. The CLI is cargo build -p opendoc-cli --release, which builds one binary, opendoc. C and other languages call the engine's C ABI, crates/opendoc-ffi, whose header is include/opendoc.h.

One example per language

Each panel is a whole file from this repository, copied by webapp/tools/build-sdk-page.mjs, and a test runs each of those files. A guard fails the build if a panel and its file disagree.

JavaScript: open, edit and save four formats

packages/opendoc/examples/read-modify-convert.mjs — run by packages/opendoc/test/examples.test.mjs
// Open any DOCX/ODT/RTF, read it, change it, and save it four ways, with the
// loss report the save returns.
//
//   node examples/read-modify-convert.mjs input.docx out-dir
import { mkdir } from "node:fs/promises";
import { join } from "node:path";

import { openFile, saveFile } from "@casualoffice/opendoc/node";

const [input, out = "out"] = process.argv.slice(2);
if (!input) throw new Error("usage: node read-modify-convert.mjs input.docx [out-dir]");
await mkdir(out, { recursive: true });

const doc = await openFile(input);
console.log(`${doc.sourceFormat}: ${doc.paragraphs.length} paragraphs, ${doc.tables.length} tables, ${doc.pageCount} pages`);
console.log("not carried at open:", doc.importReport.findings.filter((f) => f.loss).map((f) => f.feature));
for (const p of doc.paragraphs.filter((p) => p.headingLevel !== undefined)) {
  console.log(`${"  ".repeat(Math.max(0, p.headingLevel - 1))}${p.text}`);
}

const replaced = doc.replaceAll("Draft", "Final");
const note = doc.addParagraph("Reviewed by the OpenDoc SDK.", { style: "Quote" });
doc.addComment(note, 0, 8, "Automated review");
console.log(`replaced ${replaced}; ${doc.comments.length} comment(s)`);

for (const ext of ["docx", "odt", "pdf", "md"]) {
  const report = await saveFile(doc, join(out, `output.${ext}`));
  console.log(`output.${ext}: ${report.losses.length ? report.losses.map((l) => l.feature).join(", ") : "nothing lost"}`);
}

JavaScript: a docx-js program, then a PDF

packages/opendoc/examples/build-report.mjs — run by packages/opendoc/test/examples.test.mjs
// A docx-js program, unchanged apart from its import, that also renders.
//
//   node examples/build-report.mjs out-dir
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";

import {
  AlignmentType,
  Document,
  Footer,
  HeadingLevel,
  Packer,
  PageNumber,
  Paragraph,
  Table,
  TableCell,
  TableOfContents,
  TableRow,
  TextRun,
  WidthType,
} from "@casualoffice/opendoc/docx";

const out = process.argv[2] ?? "out";
await mkdir(out, { recursive: true });

const rows = [
  ["Region", "Q1", "Q2"],
  ["North", "1,200", "1,350"],
  ["South", "980", "1,105"],
];

const doc = new Document({
  creator: "Finance",
  title: "Quarterly report",
  features: { updateFields: true },
  sections: [
    {
      footers: {
        default: new Footer({
          children: [
            new Paragraph({
              alignment: AlignmentType.CENTER,
              children: [new TextRun({ children: ["Page ", PageNumber.CURRENT, " of ", PageNumber.TOTAL_PAGES] })],
            }),
          ],
        }),
      },
      children: [
        new Paragraph({ text: "Quarterly report", heading: HeadingLevel.TITLE }),
        new TableOfContents("Contents", { hyperlink: true, headingStyleRange: "1-2" }),
        new Paragraph({ text: "Summary", heading: HeadingLevel.HEADING_1 }),
        new Paragraph({
          children: [
            new TextRun("Revenue grew "),
            new TextRun({ text: "12%", bold: true, color: "2E7D32" }),
            new TextRun(" quarter on quarter."),
          ],
        }),
        new Paragraph({ text: "By region", heading: HeadingLevel.HEADING_1 }),
        new Table({
          width: { size: 100, type: WidthType.PERCENTAGE },
          rows: rows.map(
            (cells, index) =>
              new TableRow({
                tableHeader: index === 0,
                children: cells.map(
                  (text) =>
                    new TableCell({ children: [new Paragraph({ children: [new TextRun({ text, bold: index === 0 })] })] }),
                ),
              }),
          ),
        }),
      ],
    },
  ],
});

await writeFile(join(out, "report.docx"), await Packer.toBuffer(doc));
// What docx-js cannot do: the same document, laid out.
await writeFile(join(out, "report.pdf"), await Packer.toPdf(doc));
await writeFile(join(out, "report-page-1.png"), await Packer.toPng(doc, { page: 0, dpi: 96 }));
console.log(`wrote report.docx, report.pdf and report-page-1.png to ${out}`);

Python: python-docx calls, then a PDF

bindings/python/examples/quickstart.py — run by bindings/python/tests/test_opendoc.py
# SPDX-License-Identifier: Apache-2.0
"""python-docx code, then what python-docx cannot do: a PDF and a page image.

    python examples/quickstart.py out-dir

tests/test_opendoc.py runs this file, so it cannot rot.
"""
import pathlib
import sys

from opendoc import Document, Pt

out = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "out")
out.mkdir(parents=True, exist_ok=True)

document = Document()
document.add_heading("Quarterly report", 0)
p = document.add_paragraph("Revenue grew ")
p.add_run("12%").bold = True
p.add_run(" quarter on quarter.")
p.runs[1].font.size = Pt(14)
table = document.add_table(rows=1, cols=2)
table.rows[0].cells[0].text = "Region"
table.rows[0].cells[1].text = "Q2"
table.add_row().cells[0].text = "North"

report = document.save(out / "report.docx")
print("report.docx:", "nothing lost" if report.is_lossless else report.losses)
document.save(out / "report.pdf")
(out / "page-1.png").write_bytes(document.render_page_png(0, dpi=96))
print(f"{document.page_count} page(s) laid out by the engine")

Java: POI calls, then a PDF

bindings/java/src/examples/java/org/casualoffice/opendoc/examples/Quickstart.java — run by bindings/java/build.sh
// SPDX-License-Identifier: Apache-2.0
package org.casualoffice.opendoc.examples;

import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;

import org.casualoffice.opendoc.OpenDocument;
import org.casualoffice.opendoc.OpenParagraph;
import org.casualoffice.opendoc.OpenRun;
import org.casualoffice.opendoc.OpenTable;
import org.casualoffice.opendoc.ParagraphAlignment;

/** POI XWPF calls, then what POI cannot do: a PDF and a page image. build.sh runs it. */
public final class Quickstart {
    private Quickstart() {}

    public static void main(String[] args) throws Exception {
        Path out = Files.createDirectories(Path.of(args.length > 0 ? args[0] : "out"));
        try (OpenDocument doc = new OpenDocument()) {
            OpenParagraph title = doc.createParagraph();
            title.setAlignment(ParagraphAlignment.CENTER);
            OpenRun run = title.createRun();
            run.setBold(true);
            run.setText("Quarterly report");
            OpenTable table = doc.createTable(2, 2);
            table.getRow(0).getCell(0).setText("Region");
            table.getRow(1).getCell(0).setText("North");
            try (OutputStream stream = Files.newOutputStream(out.resolve("report.docx"))) {
                doc.write(stream);
            }
            doc.save(out.resolve("report.pdf"));
            Files.write(out.resolve("page-1.png"), doc.renderPagePng(0, 96f));
            System.out.println(doc.getPageCount() + " page(s); lost on save: "
                + doc.getLastSaveReport().get("losses"));
        }
    }
}

Rust: an edit batch, three outputs

crates/opendoc/examples/quickstart.rs — run by crates/opendoc/tests/sdk.rs
// SPDX-License-Identifier: Apache-2.0

//! Build a document, edit it in one all-or-nothing batch, then save it as DOCX
//! with a loss report, as PDF and as a page image, all from one layout.
//!
//! `cargo run -p opendoc --example quickstart -- out-dir`. `tests/sdk.rs` runs
//! the same `run` function, so this file cannot rot.

use std::path::Path;

use opendoc::{Document, Error, ErrorCode, Format, Position, RunFormat, SaveMode};

/// Writes `report.docx`, `report.pdf` and `page-1.png` into `out`; returns
/// the page count and the features the DOCX save lost (none here).
pub fn run(out: &Path) -> opendoc::Result<(usize, Vec<String>)> {
    let mut doc = Document::create()?;
    doc.edit(|e| {
        e.append_heading("Quarterly report", 1)?;
        let p = e.append_paragraph("Revenue grew ", None)?;
        e.append_run(&p, "12%", &RunFormat::bold())?;
        e.insert_table(&Position::End, 2, 2, &[vec!["Region".into(), "Q2".into()]])?;
        Ok(())
    })?;

    let (docx, report) = doc.save(Format::Docx, SaveMode::PreserveWhenSafe)?;
    let write = |name: &str, bytes: &[u8]| {
        std::fs::create_dir_all(out)
            .and_then(|()| std::fs::write(out.join(name), bytes))
            .map_err(|e| Error::new(ErrorCode::InvalidArgument, e.to_string()))
    };
    write("report.docx", &docx)?;
    write("report.pdf", &doc.to_pdf()?)?;
    write("page-1.png", &doc.render_page_png(0, 96.0)?)?;
    let lost = report.losses.iter().map(|l| l.feature.clone()).collect();
    Ok((doc.page_count(), lost))
}

#[allow(dead_code)]
fn main() -> opendoc::Result<()> {
    let out = std::env::args().nth(1).unwrap_or_else(|| "out".to_owned());
    run(Path::new(&out)).map(|_| ())
}

The command line

tools/opendoc-cli/src/main.rs — the usage the binary prints
usage:
  opendoc convert  IN OUT [--to FMT] [--mode exact|preserve|semantic] [--report FILE] [--strict-loss]
  opendoc render   IN [--out DIR] [--format png|svg|pdf] [--dpi N] [--pages 1-3,5]
  opendoc info     IN [--json]
  opendoc diff     OLD NEW [--json]
  opendoc template TEMPLATE DATA.json OUT [--delimiters "[[,]]"] [--strict] [--null TEXT]
  opendoc build    SPEC.json OUT [--media FILE]...
  opendoc formats
common: --system-fonts  --font FILE  --author NAME
formats: docx dotx odt rtf txt json html md pdf

The API you already know

Each language's front door copies the most-used library's names and options, and lowers to the same engine underneath. A guard checks that every name in the right-hand column exists in the file that row names.

If you use You write Here you write What you gain
docx (docx-js) new Document({ sections }), Packer.toBuffer(doc) Document, Paragraph, TextRun, Packer.toBuffer, Packer.toPdf, Packer.toPng The same classes and options, plus a PDF and page images; an unknown option is an error rather than a silent skip.
docxtemplater doc.render(data), {#items}…{/items} Template, render, toBuffer The same tags with no zip library; pictures, tables, headers and footnotes in the template are kept.
mammoth convertToHtml({ buffer }) convertToHtml, extractRawText, convertToMarkdown The same result shape; the messages are the save's loss report, and fonts, sizes and colours are kept.
python-docx Document(), add_paragraph, add_run(...).bold = True Document, add_heading, add_paragraph, add_run, add_table, render_page_png The same calls; every edit is an engine operation, applied whole or not at all, and a save can be a PDF.
Apache POI (XWPF) XWPFDocument, createParagraph(), createRun() OpenDocument, createParagraph, createRun, setText, write, renderPagePng POI's names with an XWPF prefix swapped for Open; PDF, ODT and page images, and a loss report on every save.

Measured against them

The same inputs through OpenDoc and through each library, each library and task in its own process, as the median of 5 runs after a warm-up, on Apple M4 (10 cores, 16 GB), darwin arm64 25.3.0, Node v24.22.1. Every figure below is generated from benchmarks/sdk/results.json.

Task Input OpenDoc Competitor OpenDoc speed OpenDoc peak memory Competitor peak memory
Generate a DOCX (the same docx-js program) 100 paragraphs + table 4.04 ms docx-js 4.26 ms 1.1× faster 234.4 MB 87.8 MB
Generate a DOCX (the same docx-js program) 1000 paragraphs + table 18.09 ms docx-js 17.28 ms 1.0× slower 278.8 MB 150.8 MB
Generate a DOCX (the same docx-js program) 5000 paragraphs + table 60.24 ms docx-js 75.47 ms 1.3× faster 332.2 MB 410.7 MB
Fill a template (the same template and data) 100-item loop 2.57 ms docxtemplater 2.53 ms 1.0× slower 271.4 MB 93.2 MB
Fill a template (the same template and data) 1000-item loop 17.68 ms docxtemplater 10.34 ms 1.7× slower 319.1 MB 111 MB
DOCX to HTML sample 16.42 ms mammoth 34.99 ms 2.1× faster 335.7 MB 231 MB
DOCX to HTML scale 26.1 ms mammoth 53.78 ms 2.1× faster 310.1 MB 288.4 MB
DOCX to plain text sample 12.53 ms mammoth 30.58 ms 2.4× faster 332 MB 228.7 MB
DOCX to plain text scale 17.24 ms mammoth 41.51 ms 2.4× faster 317.6 MB 273.9 MB
DOCX to PDF sample 55.21 ms libreoffice — — 362.2 MB —
DOCX to PDF scale 261.39 ms libreoffice — — 362 MB —
Generate a DOCX (the same python-docx program) 100 paragraphs + table (Python) 2.6 ms python-docx 16.2 ms 6.2× faster 18.4 MB 44.2 MB
Generate a DOCX (the same python-docx program) 1000 paragraphs + table (Python) 24.39 ms python-docx 92.43 ms 3.8× faster 19.8 MB 63.8 MB
Generate a DOCX (the same python-docx program) 5000 paragraphs + table (Python) 187.96 ms python-docx 667.32 ms 3.6× faster 26.8 MB 81 MB
Open and read every paragraph (Python) sample (Python) 7.17 ms python-docx 7.2 ms 1.0× faster 26.4 MB 52.3 MB
Open and read every paragraph (Python) scale (Python) 43.26 ms python-docx 66.88 ms 1.5× faster 44.1 MB 56.2 MB
Generate a DOCX (POI-style calls, Java) 100 paragraphs + table (Java) 4.94 ms poi 10.15 ms 2.1× faster 98.6 MB 142.5 MB
Generate a DOCX (POI-style calls, Java) 1000 paragraphs + table (Java) 30.9 ms poi 20.85 ms 1.5× slower 200.4 MB 186.7 MB
Open and read every paragraph (Java) sample (Java) 14.59 ms poi 34.9 ms 2.4× faster 103.8 MB 217.1 MB
Open and read every paragraph (Java) scale (Java) 96.38 ms poi 92.45 ms 1.0× slower 217.4 MB 242.7 MB

Where it loses

Of the 20 rows above, 14 entries below are a loss, in speed or in memory. The list is generated, so none can be left out. In JavaScript the module carries a layout engine and every bundled face, which is most of the memory.

Formats, and what a save loses

This build opens 7 formats and saves 8, read from the format descriptors of the registry every open and save goes through, crates/casual-doc-io/src.

Format Open Save Carries the whole document
Office Open XML Document .docx Yes Yes Yes
Word Template .dotx No Yes Yes
OpenDocument Text .odt Yes Yes Yes
Rich Text Format .rtf Yes No —
PDF .pdf With the pdf-import feature (the CLI has it) Yes No: a rendition, and its save report says lossyByNature
Web Page .html .htm Yes Yes No: a rendition, and its save report says lossyByNature
Markdown .md .markdown Yes Yes No: a rendition, and its save report says lossyByNature
Plain Text .txt .text Yes Yes No: a rendition, and its save report says lossyByNature
OpenDoc Normalized JSON .json Yes Yes Yes

The loss report. Every open returns an import report that names what the engine did not carry, and every save returns a report whose losses name what the file will not hold. A same-format save has three modes: exact returns the original bytes when nothing changed, preserve regenerates and keeps the source parts the engine retained verbatim, and semantic regenerates from the model alone. None of the libraries in the table above reports loss.

What this does not do yet