Skip to content

Repository files navigation

docsai

Bidirectional converter between Office / LibreOffice documents and extended Markdown, written in Rust.

docsai is a single cross-platform binary (Windows, Linux, macOS) that converts office documents to an extended Markdown profile — DocMark — designed to keep as much information as possible (styles, images, properties, formulas) and to allow the reverse conversion with minimal format loss. It can be used as a CLI tool or as an MCP (Model Context Protocol) server over stdio, for integration with AI assistants such as Claude.

Project status: Phases 0–7 completed for the core path. .docx / .odt ⇄ DocMark, .xlsx / .ods ⇄ DocMark, .xls read, and legacy .doc and .ppt read (native degraded text, or full fidelity via LibreOffice headless). Phase 6 adds inspect, batch --out-dir, stdin/stdout pipelines, --style-map, and cargo-dist release packaging. Phase 7 adds the MCP stdio server (docsai mcp), now with eleven tools and one resource. That plan (docs/development-plan.md) is delivered and superseded.

Next: development plan v2 — agent-native docsai (stable node ids, outline/read --select/search, --fidelity agent, measured token budget, patch editing) and presentations (.pptx ⇄ DocMark, .odp ⇄ DocMark, .ppt read — Phase 19 is closed). Analysis: docs/technical-analysis-presentations.md.

cargo run -p docsai-cli -- convert report.docx -o report.dmk.md
cargo run -p docsai-cli -- inspect report.docx
cargo run -p docsai-cli -- formats
cargo run -p docsai-cli -- mcp

Supported formats (target)

✅ = works today · 🕓 = planned, with the phase that lands it · ➖ = out of scope

