Webfold
Menu

Format · Spreadsheets

XLSX, XLSB, ODS, CSV and legacy XLS to Markdown tables and structured cell rows.

Webfold reads XLSX, XLSB, ODS, CSV and legacy XLS workbooks into Markdown tables, one section per sheet: on 2 XLSX fixtures its hot median is 90.4 µs against 489.9 µs for anydoc, another document converter, measured on 24 September before the rewrite; XLSX cells are shown with their number format, with a few codes unimplemented, and the XLSX and XLS fixes are in the main branch, not in the 0.1.0 release.

Reads workbook cells into Markdown tables, one section per sheet, and gives XLSX a structured Worksheet type with rows of text. XLSX sheets carry the workbook's own names in tab order, and a cell is shown with its number format: decimals, thousands separators, percentages, literals and date and time codes are applied, and codes the reader does not implement keep the stored value. Legacy XLS (Excel 97 to 2003) is read by the superdocuments crate itself, through its in-house spreadsheet reader, and gives the same Markdown as the XLSX path.

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

XLSX and XLS fixes are in Webfold's main branch; not in the 0.1.0 release

Status

  • The rewritten XLSX reader, its number formats and the XLS date fix are in Webfold's main branch. Webfold pins superdocuments at revision 68c216f, which contains them. The 0.1.0 release predates it. XLSB, ODS and CSV are unchanged apart from the ODS fix and the line-break rule for cells.

See it work

How do I try it from wherever I work?

1webfold convert book.xlsx

Input

What input does Spreadsheets reading accept?

XLSX and XLSM

A ZIP whose workbook part (the officeDocument relationship, else xl/workbook.xml) lists the sheets. Each sheet's name and relationship id give its part, in tab order. Without a workbook part, the reader falls back to the numbers in xl/worksheets/sheetN.xml file names and calls the sheets 'Sheet N' (superdocuments/src/ooxml/xlsx.rs).

XLSX cell values

A shared string is looked up by index; rich-text runs are joined and phonetic guides (rPh) are left out. Inline strings are read as text. A boolean is TRUE or FALSE, an error cell is shown as written, and a string-result formula shows its cached text. A number whose cell style has a date or time format (built-in ids or a custom format code, with the 1900 or 1904 epoch from workbookPr) is shown as YYYY-MM-DD, HH:MM:SS or both. Any other number is shown exactly as stored. A formula with no cached value is shown as =formula.

XLSB

The binary workbook. Cell records are read for their cached values, and shared strings come from xl/sharedStrings.bin. Sheet names come from xl/workbook.bin when that part exists, matched to sheets by position (document.rs:741-846).

ODS

content.xml only. Each table:table becomes one section, named by its table:name attribute (document.rs:552-628).

CSV

UTF-8 (BOM removed) or UTF-16 with a byte-order mark. The delimiter is guessed from the first line among comma, semicolon and tab (document.rs:121-177). The webfold CLI and webfold's document module use their own CSV parser instead (webfold/src/document.rs:122-125).

Legacy XLS

superdocuments reads xls through its calamine workspace crate and writes one section per worksheet in tab order, with the same cell formatting as xlsx. Checked against a LibreOffice-written .xls, whose output is byte-identical to the LibreOffice .xlsx round trip. The webfold command-line tool keeps its own adapter for xls, which also writes one section per worksheet.

Worksheet values

xlsx::read_workbook and xlsx::read_sheet return Worksheet values, with index in tab order, the workbook's sheet name, and cell text as rows. They share the reader with convert_bytes.

Output

What comes out?

Markdown tables

A '## ' heading for each sheet, then a pipe table whose first row is the header and whose rows are padded to the widest row. XLSX sections use the workbook's sheet names in tab order; XLSB, ODS and XLS use their real sheet names too. Rows with no cell content are left out.

Worksheet structs

Worksheet { index, name, rows: Vec<Vec<String>> }. Rows are indexed by column position, with empty strings for gaps. Row numbers are not kept (lib.rs:26-34; xlsx.rs:102-153).

Streamed XLSX and CSV

convert_streaming for XLSX and XLSM now calls the same reader as convert_bytes, so the output cannot differ; the separate streaming XLSX writer was removed. CSV streams through write_csv_rows, which pads rows to a common width.

