BetterOffice

Rust

Open, edit, render, and save Office files with native Rust APIs

The Rust crates provide native APIs for the engines used by the browser editors. Start with betteroffice-docx, betteroffice-xlsx, or betteroffice-pptx. Individual parsing, editing, and rendering crates are also available.

cargo add betteroffice-xlsx

Open, edit, render, save

use betteroffice_xlsx::{CalculationOptions, CellRef, RenderOptions, SheetId, Workbook};

let mut workbook = Workbook::open_recalculated(&xlsx_bytes, CalculationOptions::default())?;

workbook.edit_cell(
    SheetId(0),
    CellRef::parse_a1("A1").unwrap(),
    "42",
    CalculationOptions::default(),
)?;

let png = workbook.render_sheet(SheetId(0), &RenderOptions::default())?;
let saved = workbook.save()?;

XLSX saves preserve untouched package parts, including charts, pivot tables, comments, macros, and custom XML. Changed worksheets are patched to retain unmodified row, column, and cell markup. Worksheets with missing or out-of-order row or cell addresses, and worksheets reconstructed from collaboration updates, are serialized from the model instead.

Workbook::read_cells, find_text, validate_edits and apply_edits take the same serializable requests as JavaScript and Python. A batch commits every step as one recalculated change, or returns an EditRefusal with the workbook untouched:

use betteroffice_xlsx::{EditRequest, Workbook};

let request: EditRequest = serde_json::from_value(serde_json::json!({
    "expectVersion": workbook.version(),
    "steps": [{
        "op": "setCellInputs",
        "target": { "sheetId": "sheet:0", "range": { "kind": "a1", "a1": "B3" } },
        "inputs": [["120"]],
    }],
}))?;
match workbook.apply_edits(&request)? {
    Ok(applied) => println!("now at {}", applied.version),
    Err(refused) => println!("refused: {:?}", refused.failure.code),
}

Batches do not insert or delete rows, columns or sheets, and refuse writes to merged-cell followers, array-formula cells and protected sheets.

Workbook::export_structured and export_markdown read the committed workbook with its version as sparse cells, sheet metadata and diagnostics, or as bounded Markdown with anchor markers; export_xlsx_structured, export_xlsx_markdown and render_xlsx_markdown work from bytes or content. None recalculates: formula results are the stored values.

use betteroffice_xlsx::{XlsxExportOptions, XlsxMarkdownOptions, export_xlsx_markdown};

if let Ok(export) = workbook.export_structured(&XlsxExportOptions::default())? {
    for cell in &export.content.sheets[0].cells {
        println!("{:?} {:?} {}", cell.anchor, cell.formula, cell.display_text);
    }
}
let markdown = export_xlsx_markdown(&xlsx_bytes, &XlsxExportOptions::default(), &XlsxMarkdownOptions::default())?;

Workbook::edit_cell_profiled and apply_ops_profiled return the usual edit outcome with an EditProfile. Supply a monotonic clock reporting milliseconds to collect validate, apply, recalculation, and result-building durations. Normal edit methods do not read a profiling clock.

Formula evaluation in betteroffice-xlsx-calc computes the date functions in the 1900 date system; only TEXT formats dates in a workbook's 1904 system. RAND is not implemented, and the RANDBETWEEN seed is set on EvalContext::rand_seed, not through CalculationOptions. ROW() and COLUMN() without a reference return #VALUE! in a context built by EvalContext::new, which has no calling cell. A defined name under ROW, COLUMN, ROWS or COLUMNS counts as a direct reference, so a name bound to a computed reference keeps no dependency edge to the cells that reference reads, and an OFFSET with a computed target re-evaluates on every recalculation but may read a same-pass write to that target one recalculation late. SEARCH and exact-mode VLOOKUP, HLOOKUP and MATCH match * and ? literally.

Open and save DOCX and PPTX files through their format APIs:

use betteroffice_docx::Document;

