Cookbook: Long PDFs
Cookbook: long PDFs
Section titled “Cookbook: long PDFs”Once a PDF is 50+ pages, you cannot fit it in a single VLM context, you do not want to pay for re-runs on partial failure, and you usually want the UI to show pages as they finish rather than waiting for the whole document. This page is the recipe set for that regime.
Stream pages as they finish
Section titled “Stream pages as they finish”from scriva import Orchestrator, Worker
worker = Worker.openai("gpt-4o").cache(".scriva_cache")
recipe = ( Orchestrator() .deskew() .split.boxes(detector="legal-blocks") .recognize(worker) .reconstruct.document(sections=True) .options(page_concurrency=4))
async for page_result in recipe.stream("100p-msa.pdf"): upsert_clauses(page_result.fields.clauses, page=page_result.page_index)recipe.stream(doc) is an async iterator. Each yield is a finished
PageResult — by the time you get page 3, pages 4–N may already be
in flight (bounded by page_concurrency). The final DocumentResult
is returned at the end and stitches the pages together.
For non-async callers:
for page_result in scriva.iter_pages(recipe, "100p-msa.pdf"): ...iter_pages is the sync wrapper. It internally runs the async recipe
in a background task and yields when each page completes.
Cross-page stitching
Section titled “Cross-page stitching”When a table or section wraps:
recipe = ( Orchestrator() .split.multi_page_table(continuation_marker="continued on next page") .recognize(worker) .reconstruct.table(stitch=True) .export.json("out.json")).reconstruct.table(stitch=True) joins detected tables across pages
where the continued_from annotation aligns. Set stitch=False on
either the splitter or the reconstruct step to disable.
For arbitrary cross-page entities (e.g. a sentence that runs over a page break), reach for a custom reconstruct step:
from scriva import step
@step(phase="reconstruct", name="crossing-pages")async def crossing_pages(ctx): # merge regions whose .attrs["continued_from"] points at a previous page ... return ctx
recipe = recipe.insert_after("reconstruct", crossing_pages)Page filters
Section titled “Page filters”Only OCR what you need:
from scriva import Document
doc = Document.load("contract.pdf", pages=range(0, 5)) # first 5 pagesdoc = Document.load("contract.pdf", pages=[0, 10, 20]) # specific pagesdoc = Document.load("contract.pdf", pages=slice(0, None, 2)) # every other pageFor “find the page first, then extract”:
toc_recipe = ( Orchestrator() .split.whole_page() .recognize(worker) .reconstruct.document())toc = toc_recipe(Document.load("contract.pdf", pages=range(0, 3))).to_dict()["fields"]relevant = [c["page_index"] for c in toc["clauses"] if c["heading"].startswith("Indemnif")]
indem = recipe(Document.load("contract.pdf", pages=relevant))This pattern — cheap TOC pass, focused second pass — is usually the cheapest end-to-end for very long documents.
Concurrency knobs
Section titled “Concurrency knobs”recipe = recipe.options( page_concurrency=8, # pages run in parallel within one document)worker = worker.parallel(max=32) # regions per page run in parallelThe defaults (page_concurrency=1, Worker max_concurrency=8) are
conservative. Raise them for I/O-bound runs and when the engine’s
rate limit allows it. See Architecture › Concurrency
model.
Partial failure and resume
Section titled “Partial failure and resume”recipe = recipe.options( error_policy="page", # bad page does not poison the rest resume_file=".scriva_100p.jsonl", # per-page ledger)result = recipe("100p.pdf")With resume_file set, the recipe writes one ledger line per finished
page. A subsequent run with the same resume_file skips
already-completed pages. Use this for overnight runs or anywhere the
process might be killed mid-stream.
error_policy choices:
"continue"— attach an emptyRecognitionwitherrorset, keep going (default)."page"— abort the current page, continue with the next."abort"— raise and stop.
To force a fresh run, delete the ledger.
Cooperative cancellation
Section titled “Cooperative cancellation”import asyncio
async def main(): task = asyncio.create_task(recipe.aio("100p.pdf"))
await asyncio.sleep(10) recipe.cancel() # cooperative; steps yield at next await result = await task # returns the partial DocumentResult print(f"got {result.page_count} pages")Cancelled recipes return whatever they had finished. Combined with the ledger, you can stop and resume freely. See Architecture › Cancellation.
Memory
Section titled “Memory”Default behaviour rasters every page in memory at dpi=300. For very
long PDFs this blows out — pin a lower dpi for the cheap classification
pass and full dpi only for the regions you care about:
toc = Document.load("100p.pdf", pages=range(0, 3), dpi=150)detail = Document.load("100p.pdf", pages=relevant, dpi=300)For recipes that already process pages incrementally:
recipe = recipe.options(stream_pages=True)stream_pages=True rasterises each page on demand and discards it
after recognition finishes. Memory stays flat instead of scaling with
page count.
Cost-cap drains gracefully
Section titled “Cost-cap drains gracefully”recipe = recipe.options(max_cost_usd=2.00, on_exceed="skip")result = recipe("500p.pdf")on_exceed="skip" (instead of "raise") terminates fresh Worker
calls but lets in-flight pages finish, returning a partial
DocumentResult with result.budget_exceeded == True.
Eventing for progress
Section titled “Eventing for progress”def on_event(ev): if ev.stage == "recognize" and ev.kind == "progress": print(f"page {ev.payload.get('page_index')}: " f"{ev.payload['done']}/{ev.payload['total']}")
result = recipe("100p.pdf", on_event=on_event)For a UI, route the events to SSE — see the FastAPI hosting case study in cookbook-ocr-agent.md § 5.
What to read next
Section titled “What to read next”- Orchestrator › Running — the full streaming contract.
- Architecture › Observability — event shapes.
- Reliability — retries, rate limits, cost caps, telemetry.
- Cookbook › Batch & watch — when you have many long PDFs.