Webfold
Menu

Format · PDF

Text, headings, tables and images from born-digital PDFs, read in process.

Webfold reads the text layer of a born-digital PDF with its own parser and returns Markdown with headings, HTML tables and image markers, in 9 ms with character error rate 0.000 on a 1.8 KB invoice, process start included; pages with no text layer go to the OCR fallback, and on 200 public PDFs it scores 0.760 against 0.883 for liteparse, so it trails liteparse on tables and headings.

Figures from: PDF text extraction with OCR benchmark, PDF extraction against other extractors benchmark.

Webfold reads the text layer of a PDF with its own parser and returns Markdown with headings, HTML tables and image markers. It also returns per-page text, a JSON form, the bookmark outline and the embedded images. Pages with no text layer are not read natively; they go to the OCR fallback, described in the OCR guide.

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 pdf report.pdf
2webfold pdf report.pdf --chunk 4000 --jsonl
3webfold chunk report.pdf --size 4000 --jsonl

Input

What input does PDF reading accept?

Classic and compressed cross-reference structures

Classic `xref` tables, cross-reference streams and object streams (`/Type /ObjStm`). The chain follows `/Prev` and `/XRefStm`, so hybrid files are read, and compressed objects are read from their object stream, which is decoded once and cached.

Stream filters

FlateDecode with `/Predictor` rows undone, ASCIIHexDecode, ASCII85Decode and RunLengthDecode, applied in the order listed in `/Filter`. A stream with any other filter, such as LZWDecode, or a DCT or JPX filter on a content stream, yields no data, so that stream's text is missing. On the benchmark's ASCII85 fixture the native parser returned no text in the latest run, so that filter is covered by source and tests but not by a passing benchmark (see the limits).

Fonts and character codes

Character codes map to Unicode through the font's `/ToUnicode` CMap (`beginbfchar` and `beginbfrange`), then through `/Encoding /Differences` glyph names. Type0 (CID) fonts keep their multi-byte codes and take widths from the CIDFont `/W` array. A simple font with neither map reads single bytes from 0x20 upward as Latin-1.

Page content and Form XObjects

Text is read from each page's content stream, including Form XObjects painted with `Do`. A form uses its own fonts and images when it has `/Resources`. Line breaks follow the text matrix moves (`Tm`, `Td`, `TD`, `T*`), and the output keeps content-stream order.

Embedded images

`pdf::extract_images` lists image XObjects page by page. JPEG (`DCTDecode`) and JPEG 2000 (`JPXDecode`) bytes come back unchanged. 8-bit DeviceGray or DeviceRGB images with Flate or no filter come back as PNG, written with uncompressed (stored) deflate blocks. Other decodable images come back as raw samples with their colour space, and images with an undecodable filter are reported as unsupported with no data.

Input limits and failure modes

The bytes must start with `%PDF-`, and the file must be at most 256 MiB (`MAX_PDF_BYTES`). Malformed input and parser panics are returned as errors. Encrypted files are not decrypted, and pages without a text layer produce no native text.

Output

What comes out?

Markdown

One Markdown string for the whole file, with pages concatenated. Headings are `# ` lines at one level only. Each text line is one output line, and a word broken with a hyphen at a line end is joined when the next line starts with a lower-case letter. Native output has no page markers.

Tables as HTML

Tables are written as HTML `<table>` with `<tr>` and `<td>`, not Markdown pipe tables. A cell gets a `colspan` when its text runs into empty columns. A table comes from drawn ruling lines when they form a grid, or from text gutters when the table has no rules.

Image markers and image bytes

A picture painted on a page appears as `![](img_<object number>.png)` at its place in the output. The crate does not write image files. `pdf::extract_images` returns the bytes; with the `images` feature, `Document::images()` decodes to RGBA and `PdfImage::to_png()` encodes a PNG.

Page, text and JSON forms

`extract_pages_markdown` returns one Markdown string per page, empty for pages with no text. `extract_with_format` returns Markdown, plain text, or a JSON object with `markdown`, `text`, `metadata`, `outline` and `pages`. In that JSON, `metadata` holds only `raw_file_size`, and each `pages` entry is a placeholder with a page number and empty text. `pdf::outline` returns the `/Outlines` bookmark entries separately.