Lifted document (public type)

superdocuments exposes LiftedDoc for CSV only, keyed by (row, column). XLSX and DOCX are cached as rendered Markdown (a warm hit does no parsing), not as lifted cells. webfold does not use LiftedDoc: its cache calls convert_bytes (webfold/src/document/cache.rs:100).

webfold table renderer

CSV and XLS output in webfold uses its own renderer. Line breaks inside a cell become <br> and pipes are escaped. Rows are padded to the widest row only when (rows + 1) times the widest row is at most twice the total cell count plus the widest row. Otherwise the rows are written unpadded and no separator row is written (webfold/src/document/table.rs:4-83).

How it works

What does the code do, step by step?

  1. 1

    Dispatch by extension

    convert_bytes sends xlsx and xlsm to xlsx(), xlsb to xlsb(), ods to ods() and csv to the CSV path. It sends xls to the calamine-backed xls() reader. The webfold command line intercepts xls and csv before that call (webfold/src/document.rs:118-126) and uses its own renderers for them.

  2. 2

    Bound the archive

    The ZIP is checked for entry count (4,096), declared entry size (32 MiB) and declared total (128 MiB) before any part is read (document.rs:12-14, 27-49; xlsx.rs:10-12, 25-47).

  3. 3

    Read shared strings

    The workbook's sharedStrings relationship (default xl/sharedStrings.xml) is read into a list. A missing part gives an empty list. The styles part is read for the number formats of the cell styles.

  4. 4

    Read sheet cells

    A row element starts and ends a row. Each c element takes its column from r, its type from t and its style from s. The value is built from the shared string, the inline text, the boolean, or the stored number, which is turned into a date when the style says so. A sheet may allocate at most 20 million cells; r references that ask for more are an error.

  5. 5

    Place and write

    Cells go into their column index, gaps are filled with empty strings, rows with no content are dropped, and each sheet is written as a Markdown table.

  6. 6

    XLSB and ODS cell rules

    XLSB decodes cell records by type: RK numbers (scaled by 100 when the low bit is set), real numbers, booleans, strings and shared-string indexes. Error and blank records become empty (document.rs:789-831). ODS takes the office value attribute of each cell, then appends the text of its text:p children. A self-closing repeated cell is expanded up to 16,384 times (document.rs:577-650).

Measured

What did we measure, and how?

XLSX, XLS, XLSB, ODS and CSV: Webfold against anydoc on 11 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. 11 public fixtures in XLSX, XLS, XLSB, ODS, CSV 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
XLSX2 (1 hand-made)2 / 290.4 µs489.9 µs5.4 times2 of 22.80 ms3.46 ms2 of 2
XLS1 (0 hand-made)1 / 151.3 µs125.3 µs2.4 times1 of 12.65 ms2.93 ms1 of 1
XLSB1 (1 hand-made)1 / 123.2 µs101.3 µs4.4 times1 of 12.74 ms3.16 ms1 of 1
ODS3 (2 hand-made)3 / 380.0 µs173.0 µs2.2 times3 of 32.79 ms3.25 ms3 of 3
CSV4 (3 hand-made)4 / 41.9 µs169.2 µs86.8 times4 of 42.41 ms3.05 ms3 of 4
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

  • 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 →

Command-line calls per second against Pandoc and MarkItDown, on a pool that includes XLSX and CSV

Run of 2026-09-25

How many one-document command-line conversions per second does each tool complete when several run at once?

Corpus. A pool of 17 public fixtures from the anydoc repository (tests/fixtures, MIT licence per its README), CSV, XLSX and DOCX only and not split by format, 20 calls per worker, one process per call.

