docray
X-ray for documents. docray takes PDF or PPTX input and returns JSON describing physical elements on every page or slide — text, images, vector paths, and annotations — each with a bounding box, font, and color information. PDF provides the full character → word → line hierarchy; PPTX provides native element-granularity slide structure.
docray extract report.pdf --granularity element
{
"type": "text",
"bbox": [72.0, 61.1, 143.4, 74.5],
"text": "Quarterly results",
"font": { "name": "Helvetica-Bold", "size": 18.0, "bold": true }
}
What it’s for
- LLM / RAG pipelines — token-efficient page content with coordinates for
click-to-source citations. Start with the
granularity guide; if you are token-conscious, you want
element. - Document viewers — overlay highlights and selections on the original page using the same coordinates the extraction reports.
- ML and data extraction — deterministic, lossless physical structure at
chargranularity for training data and downstream parsing.
Design principles
Lossless and unopinionated. At full granularity docray records what the PDF physically contains — it does not guess at headings, tables, or reading order. Consumers filter down; the extractor does not interpret.
Deterministic. The same input bytes always produce byte-identical JSON. The test suite enforces this with golden files and double-extraction checks.
No silent failures. Anything skipped, unsupported, or partially parsed is
recorded in a warnings array. Scanned (raster-only) pages are flagged
"scanned": true so you know exactly which pages need OCR.
Hardened against hostile input. Documents are untrusted. The HTTP server never parses them in-process — every document runs in an isolated worker subprocess with a wall-clock timeout, memory limit, and output cap. A malformed file that crashes the parser kills one job, never the service.
The pieces
| Piece | What it is |
|---|---|
docray | CLI: PDF/PPTX in, JSON or lean text on stdout |
docray-server | HTTP API: sync extraction + async job queue |
/playground | Browser workbench: pages beside their bounding boxes, live JSON |
docray-{model,core,pdf,pptx} | Rust crates: schema, extraction traits, and format extractors |
Quickstart
Try it without installing
The playground runs entirely in your browser — extraction happens locally
via WebAssembly and your document never leaves your machine:
try docray → (also embedded in every docray-server at
/playground)
Install
# Homebrew (macOS/Linux)
brew install f2-ai-inc/tap/docray
# or a container (server + playground included)
docker run -d --rm -p 41619:41619 ghcr.io/f2-ai-inc/docray:latest
# or grab a prebuilt archive from the releases page — pdfium is bundled
Building from source instead? Clone the repo, run ./scripts/fetch-pdfium.sh
once, then cargo build --release -p docray-cli -p docray-server.
Extract your first PDF
docray extract your.pdf --granularity element | jq .
PPTX uses element granularity as well:
docray extract deck.pptx --granularity element | jq .
Run the server + playground
cargo build --release -p docray-cli -p docray-server
./target/release/docray-server
docray-server listening on http://localhost:41619
playground UI: http://localhost:41619/playground
Open the playground and drop a PDF, PPTX, or DOCX on it. PDFs appear as rendered pages; PPTX slides appear as offline visual renders inside a locked-down browser sandbox, with an extraction-derived structure schematic as the fallback. Word documents appear as a scrollable isolated render beside honest flow lenses: positioned containers only in BOXES and a labeled reading-order schematic in X-RAY. All formats sit beside extracted content and live JSON — see the playground.
Extract over HTTP:
curl -sf -F file=@your.pdf 'http://localhost:41619/v1/extract?granularity=element'
curl -sf -F file=@deck.pptx 'http://localhost:41619/v1/extract?granularity=element'
Docker
docker build -t docray .
docker run -d --rm -p 41619:41619 docray
curl -sf http://localhost:41619/healthz
The image bundles the pinned PDFium build; no host dependencies.
Why port 41619?
It’s deliberately obscure so it never collides with your other dev servers.
Override with DOCRAY_PORT.
Choosing a granularity
docray emits three output shapes. This is the most important decision you make as a consumer — it changes payload size by more than an order of magnitude.
The short version: if an LLM reads the output, use
element, then select the token-leanleanoutput format when you need positions, ormdwhen you need clean reading-order prose and do not need the JSON provenance envelope. Element JSON carries text, position, and style of every element at ~7% of the lossless payload. Only move towordwhen you need word-level highlighting, and tocharwhen you need the full archival hierarchy.
The three levels
| Level | Shape | Measured size¹ | Use when |
|---|---|---|---|
element | one text string + bbox per element | −92.9% | LLM/RAG consumption, semantic processing, citations |
word | flat [text, x0, y0, x1, y1] tuples | −89.2% | word-precise highlighting, search-hit boxes |
char (default) | full char → word → line hierarchy | baseline | archival, ML training data, anything lossless |
¹ Measured across a mixed corpus of real documents (bank statements, pitch
decks, a 49-page signed contract) totalling 22 MB of char output.
element
docray extract file.pdf --granularity element
# or: POST /v1/extract?granularity=element
{
"type": "text",
"bbox": [399.6, 90.7, 531.5, 99.4],
"text": "Customer service information",
"font": { "name": "ConnectionsBold_CZEX0AA0", "size": 9.5, "bold": true },
"color": { "fill": [35, 31, 32] }
}
Images reduce to {"type": "image", "bbox": [...]}. Paths keep their bbox
plus optional fill, stroke, and stroke_width; annotations keep their
subtype and uri. The bbox is still precise enough for click-to-source
highlighting.
word
{
"type": "text",
"bbox": [399.6, 90.7, 531.5, 99.4],
"font": { "name": "ConnectionsBold_CZEX0AA0", "size": 9.5, "bold": true },
"words": [
["Customer", 399.6, 90.8, 442.0, 99.4],
["service", 444.8, 90.8, 476.3, 99.4],
["information", 479.1, 90.7, 531.5, 99.4]
]
}
Each word is a positional tuple: [text, x0, y0, x1, y1]. Words appear in
content-stream (extraction) order — docray does not infer reading
order.
char (the default)
Omit the parameter and you get the lossless v1.1 contract, byte-identical
across versions: every text run with nested lines, words, and per-character
boxes, full font/color detail on every element, image quads and content
hashes, path stroke properties. This is the archival shape — see
the JSON contract. It never regroups anything, even on
the glyph-fragmented pages described below: a PDF that places one glyph per
text object still reports one char per text object, exactly as extracted.
Glyph-fragmented pages
Some PDF producers (certain CAD/plotter exports, font-subsetted
re-renderers) emit one text object per glyph instead of batching a run into
a single show-text operator. Left alone, that would surface as dozens of
single-character element/word entries per line instead of readable text.
element and word detect this pattern per page and regroup the glyphs into
geometric lines and words before projecting — so a glyph-fragmented page
reads the same as a normally-authored one (“hello world” as one element, not
eleven). Word boundaries that a batched run on the same page had already
resolved are carried through the regrouping rather than re-derived from glyph
positions, which at small font sizes cannot recover them. Detection and regrouping only ever run on the element/word
projection; normal pages (glyphs already batched into runs) are left
untouched, and char always reports the original, ungrouped geometry.
Rules shared by the compact levels
- Coordinates round to 1 decimal (0.05 pt max displacement — chosen over integers, which produced degenerate zero-area boxes on thin paths).
- Omitted when default for text:
bold/italicwhen false,fillwhen black, andstrokewhen absent or black. Path paint is omitted only when absent, because black fill/stroke is still authored visual content. - Never omitted: page dimensions, element type, bbox,
scannedflags, and any non-emptywarningsarray. Silent-failure freedom survives every granularity. element/wordcompact responses report"schema_version": "1.9". Requesting the explicitchargranularity reports"schema_version": "1.6"— distinct from the frozen"1.1"you get when the parameter is omitted entirely. All three echo the"granularity"you asked for.- Every compact granularity carries each page’s non-visible
hiddenitems verbatim; granularity changes visible text detail, not supplemental context.
Granularity controls which information is retained; output format controls how that information is encoded. See output formats for the measured JSON-versus-lean tradeoff and complete lean specification.
Flow documents
DOCX and DOCM support only element and default to it when the parameter is
omitted. Their schema 1.7 flow blocks have no page bboxes, so word and char
are unavailable. For token-conscious consumers, element plus lean keeps
headings, lists, tables, runs, and hidden context without a JSON envelope. See
Word extraction.
Deliberate non-optimizations
Two further size levers were measured and rejected — documented here so you know they were choices, not oversights:
- One-letter type tags (
"t"vs"text") save only 0.3% more while making the payload harder for models and humans to read. - Document-level font/color tables save ~13% more but break self-containment: a single page or element retrieved into a RAG context would no longer describe itself.
PowerPoint extraction
docray extracts .pptx presentations natively in pure Rust. Each slide is a
page. Slide dimensions are read from the presentation in EMUs, converted at
12,700 EMUs per point, and reported in the same top-left, y-down point space as
PDF output.
PPTX is available at element granularity and in lean or Markdown output:
docray extract deck.pptx --granularity element
docray extract deck.pptx --format lean
docray extract deck.pptx --format md
An omitted granularity defaults to element for PPTX, so docray extract deck.pptx (or a plain upload) just works. Requesting finer detail — char or
word — asks for a hierarchy PPTX cannot provide and returns
granularity_unavailable, with guidance to retry using granularity=element.
Extracted content
- Text shapes are emitted as one text element, with paragraphs separated by
newlines and explicit DrawingML hard breaks preserved at their document
position. The dominant summary remains on the text element, while
runspreserves each ordinary run and field’s resolved font, size, emphasis, theme color, normal-autofit scaling, and external hyperlink target. A hard break contributes\nto aggregate content but is not a styledTextRun. - Tables are emitted as first-class table elements with grid dimensions and row-major anchor cells. Grid and row dimensions determine cell boxes; merged-cell continuations are omitted and the anchor carries the row/column span and merged box. Cell text also preserves per-run styles.
- Charts are emitted as first-class chart elements at the graphic-frame bbox,
with chart type, optional title, and ordered series containing optional names
and category/value points. Category/value points are paired by source index,
and values reuse the chart series’ number format (for example,
0.41with0%becomes41%). Combo charts use the first chart node forchart_type. - SmartArt text is read from the related diagram-data part in document order and emitted as one text element at the graphic-frame bbox.
- Pictures are emitted as image elements. Their content hash covers the exact
referenced media-part bytes, including pictures carried by graphic frames.
A picture whose media relationship is missing, broken, or resolves outside
the package is still emitted at its geometry without
content_hash, with a warning naming the element. - Geometry-only shapes and connectors are emitted as path elements with the fill, stroke, and stroke width represented by the existing JSON model.
- External click hyperlinks continue to be emitted as link annotations. A
text run also carries its external target in
href; targets are returned literally and are never fetched. - Placeholder roles are emitted in the non-visible
hiddenchannel using the placeholdertypeverbatim; the ECMA default isbodywhentypeis absent. - Speaker notes are emitted as page-targeted
notes, using only the notes slide’s body placeholder. Slide-image and slide-number placeholders are ignored. - Shape and picture alternative text is emitted as element-targeted
alt, preferringdescrand falling back totitle. - Slides with
show="0"remain ordinary extracted pages and carry a page-targetedhidden-slideitem with contenttrue. - Visible non-placeholder shapes inherited from the slide master and layout
are extracted through the same shape, picture, connector, group, table, and
graphic-frame paths as slide-owned content. Their element-targeted
source-layerhidden item containsmasterorlayout. - Template placeholder shapes are not emitted, so authoring prompts such as “Click to edit …” do not appear as page content. They still participate in placeholder geometry and style inheritance for slide-owned placeholders.
- A slide with
showMasterSp="0"suppresses both layout and master shapes. A layout withshowMasterSp="0"suppresses master shapes while retaining its own shapes. An absent attribute defaults to showing inherited shapes. - Placeholder geometry is inherited from the slide layout and master. Group, shape, picture, and frame rotation/flip transforms are flattened into slide coordinates.
JSON and lean elements follow PowerPoint z-order: master shapes, layout shapes, then the
slide’s own p:spTree, with document order preserved inside each layer. docray
does not infer a semantic reading order for those lossless/positional formats.
Markdown applies geometric column and reading-order sequencing. Hidden items are explicitly marked as
non-visible context in JSON and lean so consumers do not mistake notes or
accessibility metadata for slide-visible text.
Deliberate limits
PPTX extraction does not provide character or word geometry, render slides, or
reconstruct chart and SmartArt visual geometry: OOXML stores those layouts for
the application to render. Charts report their structure at the containing
frame bbox; SmartArt remains a text element at that bbox. OLE and unknown
graphic frames with no extractable text produce a warning. Legacy .ppt,
encrypted Office documents, and other ZIP-based Office formats are rejected.
Container safety
PPTX files are OPC ZIP containers. Before parsing XML, docray enforces caps on entry count, per-entry and total inflated size, and compression ratio. Unsafe entry names are rejected, parts are read only by exact name, and nothing is extracted to disk. DTD declarations and external entities are never resolved; external relationship targets are treated as literal metadata only. The HTTP server retains its normal worker-process timeout, memory limit, and output cap.
Word extraction
DOCX and DOCM are extracted as authored flow, not as fabricated pages.
WordprocessingML stores paragraphs, runs, tables, sections, and positioned
objects, but a layout engine computes line breaks, y positions, and pages.
DOCX output therefore uses schema 1.7, layout: "flow", and
sections[].blocks[]; it never invents y coordinates or page assignments.
The playground renders the original Word file with the vendored docx-preview library inside a null-origin, network-disabled iframe. That source view is a scrollable browser interpretation, not the source of docray geometry. The BOXES lens shows only authored page/margin-positioned containers; ordinary flow blocks have no resolved boxes. X-RAY is explicitly labeled as a synthetic reading-order schematic, and both the schematic and text/JSON lenses cross-link by stable block ID. No extraction geometry is overlaid on the visual render.
DOCX is element-only and defaults to that finest level:
docray extract report.docx
docray extract report.docx --format lean
docray extract report.docx --format md
word and char return granularity_unavailable. DOCM is parsed identically,
reports source.format: "docm", and warns macro project ignored; VBA bytes
are never read.
Preserved structure
- Paragraph reading order; resolved
h1–h9,title,quote, orbodyroles; resolved fonts, sizes, emphasis, colors, and hyperlinks. - Numbering labels after list counters, overrides, and restarts are applied.
- Authored section page size, margins, and column count. These describe an intended page frame, not resolved pagination.
- Logical tables, authored grid widths, merges, nested blocks, and floating table placement constraints.
- Text inside
w:sdtcontent controls, smart tags, and custom XML wrappers; these wrappers are transparent and do not create extra flow blocks. - Inline image extents and anchored image/textbox placement constraints.
framerecords page-, margin-, column-, paragraph-, or line-relative offsets. Image bytes are represented by SHA-256 content hashes. - Section-scoped default/first/even header and footer stories. Referenced footnote and endnote paragraphs are appended to the section blocks.
Adjacent identically formatted and linked runs are merged. RTL and complex-script text remains in stored logical order; docray does not reverse or visually reorder it.
Pagination hints and limits
lastRenderedPageBreak is a producer cache, not authored layout. When present,
docray derives optional approx_page block hints and top-level approx_pages
from it. Hints in body table cells, nested tables, and textboxes advance the
same body counter; header, footer, footnote, and endnote hints do not. A table
is traversed in row/cell flow order, so its first cell paragraph retains the
page where the table starts while later cell paragraphs can advance as hints
pass. Cached PAGE field results never count as pagination. When no cache
markers exist, approx_page is omitted and the response contains exactly one
no pagination hints; approx_page omitted warning.
For flow documents, max_pages caps approx_pages when hints exist and always
enforces an additional max_pages * 200 total-block cap. Without hints the
response warns max_pages approximated as block cap for flow documents.
Non-visible context
Each section can carry stable hidden items targeted by block ID:
| Kind | Meaning |
|---|---|
field | Field instruction; only its cached result is visible |
comment | Comment body only; author and date are excluded |
tracked-insert | Inserted text, also visible in the accepted projection |
tracked-delete | Deleted text, excluded from visible content |
alt | Drawing alternative text |
footnote | Note body linked to its reference block |
endnote | Endnote body linked to its reference block |
Tracked moves follow the same accepted projection: moveTo is insertion and
moveFrom is deletion. External hyperlinks remain literal strings and are
never fetched. Missing media keeps its image block with a null hash and a
warning.
Output formats
docray has three output encodings: json, the default machine contract;
lean, a line-oriented reading format for token-conscious LLM consumers; and
md, deterministic GitHub-flavored Markdown for human review and semantic
consumption. Choose granularity separately: lean and Markdown accept element
and word; requesting either without a granularity implies element.
docray extract report.pdf --format lean
docray extract report.pdf --format lean --granularity word
curl -F file=@report.pdf 'http://localhost:41619/v1/extract?format=lean'
docray extract report.pdf --format md
curl -F file=@report.pdf 'http://localhost:41619/v1/extract?format=md'
Lean was selected by measuring real tokenizer counts on a real-document corpus, not by estimating from byte size:
| Granularity | Lean reduction vs compact JSON |
|---|---|
element | 26–39% |
word | 14.6% |
TOON was also measured and declined. Faithful TOON was worse than compact JSON for this data shape, while a type-grouped TOON variant still trailed lean by 7–10 percentage points.
Classified JSON
Paged JSON can opt into deterministic page routing metadata with
--classify (CLI), classify=true (sync and async HTTP), or the WASM
extract_classified export. The response uses schema 1.8 and adds
classification { kind, confidence, needs_ocr, reasons } to every page.
It composes with element, word, and char granularity. Classification is
JSON-only; lean and Markdown remain reading projections without routing fields.
Without the option, PDF JSON remains byte-identical schema 1.1. The legacy
scanned field and its existing heuristic are not changed.
Markdown
Markdown is an additive semantic projection over the existing extraction. It does not change the frozen schema 1.1 JSON response, compact JSON, or lean bytes. It ends with a newline and is deterministic for identical input bytes on a given platform and PDFium build.
For PDF, docray geometrically clusters text into supported columns, guards
against treating short inline font fragments as columns, orders each column
top-to-bottom, and emits columns left-to-right. It joins nearby lines into
paragraphs; recognizes bullet, numeric, and letter list markers; preserves
bold, italic, and hyperlink runs; and separates pages with ---. Heading
levels H1-H4 are inferred from deterministic font-size ranks relative to the
document’s weighted median body size; body-size bold text is promoted only
when it also reads like a heading (a short line), and a bold heading is
promoted one tier. A heading is never additionally wrapped in **bold**.
URI annotations are associated with the overlapping or nearest text. On
the PDF path, a strict ruled-table detector reconstructs grids with at least
two columns and two rows from thin vector rulings and renders their cells as
GFM pipe tables. Cell text
is assigned by geometric center and removed from ordinary reading-order output,
so it appears exactly once. Incomplete grids and path-dense chart-like regions
are rejected; table-like candidates that cannot be reconstructed are reported
as Markdown warning callouts. After ruled detection, a borderless
(alignment-based) detector runs only on the text no ruled table already claimed:
it groups visual lines into rows and finds columns from stable whitespace
gutters. The gate combines geometry (at least two columns and three rows, tight
column edges, and a dense fill) with content discriminators — a candidate whose
cells are predominantly sentence-like is rejected as multi-column running prose,
and a two-column candidate whose first column is colon-terminated labels is
rejected as a key/value form — so prose, code, and forms stay reading-order
text. Merged cells are recovered as colspan/rowspan — from missing interior
separators in ruled grids, and from cells straddling a gutter in borderless
ones. A table with any merged cell is emitted as a raw HTML <table> (which
carries the spans), while simple all-single-span tables stay GFM pipe tables;
every cell string is HTML-escaped so untrusted document text cannot break out of
the table, and link targets are dropped unless their scheme is http, https,
or mailto (scheme-less internal links are kept). This Markdown-only
reconstruction does not change schema 1.1 JSON, compact JSON, or lean output.
DOCX Markdown renders authored flow structure directly: h1-h9 and title
roles become headings (levels above six use H6), quotes become block quotes,
lists retain nesting, and logical tables become GFM pipe tables. PPTX uses its
paged element model: placeholder roles inform headings, geometric sequencing
sets reading order, and existing first-class PowerPoint tables become GFM pipe
tables. Images without an exported asset URL are represented by an HTML
comment. Page and section breaks render as ---.
Warnings remain visible as GFM warning callouts. Non-visible hidden-channel
items are retained as trailing docray-hidden HTML comments, so they do not
appear as document prose. Markdown escapes document-controlled Markdown/HTML
syntax, percent-encodes unsafe link-target characters, and drops any link whose
scheme is not http, https, or mailto (rendering its text only) across all
formats.
Markdown is a reading format, not a lossless replacement for JSON. It omits
geometry, source hashes, paint, and reconstruction metadata. Use element for
Markdown unless the same request plumbing also needs word; Markdown itself
does not emit coordinate detail. Use JSON char for the full archival model.
Native CLI and HTTP use --format md / ?format=md; HTTP returns
Content-Type: text/markdown; charset=utf-8. The WASM API exports
extract_markdown(bytes, granularity, max_input_bytes, max_output_bytes).
Lean format specification
Lean is deterministic, line-oriented UTF-8 with \n separators and a final
newline. The first two lines are always:
#docray <granularity> v<schema_version> pages=<N>[ warnings=<K>]
#legend <the fixed legend for the selected granularity>
When the response contains run, table, or chart detail, the element legend is:
#legend T x0 y0 x1 y1 font size style text | r font size style [href#<uri>] text | TB x0 y0 x1 y1 rows cols | c row col rowspan colspan x0 y0 x1 y1 font size style text | CH x0 y0 x1 y1 type [title] | s series-name | p [category] value | I/P x0 y0 x1 y1 | A x0 y0 x1 y1 subtype uri | pt, top-left origin
and the word legend is:
#legend T x0 y0 x1 y1 font size style | w x0 y0 x1 y1 word | r font size style [href#<uri>] text | TB x0 y0 x1 y1 rows cols | c row col rowspan colspan x0 y0 x1 y1 font size style text | CH x0 y0 x1 y1 type [title] | s series-name | p [category] value | I/P x0 y0 x1 y1 | A x0 y0 x1 y1 subtype uri | pt, top-left origin
Responses without run, table, or chart detail retain the preceding schema-1.3
legend shape (without r, TB, c, CH, s, or p). In particular, PDF
lean output has no such detail. Lean deliberately keeps path records bbox-only,
so the schema-1.6 bump changes PDF lean bytes only in the header version token;
compact JSON paths additionally carry their authored paint.
When any page contains non-visible context, one additional legend line follows the element/word legend:
#legend <hidden> kind [element-id] content | non-visible document context
When warnings exist, each follows the legend immediately. Newlines and tabs inside a warning are collapsed to one space:
#warning <warning text>
Each page then starts with:
#page <n> <W>x<H>[ rot=<degrees>][ scanned]
Schema 1.7 flow output uses a different fixed header and records because it has no resolved pages or coordinates:
#docray element v1.7 sections=<N>[ warnings=<K>]
#legend #section width height | H1..H9/TI/Q/P text | LI level o|b label text | r font size style [href#<uri>] text | TB cols col-width... | c row col rowspan colspan text | I [width height] | BR page|column|section | ~page N | pt, authored flow; no resolved coordinates
#section 612 792
H1 Heading text
LI 1 o 1.a) Nested item
TB 2 72 144
c 0 0 1 2 Merged cell
I 100 50
BR section
~page N comes only from lastRenderedPageBreak. Headers and footers are
written around their section body in story order. Textbox and nested-cell
blocks recurse into the same grammar. Flow hidden items use the same bounded
<hidden> block and escaping rules as paged output.
Elements follow in extraction/content-stream z-order:
# element granularity
T x0 y0 x1 y1 <font> <size> <style> <text to end of line>
# word granularity: word records are nested immediately under their T record
T x0 y0 x1 y1 <font> <size> <style>
w x0 y0 x1 y1 <word text to end of line>
# emitted directly after T when the text element has multiple runs or a linked run
r <font> <size> <style> <text to end of line>
r <font> <size> <style> href#<external-uri> <text to end of line>
TB x0 y0 x1 y1 <rows> <cols>
c <row> <col> <rowspan> <colspan> x0 y0 x1 y1 <font> <size> <style> <cell text to end of line>
# multi-run or linked cells use the same r records directly after their c record
CH x0 y0 x1 y1 <chart-type> [<title to end of line>]
s <series name to end of line>
p [<category>] <formatted value to end of line>
I x0 y0 x1 y1
P x0 y0 x1 y1
A x0 y0 x1 y1 <subtype> <uri or ->
After a page’s element records, its non-visible context is explicitly bounded:
<hidden>
<kind> [<element-id>] <content to end of line>
</hidden>
The element ID is present only when the item annotates a visible element. The
block appears after that page’s elements and before the next #page. Documents
without hidden items omit both the block and its legend line.
Hidden content uses the same escapes as visible text and annotation URIs:
backslash becomes \\, LF becomes \n, CR becomes \r, and every other
control character plus U+2028/U+2029 becomes \u{hex} with lowercase,
unpadded hexadecimal digits. An item’s content therefore occupies exactly one
physical line and can never produce a line equal to </hidden> or forge a
visible element record.
Hidden kinds are stable contract strings:
| Kind | Target | PPTX | |
|---|---|---|---|
role | element | Placeholder type (body when omitted) | not emitted |
notes | page | Speaker-notes body text | not emitted |
alt | element | Shape/picture descr, falling back to title | not emitted |
hidden-slide | page | true when the slide has show="0" | not emitted |
source-layer | element | master or layout for inherited visible shapes | not emitted |
field | block | DOCX field instruction | not emitted |
comment | block | DOCX comment body | not emitted |
tracked-insert | block | DOCX accepted insertion | not emitted |
tracked-delete | block | DOCX rejected deletion | not emitted |
footnote | block | DOCX note body linked to its reference | not emitted |
endnote | block | DOCX endnote body linked to its reference | not emitted |
New hidden semantics receive new documented kind strings; these six strings are never repurposed or renamed.
All coordinates use PDF points with a top-left origin after page rotation.
Numbers, including font and page sizes, round to one decimal and omit a
trailing .0 (72, not 72.0; 61.1 remains 61.1). Every whitespace
character in a font name becomes _; a missing font name is -.
TB introduces a first-class table and is followed by one c record per
merge-anchor cell in row-major order. Row and column indices are zero-based;
spans are at least one. Each c carries the font, size, and style of its first
run as its cell summary; an empty cell uses - for all three. A plain single
run adds no information beyond its parent T or c record, so it has no r
record. Multiple runs emit every r, and a linked single run emits its one
r so the hyperlink is not lost. A linked run inserts the literal token
href#<, the escaped external URI, and > before its text.
CH introduces a first-class chart. Its title is optional. Each named series
emits an s record before its points; an unnamed series omits that record.
Each p carries its category followed by the already-formatted value, or only
the value when the source point has no category. Series and points remain in
deterministic source/index order.
The style token concatenates b for bold and i for italic, or uses - when
neither applies. A non-default text fill is appended as lowercase RGB hex,
for example b#231f20 or -#ff0000.
Text, word, run text, run hyperlink URI, table-cell text, chart title, series
name, chart category, chart value, annotation URI, and hidden content use the
same escaping. Text-bearing fields run to end of line. Backslash becomes \\,
LF becomes \n, and CR becomes \r. Every other Unicode
control character, U+2028, and U+2029 becomes \u{hex} with lowercase,
unpadded hexadecimal digits (for example, tab is \u{9}). All other
characters are literal. A fixed-position optional value that is absent is -.
JSON versus lean
Lean is a reading format, not a lossless replacement for JSON:
- It omits the JSON envelope, including source format, SHA-256, byte size, and document metadata. The header carries only granularity, schema version, page count, and warning count.
- It includes non-default text fill color but deliberately omits stroke color and path paint. Use compact JSON when a path’s fill, stroke, or stroke width is required for reconstruction.
- It supports only
elementandword; use JSON for the losslesscharhierarchy and reconstruction metadata. - WASM exposes
extract,extract_classified,extract_lean, andextract_markdown; all four share the same input/output caps and stable error envelope.
Lean HTTP successes use Content-Type: text/plain; charset=utf-8. Async jobs
persist their requested format with the job, so the result endpoint returns
the stored bytes with the same content type. JSON behavior and bytes are
unchanged when format is omitted or set to json.
The JSON contract
This page documents the lossless char-level contract (schema 1.1,
the default when no granularity is requested). The compact shapes are
documented in choosing a granularity.
DOCX/DOCM uses the separate schema 1.7 flow contract. Its envelope contains
layout: "flow", optional approx_pages, and sections instead of pages:
{
"granularity": "element", "schema_version": "1.7", "layout": "flow",
"source": {"format": "docx", "sha256": "…", "size_bytes": 1234},
"document": {"metadata": {"title": "…", "author": "…"}},
"warnings": [], "approx_pages": null,
"sections": [{
"page_width": 612.0, "page_height": 792.0,
"margins": {"top": 72.0, "right": 72.0, "bottom": 72.0, "left": 72.0},
"headers": [], "footers": [], "blocks": []
}]
}
Flow block types are paragraph, table, image, textbox, and break.
Paragraphs contain stable block IDs, semantic roles, resolved runs, optional
list labels and approximate-page hints, and authored breaks. Tables use
authored column widths and merge-anchor cells, and can carry the approximate
page where the table starts. Positioned tables, images, and textboxes carry
tagged placement constraints, never resolved bounding boxes.
See Word extraction for provenance and limits.
Coordinate system
Everything you need to place a box on a rendered page:
- Origin top-left, y increases downward — what viewers and CV pipelines expect.
- Units are PDF points (1/72 inch).
- Coordinates are reported after page rotation — a 612×792 page with
/Rotate 90reportswidth: 792, height: 612and boxes in that rotated, visible space. What you see is what the coordinates mean. - All values are rounded to 3 decimals; output is deterministic — byte-identical for identical input on a given platform and PDFium build. (Documents using non-embedded fonts pick up the platform’s substitute font metrics, so coordinates can differ by fractions of a point across operating systems.)
- Bounding boxes are objects —
{"x0", "y0", "x1", "y1"}— never bare arrays at this level.
Envelope
{
"schema_version": "1.1",
"source": { "format": "pdf", "sha256": "…", "size_bytes": 123456 },
"document": { "page_count": 12, "metadata": { "title": "…", "author": "…" } },
"warnings": [],
"pages": [
{ "page_number": 1, "width": 612.0, "height": 792.0,
"rotation": 0, "scanned": false, "elements": [] }
]
}
warnings is the no-silent-failure channel: skipped object kinds, per-page
parse problems, geometry that couldn’t be read, and pages whose text is likely
garbled because many glyphs are unmapped, replacement characters, or control
characters. Empty means a fully clean extraction.
scanned is true when a page has no text elements and a single image
covering ≥ 85% of the page area — the signal that a page’s text
is not machine-readable and needs OCR to recover. It also flags pre-rendered
(rasterized-slide) pages, which have the same property.
Opt-in page classification (schema 1.8)
An explicit classify request adds this object to every physical page:
"classification": {
"kind": "mixed",
"confidence": 0.82,
"needs_ocr": false,
"reasons": ["text_coverage=0.012", "text_elements=3", "image_coverage=0.248"]
}
kind is text, scanned, image, or mixed. The deterministic v1 rules
use summed clipped glyph-box area divided by page area, mapped glyph and text
element counts, summed and largest image coverage, vector-path density, and
the existing page-scoped suspected_garbled_text warning:
- mapped text is meaningful at 0.001 page coverage or eight glyphs;
- an image is meaningful at 0.01 page coverage and participates in
mixedat 0.20 coverage when meaningful text is also present; - little/no mapped text plus an image covering at least 0.85 is
scanned; - meaningful mapped text without meaningful image coverage is
text; other visual/no-text pages areimage.
needs_ocr is true for scanned and image, a largest-image ratio of at
least 0.60, text coverage below 0.001, or suspected garbled text. A garbled
page therefore needs OCR even when its physical text operators make the kind
text. confidence is a rounded 0..1 decision-margin score: distance from
the thresholds used for the selected kind, not a statistically calibrated
probability. reasons records the rounded signals in stable evaluation order.
Classification does not read or mutate the legacy scanned field. Omitting
the option serializes the original schema 1.1 model directly, so no
classification field or version change can leak into default bytes.
Granularity-shaped pages (schema 1.6 for explicit char, 1.9 for
element/word) can also carry a hidden array. The field is omitted when
empty and is copied unchanged across granularities:
"hidden": [
{ "kind": "role", "element": "p1-e0", "content": "title" },
{ "kind": "notes", "content": "Presenter script" }
]
element is omitted for page-targeted items. Hidden content is supplemental,
non-visible document context and must not be treated as text rendered on the
page. The kind namespace is stable:
| Kind | Target | PPTX | |
|---|---|---|---|
role | element | Placeholder type, defaulting to body | not emitted |
notes | page | Speaker-notes body text | not emitted |
alt | element | Shape/picture alternative text | not emitted |
hidden-slide | page | true for a slide with show="0" | not emitted |
source-layer | element | master or layout for inherited visible shapes | not emitted |
field | block | DOCX field instruction | not emitted |
comment | block | DOCX comment body | not emitted |
tracked-insert | block | DOCX accepted insertion | not emitted |
tracked-delete | block | DOCX rejected deletion | not emitted |
footnote | block | DOCX note linked to its reference | not emitted |
Elements
One element per native PDF page object, in z-order, discriminated by
"type". IDs are stable within a response: p{page}-e{index}.
Content inside Form XObjects (containers PowerPoint exports wrap everything
in) is recursively extracted and flattened into the page’s element stream
with correct page-space coordinates.
text
{
"id": "p1-e4", "type": "text",
"bbox": {"x0": 294.9, "y0": 48.0, "x1": 300.3, "y1": 61.2},
"content": "Introduction to parsing",
"font": { "name": "NimbusRomNo9L-Regu", "size": 10.909, "bold": false, "italic": false },
"color": { "fill": [0, 0, 0], "stroke": null },
"lines": [
{ "bbox": {}, "baseline_y": 61.2,
"words": [
{ "content": "Introduction", "bbox": {},
"chars": [ { "content": "I", "bbox": {}, "unicode": 73 } ] }
] }
]
}
One text element per native text run. Lines and words are grouped
geometrically and deterministically; whitespace characters separate words and
are not emitted as chars. Word order is content-stream order — reading
order is not inferred.
Granularity-shaped text elements (schema 1.6 for explicit char, 1.9 for
element/word) can additionally carry runs. Each run preserves its own
content, resolved font, color, and optional external hyperlink target:
"runs": [
{
"content": "linked text",
"font": { "name": "Aptos", "size": 18.0, "bold": true },
"color": { "fill": [31, 78, 121] },
"href": "https://example.com"
}
]
href is omitted for an unlinked run. Element/word compact output applies the
same compact font and color rules to runs as to their parent: false emphasis
flags, black fill, and empty color objects are omitted. PDF text has no
separate native shape run layer and omits runs, preserving the frozen
no-parameter schema 1.1 bytes. PPTX text keeps content, font, and color
as the concatenated and dominant summary while using runs for the per-run
detail.
table
Granularity-shaped output (schema 1.6 for explicit char, 1.9 for
element/word) carries first-class table elements for PPTX:
{
"id": "p1-e2", "type": "table",
"bbox": {"x0": 72.0, "y0": 90.0, "x1": 272.0, "y1": 170.0},
"rows": 2, "cols": 2,
"cells": [
{
"bbox": {"x0": 72.0, "y0": 90.0, "x1": 272.0, "y1": 120.0},
"row": 0, "col": 0, "row_span": 1, "col_span": 2,
"content": "Merged heading",
"runs": []
}
]
}
rows and cols are the source grid dimensions. Only merge-anchor cells are
emitted; continuation cells are omitted and the anchor carries the clamped
span and merged bounding box. Cell paragraphs are joined with \n, and cell
runs use the same shape as text-element runs. PDF emits no table elements.
chart
Granularity-shaped output (schema 1.6 for explicit char, 1.9 for
element/word) carries first-class chart elements for PPTX:
{
"id": "p1-e3", "type": "chart",
"bbox": {"x0": 72.0, "y0": 72.0, "x1": 432.0, "y1": 288.0},
"chart_type": "doughnut",
"title": "Channel mix",
"series": [
{
"name": "Share",
"points": [
{"category": "Direct", "value": "41%"},
{"category": "Reseller", "value": "59%"}
]
}
]
}
chart_type is bar, pie, doughnut, line, area, scatter, or
other, derived from the chart node in plotArea. Combo charts use the first
chart node in document order while retaining every series in document order.
The optional chart title and series name are omitted when absent. Points pair
categories and finite values by their source index; unmatched values remain as
points without category. Values are strings formatted with the series’
OOXML formatCode, so a stored 0.41 displayed as 0% is returned as
"41%". PDF emits no chart elements.
image
Bounding box plus a quad (four corner points — meaningful when the image is
placed with rotation or skew), pixel dimensions, colorspace, and a
content_hash (sha256 of the raw image data) for deduplication. Pixel data
itself is never embedded.
path
Bounding box plus paint: fill/stroke colors and stroke width. Path operator
lists are not included. Schema 1.9 compact element/word paths retain the
same optional fill, stroke, and stroke_width fields while rounding the
bbox and stroke width to one decimal. An absent paint field is omitted;
compact images remain bbox-only.
annotation
Subtype (link, highlight, widget, …), bounding box, and uri for
links.
Stability
| Schema | Selected by |
|---|---|
1.1 | PDF JSON with no shaping parameters |
1.6 | Explicit char granularity for paged output |
1.7 | DOCX/DOCM authored-flow output |
1.8 | Opt-in classified paged output, with or without granularity |
1.9 | Explicit element or word granularity for paged output |
- The no-parameter response is frozen at schema
1.1— new fields are only ever additive. Explicitchargranularity carries schema1.6; explicitelement/wordgranularity carries schema1.9, both with agranularitydiscriminator.element/wordadditionally detect glyph-fragmented pages (one text object per glyph, with no batched runs) and regroup them into geometric lines and words before projecting —charalways reports the original per-glyph geometry, unregrouped. Flow responses use schema1.7. Opt-in classified paged responses use schema1.8and may also carry the requested granularity discriminator. PDF emits no hidden items, runs, tables, or charts, so its no-parameter1.1bytes remain unchanged. - Element IDs, field names, and the coordinate system are load-bearing contract; they do not change within a major schema version. Hidden kind strings are equally stable and are never renamed.
CLI reference
docray extract <FILE> [OPTIONS]
Options:
--granularity <element|word|char> Output detail. Omit for byte-identical
lossless (schema 1.1) output.
--format <json|lean|md> Output encoding. Default: json. Lean and
Markdown imply element granularity.
--classify Add per-page classification (schema 1.8;
JSON only).
--max-pages <N> Refuse documents over the page/flow cap.
--pages <spec> Extract only a sub-range of pages, e.g.
"7" or "1-200". PDF only.
--pretty Pretty-print the JSON.
The selected document representation is written to stdout; nothing else ever is. The CLI is also the isolation worker the server spawns per document, so its contract is deliberately strict and machine-parseable.
Errors
Failures print a single JSON object to stderr:
{"error": {"code": "encrypted_pdf", "message": "PDF is encrypted / password-protected"}}
with a stable exit code:
| Exit | Code | Meaning |
|---|---|---|
| 0 | — | success (warnings are inside JSON, #warning lean lines, or Markdown callouts) |
| 2 | unsupported_format | input is not supported PDF/PPTX/DOCX/DOCM, or is legacy/encrypted Office |
| 3 | encrypted_pdf | password-protected |
| 4 | parse_failure | document could not be opened |
| 5 | io_error | file unreadable / missing |
| 6 | too_many_pages | over the --max-pages cap |
| 7 | bad_format | invalid format, or lean/Markdown requested with char granularity |
| 7 | bad_pages | --pages value is unparseable, reversed (start > end), zero, or negative |
| 8 | granularity_unavailable | the requested granularity is finer than this source provides |
| 9 | page_out_of_range | --pages range extends beyond the document’s last page |
| 10 | page_selection_unsupported | --pages was given for a non-PDF format (PPTX, DOCX, DOCM) |
Anything else (e.g. 101, or death by signal) means the parser crashed —
treat it as crash. The server does exactly this mapping.
Environment
| Variable | Purpose |
|---|---|
DOCRAY_PDFIUM_DIR | Directory containing the PDFium dynamic library. Falls back to ./.pdfium/lib, then the system library. |
Pipeline examples
# All text of a document, one line per element
docray extract report.pdf --granularity element \
| jq -r '.pages[].elements[] | select(.type=="text") | .text'
# Pages that need OCR, including mixed/garbled cases
docray extract scan.pdf --classify --granularity element \
| jq '[.pages[] | select(.classification.needs_ocr) | .page_number]'
# Fail a CI step if extraction produced warnings
docray extract input.pdf | jq -e '.warnings | length == 0'
# Token-lean element output for an LLM
docray extract report.pdf --format lean
# Reading-order Markdown with inferred PDF headings
docray extract report.pdf --format md
--format lean --granularity word emits word boxes. Lean with no explicit
granularity implies element; --format lean --granularity char fails with
exit 7 and code bad_format. --pretty affects JSON only. See
output formats for the line format and its deliberate
lossless-JSON deltas.
--format md follows the same granularity defaults and char rejection.
Markdown emits semantic prose rather than coordinate detail; element is the
recommended setting.
--classify is opt-in and available only for JSON. Paged PDF/PPTX responses
use schema 1.8 and add a classification object to each page. With no flag,
PDF output remains the byte-identical schema 1.1 contract and the legacy
scanned field is unchanged.
--pages selects a 1-based sub-range of a PDF: --pages 7 for a single
page, --pages 1-200 for an inclusive range. Page numbers stay absolute
over the whole document — --pages 201-287 on a 287-page PDF emits pages
numbered 201 through 287, not renumbered from 1. --max-pages
compares against the selected page count, so --pages 1-200 --max-pages 200 succeeds on a document with far more than 200 pages. Omitting
--pages extracts the whole document, unchanged. --pages is PDF-only;
requesting it on PPTX or DOCX/DOCM fails with exit 10 and
page_selection_unsupported.
PPTX supports element granularity. An omitted --granularity defaults to
element for PPTX (so docray extract deck.pptx just works), and lean also
defaults to element; asking for finer detail (word or char) returns exit 8
with granularity_unavailable. See PowerPoint extraction.
DOCX and DOCM also default to element and support lean and Markdown. They emit schema 1.7
flow sections/blocks; word and char return exit 8. With pagination hints,
--max-pages caps the approximate page count. Without hints it caps blocks at
N * 200 and records the approximation warning. See Word extraction.
HTTP API
Endpoints accept PDF, PPTX, DOCX, or DOCM multipart uploads and return JSON by default. Successful lean and Markdown extractions return UTF-8 text; every error — at any layer — still uses the same JSON envelope:
{"error": {"code": "…", "message": "…"}}
Sync extraction
POST /v1/extract[?granularity=element|word|char][&format=json|lean|md][&classify=true][&pages=<spec>]
Content-Type: multipart/form-data (field name: file)
Returns 200 with extraction JSON, or text/plain; charset=utf-8 for lean.
Markdown uses text/markdown; charset=utf-8. Lean and Markdown with no
granularity imply element; either with char returns
400 bad_format. The endpoint is bounded for interactive use: 25 MB / 200
pages by default (configurable). Oversized requests get 413 pointing you
to the jobs API.
curl -sf -F file=@report.pdf 'http://localhost:41619/v1/extract?granularity=element'
# PPTX and DOCX default to element granularity
curl -sf -F file=@deck.pptx 'http://localhost:41619/v1/extract?granularity=element'
curl -sf -F file=@report.docx 'http://localhost:41619/v1/extract'
curl -sf -F file=@report.pdf 'http://localhost:41619/v1/extract?format=md'
curl -sf -F file=@report.pdf 'http://localhost:41619/v1/extract?classify=true'
curl -sf -F file=@report.pdf 'http://localhost:41619/v1/extract?pages=1-200'
Page selection
pages=<spec> restricts extraction to a sub-range of a PDF, on both
POST /v1/extract and POST /v1/jobs. The spec is 1-based:
pages=7— a single page.pages=1-200— an inclusive range.
Page numbers are absolute over the whole document, not relative to the
selection: pages=201-287 on a 287-page PDF emits pages numbered #page 201 through #page 287 (lean) / page_number: 201..287 (JSON), not a
renumbered 1..87. The response’s page count — the JSON pages array
length, and the LEAN header’s pages= value — always equals the number of
pages actually selected, not the document’s total page count.
Any configured page cap (too_many_pages, 413) is evaluated against the
selected page count, not the document total: a 287-page PDF with
pages=1-200 succeeds against a 200-page cap because only 200 pages were
requested.
Omitting pages extracts the whole document, unchanged from prior
behavior. pages is PDF-only; sending it with a PPTX or DOCX/DOCM upload
returns 400 page_selection_unsupported.
Async jobs
For large documents (default cap 1 GiB):
POST /v1/jobs[?granularity=…][&format=json|lean|md][&classify=true][&pages=<spec>] → 202 {"job_id": "…"}
GET /v1/jobs/{id} → {"job_id", "status", "error"}
GET /v1/jobs/{id}/result → 200 stored JSON, lean, or Markdown bytes
status walks queued → running → succeeded | failed. The result endpoint
returns 404 with code not_ready until the job succeeds and not_found
for unknown ids. Jobs and results are retained for 24 h (configurable), then
swept. The requested format, classification option, and page selection are
persisted on the job, and the result endpoint uses them to return the
corresponding JSON, lean, or Markdown content type against the same page
range that was requested at submit time. Job
state is instance-local — see
architecture & guarantees.
Error code map
| HTTP | Code | Meaning |
|---|---|---|
| 400 | bad_granularity | invalid granularity value |
| 400 | bad_format | invalid format, or lean/Markdown combined with char |
| 400 | granularity_unavailable | the requested granularity is finer than this source provides |
| 400 | bad_multipart / missing_file | malformed upload |
| 400 | bad_pages | pages value is unparseable, reversed (start > end), zero, or negative |
| 400 | page_out_of_range | pages range extends beyond the document’s last page |
| 400 | page_selection_unsupported | pages was given for a non-PDF format (PPTX, DOCX, DOCM) |
| 413 | too_large / too_many_pages | over sync caps — use jobs; too_many_pages is evaluated against the selected page count when pages is set |
| 415 | unsupported_format | not supported PDF/PPTX/DOCX/DOCM, or legacy/encrypted Office |
| 422 | encrypted_pdf / parse_failure | unprocessable document |
| 500 | crash | worker died (hostile/malformed input — contained) |
| 500 | output_too_large | extraction JSON exceeded the output cap |
| 500 | store_error / io_error | server-side storage trouble |
| 504 | timeout | extraction exceeded the wall-clock limit |
Health
GET /healthz → 200 {"status": "ok"}
Concurrency behavior
Sync extractions are bounded by a semaphore sized to the worker count — excess requests queue rather than spawning unbounded subprocesses. The job queue runs on its own bounded pool. Both pools spawn one isolated worker subprocess per document.
The playground
There are two ways to use the playground:
- Hosted, fully in-browser at
/tryon this site — extraction runs as WebAssembly inside a Web Worker; documents never leave your machine. Inputs cap at 100 MB. - Embedded in
docray-serverat/playground— extraction uses the server’s native engine (both engines appear in a selector when available).
docray-server embeds a browser workbench at /playground — the fastest
way to understand what docray extracts and to debug a specific document.
Drop a PDF, PPTX, DOCX, or DOCM on it. PDF pages and PPTX slides use paged navigation. Word documents use their authored sections; a one-section document hides the rail rather than inventing pages.
The workbench provides:
- A thumbnail rail for navigation (arrow keys work; scanned pages carry a badge). PPTX slides start with clean extraction schematics and the current slide upgrades to an isolated visual render. Word sections use clean reading-order schematic thumbnails.
- Two independent panels, each switchable between six lenses:
- source — the rendered PDF page, or an offline visual PPTX/Word render
inside a locked-down, null-origin browser sandbox. Word source is one
scrollable flow; page breaks appear only where the renderer can honor them.
Embedded EMF/WMF images are rendered client-side when possible and carry a
small
≈marker because Windows metafile previews are approximate. - boxes — the page with filled, color-coded bounding boxes (text, image, path, annotation, table)
- x-ray — the page dimmed with wireframe boxes over it
- text — the extracted content, element by element
- json — the page’s JSON, syntax-highlighted, with a copy button
- lean — the whole document in the token-lean format (canonical bytes
from the same renderer as
--format lean), with a copy button
- source — the rendered PDF page, or an offline visual PPTX/Word render
inside a locked-down, null-origin browser sandbox. Word source is one
scrollable flow; page breaks appear only where the renderer can honor them.
Embedded EMF/WMF images are rendered client-side when possible and carry a
small
- Cross-panel sync — click any box and the opposite panel jumps to and flashes that exact element’s JSON; click a JSON element and its box flashes on the page.
- Filter chips to isolate element types, zoom controls, a granularity selector that re-extracts live, and per-page/document JSON views.
For Word flow output, boxes shows the authored page frame and margins plus only containers with numeric page/margin-relative placement and extents. It states that ordinary flow content has no resolved boxes. x-ray becomes a prominently labeled reading-order schematic whose vertical stack and lanes are synthetic, not positions. It cross-links blocks to text and JSON by stable ID. Extraction geometry is never overlaid on the separate docx-preview render.
Hover a PDF/PPTX box for its content, font, and coordinates. Hover a Word block for its content and authored placement constraint, when one exists.
Notes
- The page loads pdf.js and fonts from CDNs, so the browser needs internet access for PDF rendering and web fonts — the extraction API itself does not. The PPTX and Word renderers are vendored and make no network requests.
- A failed or unsupported EMF/WMF conversion remains the renderer’s labeled unsupported-image placeholder; it is never presented as a successful render.
- Hostile PPTX and Word visual rendering runs in an iframe with only
sandbox="allow-scripts"and adefault-src 'none'Content Security Policy. The parent transfers document bytes in, never reads the iframe DOM, and accepts only exact, bounded status messages. Errors and timeouts fall back to extraction-derived structure or reading-order schematics. - Uploads go to the server’s own
/v1/extract; nothing leaves your deployment. - The UI is a single self-contained HTML file compiled into the server binary — there is no build step and no separate deployment.
Configuration
Everything is environment variables with sensible defaults. All limits exist to keep hostile or pathological documents from taking the service down.
| Variable | Default | Purpose |
|---|---|---|
DOCRAY_PORT | 41619 | HTTP listen port |
DOCRAY_CLI_PATH | docray beside the server binary, else on PATH | Worker binary the server spawns per document |
DOCRAY_PDFIUM_DIR | ./.pdfium/lib | Directory of the PDFium dynamic library |
DOCRAY_DATA_DIR | ./data | Job uploads, results, and the SQLite job store |
DOCRAY_SYNC_MAX_BYTES | 26214400 (25 MB) | Sync upload cap |
DOCRAY_SYNC_MAX_PAGES | 200 | Sync page cap |
DOCRAY_JOBS_MAX_BYTES | 1073741824 (1 GiB) | Jobs upload cap |
DOCRAY_TIMEOUT_SECS | 300 | Wall-clock limit per extraction |
DOCRAY_OUTPUT_CAP_BYTES | 536870912 (512 MB) | Max JSON a worker may produce |
DOCRAY_MEM_LIMIT_BYTES | 2147483648 (2 GiB) | Per-worker memory rlimit (enforced on Linux) |
DOCRAY_WORKERS | CPU cores (min 1) | Job worker pool size; also bounds concurrent sync extractions |
DOCRAY_RESULT_TTL_SECS | 86400 (24 h) | How long finished jobs and results are kept |
DOCRAY_TELEMETRY_LOGS | off | json emits a bounded extraction-completed event to stdout |
OTEL_METRICS_EXPORTER | none | otlp enables OpenTelemetry metrics over OTLP/HTTP protobuf |
Telemetry
Structured events and OTLP metrics are independently opt-in. A typical collector configuration is:
DOCRAY_TELEMETRY_LOGS=json \
OTEL_METRICS_EXPORTER=otlp \
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 \
OTEL_SERVICE_NAME=docray-server \
OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=production \
docray-server
The exporter honors standard OpenTelemetry endpoint, metric-specific endpoint, header, timeout, export interval, service-name, and resource attribute variables. Metrics cover request outcomes, latency, queueing, concurrency, and byte sizes. LEAN responses also report pages, records, warnings, and textless pages. JSON and Markdown responses are not reparsed for telemetry, avoiding an additional allocation proportional to a potentially large response.
| Metric | Instrument | Unit |
|---|---|---|
docray.extraction.requests | Counter | {request} |
docray.extraction.errors | Counter | {error} |
docray.extraction.timeouts | Counter | {timeout} |
docray.extraction.request.duration | Histogram | s |
docray.extraction.queue.duration | Histogram | s |
docray.extraction.worker.duration | Histogram | s |
docray.extraction.active | Up-down counter | {request} |
docray.extraction.input.size | Histogram | By |
docray.extraction.output.size | Histogram | By |
docray.extraction.pages | Counter | {page} |
docray.extraction.records | Counter | {record} |
docray.extraction.warnings | Counter | {warning} |
docray.extraction.textless_pages | Counter | {page} |
Only bounded operational attributes are emitted. Filenames, job IDs, source hashes, document text, tenant identifiers, and extracted content are never telemetry fields. Requests rejected by the route’s body-limit layer before the extraction handler must be counted at the proxy or load balancer.
Invalid numeric limit values fall back to their defaults. Unknown telemetry modes fail startup so an explicitly requested exporter is never silently disabled.
Sizing guidance
Task/container memory should exceed
DOCRAY_WORKERS × DOCRAY_MEM_LIMIT_BYTES plus headroom for the server
process itself (e.g. 2 workers × 2 GiB + ~1 GiB ≈ 5 GiB). The per-worker
rlimit caps each extraction before container-level OOM would trigger.
Deployment
docray ships as one container image containing the server, the worker CLI, and the pinned PDFium build. The same image runs everywhere.
Docker
docker build -t docray .
docker run -d --rm -p 41619:41619 \
-e DOCRAY_WORKERS=2 \
docray
The image runs as a non-root user with the data directory at /data; mount
a volume there if you want job results to survive restarts.
The runtime stage is distroless
(gcr.io/distroless/cc-debian13): it contains the two docray binaries, the
PDFium shared library, glibc/libstdc++, CA certificates and tzdata — no shell,
package manager, curl, perl or openssl. docker exec ... sh does not work in
this image, and health checks cannot shell out to curl. Use the built-in
probe instead:
docray-server --healthcheck # exit 0 when GET /healthz on DOCRAY_PORT returns 200, else 1
The image declares a Docker HEALTHCHECK with this command; ECS task
definitions must use the exec form ("CMD", not "CMD-SHELL") as in the
example below.
AWS ECS Fargate
A validated task-definition example lives at
deploy/ecs-task-def.example.json:
1 vCPU / 5 GB memory with 2 workers (see the
sizing guidance), a container health
check via docray-server --healthcheck, and CloudWatch logging. Before
registering it:
- push the image to ECR and fill in the image URI,
- create the CloudWatch log group (
/ecs/docray), - set an
executionRoleArnthat can pull from ECR and write logs.
What to know operationally
- Job state is instance-local (SQLite + files under
DOCRAY_DATA_DIR). One instance is the supported topology; horizontal scaling would require an external job store. - On restart, jobs that were mid-flight are automatically re-queued.
- With telemetry disabled (the default), the server needs no outbound network — only the playground’s browser assets (pdf.js, fonts) load from CDNs, client-side. Enabling the OTLP exporter requires outbound access to the configured collector.
- Responses are not compressed at the HTTP layer yet; if you front docray
with a reverse proxy, enabling gzip/brotli there shrinks
char-level responses dramatically.
OpenTelemetry
For production, send metrics to an OpenTelemetry Collector rather than configuring a vendor SDK in the docray container:
OTEL_METRICS_EXPORTER=otlp
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_SERVICE_NAME=docray-server
OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=production
The collector can fan metrics out to CloudWatch, Prometheus, Grafana,
Datadog, Honeycomb, New Relic, or multiple destinations. Set
DOCRAY_TELEMETRY_LOGS=json when the deployment also wants per-request
structured events in its existing log pipeline.
Architecture & guarantees
The shape of the system
┌──────────────────────────────┐
document upload ─►│ docray-server (axum) │
│ sync: bounded semaphore │
│ jobs: SQLite queue + pool │
└──────────────┬───────────────┘
│ spawns per document
┌──────────────▼───────────────┐
│ docray CLI (subprocess) │
│ docray-pdf / docray-pptx │
│ timeout · memory rlimit · │
│ output cap │
└──────────────┬───────────────┘
│ JSON or lean text on stdout
▼
response / stored result
Crates: docray-model (the serde schema — the contract everything shares),
docray-core (extractor trait, format sniffing, geometric char→word→line
grouping), docray-pdf (PDFium-backed extractor), docray-pptx (pure-Rust
OOXML extractor), docray-cli,
docray-server.
Why a subprocess per document
Documents are hostile input, and PDF parsers in particular are large C++ codebases. docray treats parser compromise/crash as expected: the server never parses PDF or PPTX input in its own process. Each extraction runs in a worker with:
- a wall-clock timeout (killed, reported as
timeout), - a memory rlimit (Linux; the worker dies before the container OOMs),
- an output cap enforced while streaming (a JSON bomb is killed
mid-stream, reported as
output_too_large), - concurrent pipe draining and bounded stderr capture (a worker that floods stderr cannot deadlock the server).
A segfault in the parser costs one request, never the service.
Extraction guarantees
- Deterministic: identical input bytes → byte-identical JSON. PDF goldens are Linux-canonical because font metrics can vary; PPTX goldens are byte-exact on every platform.
- Lossless at
charlevel: every text run, glyph box, image, path, and annotation the PDF physically contains, including content nested inside (possibly deeply nested) Form XObjects, with ancestor transforms composed into page space. - No silent failures: unsupported object kinds, unreadable geometry, and
per-page parse problems all land in
warnings. Raster-only pages are flaggedscanned. - Rotation-correct: page dimensions and all coordinates are reported in the rotated, visible page space.
Engine choice
Extraction geometry comes from PDFium (the renderer inside Chrome) via
the pdfium-render crate, pinned to an exact version. Character-level
bounding boxes require interpreting content streams, font metrics, CMaps, and
transformation matrices — the hardest part of PDF — and PDFium has been
hardened by billions of real-world documents. docray’s own code owns the
schema, the grouping, the hierarchy, and everything above the glyph level.
Limitations
docray is honest about what it does not do. Everything here is by design or known, not hidden.
- PPTX is element-only. It has no character/word boxes, faithful rendering, or chart/SmartArt geometry. See PowerPoint extraction.
- DOCX/DOCM is flow-only. It has no resolved y coordinates, line boxes, or trustworthy pages. Optional approximate-page hints come only from Word’s cached break markers. See Word extraction.
- No OCR. Raster-only pages are flagged (
"scanned": true) but their text is not recovered — recovering it requires OCR downstream. - Broken font encodings. PDF pages dominated by unmapped glyphs, Unicode
replacement characters, or non-whitespace control characters emit a
suspected_garbled_textwarning. The extracted text and geometry remain unchanged; cipher-style mappings to plausible but wrong letters are not detected. - No semantic layer. docray reports physical structure — it does not classify headings or lists and does not infer reading order. PDF text and words appear in content-stream order; PPTX elements appear in z-order.
- Silently recovered corruption. When the underlying parser encounters a corrupt page it sometimes recovers by rendering an empty page without reporting an error; such pages are indistinguishable from genuinely blank ones.
- Shading objects (gradient fills) are skipped, with a warning per skipped object.
- Job state is instance-local — single-instance deployments are the supported topology.
- Rotated-page text grouping: geometry on rotated pages is correct, but text that renders vertically groups one character per line.
- No HTTP response compression — front docray with a reverse proxy and
enable gzip/brotli there if you transport large
char-level responses.