Webfold
Menu

Format · OpenDocument

ODT, ODP and ODS to Markdown, and the structure the reader drops.

Webfold reads ODT, ODP and ODS files by parsing content.xml, so paragraphs and headings come out as Markdown and each ODS sheet becomes a table: on 6 ODT fixtures its hot median is 19.8 µs against 55.8 µs for anydoc, another document converter; lists, images, slide boundaries and document metadata are not returned.

Reads OpenDocument text (ODT), presentation (ODP) and spreadsheet (ODS) files by parsing content.xml inside the ZIP package. Paragraphs and headings come out as Markdown text, and each ODS sheet becomes a Markdown table under its own heading. Lists, images, slide boundaries and document metadata are not returned.

Last updated , the newest measurement or check on record for this page.

See it work

How do I try it from wherever I work?

1webfold convert report.odt
2webfold convert budget.ods
3webfold convert slides.odp

Input

What input does OpenDocument reading accept?

ODT and ODP (text and presentation)

One reader, odt() in src/document.rs, serves both extensions. It opens the ZIP and reads content.xml only, and keeps the text of text:p and text:h elements wherever they sit in the tree. styles.xml, meta.xml and settings.xml are never opened, so style-based headings, document properties and view settings do not reach the output.

ODS (spreadsheet)

Read by ods(). It walks table:table, table:table-row and table:table-cell in content.xml. A cell value comes from the first of office:string-value, office:value, office:date-value, office:time-value or office:boolean-value that is present, and then any text:p text inside the cell is appended to it.

Package limits

archive() rejects a ZIP with more than 4,096 entries, any entry whose declared size is over 32 MiB, or more than 128 MiB declared in total. entry() applies the same 32 MiB cap to the bytes it reads from content.xml.

Not read

Flat ODF (.fodt) is not routed: convert_bytes matches only odt, odp and ods. Encrypted packages are not decrypted, and the source has no encryption code. Legacy binary Office formats are outside this reader.

Entry points

document::convert_bytes(extension, bytes) takes 'odt', 'odp' or 'ods' (case-insensitive). document::convert_path reads a file and dispatches on its extension. The webfold CLI routes .odt, .odp and .ods to the same code through webfold convert (superdocuments src/document.rs:849; webfold webfold/src/cli/document.rs:130).

Output

What comes out?

Markdown from ODT and ODP

Paragraphs separated by one blank line. Each text:h is written as a line that starts with '## ', whatever its outline level. Text inside list items and table cells is written as plain paragraphs, because the reader does not look at the parent element. There are no bullets, numbers, table syntax, image references or slide markers.

Markdown tables from ODS

One section per sheet, written as '## <sheet name>' followed by a pipe table. The first row of the sheet is the header row. Rows are padded to the widest row, pipe characters in cells are escaped, trailing empty cells and empty rows are dropped. Sheet names come from table:name, with newlines replaced by spaces.

One string, no metadata

convert_bytes returns a single String, with sheets or paragraphs in document order. Title, author and dates are not returned. convert_streaming falls back to the same string for these extensions.

Errors

A package with no content.xml returns 'missing content.xml'. An unknown extension returns 'unsupported document extension: <ext>'. Malformed XML returns the parser's message as a String error.

How it works

What does the code do, step by step?

  1. 1

    Check the package

    archive() reads the ZIP directory and rejects it if the entry count, any entry or the declared total is over its cap (src/document.rs:27).

  2. 2

    Read content.xml

    entry() reads the named file, capped at 32 MiB (src/document.rs:51). No other part of the package is opened.

  3. 3

    ODT and ODP

    The XML is streamed. Text collection starts at a text:p or text:h start tag. At the matching end tag the trimmed paragraph is written with a blank line after it, and text:h paragraphs get '## ' first. Empty paragraphs are skipped (odt, src/document.rs:516-549).

  4. 4

    ODS cells and rows

    The current sheet is taken from table:table. Each row collects cell values. Empty cells that carry a repeat count are expanded, up to 16,384 copies per element (odf_repeat, src/document.rs:629). A non-empty cell is read once, whatever its repeat count.

  5. 5

    ODS sheets

    At the end of a row, trailing empty cells are removed and the row is kept if anything is left. At the end of a sheet, '## <name>' and the Markdown table are written (ods, src/document.rs:552-625; markdown_table, :93).

  6. 6

    Join

    The sections are joined in document order and the trailing newline is trimmed.

Measured

What did we measure, and how?

ODT and ODS: Webfold against anydoc on 9 public fixtures

Run of 2026-09-24

How fast does Webfold convert these files to Markdown, in process and from a fresh process, against anydoc on the same bytes?

Corpus. 9 public fixtures in ODT, ODS from the anydoc repository (tests/fixtures; MIT licence per its README), out of 45 across ten formats. Fixture hashes: document-fixture-sha256-2026-09-24.txt.