let mut document = Document::open(&docx_bytes)?;
let paragraph_id = document.paragraphs()[0].para_id.clone().unwrap();

document.replace_paragraph_text(&paragraph_id, "Updated in Rust")?;
let saved = document.save()?;
use betteroffice_pptx::Presentation;

let mut presentation = Presentation::open(&pptx_bytes)?;
let deck = presentation.snapshot()?;

presentation.register_font("Inter", false, false, &font_bytes)?;
let rendered = presentation.render_slide(0)?;
let saved = presentation.save()?;

open_with_limits on DOCX and PPTX accepts custom parser resource limits. Files that exceed those limits are rejected.

Document::export_structured exports the current DOCX model as read-only structured content, and export_markdown renders it as Markdown with source anchors:

use betteroffice_docx::{Document, ExportOptions, RevisionView, StorySelection};

let document = Document::open(&docx_bytes)?;
let content = document.export_structured(&ExportOptions {
    stories: Some(vec![StorySelection::Body, StorySelection::Headers]),
    ..ExportOptions::new(RevisionView::Accepted)
})?;
let markdown = document.export_markdown(&ExportOptions::new(RevisionView::Markup))?;

The export reads edits made through the model, lists omitted and unsupported content in diagnostics, and returns Error::Export for unusable limits. The same content comes from docx_edit::structured for a live EditingDoc or raw bytes. Document exports do not yet generate page maps: Document::layout paginates measurements you supply, which prove nothing about the current model. An EngineSession in betteroffice-docx-edit that lays a document out itself attaches one with export_structured_with_pages, refusing a stale or incomplete layout as the JavaScript API does.

Document::list_content_controls lists the content controls of the current model in document order, with the structured export's control metadata plus placement, anchor, parent control, current text and effective lock; find_content_controls keeps the exact matches of an id, tag, authored w:id or alias:

use betteroffice_docx::{ContentControlQuery, ContentControlsOptions, Document};

let document = Document::open(&docx_bytes)?;
let controls = document.list_content_controls(&ContentControlsOptions::default())?;
let query = ContentControlQuery::Tag { tag: "customer.name".to_owned() };
let named = document.find_content_controls(&query, &ContentControlsOptions::default())?;

Ids and anchors address the returned snapshot. A live EditingDoc lists the same controls with its version and fills plain- and rich-text ones through setContentControlText steps of apply_edits; the Document facade has no fill yet.

Comparing two DOCX files into tracked changes is available from the JavaScript package's compareDocx. The native Rust and Python APIs do not offer it yet: they follow once edited sessions save natively.

PPTX edits can also run as version-checked batches. Presentation::read_content and find_text return story text with the session version, and apply_edits applies every step against that version as one transaction and one undo step. Policy refusals, such as a stale version or an ambiguous search, come back as the inner Err(EditRefusal) with the deck unchanged:

use betteroffice_pptx::{EditRequest, ReadRequest};

let read = presentation.read_content(&ReadRequest::default())?.expect("readable deck");
let request: EditRequest = serde_json::from_value(serde_json::json!({
    "expectVersion": read.version,
    "steps": [{"op": "setSlideNotes", "target": {"slideId": read.slides[0].id}, "text": "Updated"}],
}))?;
match presentation.apply_edits(&request)? {
    Ok(applied) => assert!(applied.applied),
    Err(refusal) => eprintln!("{:?}: {}", refusal.failure.code, refusal.failure.message),
}

The request types are the JSON contract the JavaScript core and Python binding use; validate_edits runs the same checks without changing anything.

export_structured and export_markdown read the committed deck as structured content or Markdown with the version it was read at, and betteroffice_pptx::export_pptx_structured reads bytes as a snapshot. Records carry session or snapshot anchors and source provenance, and every omission is a diagnostic:

use betteroffice_pptx::PptxExportOptions;