Command line and MCP output

`webfold pdf` prints the Markdown. With `--chunk N` it prints chunks sized to a byte target, and with `--jsonl` each chunk is one JSON object with `source`, `chunk`, `start`, `end` and `text`. The MCP tool `webfold_pdf` runs the same command, with `--chunk` when `chunk_chars` is given and without `--jsonl`.

Layout blocks and document graph (library)

`pdf::page_blocks` takes Markdown bytes, not PDF bytes, and labels each line as heading, paragraph, list item or table row. `graph::from_pdf_path` builds a `DocumentGraph` from the OCR-capable extraction and always labels its source as OCR, whether or not OCR ran.

How it works

What does the code do, step by step?

  1. 1

    Check the input

    The file is read up to a 256 MiB cap, and the bytes must begin with `%PDF-`. A file over the cap is refused before parsing.

  2. 2

    Expand compressed objects (webfold CLI only)

    Before native parsing, `webfold pdf` rewrites object-stream members as ordinary objects (`webfold/src/cli/pdf_objstm.rs`). It accepts only unfiltered or FlateDecode object streams and stops at 128 streams, 32,768 objects, 32 MiB per stream and 64 MiB expanded. If this adapter fails, the file goes straight to the OCR entry point.

  3. 3

    Build the object table

    The parser reads the cross-reference chain from `startxref`, follows `/Prev` and `/XRefStm`, and stores each object by number. Objects load on first use. A compressed object is read from its object stream, and a refused stream is cached so it is decoded only once.

  4. 4

    Read text from content streams

    For each page, the parser decodes the content stream through its filters, builds the page's font maps (`/ToUnicode`, `/Differences`, `/Widths`) and runs a text accumulator over the operators. Each positioned run keeps its gap from the previous run, so spacing and table columns can be read from geometry.

  5. 5

    Classify lines

    Each line is tested for a heading, a table region and an image draw. A heading is a line larger than the page body size, a bold short line, or a numbered section such as `1.2 Methods`. The body size is the character-weighted modal size across the document. Table regions come from drawn rules when they form a grid, otherwise from text gutters.

  6. 6

    Emit Markdown per page

    Table regions are written as `<table>`, image draws as markers at their place in the stream, and hyphenated line ends are joined. Pages are concatenated with a newline between them, and no page marker is written. If the result has no alphanumeric text line, or native parsing fails, the caller can run the OCR fallback (see the OCR guide).

Measured

What did we measure, and how?

Eight PDFs: text recovery and time against pdftotext, MarkItDown and Tesseract

Run of 2026-11-13

Does the PDF route recover the text of scans, rotated pages and columns, and what does it cost in time?

Corpus. The 8 PDFs in tests/fixtures of the superdocuments repository (Apache-2.0), 1.3 to 71.6 KB, each with a hand-checked .expected.txt. Five are made by make_adversarial.py (rotated, blurred scan, mixed, two-column, borderless table); the rest are a reportlab invoice, a reportlab ASCII85 file and a plain scan.