ToolVersionRole
Webfoldgit 614483614e4e (document::convert_bytes, in process)Subject. The readers as of that revision, not the newer ones described on this page.
anydocgit 261fc257d17c (anydoc::to_markdown_bytes, Rust library)Comparison converter, linked into the same binary and given identical bytes.
MetricHow it is computed
Hot medianPer fixture: the median of 7 samples, each the mean time of 20 back-to-back in-process conversions of the same bytes, output consumed so it is not optimised away. The cell is the median of those per-fixture medians over the format's fixtures.
Cold medianPer fixture: the median of 7 timed launches (after 2 discarded) of a fresh runner process that reads the file and converts it once, with a warm operating-system cache. Wall time, process start included. The cell is the median of the per-fixture medians.
Converted without errorThe call returned a result. The run records conversion success and output size only; it has no gold text and no fidelity score.
FormatFixturesConverted without error, Webfold / anydocHot median, WebfoldHot median, anydocHot, anydoc over WebfoldWebfold faster, hotCold median, WebfoldCold median, anydocWebfold faster, cold
ODT6 (5 hand-made)6 / 619.8 µs55.8 µs2.8 times6 of 62.74 ms3.21 ms5 of 6
ODS3 (2 hand-made)3 / 380.0 µs173.0 µs2.2 times3 of 32.79 ms3.25 ms3 of 3
1# webfold-bench is private until the public release of Webfold. Run this from a checkout of it.
2git clone https://github.com/firecrawl/anydoc
3cd webfold-bench/bench/document-h2h
4cargo run --release -- --fixtures ../../../anydoc/tests/fixtures --out document-results.json --warmup 2 --samples 7 --iterations 20

Reproduce: webfold-bench/bench/document-h2h/src/main.rs; revisions and hashes: webfold-bench/bench/results/document-rust-cold-hot-2026-09-24.provenance.json. Figures read from webfold-bench/bench/results/document-rust-cold-hot-2026-09-24.json and document-fixture-sha256-2026-09-24.txt.

Limits of this measurement

  • The run has no ODP fixture, so presentations are not measured.
  • Fixtures are tiny: 0.4 to 25 KB each, and most are hand-made feature probes from the anydoc repository, not real-world documents.
  • Speed on success only. Nothing here says the output is right: there is no gold text and no fidelity score, and a conversion that returns almost nothing still counts as converted.
  • One host (Intel Core i5-1340P, rustc 1.96.1) and one run, 2026-09-24. Hot timings are in process and exclude start-up; cold timings include process start, which dominates them (about 2 to 3 ms for both tools), so the cold gap is small.
  • The run measured the Webfold revision named above. The harness manifest pins a later Webfold revision (391c3e290b19) through git dependencies, so a rerun at the pin measures newer readers; to reproduce these figures, use the revisions listed in the provenance file. The readers written since the measured revision have not been benchmarked.

Document conversion benchmark →

Evidence

How do we know each claim?

Each row says how we know. Read from the code means the behaviour is in the implementation at the path shown. Covered by a test names a test in the repository; this page does not claim it was run for this release. Measured points to a benchmark with its protocol and limits.

ClaimHow we knowWhereDetail
ODT and ODP text is read from content.xml only, from text:p and text:hRead from the codesuperdocuments/src/document.rs:516odt() matches Start events for text:p and text:h, collects Text events while active, and writes each paragraph on its End event. No other element is matched.
Every ODT heading is written as '## ' whatever its outline levelRead from the codesuperdocuments/src/document.rs:533The End arm writes '## ' only when the element is text:h. text:outline-level and styles.xml are not referenced anywhere in src/document.rs.
ODS sheets become '## <name>' sections followed by a Markdown tableRead from the codesuperdocuments/src/document.rs:611The table:table End arm writes the heading and markdown_table(rows). The name comes from table:name at :570.
Empty repeated cells are expanded up to 16,384 copies; non-empty repeated cells are read onceRead from the codesuperdocuments/src/document.rs:581Only the Empty table-cell arm calls odf_repeat (capped with .min(16_384) at :633). The Start arm pushes one cell at its End.
Package bounds are 4,096 entries, 32 MiB per entry and 128 MiB totalRead from the codesuperdocuments/src/document.rs:12MAX_ENTRY, MAX_TOTAL and MAX_ENTRIES constants at :12-14. archive() checks entry count and declared sizes at :27-49.
Spaces, tabs and line breaks in ODF text are not emittedRead from the codesuperdocuments/src/document.rs:524text:s, text:tab and text:line-break are empty elements. The odt() match has no Empty arm, so they contribute no text.
ODS cell value attributes are read in a fixed orderRead from the codesuperdocuments/src/document.rs:636odf_cell_value returns the first present attribute of office:string-value, office:value, office:date-value, office:time-value, office:boolean-value.
convert_bytes routes odt and odp to odt(), ods to ods(), and rejects other extensionsRead from the codesuperdocuments/src/document.rs:849The dispatch matches odt | odp to odt(), ods to ods(). fodt is not in the match. Unknown extensions return 'unsupported document extension'.
webfold convert sends odt, ods and odp to superdocumentsRead from the codewebfold/src/cli/document.rs:130markdown_source tests the extension list and calls webfold::document::convert_path, which calls superdocuments::document::convert_bytes (webfold src/document.rs:126). The CLI usage is in webfold/src/main.rs:214.
The ODF test covers the heading, paragraph, sheet and presentation casesCovered by a testsuperdocuments/src/document.rs:2025office_packages_convert_through_public_api asserts that odt gives '## Title\n\nBody', that an ods cell of 42 gives a row containing '| 42 |', and that odp contains 'Body'. Not run.
The sparse-column spreadsheet test covers empty-cell repeatsCovered by a testsuperdocuments/src/document.rs:2016spreadsheet_cells_preserve_sparse_columns. Named in the repo; not run.
A numeric ODS cell with a value attribute and display text is read once, not twice, in the superdocuments revision Webfold pinsCovered by a testsuperdocuments/src/document.rs, test ods_cell_with_a_value_attribute_and_display_text_is_not_doubledRun on 2026-11-07: failed before the change with '| 4242 | Name | 7.57.50 |' and passes after it; the library suite is 375 passed, 0 failed, 1 ignored. The test is in the superdocuments revision Webfold pins (68c216f).