ToolVersionRole
Webfold CLIwebfold convert <file>, binary built from the Webfold revision recorded in the run's provenance fileSubject.
Pandoc3.1.11.1 (pandoc -f <format> -t markdown)Comparison. This version has no xlsx reader, so its XLSX calls fail and are counted as errors.
MarkItDown0.1.8 (markitdown <file>, with the docx, xlsx, pdf and all extras)Comparison. Starts a Python interpreter for each call.
MetricHow it is computed
Calls per secondDocuments completed across all workers divided by the wall time of the run. A failed call counts as a call.
ConcurrencyWebfold CLIPandoc 3.1.11.1MarkItDown 0.1.8
1 worker332.639.2 (5 of 18 calls failed)0.44
4 workers739.5159.1 (20 of 72 calls failed)1.35
16 workers2,172.8329.0 (80 of 288 calls failed)2.80
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-vs-incumbents
4cargo run --release -- --fixtures ../../../anydoc/tests/fixtures --out document-vs-incumbents.json

Reproduce: webfold-bench/bench/document-vs-incumbents/src/main.rs; protocol: webfold-bench/bench/results/document-vs-incumbents-2026-09-25.provenance.json. Figures read from webfold-bench/bench/results/document-vs-incumbents-2026-09-25.json and .provenance.json.

Limits of this measurement

  • The pool is CSV, XLSX and DOCX only, and the figures are not split by format, so this is not a DOCX-only or spreadsheet-only number.
  • Pandoc 3.1.11.1 has no xlsx reader: the XLSX files in the pool failed for it, and those failed calls are inside its calls per second. At 16 workers, 80 of its 288 calls failed.
  • Throughput counts calls, not correct output: there is no fidelity score. Peak memory for the subprocess tools includes the child process.
  • One host and one run. The harness does not record the CPU model in the result file. The manifest pins Webfold through git dependencies; use the revisions in the provenance file to reproduce.

Document conversion benchmark →

Workbook reader: the calamine fork against upstream calamine

Run of 2026-09-24

The spreadsheet reader is a fork of the calamine crate with added workbook maps. Does the fork read the same cells as upstream, and does it cost more time?

Corpus. 17 workbooks from calamine's own test directory (tests/ in tafia/calamine, MIT licence), 4 formats (XLSX, XLSB, XLS, ODS). One further file, any_sheets.xlsb, is excluded because both readers fail on its chart sheet.

ToolVersionRole
calamine (upstream)revision 0af05f4f6030Baseline.
calamine fork (apeiron workbook maps)forked from the same revision; calamine/ in the superdocuments repositorySubject. This is the reader Webfold's spreadsheet path uses.
MetricHow it is computed
Same outputSHA-256 of a digest of every cell of every sheet is identical for both readers.
Hot medianMedian of 7 samples, each the mean of 20 in-process workbook parses plus the cell digest, with the file bytes preloaded; the cell is the median over the format's workbooks.
Cold medianMedian of 7 fresh runner processes (after 2 discarded) that read the file, parse it and digest it; warm operating-system cache.
FormatWorkbooksSame cell digestHot, upstreamHot, forkCold, upstreamCold, fork
XLSX99 of 9359.4 µs265.5 µs2.10 ms1.83 ms
XLSB33 of 355.6 µs52.1 µs1.83 ms1.60 ms
XLS33 of 333.3 µs35.3 µs1.38 ms1.41 ms
ODS22 of 264.7 µs67.2 µs1.84 ms1.87 ms
All1717 of 1788.9 µs94.5 µs1.77 ms1.79 ms
1# webfold-bench is private until the public release of Webfold. Run this from a checkout of it.
2git clone https://github.com/tafia/calamine
3cd webfold-bench/bench/calamine-h2h
4cargo run --release -- --fixtures ../../../calamine/tests --out calamine-h2h.json --warmup 2 --samples 7 --iterations 20

Reproduce: webfold-bench/bench/calamine-h2h/src/main.rs; command and hashes: webfold-bench/bench/results/calamine-apeiron-cold-hot-2026-09-24.provenance.json. Figures read from webfold-bench/bench/results/calamine-apeiron-cold-hot-2026-09-24.json and .provenance.json.