ToolVersionRole
Webfold PDF routesuperdocuments branch ccr-11c60241-pdf (the PDF parser changes), plus an interim OCR page rule in an uncommitted build (OCR a page with an image and under 100 letters or digits). No commit hash is recorded for it; the ocr_file example (extract_file_with_ocr_options)Subject. This is the library route, not a rebuilt webfold pdf binary. Webfold main pins superdocuments 68c216f, the merge of #37, which carries a different rule (image coverage and text density). The 0.1.0 release predates it. The numbers were not re-measured on that binary.
Native parser alonesame build, no OCR; the pdf_markdown exampleShows which files the native parser reads by itself.
pdftotextPoppler 24.02.0Text-extraction baseline.
MarkItDown0.1.9Python converter baseline; starts an interpreter per call.
Tesseract directtesseract 5.3.4 with Poppler 24.02.0 pdftoppm at 300 dpi, page 1 only, default page segmentationOCR baseline, isolated from the library. It cannot read the second page of the mixed file.
MetricHow it is computed
Character error rate (CER)Levenshtein distance between the alphanumeric, lower-cased output and the fixture's .expected.txt, divided by the length of the expected text, with HTML tags removed first. Lower is better. It scores text, not layout: it does not score table structure or reading order as such, though interleaved columns cost edits.
TimeWall time in milliseconds: the median of 6 calls after 1 warm-up. Subprocess tools and the example programs include process start.
no text / no CERThe output was empty, so CER is undefined. For Tesseract direct on the rotated page the reference exists and no CER was recorded.
PDFWebfold route: CER (ms)Native parser alonepdftotextMarkItDownTesseract direct, page 1
Born-digital invoice, 1.8 KB0.000 (9 ms)0.000 (12 ms)0.000 (20 ms)0.000 (1,506 ms)0.000 (1,774 ms)
Rotated 15 degrees, 1.3 KB0.000 (12 ms)0.000 (8 ms)0.686 (19 ms)0.264 (1,372 ms)no CER (1,882 ms)
Scanned text, 35.1 KB0.000 (640 ms)0.955 (9 ms)no text (20 ms)no text (1,498 ms)0.000 (1,759 ms)
Scanned, blurred, 71.6 KB0.023 (776 ms)0.581 (11 ms)0.628 (23 ms)0.628 (1,448 ms)0.023 (1,915 ms)
Mixed native and scanned, 27.9 KB0.119 (1,005 ms)0.202 (6 ms)0.212 (23 ms)0.212 (1,511 ms)0.212 (1,855 ms)
Two columns, 1.9 KB0.141 (8 ms)0.141 (7 ms)0.141 (23 ms)0.141 (1,487 ms)0.141 (3,091 ms)
Borderless table, 1.9 KB0.000 (12 ms)0.000 (5 ms)0.000 (26 ms)0.000 (1,386 ms)0.000 (1,867 ms)
Born-digital, ASCII85 streams, 1.9 KB0.000 (9 ms)0.000 (9 ms)0.000 (19 ms)0.000 (1,418 ms)0.000 (1,937 ms)
1bash tests/pdf-extractors-v1/reproduce.sh [--quick] [WORKDIR]

Reproduce: webfold-bench repository, tests/pdf-extractors-v1/README.md. Figures read from webfold-bench repository, tests/pdf-extractors-v1/results/SUMMARY.md and results/fixtures; the withdrawn run: webfold-bench repository, bench/results/ocr-vs-incumbents-2026-10-14.md.

Limits of this measurement

  • Eight small fixtures made for testing: this says nothing about large real-world PDFs, and the expected texts were written by us. The two-column reference leaves out one sentence that the PDF contains, so 0.141 is the best a correct read can score.
  • The Webfold route rows are not the merged route. They use an interim rule, in a build that was never committed, that OCRs a page with an image and fewer than 100 letters or digits. The rule that merged looks at image coverage and text density. Webfold main pins superdocuments 68c216f, the merge of that rule, so its command line has the merged rule. The numbers were not re-measured on that binary. The results record no commit hash for the measured build.
  • On pages that go to OCR the route cannot beat Tesseract, because it calls Tesseract. Tesseract direct was run on page 1 only at 300 dpi, so its times are not like for like with the route's, and it cannot read the scanned second page of the mixed file (0.212 against 0.119).
  • CER does not score table structure or reading order as such. The route read a rotated page natively only because its text layer is intact; Tesseract direct gave no scorable text on that page, and the code has no rotation handling for a scanned rotated page.
  • Peak memory is not comparable: subprocess tools report parent-side memory only. Pandoc cannot read PDF, so it is omitted.
  • The document-conversion benchmark has no PDF files, so none of its figures describe PDF.
  • This run replaces the 14 October run, which is withdrawn: its error-rate code counted extra leading text as free, its two-column figure of 0.027 does not reproduce, and its parser did not decode ASCII85 streams.

PDF text and OCR benchmark →

PDF extraction on public test sets, against liteparse, PyMuPDF and the CPU extractors

Run of 2026-11-13

How does the native PDF parser compare with other CPU extractors on reading order, tables, headings and stray line breaks?