What are the limits?

Know the limits

  • Headings are flattened. Every text:h becomes '## ', whatever its outline level, because the reader does not read text:outline-level or styles.xml (src/document.rs:533).
  • Before the fix in superdocuments (merged on 2026-11-07), a numeric ODS cell that carried both an office:value attribute and its formatted text:p child came out twice: 42 as 4242, and 7.5 shown as 7.50 as 7.57.50. LibreOffice writes number cells that way, so any ODS saved by LibreOffice was affected. A cell that starts with a typed value is now taken from the attribute alone. The revision Webfold pins (68c216f) has the fix. Covered (merged) cells are not read.
  • No lists, tables or images inside text documents. List items and table cells come out as plain paragraphs. Pictures inside the package's Pictures folder are returned by document::extract_images with their type, dimensions and alt text (svg:desc); the Markdown does not carry a reference to them yet.
  • Spacing can be lost. text:s, text:tab and text:line-break produce no text, so words separated only by those elements join together (src/document.rs:524-546).
  • ODP slides are not separated. The reader does not match draw:page, so slide text comes out as one stream in document order. Speaker-notes text is included the same way, because text:p is matched wherever it appears.
  • ODS rows and merged cells. A row marked table:number-rows-repeated is written once. Merged cells (table:covered-table-cell) are not matched, so a row with a merged cell comes out shorter and later cells can move left. Both come from reading the code and have not been run.
  • Only content.xml is read. Comments, tracked changes, metadata and header or footer text in styles.xml are not returned.
  • Flat ODF (.fodt) is not routed, and encrypted packages are not decrypted.

Compared

How does it compare with other tools?

Compared withWhat the evidence saysSource
pandocPandoc's manual lists odt and fodt among its inputs, but not ods or odp. For ODT, fidelity was scored against Pandoc 3.1.3's own reading of 34 files: 0.548 for superdocuments before the Pandoc-compatibility work, 0.948 after it; MarkItDown 0.1.9 does not read ODT. These scores are for superdocuments main. Part of the work behind them is in the revision Webfold pins and the rest, the Pandoc layout, came after it, so the Webfold command line has not been scored. Superdocuments reads LibreOffice's code and horizontal-rule styles as code and rules, which Pandoc does not. Nothing was compared for ods or odp.Pandoc manual
MinerUMinerU's README lists ODT, ODS and ODP among its inputs, with local native parsing in its flash tier. It states inputs only, so output quality is not compared.MinerU README
DoclingDocling's supported-formats page lists ODT, ODS and ODP. It states inputs only.Docling supported formats

Where should I read next?

FAQ

Frequently asked questions

Which command converts an ODT file?

webfold convert report.odt. It goes through webfold::document::convert_path to superdocuments. The MCP server has no tool that converts ODF files; webfold_pdf covers PDF only.

Does the output keep heading levels?

No. Every ODT heading becomes a level-2 Markdown heading, because the reader does not read the outline level.

Are my ODS numbers right?

Yes, for the number-plus-display-text shape that LibreOffice writes, in superdocuments and in the revision Webfold pins. In older builds a numeric cell that has both an office:value attribute and a text:p child showed its value twice (42 became 4242). Check numeric ODS output on your own files, because only that shape is tested.

Can I split an ODP into slides?

Not with this reader. Slide boundaries are not matched, so the output is one stream of text. You would need your own step at the slide boundaries, or to read the XML directly.

Are lists and tables kept?

Not in text documents. List items come out as plain paragraphs, and table cells in ODT come out as paragraphs. An ODS sheet is the exception: it becomes a Markdown table.

Read OpenDocument with Webfold.

Run the command-line tool today, and join the waitlist for the hosted API.