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.
@casualoffice/opendoc-viewer@casualoffice/opendoc-editor@casualoffice/opendocInstall 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/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:
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/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
// 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
// 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
# 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
// 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
// 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
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.
- More memory: Generate a DOCX (the same docx-js program), 100 paragraphs + table,
docx-js— 234.4 MB against 87.8 MB - Slower: Generate a DOCX (the same docx-js program), 1000 paragraphs + table,
docx-js— 1.0× slower - More memory: Generate a DOCX (the same docx-js program), 1000 paragraphs + table,
docx-js— 278.8 MB against 150.8 MB - Slower: Fill a template (the same template and data), 100-item loop,
docxtemplater— 1.0× slower - More memory: Fill a template (the same template and data), 100-item loop,
docxtemplater— 271.4 MB against 93.2 MB - Slower: Fill a template (the same template and data), 1000-item loop,
docxtemplater— 1.7× slower - More memory: Fill a template (the same template and data), 1000-item loop,
docxtemplater— 319.1 MB against 111 MB - More memory: DOCX to HTML, sample,
mammoth— 335.7 MB against 231 MB - More memory: DOCX to HTML, scale,
mammoth— 310.1 MB against 288.4 MB - More memory: DOCX to plain text, sample,
mammoth— 332 MB against 228.7 MB - More memory: DOCX to plain text, scale,
mammoth— 317.6 MB against 273.9 MB - Slower: Generate a DOCX (POI-style calls, Java), 1000 paragraphs + table (Java),
poi— 1.5× slower - More memory: Generate a DOCX (POI-style calls, Java), 1000 paragraphs + table (Java),
poi— 200.4 MB against 186.7 MB - Slower: Open and read every paragraph (Java), scale (Java),
poi— 1.0× slower
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
- No release on any registry; build from a checkout.
- No .NET binding. C# can call the C ABI directly; a familiar API over it is later work.
- Java 8 to 21 would need a JNI shim over the same C ABI, which is not built.