Corpus. A seeded 144-PDF sample of olmOCR-bench (ODC-BY, dataset revision 54a96a6f), the 200 PDFs of opendataloader-bench (Apache-2.0, revision 7af1d8f) with ground-truth Markdown, and 60 Federal Register of Legislation PDFs (CC BY 4.0), 42 of them born-digital with a Word reference.

ToolVersionRole
Webfold native parserbefore: superdocuments 2aaec131; after: the PDF parser change now in superdocuments mainSubject, native text layer only.
liteparse@llamaindex/liteparse 2.18.0, Markdown modeStrongest comparison.
pymupdf4llm2.1Markdown comparison.
pdftotextPoppler 24.02.0Text baseline.
MetricHow it is computed
olmOCR-bench pass rateShare of tests passed, by the benchmark's own aggregation. A missing or crashed output fails. Tests that expect absent text pass on empty output.
opendataloader-bench NID, TEDS, MHSReading order, table structure and heading scores from that repository's evaluator. The overall score is their mean.
Stray line breaksPer page, newlines inside a reference paragraph, with the share of reference words found beside it so that an empty output cannot look good.
MeasurementWebfoldWebfold beforeliteparse, Markdownpymupdf4llm
olmOCR-bench, share of tests passed31.8%28.0%45.9%38.9%
olmOCR-bench, table tests passed12.8%2.0%54.1%66.9%
opendataloader-bench, overall0.7600.7270.8830.869
opendataloader-bench, tables (TEDS)0.3760.2440.8180.773
opendataloader-bench, headings (MHS)0.5740.5200.8240.783
Stray line breaks per page2.3528.682.060.78
1bash tests/pdf-extractors-v1/reproduce.sh [--quick] [WORKDIR]

Reproduce: webfold-bench repository, tests/pdf-extractors-v1/README.md. Figures read from webfold-bench repository, tests/pdf-extractors-v1/results/SUMMARY.md and results/website-entry.json.

Limits of this measurement

  • Small samples. Ranks within a few points are not separated.
  • Webfold trails liteparse and pymupdf4llm on tables, and liteparse on headings and overall score.
  • No layout model, GPU or vision-language model. ParseBench, Docling, marker and MinerU were not run.
  • The Webfold binary was not rebuilt for the final parser commit. The figures come from the superdocuments example program the command calls.

PDF extraction benchmark →

Embedded image listing against poppler pdfimages

Run of not recorded (the results files carry no date)

Does pdf::extract_images list the same images as poppler's pdfimages, with the same page, size, colour space, bits and encoding, and the same decoded pixels?

Corpus. Two PDFs in tests/fixtures/pdf-images/ of the superdocuments repository (Apache-2.0): images-mixed.pdf (6 images across 4 pages) and inherited-predictor.pdf (1 image). 7 images in all.

ToolVersionRole
superdocuments pdf::extract_imagesthe example pdf_image_list, built from the checked-out revisionSubject.
pdfimagesPoppler; the version is not recorded in the results files (24.02.0 is the version used for the other PDF benchmark)Reference: -list, -j and -png.
MetricHow it is computed
AgreementPer image: page, width and height, colour space, bits per component and encoding category match pdfimages -list; JPEG bytes equal pdfimages -j output; decoded PNG pixels equal pdfimages -png output (both decoded with Pillow). A disagreement is any image where one check fails, or a different image count.
FixturePage and objectSize and colour spaceOurs / poppler encodingResult
images-mixed.pdfpage 1, object 316x8 DeviceRGB, 8 bitpng / imageok
images-mixed.pdfpage 1, object 48x4 DeviceGray, 8 bitpng / imageok
images-mixed.pdfpage 1, object 532x24 DeviceRGB, 8 bitjpeg / jpegok
images-mixed.pdfpage 2, object 64x2 Indexed, 2 bitraw / imageok
images-mixed.pdfpage 3, object 716x16 DeviceGray, 8 bitjpeg / jpegok
images-mixed.pdfpage 4, object 316x8 DeviceRGB, 8 bitpng / imageok
inherited-predictor.pdfpage 1, object 35x6 DeviceRGB, 8 bitpng / imageok
Total7 images0 disagreements
1git clone https://github.com/webfold-ai/superdocuments
2cd superdocuments
3python3 benches/pdf-images/compare_pdfimages.py tests/fixtures/pdf-images/images-mixed.pdf
4python3 benches/pdf-images/compare_pdfimages.py tests/fixtures/pdf-images/inherited-predictor.pdf

