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 do not need the JSON provenance envelope. It carries the 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.
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. - Compact responses report
"schema_version": "1.6"and 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 output:
docray extract deck.pptx --granularity element
docray extract deck.pptx --format lean
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.
- 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.
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. 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
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 two output encodings: json, the default machine contract, and
lean, a line-oriented reading format for token-conscious LLM consumers.
Choose granularity separately: lean supports element and word; requesting
lean 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'
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.
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. - The Rust/Wasm API emits JSON only. Lean is available from the native CLI and HTTP server.
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. 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.
Granularity-shaped schema 1.6 pages 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.
Schema 1.6 granularity-shaped text elements 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
Schema 1.6 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
Schema 1.6 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.6 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
- The no-parameter response is frozen at schema
1.1— new fields are only ever additive, and granularity-shaped responses carry their own version (1.6) and agranularitydiscriminator. Flow responses use schema1.7. 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> Output encoding. Default: json. Lean
implies element granularity.
--max-pages <N> Refuse documents over the page/flow cap.
--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, if any, are inside the JSON / #warning lines in lean) |
| 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 requested with char granularity |
| 8 | granularity_unavailable | the requested granularity is finer than this source provides |
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
docray extract scan.pdf --granularity element \
| jq '[.pages[] | select(.scanned) | .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
--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.
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. 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 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]
Content-Type: multipart/form-data (field name: file)
Returns 200 with extraction JSON, or text/plain; charset=utf-8 for lean.
Lean with no granularity implies element; lean 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'
Async jobs
For large documents (default cap 1 GiB):
POST /v1/jobs[?granularity=…][&format=json|lean] → 202 {"job_id": "…"}
GET /v1/jobs/{id} → {"job_id", "status", "error"}
GET /v1/jobs/{id}/result → 200 stored JSON or lean 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 is persisted on the job, and the result endpoint
uses it to return application/json or text/plain; charset=utf-8. 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 combined with char |
| 400 | granularity_unavailable | the requested granularity is finer than this source provides |
| 400 | bad_multipart / missing_file | malformed upload |
| 413 | too_large / too_many_pages | over sync caps — use jobs |
| 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 |
Invalid values fall back to the default rather than failing startup.
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.
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 /healthz container
health check, 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.
- The server needs no outbound network — only the playground’s browser assets (pdf.js, fonts) load from CDNs, client-side.
- 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.
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. - 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.