docray extracts structured, deterministic JSON (text with font/color/hierarchy,
images, vector paths, and annotations, all with page-space coordinates) from
PDF documents, PPTX presentations, and DOCX/DOCM word-processing files. PDF
supports char, word, and element granularity; PPTX supports positioned element
output; DOCX/DOCM supports semantic flow elements without fabricated pages. It
ships as a CLI (docray) for
one-shot extraction and a server (docray-server) exposing a sync endpoint for
small/fast documents and an async job queue for everything else. The full
JSON contract is documented in the API and granularity sections below.
Homebrew (macOS/Linux):
brew install f2-ai-inc/tap/docray
docray extract your.pdf --granularity elementContainer (GHCR):
docker run -d --rm -p 41619:41619 ghcr.io/f2-ai-inc/docray:latest
# API on :41619, playground at /playgroundPrebuilt binaries: download a .tar.gz for your platform from the
releases page — the pdfium
library is bundled; bin/docray works out of the box.
-
Rust stable (see Docker below for the exact version this repo is verified against — the toolchain's
rustupdefaultstablechannel works for local development). -
Run all cargo commands from the workspace root.
-
Fetch the pinned PDFium binary before building or testing anything that touches
docray-pdf,docray-cli, ordocray-server:./scripts/fetch-pdfium.sh
This downloads a pinned
bblanchon/pdfium-binariesrelease into.pdfium/lib.docray-pdflooks for the library in this order: theDOCRAY_PDFIUM_DIRenv var, then./.pdfium/lib(relative to the process's current directory), then the system library. The integration tests undercrates/docray-pdf/tests,crates/docray-cli/tests, andcrates/docray-server/testsself-setDOCRAY_PDFIUM_DIRto<repo-root>/.pdfium/lib, so a barecargo testworks as long as you ranfetch-pdfium.shonce — you do not need to export the variable yourself for local development.
Build the docray CLI first, then run the server:
cargo build -p docray-cli # or: cargo build --workspace
cargo run -p docray-serverThe server spawns the docray CLI binary as a subprocess for every document
(see DOCRAY_CLI_PATH below), so that binary must already exist on disk —
cargo run -p docray-server builds only the server crate, not the CLI. If you
skip the build step every extraction fails with io_error because the
worker binary can't be found.
By default it listens on 0.0.0.0:41619 and stores job data under ./data.
# Health check
curl -sf http://localhost:41619/healthz
# {"status":"ok"}
# Synchronous extraction (caps: 25 MB / 200 pages by default; over-cap
# requests get 413 and should use the async jobs API instead)
curl -sf -F file=@testdata/simple.pdf http://localhost:41619/v1/extract
# Async: submit a job, returns 202 + job_id
curl -sf -F file=@testdata/simple.pdf http://localhost:41619/v1/jobs
# {"job_id":"..."}
# Poll job status
curl -sf http://localhost:41619/v1/jobs/<job_id>
# {"job_id":"...","status":"queued|running|succeeded|failed","error":null}
# Fetch the result once status is "succeeded" (404 until then)
curl -sf http://localhost:41619/v1/jobs/<job_id>/resultcargo run -p docray-cli -- extract file.pdf --pretty
# or, once built:
docray extract file.pdf [--max-pages N] [--pretty] [--granularity element|word|char] [--format json|lean]
docray extract deck.pptx --granularity element
docray extract report.docxFor PDF, docray emits the lossless char-level v1.1 response by default.
Passing --granularity element|word|char to the CLI, or
?granularity=element|word|char to POST /v1/extract or POST /v1/jobs,
selects an explicit v1.6 response with a top-level granularity field. Jobs
persist the requested level and use it when their worker runs. Invalid query
values return 400 {"error":{"code":"bad_granularity",...}}.
PPTX and DOCX are element-only and omitted granularity defaults to element.
word and char requests return granularity_unavailable. PPTX output does
not use the PDF token-reduction measurements below; see the
PPTX documentation. DOCX/DOCM emits schema 1.7 flow
sections and blocks without invented coordinates or pages; see the
Word documentation.
docray extract file.pdf --granularity word
curl -sf -F file=@testdata/simple.pdf \
'http://localhost:41619/v1/extract?granularity=element'char retains the complete lossless hierarchy. word and element are
LLM/RAG-oriented, one-decimal-point representations: their bbox is
[x0,y0,x1,y1], and their words remain in extraction/content-stream order;
docray does not perform semantic reordering.
| Level | Four-file measured total | Estimated tokens* | Reduction vs current char |
|---|---|---|---|
| char (default) | 22.01 MB | 6.29 M | — |
| word (W9) | 2.39 MB | 0.68 M | 89.15% |
| element (E8) | 1.57 MB | 0.45 M | 92.85% |
*Estimated as bytes / 3.5 because tiktoken was unavailable during the
measurement run; use it for comparison, not a model-specific token count.
Word records are positional tuples [text,x0,y0,x1,y1] so text and its
click-to-source bbox stay adjacent:
{
"type": "text",
"bbox": [72.0, 61.1, 134.0, 74.5],
"words": [["Hello", 72.0, 61.1, 99.3, 74.5], ["World", 102.7, 61.1, 134.0, 74.5]],
"font": {"name": "Helvetica", "size": 12.0}
}Element output keeps one text string and its source bbox:
{
"type": "text",
"bbox": [72.0, 61.1, 134.0, 74.5],
"text": "Hello World",
"font": {"name": "Helvetica", "size": 12.0}
}At word and element levels, id, chars, line/baseline data, and image
reconstruction data are omitted. Compact paths retain fill, stroke, and stroke
width when present. font.name and font.size are
always present for text; bold and italic are omitted when false. Text
fill is omitted when it is [0,0,0]; text stroke is omitted when it is
null or [0,0,0]. Empty warnings arrays are omitted, but every non-empty
warnings array is retained at every explicit granularity level.
JSON remains the default and preserves the existing byte contract. For an LLM
reading element or word output, --format lean emits deterministic
line-oriented text and implies element when granularity is omitted:
$ docray extract file.pdf --format lean
#docray element v1.6 pages=1
#legend T x0 y0 x1 y1 font size style text | I/P x0 y0 x1 y1 | A x0 y0 x1 y1 subtype uri | pt, top-left origin
#page 1 612x792
T 72 61.1 134 74.5 Helvetica 12 - Hello World
On the measured corpus, lean used 26–39% fewer tokens than compact element
JSON and 14.6% fewer at word granularity. It omits the provenance envelope and
stroke color; use JSON when those or char granularity are required. HTTP
uses ?format=lean and returns text/plain; charset=utf-8; jobs persist the
format through the result endpoint. The complete specification is in the
output formats guide.
Exit codes:
| Code | Meaning |
|---|---|
| 0 | ok |
| 2 | unsupported_format |
| 3 | encrypted_pdf |
| 4 | parse_failure |
| 5 | io_error |
| 6 | too_many_pages |
| 7 | bad_format |
| 8 | granularity_unavailable |
On failure, docray prints {"error": {"code": ..., "message": ...}} to
stderr and exits with the corresponding code above.
An absent granularity parameter emits the byte-identical
"schema_version":"1.1" response. Every explicit granularity request emits
"schema_version":"1.6" plus its granularity discriminator. Each page includes a plain
boolean scanned field. A page is scanned: true when it has ZERO text
elements AND at least one image element whose bbox covers ≥ 85% of the page
area. This intentionally also flags pre-rendered (rasterized-slide) pages —
the flag means "no machine-readable text; a full-page raster carries the
content; OCR required", not strictly "came from a scanner". A scan with
an invisible OCR text layer has text elements and is therefore
scanned: false; this is correct because text is extractable.
docray-server is configured entirely via environment variables (see
crates/docray-server/src/config.rs):
| Variable | Default | Meaning |
|---|---|---|
DOCRAY_PORT |
41619 |
HTTP listen port |
DOCRAY_CLI_PATH |
docray next to the running binary, else docray on PATH |
Path to the docray CLI binary the server spawns per document |
DOCRAY_PDFIUM_DIR |
unset | Directory containing the PDFium shared library (passed through to the spawned CLI) |
DOCRAY_DATA_DIR |
./data |
Root for uploads, job results, and the jobs SQLite database |
DOCRAY_SYNC_MAX_BYTES |
26214400 (25 MiB) |
Max upload size accepted by POST /v1/extract |
DOCRAY_JOBS_MAX_BYTES |
1073741824 (1 GiB) |
Max upload size accepted by POST /v1/jobs (enforced as both the route body limit and the streaming-to-disk byte cap) |
DOCRAY_SYNC_MAX_PAGES |
200 |
Max page count accepted by POST /v1/extract |
DOCRAY_TIMEOUT_SECS |
300 |
Wall-clock timeout per extraction before the worker is killed |
DOCRAY_OUTPUT_CAP_BYTES |
536870912 (512 MiB) |
Max stdout size read from a worker before it's killed as output_too_large |
DOCRAY_MEM_LIMIT_BYTES |
2147483648 (2 GiB) |
Per-worker memory rlimit (Linux only) |
DOCRAY_WORKERS |
number of CPU cores (min 1) | Size of the async job worker pool; also bounds concurrent /v1/extract extractions. The sync path and the job pool share this knob but keep independent concurrency counts |
DOCRAY_RESULT_TTL_SECS |
86400 (24 h) |
Age at which succeeded/failed jobs and their results are swept |
- Scanned pages are detected, but not OCRed. Raster-only pages are no
longer silent:
scanned: trueidentifies pages that require OCR, but docray does not itself recover text from the raster. - Corrupt-but-recoverable pages surface as empty pages without warnings.
When PDFium silently recovers from a damaged content stream (e.g. it drops
unreadable operators but still returns a valid page object), docray emits that
page with zero elements and no warning, because the recovery happens inside
PDFium with no signal back to us. A page that fails to parse outright does
get a
page N failed to parsewarning. - Shading objects (gradient fills) are skipped with a warning. Each
skipped object adds a
p{page}-e-skipped: unsupported object type ...warning. (Form XObjects are recursively extracted with correct page-space coordinates.) - Job state is instance-local and ephemeral. The jobs SQLite database and result files live on the task's local disk, so job IDs are only meaningful to the instance that created them and do not survive a task replacement. See the Deploy section for why v1 is single-task only.
# Run the full workspace test suite (requires ./scripts/fetch-pdfium.sh to
# have been run at least once; see Prerequisites)
cargo test
# Update golden fixtures after an intentional output change — review the
# resulting diff in git before committing
UPDATE_GOLDEN=1 cargo test -p docray-pdf
# Regenerate the generated PDF corpus under testdata/ (rotation, CJK,
# ligatures, multi-column, etc.) — run after changing the generator, then
# regenerate goldens with UPDATE_GOLDEN=1 above
cargo run -p docray-pdf --example gen_fixturesAlso run cargo clippy --all-targets -- -D warnings before committing;
CI-equivalent gate for this repo is cargo test --workspace && cargo clippy --all-targets -- -D warnings.
The image is a two-stage build: rust:1.88-slim compiles docray and
docray-server (release), and debian:bookworm-slim runs them alongside the
fetched PDFium shared library. (rust:1.79-slim, the initial target, cannot
build this workspace's dependency graph — clap_lex requires the
edition2024 Cargo feature, stabilized in 1.85, and image/libloading
require rustc 1.88; 1.88 is the lowest verified-working tag.)
docker build -t docray:dev .
docker run -d --rm -p 41619:41619 --name docray-smoke docray:dev
sleep 2
curl -sf http://localhost:41619/healthz
curl -sf -F file=@testdata/simple.pdf http://localhost:41619/v1/extract | head -c 300
docker stop docray-smokeExpected: healthz returns {"status":"ok"}; extract returns JSON
starting with {"schema_version":"1.1".
v1 targets a single ECS Fargate task (see
deploy/ecs-task-def.example.json):
-
Build and push the image to ECR:
aws ecr get-login-password --region <region> | docker login --username AWS --password-stdin <account>.dkr.ecr.<region>.amazonaws.com docker build -t <account>.dkr.ecr.<region>.amazonaws.com/docray:latest . docker push <account>.dkr.ecr.<region>.amazonaws.com/docray:latest
-
Before registering, create the CloudWatch Logs group
/ecs/docrayand an ECS task execution role (ecsTaskExecutionRole, with the managedAmazonECSTaskExecutionRolePolicy); the task definition references both (logConfiguration.options.awslogs-groupandexecutionRoleArn) and registration/launch fails without them. -
Register the task definition (fill in
<account>/<region>) and run it as an ECS service with a single task behind an ALB target group (health checkGET /healthz) or plain service discovery — either way, route all traffic to that one task/service. Note the Fargatecpu/memorypair must be valid: the example usescpu: "1024"(1 vCPU) withmemory: "5120"(5 GiB);cpu: "512"does not permit 5 GiB. -
Job state (SQLite + result files) lives on the task's local disk, so v1 is single-task only: scaling the service to N tasks would split job state across N independent, inconsistent stores. Horizontal scaling requires swapping the job store for a shared backend (S3/DynamoDB behind the existing store trait) with no API changes — see "Job state" in the design spec — before running more than one task.
-
Task memory must exceed
DOCRAY_WORKERS×DOCRAY_MEM_LIMIT_BYTESwith headroom left over for the server process itself — e.g. 2 workers × 2 GiB- ~1 GiB headroom = 5 GiB, hence
memory: "5120"in the example task definition. The per-workerRLIMIT_AScaps each extraction's address space before the task-level OOM killer would otherwise trigger, but that only holds if the task itself has enough memory above the workers' combined limit to avoid being killed first.
- ~1 GiB headroom = 5 GiB, hence
Contributions welcome — start with CONTRIBUTING.md (setup, gates, DCO sign-off). Security reports go through SECURITY.md, never public issues.
Licensed under either of Apache License, Version 2.0 or MIT license at your option. docray is provided "AS IS", without warranty of any kind; F2 AI, Inc. and the contributors accept no liability for its use — including its behavior on untrusted or malformed documents. Validate it for your own use case. See NOTICE. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in docray by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.