Reproduce: superdocuments/benches/pdf-images/compare_pdfimages.py (needs poppler-utils, Pillow and a Rust toolchain). Figures read from superdocuments/benches/pdf-images/results-images-mixed.txt and results-inherited-predictor.txt.

Limits of this measurement

  • Seven small images in two PDFs, tiny (at most 32 by 24 pixels) and made for the test. It shows the listing agrees with poppler on these files; it says nothing about large or unusual real-world PDFs.
  • Agreement with pdfimages is not a correctness proof: both could be wrong the same way, and an image that pdfimages does not list is not checked.
  • The results files are undated and record no tool versions; the run was not repeated for this page. There is no timing: this is a correctness check only.
  • Our PNG output uses stored (uncompressed) deflate blocks, so the files are larger than they need to be.

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
The native entry point checks the size cap and the %PDF- header, then parses inside catch_unwind and returns errors rather than panicking.Read from the codesuperdocuments/src/pdf/mod.rs:62-86native_pages_of_bytes checks MAX_PDF_BYTES and the header, then wraps Document::parse and markdown_pages in catch_unwind.
The filter chain supports FlateDecode, ASCIIHexDecode, ASCII85Decode and RunLengthDecode. Any other filter returns no data for that stream.Read from the codesuperdocuments/src/pdf/parser/decode.rs:266-300decode_within applies each named stage in order. LZWDecode, the image filters and any undecodable stage hit the default arm and return None. The doc comment on decode_within states this.
The parser has no decryption code. /Encrypt appears only in the AcroForm repair path, which skips encrypted files.Read from the codesuperdocuments/src/pdf/acroform.rs:57A search of superdocuments/src/pdf/parser finds no Encrypt, password or RC4/AES handling. README.md format matrix (PDF row) says there is no decryption code. The only encrypted-file test is acroform.rs:430, encrypted_pdf_is_skipped.
Character codes decode through the ToUnicode CMap first, then the /Differences map, then a Latin-1 fallback for single-byte codes at 0x20 and above.Read from the codesuperdocuments/src/pdf/parser/parse.rs:1814-1830CMap::decode looks up map then enc, and falls back to the byte as a character only when the code width is one and the code is at least 0x20. page_fonts (parse.rs:2127-2170) builds the ToUnicode map and the Differences map for every font.
Headings are classified by font size against the body size, by bold short lines, and by numbered section prefixes, and are written as a single-level # prefix.Read from the codesuperdocuments/src/pdf/parser/parse.rs:347-358is_heading gates each signal to lines under 150 characters, and to under 90 for bold and numbered lines. emit_lines writes the prefix # only (parse.rs:977). No level 2 or deeper heading is produced.
Tables are emitted as HTML table markup with colspan for cells that run into empty columns.Read from the codesuperdocuments/src/pdf/parser/parse.rs:828-870emit_grid_table writes <table> (line 851), one <tr> per row and one <td> per cell, and adds colspan when the next columns are empty and the text reaches past their midpoint.
Reading order is the order of the content stream. The text accumulator does not sort text by position.Read from the codesuperdocuments/src/pdf/parser/parse.rs:1557-1560The doc comment on the accumulator says document order is the stream order, with no sort and no binary search. Columns are detected only inside table candidates (columns_from_gutters, parse.rs:560).
A picture painted with Do is written as an image marker at its place in the document flow.Read from the codesuperdocuments/src/pdf/parser/parse.rs:968-972emit_lines writes ![](img_<object>.png) for an image marker line. The Do operator (parse.rs:1752) places the marker where the image is painted.
Image listing returns JPEG and JPEG 2000 bytes unchanged, Flate or unfiltered 8-bit gray and RGB images as PNG with stored deflate blocks, and other images as raw samples.Read from the codesuperdocuments/src/pdf/images.rs:1-20, 262-300The module doc lists the encodings. The match on the last filter handles DCTDecode (JPEG bytes unchanged when no filter precedes it, or Flate only), JPXDecode, and the default arm, which uses encode_png only when bpc is 8 and the colour space is DeviceGray or DeviceRGB.
Object streams and xref streams are loaded natively, and a refused object stream is decoded only once.Covered by a testsuperdocuments/src/pdf/parser/xref_tests.rs:234 and superdocuments/src/pdf/parser/xref_tests.rs:650Named tests: xref_stream_with_object_stream_loads_compressed_catalog and refused_object_stream_is_decoded_once. Not run for this sheet. Also flate_xref_stream_with_png_predictor_is_undone (xref_tests.rs:245).
On the mixed fixture, extract_pages_markdown returns two pages, with native text on page 1 and no text on page 2.Covered by a testsuperdocuments/src/pdf/mod.rs:1619Named test: mixed_fixture_keeps_native_page_and_ocrs_only_the_scan. It checks page count and text presence only. Not run for this sheet.
Embedded image listing agrees with poppler's pdfimages on the recorded fixtures, with zero disagreements.Measuredsuperdocuments/benches/pdf-images/results-images-mixed.txt and superdocuments/benches/pdf-images/results-inherited-predictor.txtSeven images in all: six in the mixed file and one in the inherited-predictor file, 0 disagreements in both. compare_pdfimages.py compares page, size, colour space, bits, encoding category, JPEG bytes and decoded PNG pixels. The results files carry no date or poppler version, and the run was not repeated here.
On a PDF whose text is rotated 15 degrees, the native parser returns the text with a character error rate of 0.000 in about 8 ms with process start. pdftotext scores 0.686 and MarkItDown 0.264.MeasuredPDF text and OCR benchmark; webfold-bench repository, tests/pdf-extractors-v1/results/SUMMARY.mdEight PDFs from superdocuments/tests/fixtures; character error rate against each file's expected text with HTML tags removed first; median of 6 calls after 1 warm-up, on a shared 4-core machine at a load average of 12 to 30; Poppler 24.02.0, Tesseract 5.3.4 and MarkItDown 0.1.9. The Webfold figures are a superdocuments build with the PDF parser changes, run through its example programs. One fixture; the text layer is intact, so this is not a rotated scan.
Scanned pages are recovered through the OCR fallback: 0.023 on a blurred scan where pdftotext and MarkItDown score 0.628, and 0.000 on a plain scan where they return no text.MeasuredPDF text and OCR benchmark; webfold-bench repository, tests/pdf-extractors-v1/results/SUMMARY.mdEight PDFs from superdocuments/tests/fixtures; character error rate against each file's expected text with HTML tags removed first; median of 6 calls after 1 warm-up, on a shared 4-core machine at a load average of 12 to 30; Poppler 24.02.0, Tesseract 5.3.4 and MarkItDown 0.1.9. The route ties Tesseract run directly on page 1, so the gain is over text extractors, not over OCR. The Webfold rows come from a build with an interim OCR page rule that was never committed, not the rule that merged. The results record no commit hash for that build. Eight small fixtures made for the repository.
The native parser reads the five born-digital fixtures, and the text page of the mixed file. It gets little from the two scans.MeasuredPDF text and OCR benchmark; webfold-bench repository, tests/pdf-extractors-v1/results/SUMMARY.mdEight PDFs from superdocuments/tests/fixtures; character error rate against each file's expected text with HTML tags removed first; median of 6 calls after 1 warm-up, on a shared 4-core machine at a load average of 12 to 30; Poppler 24.02.0, Tesseract 5.3.4 and MarkItDown 0.1.9. Native-only error rates: 0.000 on the invoice, rotated page, borderless table and ASCII85 file, 0.141 on two columns (the floor, because the reference omits a sentence the PDF contains), 0.202 on the mixed file, 0.581 and 0.955 on the two scans. The 14 October run, which showed an empty native output on three born-digital files, is withdrawn: its parser did not decode ASCII85 streams.
The webfold CLI reads PDFs with pdf and convert, with OCR flags that set environment variables.Read from the codewebfold/src/main.rs (the pdf arm, from line 800)The usage string is webfold pdf <file.pdf> [--no-ocr] [--chunk n] [--ocr-lang eng+chi_sim] [--ocr-max-pages n] [--ocr-max-dim px] [--ocr-timeout secs]. Each --ocr-* flag sets WEBFOLD_OCR_* for the call, and --no-ocr turns OCR off. convert (main.rs, the convert arm) also routes .pdf through pdf_to_markdown and takes --no-ocr.
The CLI decides OCR page by page: a page with no text, or a scan whose only text is a stamp, is OCRed. Born-digital pages keep their native text.Read from the codewebfold/src/cli/document.rs (pdf_to_markdown, pdf_to_markdown_opts); superdocuments/src/pdf/page_profile.rsThe page rule treats a page as needing OCR when it has no alphanumeric text, or when images cover half the page or more and its text is under 0.4 alphanumeric characters per 1000 pt². webfold pdf --no-ocr starts neither pdftoppm nor tesseract and names on stderr the pages it did not read. The OCR options are still set by WEBFOLD_OCR_* variables or the --ocr-* flags.
arXiv PDFs convert through the fallback path on real papersCovered by a testwebfold/src/arxiv/mod.rs (tests module); live check 2026-11-07With the HTML route forced to 404 against live arXiv, 1706.03762v1 (29k characters), hep-th/9901001 (20k) and 2401.04088v1 (22k) converted. The live check was a throwaway test and is not committed; the committed tests use a generated PDF and the shipped example PDF. The fallback is in Webfold's main branch.

