Skip to content

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.

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.

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)

Only OCR what you need:

from scriva import Document
doc = Document.load("contract.pdf", pages=range(0, 5)) # first 5 pages
doc = Document.load("contract.pdf", pages=[0, 10, 20]) # specific pages
doc = Document.load("contract.pdf", pages=slice(0, None, 2)) # every other page

For “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.

recipe = recipe.options(
page_concurrency=8, # pages run in parallel within one document
)
worker = worker.parallel(max=32) # regions per page run in parallel

The 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.

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 empty Recognition with error set, keep going (default).
  • "page" — abort the current page, continue with the next.
  • "abort" — raise and stop.

To force a fresh run, delete the ledger.

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.

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.

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.

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.