Documents
tors.documents converts document formats to GitHub-Flavored Markdown or plain
text: PDF, the office and text formats (doc/docx, xls/xlsx, ppt/pptx, rtf,
odt/ods/odp, epub, csv/tsv), and HTML. The whole read + sniff + convert pass
runs inside one py.detach, so the GIL stays free for the rest of your program
while a large document converts. It ships as a second wheel:
pip install tors[documents]
From a checkout: uv sync --locked --extra documents.
Why two wheels
The extraction engines live in tors-core behind the cargo feature
documents, which is default OFF, so the base tors build carries none of the
engine weight; only the payload wheel compiles it. The compiled surface itself
is the payload package tors_documents; the base wheel ships only a typed
shim, tors.documents, which re-exports the payload's names when it is
installed (import through tors.documents: the payload's own name is the
distribution it rides on, not the API) and raises the install hint when it is
not:
ImportError: the documents-extraction surface ships in the tors-documents wheel: install it with `pip install tors[documents]`
Why the split: the base extension is 7.4 MiB; the same extension with the
documents feature compiled in (engines, extraction cores, bindings) is
30.1 MiB, 4.1x (linux x86-64, release build, symbols stripped). Users who
don't extract documents keep the lean download. One known cost of the
current build: the payload .so also carries the base surface's compiled
code a second time (~37.5 MiB of extensions against the ~30 MiB a single
combined wheel would weigh), an engine-lane cleanup candidate, not a cost of
the split itself.
The payload is version-locked to tors: the same number in both
pyproject.tomls and both Cargo.tomls, bumped together by release-please,
with the publish workflow asserting the lockstep before uploading.
Example
import tors.documents
fmt, markdown = tors.documents.to_markdown("report.docx")
# (Format.DOCX, "Quarterly Review Q3 2026\n\n...")
cls = tors.documents.pdf_classify("incoming.pdf")
if cls.image_only:
route_to_ocr("incoming.pdf") # every page is a scan: nothing local can read it
elif cls.pages_needing_ocr: # 0-based indices of the image-only pages
ocr_pages("incoming.pdf", cls.pages_needing_ocr)
try:
fmt, text = tors.documents.to_text("incoming.pdf", backend="anydoc")
except tors.documents.NeedsOcrError as exc:
ocr_pages("incoming.pdf", exc.pages) # 0-based indices, same as pages_needing_ocr
pages= selects a PDF page subset on the pdf_oxide lane only (one 0-based
page, a list, or a half-open (start, stop) range); the selection is deduped
into document order and a full range is byte-identical to the whole-document
conversion. Any other format, or backend="anydoc" on a PDF, refuses pages=
with ValueError instead of silently converting the whole document.
The API
to_markdown(path=None, data=None, format=None, backend="auto", pages=None,
password=None, max_bytes=None) -> tuple[Format, str]
to_text(path=None, data=None, format=None, backend="auto", pages=None,
password=None, max_bytes=None) -> tuple[Format, str]
sniff(data: bytes) -> Format | None
pdf_extract(path=None, data=None, password=None, backend="auto",
max_bytes=None) -> tuple[list[str], str]
pdf_page_count(path=None, data=None, password=None, backend="auto",
max_bytes=None) -> int
pdf_classify(path=None, data=None, password=None, backend="auto",
max_bytes=None) -> PdfClassification
pdf_link_uris(path=None, data=None, password=None, backend="auto",
max_bytes=None) -> list[list[str]]
The full per-function contract (signatures, the pages= and
format-resolution rules, the error taxonomy, the enums, PdfClassification,
NeedsOcrError, tors.documents.aio) is the documents section of the
API reference. The native docstrings live in
tors-documents/src/lib.rs, held by tests/test_documents_engines.py and
tests/test_pdf.py.
The rules in brief:
- The document is a
pathor in-memorydata=bytes; adata=call converts byte-identically with no temp-file roundtrip. format=names the format explicitly (extension spelling, case-insensitive). The container variantsdocm/xlsm/ppsxmap onto their parents: the same OOXML packages with the content-type override naming the macro/show variant, resolved and sniffed as their base kinds.tsvis accepted as a name and resolves onto the csv kind; the resolved and sniffed name is always"csv", never"tsv".xlsbis vocabulary only: the name routes the Excel kind, but genuine xlsb content is BIFF12.binsheets, not worksheet XML, and is refused (the engines do not read it).Nonesniffs the format from content markers (the PDF header, the RTF open group, OLE stream names, the ZIP package mimetype, the HTML document marker, with a delimiter-agreement heuristic for signature-less CSV), with the path's extension as the fallback, so a mislabeled file still converts. The returnedFormatis what the conversion actually used, and the Excel family reports its container honestly:"xls"for OLE bytes,"xlsx"for a ZIP package, regardless of what the explicit name said.sniff(data)answers the same content question standalone, over bytes alone with no path: the mislabeled-download answer. It opens and parses the container (anydoc's detection reads the ZIP/OLE package metadata; a 120 KiB zip measured 267 MiB peak RSS to answer docx), so budget it like a parse, not a marker scan.Noneis not an error: the content names no format.-pdf_extractreturns(per_page_plain_text, whole_document_markdown)from one pass over one open document; the parse is paid once for both outputs. An image-only page is an empty string, not an error; routing is the caller's decision.pdf_classifyis the cheap text-vs-image preflight:PdfClassificationcarries.page_count,.page_kinds(per page:"text","scanned","image_text","mixed", or"empty"; a blank page is a different thing from a scan),.pages_needing_ocr(the 0-based indices of image-only pages, excluding blanks), and the derived.has_text/.image_only. Encrypted documents fail closed on every entry (ValueErrorwithoutpassword=).max_bytes=is the input budget. An explicit value binds every engine lane before a byte is read or copied;Nonekeeps the 32 MiB default, a post-read check on the amplifying lanes (anydoc, office_oxide) only. On the PDF-only familyNoneis unmetered: those calls never run either metered lane.- Errors:
OSErrorfor a missing/unreadable file (the matched subclass, e.g.IsADirectoryErroron a directory,FileNotFoundErrorfor a missing path);ValueErrorfor an unknown format name, an undetectable file, an unusable backend/format pair, the PDF-only family'sbackend="anydoc"capability refusal (raised before any work runs), an invalidpages=selection, a malformed or encrypted document, an input overmax_bytes=, a non-regularpath=(FIFO/device/socket: typed, namingpathand the kind, before the read), or a NUL byte insidepath=(CPython's ownopen()convention).NeedsOcrError(aValueErrorsubclass carrying.pages, the 0-based indices needing OCR, and.page_count) is raised only on the anydoc PDF lane; the default pdf_oxide lane returns empty output for scanned pages and leaves the OCR decision topdf_classify/pdf_extract.
The engine matrix
Four engines, routed per format family by head-to-head measurement (the full
comparison lives in the documents_impl crate docs; the suite pinning it is
tests/test_documents_engines.py). backend="auto" is the measured-best
lane; a forced backend raises on a format its engine cannot read, never a
silent fallback:
| format family | backend="auto" |
backend="oxide" |
backend="anydoc" |
|---|---|---|---|
| pdf_oxide 0.3.78 | pdf_oxide | anydoc (pdf-inspector) | |
the PDF-only family (pdf_extract/pdf_page_count/pdf_classify/pdf_link_uris) |
pdf_oxide | pdf_oxide | ValueError: a capability refusal, not a format one (see below) |
| html / htm | html-to-markdown-rs 3.12 | ValueError |
ValueError |
doc / docx (incl. docm) |
anydoc 0.2.4 | office_oxide 0.1.10 | anydoc |
xls / xlsx (incl. xlsm) |
anydoc | office_oxide | anydoc |
ppt / pptx (incl. ppsx) |
anydoc | ValueError (ppsx) / office_oxide (pptx) |
anydoc |
| rtf | anydoc | ValueError |
anydoc |
| odt / ods / odp | anydoc | ValueError |
anydoc |
| epub | anydoc | ValueError |
anydoc |
| csv / tsv | anydoc | ValueError |
anydoc |
xlsb is absent on purpose: the name routes the Excel kind, but genuine xlsb
content (BIFF12 .bin sheets, not worksheet XML) is refused by both engines.
ppsx converts on the auto/anydoc lane and is refused by office_oxide, which
checks the presentation content type. Both refusals are typed errors pinned in
the suite.
The PDF-only family's row is a capability refusal, not a format one:
PDF+anydoc converts (to_markdown/to_text with backend="anydoc"), but
anydoc's entire PDF surface is whole-document markdown:
to_markdown(bytes) is the one function its PDF module exposes
(~anydoc-0.2.4/src/formats/pdf.rs), and its only per-page knowledge is the
NeedsOcr refusal. The four probe calls need exactly the surfaces it lacks: the
per-page text probe (the OCR-routing signal), the page tree, per-page
classification, and the /Annots walk. backend="anydoc" on any of the four
is a named ValueError raised before any work runs, pointing at the
conversion pair.
Why this routing: pdf_oxide reads two-column layouts as separate
reading-order blocks, renders /Link annotations as [text](uri), and
detects oversize-font headings, where anydoc's PDF engine (pdf-inspector)
interleaves columns line-by-line and drops links; html-to-markdown-rs is the
only measured HTML engine that drops <script>/<style> by construction;
anydoc renders style-based docx headings (pStyle → #) and list markers
that office_oxide drops entirely, and covers rtf/odt/epub/csv office_oxide
cannot read at all. backend="oxide" exists so a pipeline can diff the two
engines' output on its own corpus; office_oxide's one measured win is exact
entity text (no &-escaping, which anydoc does and the plain-text strip
normalizes back).
Every probe-able cell above was verified against the committed fixtures in
tests/engines_corpus/, the OOXML alias containers included (docm/xlsm/
ppsx fixtures are [Content_Types].xml rewrites of the base generators,
converted byte-identically to their bases and pinned as such). The legacy
doc/xls/ppt cells have no Python fixture (OLE compound files are not
hand-generatable without an Office writer) and are pinned by the crate-side
routing-table test (src/documents_impl.rs) plus the mutation lane's
typed-error contract; they are uncovered at the conversion level.
Async and the GIL
tors.documents.aio gives the six input-scaling functions (to_markdown,
to_text, pdf_extract, pdf_page_count, pdf_classify, pdf_link_uris)
their asyncio.to_thread twins: same signatures, same discipline as
tors.aio (an unconditional thread hop, never a size-based
branch). sniff stays sync-only: its cost is a container parse, but it
remains a single short native pass a sync caller runs directly.
Every function runs its whole native pass (file read, format sniff, engine
conversion, and to_text's strip) inside one py.detach, with only the
argument borrow, validation, and the O(output) return marshalling under the
GIL. The O(n) bytes copy a data= call pays rides inside the detach with the
rest of the pass: a 400 MB call's max heartbeat gap is ~1.1 ms. The official
pdf_oxide pyo3 wheel measures as GIL-held per call (worst heartbeat gap
23.6 ms on a 9-page document, growing with document size), so this payload
calls the crate's Rust API directly under py.detach instead.
The bands are pinned by the heartbeat-granularity and 8-thread
byte-identical concurrency gates in tests/test_documents_engines.py and
tests/test_pdf.py (timing-lane marked; CI's matrix legs deselect the
lane, one 3.12 leg runs it).
asyncio.to_thread is uncancellable mid-pass (the aio docstrings carry this
too): a wait_for timeout returns control while the thread runs the
conversion to completion, and repeated timeouts pin the default executor's
threads. See tors.documents.aio in
the API reference.