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,.xlsread, and legacy.docand.pptread (native degraded text, or full fidelity via LibreOffice headless). Phase 6 addsinspect, batch--out-dir, stdin/stdout pipelines,--style-map, andcargo-distrelease 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,.pptread — 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✅ = 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 byte — mimetype 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).
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.
{width=450px height=300px anchor=inline}From source:
cargo install --path crates/docsai-cliRelease 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.
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.
docsai mcp # starts the MCP server over stdioRegistration 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
 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_imagesdefaults torefs, whereconvert_to_markdownused to return every image inline. Passinclude_images: "full"for the old behaviour; clients that already passedassets: "inline-base64"keep working unchanged.Breaking changes (E-pass,
kb/58): responses no longer carrystructuredContentunlessDOCSAI_MCP_STRUCTURED=1;outline_documentreturns one level and 200 nodes by default (depth: 0asks for every level); andconvert_to_markdownrefuses to inline a document overDOCSAI_MCP_MAX_INLINE_TOKENSinstead 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.
| 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 |
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 tripThe 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.
- Single binary, no external runtime: pure Rust libraries whenever possible; external fallbacks (LibreOffice headless for
.doc) are optional and detected at runtime, never required. - Pivot IR: every format converges on an intermediate document model (inspired by Pandoc's AST and DoclingDocument); converters never talk to each other directly.
- Measurable fidelity: format loss is not estimated, it is measured — the
roundtripcommand and the round-trip test suite are part of the product. - Markdown readable first, complete second: extended metadata degrades gracefully; a normal Markdown viewer shows a useful document even if it ignores the attributes.
- User data is never lost silently: what cannot be represented is kept in raw blocks or reported as an explicit warning.
- 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.
See LICENSE.