What are the limits?

Know the limits

  • Encryption is not handled. superdocuments has no decryption code, so an encrypted file's content streams are not read, native text is expected to be empty, and the OCR fallback runs. No test covers text from an encrypted file.
  • Some filters are refused. LZWDecode, DCTDecode, JPXDecode, CCITTFaxDecode and JBIG2Decode on a content stream give no data for that stream. The webfold object-stream adapter accepts only unfiltered or FlateDecode object streams.
  • Font encodings are partial. The source has no WinAnsiEncoding, MacRomanEncoding or StandardEncoding table. Without ToUnicode or /Differences, bytes from 0x80 to 0xFF are read as Latin-1, so curly quotes, dashes and the euro sign in WinAnsi text can come out wrong.
  • Native coverage was wrongly reported as narrow on 14 October: that run's parser did not decode ASCII85 streams, so it returned nothing for three born-digital files. The decode was added before the revision Webfold pins, and the corrected run reads all five born-digital fixtures. On the public sets the native parser still trails liteparse and pymupdf4llm on tables and headings.
  • Reading order is content-stream order, not visual order. Prose in two columns is not reordered by the native parser, so a producer that writes column by column can interleave the columns. On the two-column fixture the native parser scores 0.141, the same as pdftotext and Tesseract, and that is the floor because the reference leaves out one sentence. On olmOCR-bench's multi-column tests it passes 59 of 89 (66.3%), against 68 of 89 for liteparse.
  • Headings, tables and figures are heuristics. Headings have one level. Tables depend on drawn rules, repeated text gutters or aligned cells. On opendataloader-bench the table score (TEDS) is 0.376 against 0.818 for liteparse, the heading score (MHS) is 0.574 against 0.824, and on olmOCR-bench's table tests Webfold passes 19 of 148 against 80 for liteparse. We lose to liteparse on tables and headings. The fixture error rate cannot see table structure. Figures are image markers only, with no captions linked to them. Equations come out as the glyphs in the file, with no formula recognition.
  • Output is thin beyond the text. Native output has no page markers. In JSON, metadata is only the file size, so title, author and date from the /Info dictionary are not returned, and the pages entries carry no text or geometry. The document-conversion benchmark has no PDF files. PDF extraction is scored in the PDF extraction benchmark on small samples of public test sets, with no layout model, so it says little about unusual real-world PDFs.
  • The measured figures come from the superdocuments example programs that the command calls. The Webfold binary was not rebuilt for the final parser commit, and the OCR rows use an interim page rule that was never committed, not the rule that merged.
  • The arXiv PDF fallback of webfold arxiv get is in Webfold's main branch. It reads the PDF the way webfold pdf does and records why the HTML step was skipped. Before it, the fallback called document::convert_bytes with pdf, which has no PDF handler, so a paper with no HTML version failed. The arXiv text it produced for three real papers was complete but structurally rough, with author names as separate headings. The hosted /v1/arxiv/retrieve endpoint had its own fallback bugs, fixed in the hosted API's main branch.

Compared

How does it compare with other tools?

Compared withWhat the evidence saysSource
PyMuPDFPyMuPDF (AGPL-3.0 or commercial) extracts PDF text, renders pages to images and offers OCR as an add-on, with Tesseract through MuPDF or RapidOCR. Our text path is native, we do not render pages, and our OCR calls pdftoppm and tesseract.PyMuPDF README
pdfminer.sixpdfminer.six extracts text and layout objects with positions, and embedded images with an extra, and its README documents no OCR engine. Our layout is partial, and we add an OCR fallback through an external Tesseract.pdfminer.six README
Poppler utilitiesPoppler provides pdftotext (with -layout), pdftoppm for page images and pdfimages. None of the three has an OCR option, so a scanned page needs an external OCR step. Our OCR path depends on Poppler's pdftoppm to render pages before Tesseract reads them. Measured against pdftotext, with numbers: see the measured section above.pdftotext source; pdftoppm source; pdfimages source
marker, MinerU and doclingThese use models for layout, figures and equations, and need model downloads. marker's weights and MinerU's licence also carry commercial-use terms (marker above 5 million dollars of funding or revenue; MinerU above 100 million monthly users or 20 million dollars of monthly revenue). We use no models, so we do not recognise equations or link figures to captions.marker README; MinerU README; MinerU LICENSE; Docling installation