Format Extension Read Write Notes
Word OOXML .docx Styles, images, tables, lists, headers/footers, footnotes, fields, properties
Word binary .doc Read only: native degraded text, or LibreOffice headless → docx (--use-loffice)
Excel OOXML .xlsx Values and formulas, number formats, merged cells, anchored images
Excel binary .xls Read only (calamine)
OpenDocument Text .odt Free equivalent of .docx
OpenDocument Spreadsheet .ods Free equivalent of .xlsx
PowerPoint OOXML .pptx 🚧 v2 P15 Slides, placeholders, notes, tables, images, charts; original package skeleton preserved. docsai convert deck.pptx -o deck.dmk.md writes DocMark-P, and inspect, outline, tokens, search and read --select work over a deck. Writing goes back into the preserved package: a slide's text, its pictures (media re-injected as its own bytes, with the relationship and the content type it needs), its tables and its hyperlinks are rebuilt from the document, a preserved object (SmartArt, OLE, a group, a custom geometry, an animation) goes back as the original markup at its original z-order, and a chart's data is still copied from it with a warning. A deck with no preserved package — plain Markdown with no attributes — is written on an embedded default template, with the layout of each slide chosen from the shape of its content, and a slide or a notes page the package does not have is written rather than refused. docsai roundtrip deck.pptx closes the cycle and reports the per-category fidelity metric; the whole deck corpus round-trips idempotent, and a deck nobody edited comes back as the package that was read, byte for byte. Phase 15 is closed with one item outstanding: headless LibreOffice now opens and renders every one of the seventeen generated packages without failing and with no page coming out blank (the render gate, run for real), but PowerPoint has still never opened a written deck, and PowerPoint is the loader that decides — the checklist that would say so is prepared and unrun in docs/spikes/P2-powerpoint-gate.md, and risk P2 stays open until it is signed
PowerPoint binary .ppt Read only, and read two ways, as a .doc is. With LibreOffice (`--use-loffice auto
OpenDocument Presentation .odp Free equivalent of .pptx, and the same IR and the same DocMark-P a .pptx goes through — the deck writer has no branch on the package it came from. Pages, frames, presentation:class as a placeholder type, master pages and presentation page layouts, nested lists, notes, comments, tables, pictures and embedded charts all read, with ODF's automatic styles resolved into the model, and everything the IR has no node for (draw:g, a custom shape, a page transition, an anim: tree, an OLE object) kept as a stub over its own preserved markup plus a warning. Writing splices into the preserved archive entry by entry, so a deck nobody edited comes back byte for bytemimetype first and stored, as ODF requires — and a text body that changed is rewritten in place while everything else keeps its bytes; a slide or shape added or removed, and a table, chart, picture or preserved object that changed, are named in a warning rather than written. pptx ⇄ odp converts through the IR against a 22-cell matrix in which every drop is warned and no warning is false. Validated against the vendored OpenDocument 1.3 RELAX NG grammar and opened by LibreOffice on every fixture and every crossing
Extended Markdown .dmk.md Pivot format DocMark (superset of CommonMark + GFM)

docsai formats prints this same matrix for what the binary can actually do.

Writing .doc, .xls and .ppt (the legacy binary formats) is deliberately out of scope: the recommended output path into the Microsoft ecosystem is always OOXML (.docx / .xlsx / .pptx).

What is DocMark?

DocMark is an extended Markdown profile defined in this project (see docs/docmark-specification.md). It is human-readable and hand-editable Markdown that renders reasonably on GitHub or any CommonMark viewer, but adds metadata layers so information is not lost:

  • YAML front matter with document properties (title, author, language…) and the original style catalogue.
  • Inline and block attributes {#id .class key="value"} (Pandoc-compatible syntax) to attach styles, image dimensions, cell properties, and more.
  • Fenced containers ::: {...} for sections, text boxes, headers and footers.
  • Extended tables with per-cell metadata (formulas, types, number formats, merges) for spreadsheets.
  • External assets: images are extracted to an assets/ directory (deduplicated by content hash) and referenced with a full geometry attribute model: display and native size, position and anchoring (inline, floating with wrap and z-order, or cell-anchored in spreadsheets), rotation, crop, flip, alternative text and hyperlink.
  • Fidelity hatch (raw-block) for fragments with no Markdown representation, kept opaque and restored on the reverse conversion.

Minimal example:

---
docmark: "1.0"
source-format: docx
title: "Annual Report"
styles:
  Heading1: { font: "Calibri Light", size: 16pt, color: "#2E74B5" }
---

# Annual Report {.Heading1}

Text with **bold** and [color]{color="#FF0000"} custom colour.

![Sales chart](assets/img-001.png){width=450px height=300px anchor=inline}

Install

From source:

cargo install --path crates/docsai-cli

Release binaries and installers (shell / PowerShell) are produced by cargo-dist when a version tag is pushed. See CHANGELOG.md and the GitHub Releases page.

The macOS binaries are signed with the DIGIO Developer ID (hardened runtime) and notarized by Apple, so they run without a Gatekeeper prompt; Linux and Windows binaries are unsigned. See docs/release-signing.md.

Usage (CLI)

docsai convert report.docx -o report.dmk.md      # extracts assets/ next to the .md
docsai convert report.docx                         # DocMark on stdout
docsai convert report.docx -o -                    # same, explicit stdout
docsai convert - --to docmark < report.docx        # stdin → stdout pipeline
docsai convert report.docx --fidelity agent        # the projection an agent edits from
docsai convert report.docx --fidelity plain        # clean CommonMark, for LLM/RAG
docsai convert report.docx -o out.md --json        # conversion report as JSON
docsai convert *.docx --out-dir md/                # batch (parallel) into a folder
docsai convert report.docx --ids never             # DocMark 1.0 output, no node ids
docsai convert report.docx --raw inline            # raw-block bytes in the body, not aside
docsai convert report.docx --style-map map.yaml    # publication mode (spec §5)
docsai convert sheet.xlsx --max-cells 100000       # refuse oversized workbooks
docsai convert legacy.doc -o legacy.dmk.md         # .doc: LO if installed, else native text
docsai convert legacy.doc --use-loffice never      # force native degraded path
docsai convert legacy.doc --use-loffice require    # fail if LibreOffice is missing
docsai convert legacy.ppt -o deck.dmk.md          # .ppt: read-only, same three policies
docsai inspect report.docx                         # metadata, styles, media, stats
docsai inspect report.docx --json                  # same, machine-readable
docsai outline report.docx                         # map of addressable nodes + cost
docsai outline report.docx --depth 1 --json        # top level only, machine-readable
docsai read report.docx --select s7-s9             # just those nodes, as DocMark
docsai read report.docx --select '#n7,text:riesgo' # by id, or by what it says
docsai search report.docx "riesgo"                 # where it says that, with context
docsai search report.docx "riesgo" --json --limit 5 # machine-readable, capped
docsai edit deck.pptx --ops @ops.json              # apply the operation set, all-or-nothing
docsai edit deck.pptx --ops @ops.json --dry-run    # same report, the file untouched
docsai edit deck.pptx --ops @ops.json -o out.pptx  # write elsewhere, leave the input alone
docsai validate draft.dmk.md                       # every problem in the DocMark, not the first
docsai validate draft.dmk.md --json                # same, machine-readable
docsai tokens report.docx                          # what the document costs an LLM
docsai tokens report.docx --fidelity plain --json  # per-node costs, machine-readable
docsai formats                                      # support matrix for this binary
docsai roundtrip report.docx                       # full cycle, with per-category fidelity
docsai roundtrip deck.pptx --json                  # same for a deck, machine-readable
docsai mcp                                          # MCP server over stdio (Phase 7)

Fidelity levels (--fidelity, spec §6): full (default, round-trip grade), agent (a projection for programs, below), standard (rich Markdown without catalogues or raw-blocks) and plain (pure CommonMark+GFM).

--fidelity agent (spec §6.1) is what an agent reads before editing. It keeps the text, the structure, every node id and a stub for everything opaque, and drops what no program edits: the style and list catalogues, indents and spacing, page geometry, image EMUs, column widths. On the corpus that is a 62–75 % cut against full for documents whose cost is their formatting — and almost nothing for a document whose cost is its prose, which is what docsai outline and selectors are for. It says fidelity: agent in its front matter: read it whole, write it back node by node.

Units (--precision, spec §2): a length is written in the unit of what it measures — points for layout (indents, margins, column widths), pixels for bitmaps and drawing offsets — and zero carries no unit. A unit is only used when it names the length exactly: --precision N (default 2) sets how many decimals it may use, and emu is the escape hatch when none of them fits. It buys readable units, never rounding, so the round-trip tolerance for a length is zero.

Repeated formatting (spec §3.7): what a style implies is never written — not the paragraph's, not the run's, not what a style inherits from its parent — and what no style implies but repeats anyway is written once. A pattern used three times or more is interned in the front matter under attribute-sets: and referenced by class ({.g1}); the reader expands it before anything reads the block, so the document means exactly what it meant. On a document written without styles that is a 15 % cut at full and 22 % at standard.

Node ids (--ids, spec §11.1): at --fidelity full the output is DocMark 1.1 — addressable nodes carry {#n7} and the front matter declares next-id, so an agent can point at a node and keep pointing at it across edits. Ids are never renumbered on insertion and never reused after deletion. --ids preserve writes back only the ids a document already had, --ids never reproduces the DocMark 1.0 shape. The lossy levels default to never, and plain never carries ids.

Raw-blocks (--raw, spec §7): what no DocMark construct can express — SmartArt, OMML maths, signed content — travels as opaque source bytes. By default those bytes go to a sidecar, assets/_raw/<id>.xml, and the body keeps a one-line stub naming it, so reading the document does not mean paying for markup nobody can edit. --raw inline puts the payload back in a fenced block, which is what a self-contained single file needs. Either way nothing is lost: a missing sidecar is an error, not a warning.

Document map (docsai outline): the tree of addressable nodes — id, kind, a ~60-character preview and the measured cost of each — so an agent can decide what not to read. The tree follows containment (a footnote hangs from its paragraph, a nested list from its parent); heading level shows in the preview. --depth N keeps the first N levels.

n1 heading 13 # Informe tecnico de seguimiento
n2 heading 17 ## 1. Estado de alcance del proyecto
25 nodes · outline 345 tokens · document 9158 tokens (3.8 %)

Partial reads (docsai read --select, spec §2.1): outline says where the paragraph is; this hands it over and nothing else, as valid self-contained DocMark. Selectors are s4 and s7-s9 (positions in the order outline prints them), #n7, type:heading and text:foo; comma-separated terms are unioned and the output always comes back in document order. The body is what the whole document wrote for those nodes, byte for byte, and the front matter is the minimum needed to parse and re-write it — no metadata, no page geometry, no catalogues. It declares partial: true and an etag per node, so an edit can be written back with a precondition and nobody mistakes a fragment for the document: writing one back whole is a severe warning, every time.

$ docsai read report.docx --select s2-s3
---
docmark: "1.1"
source-format: docx
next-id: 26
partial: true
etags:
  n2: "5e8876"
  n3: "8eb3e5"
---

## \1. Estado de alcance del proyecto {#n2}

### Conclusion de alcance del proyecto {#n3}

Finding text (docsai search <in> <query>): the answer to where does it say that, without paying for the document. Matching is a case-insensitive literal over the text a conversion would write, and the unit is the DocMark block, not the addressed node — ordinary prose paragraphs carry no id (spec §11.1: "reached by relative path"), and a search that only looked at addressed nodes would find headings and nothing else. A block that carries an id is reported at it, with the selector that reads it back; one that does not is reported relative to the last id before it (n12.b2). --context N sets how many characters either side of a match are quoted, --limit N how many blocks are listed — the rest are counted, not dropped.

$ docsai search report.docx "rendimiento medido" --limit 2
s12 #n12 heading 14 tokens ×1
  …## \6. Estado de «rendimiento medido»…
n12.b1 text 81 tokens ×4
  …El equipo revisa «rendimiento medido» en cada iteracion y deja constancia…
… 9 more block(s) not listed (--limit)
35 match(es) in 11 block(s) · hits 386 tokens · document 9083 tokens (4.2 %)

A hit that names a selector composes with the previous command — docsai read --select '#n12' returns exactly what the hit pointed at. A relative hit names no selector, because read --select has no .bN term yet: saying so is the point, rather than handing back an address that would read something else.

Token budget (docsai tokens): the cost of the document is measured with a real BPE tokenizer (o200k_base, embedded — no network, no Python), never estimated from the file size. The report splits front matter from body and lists the heaviest addressed nodes, each counted over the exact DocMark it wrote:

report.docx  docx  fidelity=full  encoding=o200k_base
  total             701 tokens (2063 bytes)
  front matter      565
  body              137

Nested nodes are counted more than once on purpose (a section's cost includes its headings'), so the per-node numbers do not sum to the total.

Style maps (--style-map, spec §5) are unidirectional publication helpers:

Heading1: h1
Title: h1
SourceCode: code-block
Comment: ignore

Legacy .doc policy (--use-loffice): auto (default — use LibreOffice when found), never (native piece-table extractor only), require (error if LibreOffice is missing). Override the binary with DOCSAI_LIBREOFFICE.

Exit codes: 0 success, 1 conversion with losses (--strict also treats minor warnings as failures), 2 input error, 3 unsupported format. Logs always go to stderr (RUST_LOG / --verbose); stdout stays free for DocMark or --json.

MCP server

docsai mcp        # starts the MCP server over stdio

Registration in an MCP client (e.g. Claude Desktop / Claude Code / MCP Inspector):

{
  "mcpServers": {
    "docsai": { "command": "docsai", "args": ["mcp"] }
  }
}

Tools: estimate_tokens, outline_document, search_document, read_selection, apply_edits, validate_docmark, convert_to_markdown, convert_from_markdown, inspect_document, render_slides, list_supported_formats. Each accepts a filesystem path or content_base64 + filename. Logs always go to stderr; stdout is the JSON-RPC channel only.

Patch editing. apply_edits — and its CLI door, docsai edit — changes a document without rewriting it. Ten operations (replace_text, set_props, insert_after, insert_before, delete, move, duplicate_slide, set_cell, set_notes, replace_image), addressed by node id or index path, applied in order and all-or-nothing, with an etag as an optional precondition. The normative set is docs/edit-operations.md; anything outside it is still done with read --select and a rewrite.

apply_edits { path: "deck.pptx", operations: [
  {"op":"replace_text","target":"n4/1","text":"Resultados del Q3","if_match":"74e8ca"},
  {"op":"insert_after","target":"n4/1","content":"- Ingresos al alza\n"},
  {"op":"set_props","target":"n121","props":{"bold":true}} ] }
→ {"changed":[{"id":"n4/1","etag":"00505d"},{"id":"n7","etag":"e39d88"}],
   "created":[{"id":"n121","kind":"list"}]}

The answer carries the ids of what was created and the new etags of what changed, so no confirmation read is needed — the next edit guards itself with what this one returned. A stale etag is a typed refusal carrying both values and is never an overwrite; dry_run returns the same report without touching the file; the write is a temporary file in the destination's own directory renamed over the original, so a failure at any point leaves the original byte-identical.

apply_edits is the one tool that writes over a document, and its reach is deliberately small: it reads path (plus the picture a replace_image names), writes exactly one file — output_path, or path itself — and creates a .docsai-edit-*.tmp beside that destination for the length of the transaction. It never creates a directory, walks a glob, edits a batch of files, or converts between formats: docsai edit report.docx -o out.pptx is refused, because writing docx bytes under a .pptx name is a silent degradation.

validate_docmark (CLI: docsai validate) answers with every problem in a piece of DocMark at once rather than the first — each with a line, a stable code (container/unclosed, id/duplicate, …), the node id in force there, the specification rule it breaks, and a fix where one can be made honestly. The parser is the only authority: an error is text it refuses, a warning is text it accepts and reads as something other than what it looks like.

One resource is served beside them: docmark://cheatsheet/{path} answers with the DocMark syntax that document actually uses — front matter and node ids, plus one entry per construct present, each with an example. It is derived by walking the document, so a plain report is never told about slides, charts or comments and never pays for their syntax: 221 tokens for corpus/docx/basic-text.docx against 914 for a deck with charts, a table, an image, a stub, speaker notes and a comment. It is a resource rather than a tool because a tool is published to every session whether it is wanted or not; this costs a session 4 tokens of declared capability and is fetched only on request.

The first four are the ones to reach for. They are the MCP face of docsai tokens, docsai outline, docsai search and docsai read --select, and together they answer "change the third heading of this report" without ever sending the report:

estimate_tokens  { path }                     → full 9 083 · agent 8 744 · standard 8 698 · plain 8 545
outline_document { path }                     → n1 heading, n12 heading, … with outline-ratio
search_document  { path, query: "riesgo" }    → s12 #n12, select "#n12", the words around it
read_selection   { path, select: "#n12" }     → that node as DocMark, with its etag

A whole document moves between files, not through the conversation. Give convert_to_markdown an output_path and it writes the DocMark and answers with a receipt; give convert_from_markdown a markdown_path and an output path and the package is written the same way. The written .dmk.md is itself an input to outline_document, search_document and read_selection, so the edit loop never pays for the document twice:

convert_to_markdown   { path: "report.docx", output_path: "report.dmk.md" }
                                              → wrote report.dmk.md (42 727 bytes, 9 083 tokens at full)
outline_document      { path: "report.dmk.md" }
read_selection        { path: "report.dmk.md", select: "#n12" }
convert_from_markdown { markdown_path: "report.dmk.md", target_format: "docx", path: "out.docx" }

What a session costs is measured, not claimed. crates/docsai-mcp/tests/goldens/wire-cost.md records the bytes and tokens of the framed JSON-RPC that actually crossed the transport for each canonical agent task, handshake included, and CI fails on a regression. Today: the full edit loop on a 40-slide deck — locate slide 4, retitle it, add a bullet, bold it, verify — costs 804 tokens in 2 tool calls; fixing a typo in a 30-page report and writing the docx back, 1 031; adding a row to a sheet, 870. The tool listing every session opens with costs 2 481, which is more than any single task and is why the schemas published by tools/list are written for size: apply_edits publishes operations as an array of anything with its ten shapes in one line of prose, because the derived schema for the same enum is over a thousand tokens.

That deck loop is plan v2 Phase 17's acceptance criterion — «≤ 3 tool calls and ≤ 2 000 tokens» — and it is a hard gate, not a golden: LOOP_MAX_CALLS and LOOP_MAX_TOKENS are asserted before the golden file is consulted at all, so regenerating a golden can never make the loop affordable. The session handshake is reported beside it and is not part of that budget: it is what a session pays once, whatever it then does.

Without an output_path, convert_to_markdown still returns the DocMark inline — up to DOCSAI_MCP_MAX_INLINE_TOKENS, past which it refuses and names the argument that fixes it. Its include_images chooses what image payload comes back:

Value The client gets
none the count and the byte total, nothing else
refs (default) name, MIME type and size of each image
thumbnails the above plus a PNG downscaled to 256 px, to actually look at
full the original bytes, base64

The markdown is identical at every rung — the body always keeps its ![](assets/…) links — so the choice is about cost, never about fidelity. On a document with one 1200 × 900 screenshot the response goes from 906 709 bytes at full to 2 289 at refs. Media can still be written to disk with assets=files + assets_dir.

Breaking change (plan v2 Phase 11): include_images defaults to refs, where convert_to_markdown used to return every image inline. Pass include_images: "full" for the old behaviour; clients that already passed assets: "inline-base64" keep working unchanged.

Breaking changes (E-pass, kb/58): responses no longer carry structuredContent unless DOCSAI_MCP_STRUCTURED=1; outline_document returns one level and 200 nodes by default (depth: 0 asks for every level); and convert_to_markdown refuses to inline a document over DOCSAI_MCP_MAX_INLINE_TOKENS instead of returning it. Silence means cheap.

Environment:

Variable Default Meaning
DOCSAI_MCP_MAX_INPUT_BYTES 52428800 (50 MiB) Cap on path size and decoded base64
DOCSAI_MCP_TIMEOUT_SECS 120 (0 = off) Per-tool wall-clock timeout
DOCSAI_MCP_MAX_INLINE_TOKENS 2000 (0 = off) Largest DocMark a response carries inline before output_path is required
DOCSAI_MCP_STRUCTURED 0 Also send structuredContent; off, a response says everything once

Details in docs/architecture.md §6 and kb/10-phase-7-mcp.md.

Project documentation

Document Contents
docs/technical-analysis.md Format analysis, evaluated Rust libraries, prior open-source projects (Pandoc, MarkItDown, Docling, mammoth…), decisions and risks
docs/technical-analysis-presentations.md Presentations (.pptx/.ppt/.odp) and agent context economics: format anatomy, crate evaluation, token-cost analysis, risks
docs/docmark-specification.md DocMark format specification (extended Markdown) v1.0, plus the committed 1.1 / 1.2 bumps
docs/architecture.md Software architecture: crate workspace, intermediate document model (IR), CLI, MCP server
docs/development-plan-v2.md Current plan (Phases 10–20): agent-native primitives and presentations
docs/development-plan.md Plan v1 (Phases 0–9), delivered and superseded — historical record
CHANGELOG.md Keep-a-changelog release notes
AGENTS.md Operational guide for developers and AI agents working in this repository
corpus/README.md The test corpus: what each document isolates and how it is regenerated
docs/spikes/ Risk-spike reports, with the decision that closed each one
kb/ Knowledge base: what is built, how it is structured, technical decisions and what later phases will face

Development

cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all -- --check
python3 corpus/generate.py --check     # the corpus is generated, not hand-drawn
python3 corpus/schema_gate.py          # the .pptx parts we write, against the ECMA-376 XSDs
python3 corpus/render_gate.py          # how a deck looks, before and after the round trip

The schema gate needs xmllint (Debian/Ubuntu libxml2-utils) and a built CLI. The schemas it validates against are vendored, unmodified, in schemas/ — see schemas/README.md for their provenance and licence. It is CI-only: nothing it uses is a dependency of any binary.

The render gate needs headless LibreOffice and pdftoppm (libreoffice-impress poppler-utils) and the same CLI: it renders every corpus deck before and after the round trip and scores the pages with a perceptual diff, failing over a threshold and naming the deck, the page and the number. Without a working renderer it skips — a banner saying what was therefore not measured, and exit 3, never a green tick — while CI runs it with --require-renderer, where a missing renderer is a failure. Its comparator can be measured on its own, with no renderer at all, by python3 corpus/render_gate.py --self-test. It is CI-only too, and adds no image dependency anywhere.

What the render gate reports today, run over the whole corpus with a real renderer: 21 decks, 21 round trips identical by bytes, 0 rendered, 67 pages compared, 0 failures, 0 not measured. The round trips are byte-identical packages, which the gate treats as strictly stronger than a render and does not spend renderer time on; the 67 rendered pages are the packages written on the embedded template, and they are reported rather than scored — the template's canvas differs from the corpus deck's, so the pages cannot be paired pixel for pixel and only the «did anything come out blank» check applies. So the gate has shown that every generated package loads and renders, and it has not yet produced a single perceptual-diff score on real output: the threshold is still the one chosen from the comparator's self-test, not one validated against renderer noise.

If soffice is installed and still fails with error while loading shared libraries: libreglo.so, that is a dynamic-loader path problem, not a broken package — the Linux branch of the soffice wrapper does not export LD_LIBRARY_PATH. Prefix the command with LD_LIBRARY_PATH=/usr/lib/libreoffice/program, or fix it once with an /etc/ld.so.conf.d/libreoffice.conf naming that directory followed by ldconfig.

Neither gate is the oracle for a .pptx, and neither pretends to be: schema-valid markup that hangs together as a package can still be repaired by PowerPoint, and LibreOffice has been measured opening a deck with five dangling relationships in silence. The check that decides it is a human one, written out item by item in docs/spikes/P2-powerpoint-gate.md, which builds its three decks and its negative control in one paste. It has not been run.

Golden files live next to the corpus (corpus/docx/*.expected.dmk.md). To update them: DOCSAI_UPDATE_GOLDENS=1 cargo test -p docsai-convert --test goldens, and review the diff.

Design principles

  1. Single binary, no external runtime: pure Rust libraries whenever possible; external fallbacks (LibreOffice headless for .doc) are optional and detected at runtime, never required.
  2. Pivot IR: every format converges on an intermediate document model (inspired by Pandoc's AST and DoclingDocument); converters never talk to each other directly.
  3. Measurable fidelity: format loss is not estimated, it is measured — the roundtrip command and the round-trip test suite are part of the product.
  4. Markdown readable first, complete second: extended metadata degrades gracefully; a normal Markdown viewer shows a useful document even if it ignores the attributes.
  5. User data is never lost silently: what cannot be represented is kept in raw blocks or reported as an explicit warning.
  6. Context cost is measured, not estimated (plan v2): tokens per document and tool calls per task are tracked in CI like any other budget, because the primary consumer of this tool is an AI agent with a finite context window.

License

See LICENSE.

About

Office and Libre office documents rich formats to text styled markdown extended for AI document edition

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages