Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 char granularity 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

PieceWhat it is
docrayCLI: PDF/PPTX in, JSON or lean text on stdout
docray-serverHTTP API: sync extraction + async job queue
/playgroundBrowser 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-lean lean output 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 to word when you need word-level highlighting, and to char when you need the full archival hierarchy.

The three levels

LevelShapeMeasured size¹Use when
elementone text string + bbox per element−92.9%LLM/RAG consumption, semantic processing, citations
wordflat [text, x0, y0, x1, y1] tuples−89.2%word-precise highlighting, search-hit boxes
char (default)full char → word → line hierarchybaselinearchival, 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/italic when false, fill when black, and stroke when 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, scanned flags, and any non-empty warnings array. 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 hidden items 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 runs preserves each ordinary run and field’s resolved font, size, emphasis, theme color, normal-autofit scaling, and external hyperlink target. A hard break contributes \n to aggregate content but is not a styled TextRun.
  • 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.41 with 0% becomes 41%). Combo charts use the first chart node for chart_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 hidden channel using the placeholder type verbatim; the ECMA default is body when type is 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, preferring descr and falling back to title.
  • Slides with show="0" remain ordinary extracted pages and carry a page-targeted hidden-slide item with content true.
  • 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-layer hidden item contains master or layout.
  • 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 with showMasterSp="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 h1h9, title, quote, or body roles; 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:sdt content 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. frame records 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:

KindMeaning
fieldField instruction; only its cached result is visible
commentComment body only; author and date are excluded
tracked-insertInserted text, also visible in the accepted projection
tracked-deleteDeleted text, excluded from visible content
altDrawing alternative text
footnoteNote body linked to its reference block
endnoteEndnote 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:

GranularityLean reduction vs compact JSON
element26–39%
word14.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:

KindTargetPPTXPDF
roleelementPlaceholder type (body when omitted)not emitted
notespageSpeaker-notes body textnot emitted
altelementShape/picture descr, falling back to titlenot emitted
hidden-slidepagetrue when the slide has show="0"not emitted
source-layerelementmaster or layout for inherited visible shapesnot emitted
fieldblockDOCX field instructionnot emitted
commentblockDOCX comment bodynot emitted
tracked-insertblockDOCX accepted insertionnot emitted
tracked-deleteblockDOCX rejected deletionnot emitted
footnoteblockDOCX note body linked to its referencenot emitted
endnoteblockDOCX endnote body linked to its referencenot 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 element and word; use JSON for the lossless char hierarchy 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 90 reports width: 792, height: 612 and 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:

KindTargetPPTXPDF
roleelementPlaceholder type, defaulting to bodynot emitted
notespageSpeaker-notes body textnot emitted
altelementShape/picture alternative textnot emitted
hidden-slidepagetrue for a slide with show="0"not emitted
source-layerelementmaster or layout for inherited visible shapesnot emitted
fieldblockDOCX field instructionnot emitted
commentblockDOCX comment bodynot emitted
tracked-insertblockDOCX accepted insertionnot emitted
tracked-deleteblockDOCX rejected deletionnot emitted
footnoteblockDOCX note linked to its referencenot 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 a granularity discriminator. Flow responses use schema 1.7. PDF emits no hidden items, runs, tables, or charts, so its no-parameter 1.1 bytes 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:

ExitCodeMeaning
0success (warnings, if any, are inside the JSON / #warning lines in lean)
2unsupported_formatinput is not supported PDF/PPTX/DOCX/DOCM, or is legacy/encrypted Office
3encrypted_pdfpassword-protected
4parse_failuredocument could not be opened
5io_errorfile unreadable / missing
6too_many_pagesover the --max-pages cap
7bad_formatinvalid format, or lean requested with char granularity
8granularity_unavailablethe 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

VariablePurpose
DOCRAY_PDFIUM_DIRDirectory 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

HTTPCodeMeaning
400bad_granularityinvalid granularity value
400bad_formatinvalid format, or lean combined with char
400granularity_unavailablethe requested granularity is finer than this source provides
400bad_multipart / missing_filemalformed upload
413too_large / too_many_pagesover sync caps — use jobs
415unsupported_formatnot supported PDF/PPTX/DOCX/DOCM, or legacy/encrypted Office
422encrypted_pdf / parse_failureunprocessable document
500crashworker died (hostile/malformed input — contained)
500output_too_largeextraction JSON exceeded the output cap
500store_error / io_errorserver-side storage trouble
504timeoutextraction 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 /try on this site — extraction runs as WebAssembly inside a Web Worker; documents never leave your machine. Inputs cap at 100 MB.
  • Embedded in docray-server at /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
  • 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 a default-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.

VariableDefaultPurpose
DOCRAY_PORT41619HTTP listen port
DOCRAY_CLI_PATHdocray beside the server binary, else on PATHWorker binary the server spawns per document
DOCRAY_PDFIUM_DIR./.pdfium/libDirectory of the PDFium dynamic library
DOCRAY_DATA_DIR./dataJob uploads, results, and the SQLite job store
DOCRAY_SYNC_MAX_BYTES26214400 (25 MB)Sync upload cap
DOCRAY_SYNC_MAX_PAGES200Sync page cap
DOCRAY_JOBS_MAX_BYTES1073741824 (1 GiB)Jobs upload cap
DOCRAY_TIMEOUT_SECS300Wall-clock limit per extraction
DOCRAY_OUTPUT_CAP_BYTES536870912 (512 MB)Max JSON a worker may produce
DOCRAY_MEM_LIMIT_BYTES2147483648 (2 GiB)Per-worker memory rlimit (enforced on Linux)
DOCRAY_WORKERSCPU cores (min 1)Job worker pool size; also bounds concurrent sync extractions
DOCRAY_RESULT_TTL_SECS86400 (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 executionRoleArn that 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 char level: 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 flagged scanned.
  • 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.