Limits of this measurement

  • This compares the fork with the library it was made from. It is not a comparison with another spreadsheet converter, and it shows parity, not a speed-up: the overall medians are within about 6 percent of each other, and the fork is slower on some files.
  • It measures parsing and a cell digest, not Markdown conversion. The harness manifest pins the fork by git revision (ec31d2d5a6b1); the provenance file records the hash of the fork diff that was measured, so check that they match before comparing.
  • One host (x86_64, rustc 1.96.1) and one run. Cells are counted and digested, not scored against an expected value.

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
XLSX sheets use the workbook's names in tab order, with dates and booleans readableCovered by a testsuperdocuments/tests/office_real.rs (xlsx_uses_workbook_names_tab_order_dates_and_booleans); fixture tests/fixtures/office/basic.xlsxRan 2026-11-08, passing. The openpyxl fixture's tab order is Costs then Revenue; a date, a float and booleans in it read as 2026-11-07, 1234.5, TRUE and FALSE, matching the values openpyxl reads back.
A LibreOffice-written XLSX reads the sameCovered by a testsuperdocuments/tests/office_real.rs (libreoffice_written_xlsx_and_pptx_read_the_same_content); fixture tests/fixtures/office/lo.xlsxRan 2026-11-08, passing. Written by LibreOffice from basic.ods.
Serial dates follow Excel, including the 1900 leap-year offset and the 1904 epochCovered by a testsuperdocuments/src/ooxml/xlsx.rs (serial_dates_match_excel)Ran 2026-11-08, passing: 1 is 1900-01-01, 61 is 1900-03-01, 45000 is 2023-03-15, and 0 in the 1904 system is 1904-01-01.
Date number formats are told apart from numeric onesCovered by a testsuperdocuments/src/ooxml/xlsx.rs (date_formats_are_told_from_number_formats, style_sheet_marks_date_cells)Ran 2026-11-08, passing: yyyy-mm-dd, d-mmm-yy and [h]:mm are dates; 0.00, 0.0% and quoted 'd' are not.
Inline, rich-text, error, boolean and uncalculated-formula cells; phonetic guides left outCovered by a testsuperdocuments/src/ooxml/xlsx.rs (cell_kinds, shared_strings_are_rich_text_without_phonetic_guides)Ran 2026-11-08, passing.
A column past XFD is appended to the row rather than overwriting column A, and a sheet that asks for too many cells is refusedCovered by a testsuperdocuments/src/ooxml/xlsx.rs (columns_past_xfd_and_huge_gaps_are_not_misplaced_or_unbounded)Ran 2026-11-08, passing. This replaces the earlier note that the streaming path overwrote column A: the streaming path no longer exists.
convert_streaming and the cache use the same XLSX reader as convert_bytesRead from the codesuperdocuments/src/document.rs (convert_streaming fallback arm; DocFormat::is_streaming)write_xlsx_streaming, lift_xlsx and render_xlsx were deleted.
Shared strings are read from xl/sharedStrings.xml; a missing part gives an empty tableRead from the codesuperdocuments/src/document.rs:390-416, 483-486; superdocuments/src/xlsx.rs:64-90, 159-162shared_strings() collects the text of every t inside each si element.
ODS repeat counts apply only to self-closing cellsRead from the codesuperdocuments/src/document.rs:577-584, 629-634number-columns-repeated is read only in the Empty arm, and the count is capped at 16,384 by odf_repeat. A repeated cell that has content is emitted once.
XLSB sheet names come from workbook.bin record 156, matched to sheets by positionRead from the codesuperdocuments/src/document.rs:745-754, 839-842names.get(number - 1), falling back to 'Sheet N'.
webfold reads legacy XLS through calamine::Xls and renders cells with calamine's DisplayRead from the codewebfold/src/document.rs:170-190; webfold/Cargo.toml:94-98, 221calamine is an optional dependency enabled by the default native feature. Data::Empty writes nothing. Merge data is not used.
No webfold test names XLSCovered by a testwebfold/tests/ (search for xls) and webfold/src/document.rsA search found no XLS test in tests/ or in any unit test in src/document.rs. The superdocuments test for an XLSB sheet uses an empty sheet part (document.rs:2065-2066).
webfold's CSV renderer keeps line breaks as <br> and escapes pipes, and does not pad sparse tablesCovered by a testwebfold/tests/document_review.rs:18-31, 49-59; webfold/src/document/table.rs:4-23, 43-49, 56-71Named tests multiline_cells_are_single_markdown_cells, lone_cr_is_a_record_boundary_and_limits_delimiter_detection and ragged_rows_do_not_expand_to_a_cartesian_grid. Not run.
On 45 public mixed-format fixtures, webfold's median conversion time is about 27.7 microseconds when warm and in processMeasured/benchmarks/documents/ ; webfold-bench/bench/results/document-rust-cold-hot-2026-09-24.jsonUncached, mixed formats; spreadsheets are not reported separately, so this is not an XLSX figure. From a fresh process the median is 2.77 ms. Measured with the older readers; the new ones have not been benchmarked.
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

  • Only date, time and boolean cells are formatted. Percentages, currency, thousands separators and fixed decimals are not applied: a cell shown as 12.50% in Excel is 0.125 here.
  • No file written by Microsoft Excel is among the test fixtures. The real files come from openpyxl and LibreOffice.
  • Row numbers are not kept. Rows with no content are dropped and gaps between rows are not reproduced. Column positions are kept. A formula's text is shown only when the file holds no cached value.
  • Merged cells, hidden sheets and rows, comments, charts and images are not read, and hidden sheets are written like any other. Sheets that are not worksheets (chart sheets) are left out.
  • 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.
  • CSV differs between the Rust API and the CLI. Both write a line break inside a quoted cell as <br>. superdocuments treats a lone CR as data, which it also writes as <br>, while webfold treats a lone CR as a row end (superdocuments document.rs:117-135, 220-250; webfold document/table.rs:44-49). CSV is still an error for an unterminated quote, with no size limit (ARCHITECTURE.md).
  • Legacy XLS: the reader reports dates, times and durations, not the cell's number format, so the choice between a date and a time is made from the value (a serial under 1 is a time). A serial of exactly 0 in a date-only format shows 00:00:00 where XLSX shows 1899-12-31. On damaged files the BIFF record parser can still panic internally; the conversion catches it and returns an error, but the panic message reaches stderr. A sweep of 16,726 corrupted and truncated files produced no abort.