Where should I read next?

FAQ

Frequently asked questions

Does webfold send my PDF anywhere?

No. The native path runs in process. The only external programs are pdftoppm and tesseract, and they run locally, only when OCR is needed. The legacy pdffold adapter is not used by the native path.

Can it read a password-protected PDF?

The code does not show that it can. superdocuments has no decryption, so an encrypted file is expected to return no text. The CLI error message tells the user to check that the file is not password-protected. This was not tested.

Does it keep table structure?

Sometimes. A table with drawn ruling lines is read from the grid, and a table without rules is read from text gutters or aligned cells. The output is an HTML table. On opendataloader-bench the table score (TEDS) is 0.376, against 0.818 for liteparse and 0.773 for pymupdf4llm, so it trails them. The eight-file benchmark scores text only.

Does it keep the columns of a two-column paper?

Not by design: text comes out in content-stream order, and column detection is applied only to tables. On the two-column fixture the native parser scores 0.141, the floor for that file, as pdftotext does. On olmOCR-bench's multi-column tests it passes 59 of 89 against 68 for liteparse.

How do I get the images out?

Call pdf::extract_images on the file bytes. JPEG and JPEG 2000 bytes are returned unchanged. Simple 8-bit RGB or gray Flate images are returned as PNG data with stored, uncompressed deflate blocks, so the files are larger than they need to be. The webfold CLI does not expose image extraction.

How accurate is it on scans, rotated pages and columns?

On eight small test PDFs made for the repository, the route's character error rate was 0.000 on an invoice, a rotated page, a plain scan, a borderless table and an ASCII85 file, 0.023 on a blurred scan (pdftotext 0.628), 0.119 on a file that mixes a text page and a scanned page (pdftotext 0.212), and 0.141 on two columns (every tool 0.141). These are not real-world PDFs, and OCR pages cannot beat Tesseract because the route calls it. The measured section above has the protocol and the limits.

Is it faster than pdftotext?

On the pages it reads natively, yes: 5 to 12 ms in a process of its own against 19 to 26 ms for a pdftotext process. Pages that go to OCR take 0.6 to 1.0 seconds, far slower than pdftotext. Speed on a larger set is not reported, because the machine was loaded.

Read PDF with Webfold.

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