Live demo
The demo runs the packages on the same server as these docs (a Cloudflare Worker or Vercel), at /demo, with a JSON API at /api/*.
PDF to HTML
Upload a PDF or paste a URL. Flagged pages can be enhanced with OCR.
Webpage to article
Paste a URL or some HTML. Get the main content and its citation.
Full citation with an LLM
Pick a news, journal or reference page. Get APA, MLA, Chicago and more, with confidence per part and author qualifications.
The site lives in the monorepo at packages/extract-pdf/site. It is one Cloudflare Worker that serves:
| Path | What |
|---|---|
/, /docs/* | These docs: Fumadocs on vinext. |
/demo | The demo page. The PDF tab calls /api/convert; the Webpage tab (/demo#webpage) calls /api/extract. |
/api/convert | convertPDFToHTML from extract-pdf, text layer only. |
/api/enhance | Forwards one rendered page image to a Granite Docling processor. |
/api/warmup | Wakes the Docling processor and starts its model loading. The demo calls it when it opens. |
/api/source?url= | The PDF at url, so the browser can render flagged pages. |
/api/extract | extractContent from extract-webpage. |
/api/cite | extractCiteLLM from extract-cite: a full citation from an LLM. POST only. |
/api/health | { status, maxPdfMb, ocr, cite }. |
worker/index.ts is the Worker's entry: it answers /api/* from worker/api.ts and hands every other request to the docs.
The same site also deploys to Vercel as a plain Next.js app (vercel.json, build-vercel.sh): the docs and /demo are the same pages, and app/api/[...path]/route.ts answers every /api/* route with the same worker/api.ts. Set the variables below as Vercel environment variables; there is no KV there, so settings saved in /admin last only as long as one function instance.
The PDF API
# Upload a file
curl -F file=@paper.pdf https://YOUR-SITE/api/convert
# Raw PDF body, with page-number markers
curl -X POST -H 'Content-Type: application/pdf' --data-binary @paper.pdf \
'https://YOUR-SITE/api/convert?addPageNumbers=true'
# Remote URL
curl 'https://YOUR-SITE/api/convert?url=https://arxiv.org/pdf/1706.03762'The response:
{
"source": "https://arxiv.org/pdf/1706.03762",
"status": "ready",
"title": "…",
"author": "…",
"html": "<p id=\"page-1\">…",
"bytes": 2215244,
"ms": 1377,
"ocr": {
"needed": true,
"pageCount": 15,
"pagesNeedingOcr": [3, 7],
"reasons": { "3": ["table-caption", "numeric-grid"], "7": ["sparse-text", "figure-caption"] },
"enhance": null
}
}ocr is the text-layer scan: the pages that look scanned or carry tables and figures, and why. When pages were flagged and OCR is configured, status is "ready_with_pending_ocr" and enhance is { "endpoint": "/api/enhance", "maxPages": 10 }.
Errors are { "error": "…" } with 400 (bad input), 413 (too large), 422 (unparseable PDF) or 502 (the URL could not be fetched).
The OCR follow-up
Workers have no canvas, so they cannot rasterize pages, and a vision model is far too slow to run inside a request anyway. The demo splits the work:
POST /api/convertreturns the text-layer HTML at once, with the flagged pages.- The page renders only those pages to PNG in the browser with PDF.js. For a URL source it reads the PDF again through
/api/source. - It sends each image to
POST /api/enhanceas{ "page": 3, "imageBase64": "…" }. The Worker forwards it to the Docling processor with its secrets and returns{ page, html, ms }. - The page swaps each OCR'd page into the result as
<section class="ocr-page" id="page-N">, one at a time. A page whose OCR fails keeps its text-layer version.
Page images pass through the Worker and the Space in memory only: neither stores them, and every API response is Cache-Control: no-store.
When OCR is configured, the PDF tab calls POST /api/warmup as soon as it opens. The Worker asks the Space to start loading its model and answers within a few seconds with { ocr, modelLoaded, loading }, so a Space that was asleep is booting while the visitor picks a file.
To turn it on, deploy the Docling Space and give the Worker its URL and token:
bunx wrangler secret put DOCLING_PROCESSOR_URL # https://YOUR_HF_USERNAME-extract-pdf-docling.hf.space
bunx wrangler secret put DOCLING_API_TOKEN # the Space's DOCLING_API_TOKEN secret
bunx wrangler secret put HF_SPACE_TOKEN # private Space only: a Hugging Face read tokenWithout DOCLING_PROCESSOR_URL the demo still works and only reports which pages would benefit from OCR. Optional tuning variables: DOCLING_MAX_PAGES (10), DOCLING_MAX_TOKENS (1500) and DOCLING_MAX_IMAGE_MB (8).
The webpage API
curl 'https://YOUR-SITE/api/extract?url=https://en.wikipedia.org/wiki/Web_scraping'
# HTML you already have; the URL makes links absolute and helps the citation
curl -X POST -H 'Content-Type: application/json' \
-d '{"html": "<html>…</html>", "url": "https://example.com/post"}' https://YOUR-SITE/api/extractThe response is extractContent's article plus where it came from: { input, ms, title, author, author_cite, date, source, cite, url, word_count, html }. Flags images, links and formatting default to true. Pasted HTML is capped by MAX_HTML_MB (2).
The citation API
curl -X POST -H 'Content-Type: application/json' \
-d '{"url": "https://arxiv.org/abs/1706.03762", "apiKey": "sk-or-…", "model": "anthropic/claude-haiku-4.5"}' \
https://YOUR-SITE/api/citeapiKey is an OpenRouter key, used for that one request and never stored. Leave it out when the Worker has its own (bunx wrangler secret put OPENROUTER_API_KEY; CITE_MODEL sets the default model). html can replace url for pages that block server fetches, and styles narrows the output. The Worker first runs extract-webpage on the page and passes the article as content, so contentCheck says whether that extraction is the full article or a paywall stub, and which blocks to cut. The response is extractCiteLLM's result plus input. Errors are { "error": "…" } with 401 (no key, or the provider rejected it), 429 (the provider's rate limit), 400, 413 or 502.
Run it locally
bun install # from the monorepo root
cd packages/extract-pdf/site
bun run dev # the docs, /demo and its /api/*, on one portThe site imports extract-pdf, extract-webpage and extract-cite from their source through Vite aliases, so there is no package build first. Under bun run dev the API runs in Node, and its variables come from the shell, for example DOCLING_PROCESSOR_URL=http://127.0.0.1:7860 DOCLING_API_TOKEN=local-test-token bun run dev. bun run preview runs the built Worker in workerd.
Deploy your own
cd packages/extract-pdf/site
bun run build
bunx wrangler deployPDF parsing is CPU-bound. On the Workers Free plan (10 ms of CPU per request) most real PDFs fail with error 1102; on the Paid plan uncomment "limits": { "cpu_ms": 30000 } in wrangler.jsonc. See Running on Workers.
Admin panel
/admin signs in with a password and sets the site's global keys (the OpenRouter key and default model for the Citation tab, and the Docling OCR URL and tokens) without a redeploy. A value saved there wins over the same name in the Worker's dashboard; clearing it falls back to the dashboard value.
bunx wrangler secret put ADMIN_PASSWORD # until set, /admin is disabledKeys are stored in the SETTINGS KV namespace (created on first deploy) and are shown masked, never sent back to the browser. Sessions last 8 hours in an HttpOnly, SameSite=Strict cookie signed with the password, so changing the password signs everyone out. Eight failed logins from one IP lock it out for 15 minutes. Use a long random password.