Compared

How does it compare with other tools?

Compared withWhat the evidence saysSource
pandocPandoc's current manual lists xlsx, csv and tsv among its input formats, but not ods. The Pandoc 3.1.11.1 used in our benchmark has no xlsx reader, so its XLSX calls failed there. No spreadsheet output comparison has been run.Pandoc manual; webfold-bench/bench/results/document-vs-incumbents-2026-09-25.provenance.json
doclingDocling's supported-formats page lists XLSX and ODS as inputs without a LibreOffice note, and lists XLS as needing LibreOffice.Docling supported formats
anydocanydoc's README lists xls, xlsx, xlsm, ods and csv among its inputs. On the 45 shared fixtures, 11 of them spreadsheets or CSV, both tools converted every file without error. That run scores success and size only.anydoc README; Document conversion benchmark
MinerUMinerU's README lists XLS and XLSX, ODS and CSV among its inputs.MinerU README

Where should I read next?

FAQ

Frequently asked questions

Does the sheet name match the tab name?

Yes in the new reader: the name comes from workbook.xml, in tab order. The Webfold command line reads XLSX through the same reader, so it gives the same names. A workbook with no workbook part falls back to 'Sheet N'.

Are formulas and number formats shown?

Cached formula results are shown, and a formula nobody calculated is shown as =formula. Number formats are applied: a date reads as 2026-11-07 and not 46333, a percentage as a percentage, and a value shown to two decimals keeps two decimals. Codes with conditions, fractions, scientific notation, eras or a locale-dependent built-in id are not implemented, and those cells keep the stored value.

Can I get cells with their positions?

Yes, for XLSX. xlsx::read_sheet and xlsx::read_workbook return rows of text indexed by column, with the sheet name. Row numbers are not kept.

Does the CLI read .xls?

Yes. webfold convert and chunk read .xls today, and superdocuments::document::convert_bytes reads it too. The webfold command line still uses its own adapter for .xls.

Does a CSV line break in a cell show as <br> in both the CLI and the Rust API?

Yes. The CLI uses webfold's own CSV parser and table renderer (webfold/src/document.rs:122-125), and the superdocuments table writer also turns a line break inside a cell into <br>. They differ on a lone CR: the CLI ends the row, the library keeps it in the cell.

Read Spreadsheets with Webfold.

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