let options = PptxExportOptions {
    include_notes: Some(true),
    ..PptxExportOptions::default()
};
let read = presentation.export_structured(&options)?.expect("usable options");
for slide in &read.content.slides {
    println!("slide {}: {} shapes", slide.index, slide.shapes.len());
}
let markdown = betteroffice_pptx::export_pptx_markdown(&pptx_bytes, &options)?;

PowerPoint comments

comment_flavor reports whether a deck uses legacy or modern comments. Modern comments support replies and resolved status in PowerPoint 365. Choose the format with set_comment_flavor before adding the first comment. On legacy decks, reply_to_comment and set_comment_status return EditError::InvalidComment.

use betteroffice_pptx::EditCtx;

let context = EditCtx::local("reviewer");
presentation.add_comment(
    &context,
    &deck.slides[0].id,
    "Ada Lovelace",
    "AL",
    "Tighten this claim.",
    "2026-09-01T10:00:00.000",
    1_828_800,
    914_400,
)?;

Supply a creation timestamp when adding a comment. Coordinates use EMUs.

Rendering

XLSX includes PNG rendering by default. render_sheet uses the bundled Carlito font to measure and draw cell text. Enable the raster feature for native DOCX and PPTX PNG rendering:

cargo add betteroffice-docx --features raster
cargo add betteroffice-pptx --features raster

render_slide returns a display list and hit-test metadata. Register at least one font face before rendering. With the raster feature enabled, render_png produces PNG bytes and reads embedded images from the presentation. SVG pictures draw their paths, basic shapes, gradients, clip paths, symbols, use references and hyperlinked content, and leave out their text and embedded raster images. A picture that uses markers, filters, masks, patterns, scripts or animation is skipped and counted in skipped_images.

PPTX saves preserve untouched parts and patch edited slides to retain unsupported XML. The ZIP container is rebuilt, so the complete file may differ byte for byte.

Format APIs

CrateWhat it is
betteroffice-docxTyped native API for opening, editing, laying out, and saving DOCX documents.
betteroffice-xlsxTyped native API for opening, editing, collaborating, calculating, rendering, and saving XLSX workbooks, including version-checked atomic cell batches and anchored JSON and Markdown export.
betteroffice-pptxTyped native API for opening, editing, collaborating on, and rendering PPTX presentations.

The layers

CrateWhat it is
betteroffice-opcThe OPC container reader and writer, with ZIP expansion limits and path validation.
betteroffice-ooxml-textShared text shaping, measurement, line breaking, and bidirectional text.
betteroffice-ooxml-diffThe bounded token LCS diff behind PPTX proposal previews and DOCX comparison.
betteroffice-drawingmlThe DrawingML colors, themes, shape models, and preset geometry shared by all three formats.
betteroffice-metafileBounded EMF, EMF+ and WMF replay into vector drawings and SVG.
betteroffice-docx-parseWordprocessingML parsing, typed relationship models, and the typed document model.
betteroffice-docx-layoutDOCX pagination, line flow, tables, floats, notes, and display lists.
betteroffice-docx-editCRDT editing and history for DOCX.
betteroffice-docx-rasterThe tiny-skia backend that paints a DOCX display list to PNG. For native builds.
betteroffice-xlsx-modelWorkbook, worksheet, cell, address, value, and style types.
betteroffice-xlsx-parseStreaming SpreadsheetML parse and serialize.
betteroffice-xlsx-calcFormula parsing, dependency tracking, and evaluation.
betteroffice-xlsx-opsOperations for undo, redo, address remapping, and agent proposals.
betteroffice-xlsx-renderGrid geometry and the target-agnostic display list.
betteroffice-xlsx-rasterThe tiny-skia backend that paints an XLSX display list to PNG. For native builds.
betteroffice-pptx-parseBounded PresentationML parsing and part-preserving package writes.
betteroffice-pptx-editThe Yrs deck model for PPTX.
betteroffice-pptx-renderSlide layout and the PPTX display-list compiler.
betteroffice-pptx-rasterThe tiny-skia backend that paints a PPTX display list to PNG. For native builds.

The published Rust crates share a version.

On this page