Skip to content

Exporters

An export step serialises a DocumentResult to a format. There are two places exporters live:

  • As methods on DocumentResult.to_excel(...), .to_dict(), … — for ad-hoc serialisation after a run.
  • As .export.* steps in a recipe — for in-run writes that participate in the event stream and respect page_concurrency.

Multiple .export.* steps per recipe are allowed and useful — write .xlsx for humans and .json for downstream systems in the same run. The export phase is repeatable (0..N); each call appends one write.

The simplest path is on the result itself; no .export.* step required:

from scriva import Orchestrator, Worker
recipe = Orchestrator().split.grid().recognize(Worker.openai("gpt-4o"))
result = recipe("scan.png")
text = result.render() # str — reading-order text
data = result.to_dict() # dict; same schema as .export.json
df = result.to_dataframe() # pandas — one row per region
path = result.to_excel("out.xlsx", confidence_thresholds=(0.6, 0.8))
md = result.to_markdown() # str
hocr = result.to_hocr() # str
xml = result.to_alto() # str
pdf = result.to_pdf("out.pdf", source="scan.pdf") # searchable PDF (text over image)
parquet = result.to_parquet("out.parquet")
csv = result.to_csv("out.csv")
result.to_json("out.json") # writes a file
result.show() # matplotlib (optional extra)

Each verb takes the same select= set for field filtering and the same format-specific options as the equivalent .export.* step.

Note: result.to_dict() returns a Python dict; result.to_json(path) writes a file. The old result.to_json() (no-arg, returning a dict) is gone — use to_dict().

Use these when you want the write to be part of the run — recorded in the event stream, written before recipe(doc) returns, parallelisable across pages.

MethodOutputExtraNotes
.export.xlsx(path).xlsxexcelGrid layout, merged cells, confidence colouring
.export.json(path).jsoncoreFull result dump; stable schema; the default
.export.csv(path).csvcoreOne row per cell, with confidence
.export.markdown(path).mdcoreReading-order text + optional table rendering
.export.html(path).htmlcoreHTML with embedded crops and confidence styling
.export.parquet(path).parquetparquetColumnar; for downstream analytics
.export.hocr(path).hocr (HTML)coreW3C hOCR; consumable by hOCR viewers / pdf-merge
.export.alto(path).xmlcoreALTO XML; library-archive friendly
.export.jsonl(path).jsonlcoreOne region per line; right for ML training sets
.export.pdf(path).pdfpdfSearchable PDF — text overlay on source image
.export.debug(dir)directorycorePer-region crops + raw responses + events
.export.samples(store)side effectcoreWrites one Sample per region to a SampleStore
.export.callback(fn)side effectcoreHand the DocumentResult to your code
.export.null()nothingcoreFor test runs and dry-runs

The exporter most callers actually use. Behaves correctly on merged cells, rotated text, and confidence colouring:

recipe = recipe.export.xlsx(
"out.xlsx",
confidence_thresholds=(0.6, 0.8), # red / yellow / green
confidence_fill=None, # see below — per-cell callback
font="MS Gothic",
show_merge_borders=True,
embed_crops=False, # see below
legend_sheet=False, # add a 凡例/Legend sheet
)

confidence_thresholds=(low, high) is the easy default — three buckets, fixed colours. When you need a per-cell rule, pass confidence_fill=:

.export.xlsx(
"out.xlsx",
confidence_fill=lambda r: "red" if (r.confidence or 1) < 0.5 else None,
)

confidence_fill is Callable[[Recognition], Color | None]. Returning None opts a cell out of fill — useful for “only colour the bad ones” audits. When both options are set, confidence_fill wins; the threshold form is kept for terse defaults.

If the layout has no grid (no region.grid set), the exporter writes a flat key-value table instead.

For workflows where the spreadsheet is meant to be reviewed alongside the source image — P&ID symbol lists, annotation review, training-data audits — set embed_crops=True:

.export.xlsx(
"out.xlsx",
embed_crops=True,
crop_height_px=60,
crop_column="A", # which column hosts the thumbnail
)

The exporter writes one cropped PNG per region (cached under outputs/.crops/ so re-exports are cheap) and uses openpyxl.drawing.image.Image to anchor each one in the row. Row heights are adjusted to fit. Aspect ratio is preserved; crop_height_px is the height budget.

embed_crops adds noticeable file size — a 200-region sheet runs ~5 MB versus ~50 KB without embedded images. Default off.

Writes a searchable PDF — the source page images with a text layer overlaid. The exporter assembles per-page hOCR and feeds it through hocr-pdf (bundled with the pdf extra). The result opens in any PDF reader and is greppable.

.export.pdf(
"out.pdf",
source=None, # Path | Document | None — None re-uses doc.source
dpi=300,
text_visibility="invisible", # "invisible" | "subtle" | "visible"
)

text_visibility="invisible" is the standard searchable-PDF behaviour (text is selectable but not rendered). "subtle" makes the text faintly visible — useful for proofing split alignment. "visible" renders the recognised text over the image as a debugging view.

The equivalent verb on a result:

result.to_pdf("out.pdf", source="scan.pdf")

Persists one Sample per recognised region to a SampleStore. The export step is the bridge between a normal OCR run and the training / annotation loop:

from scriva import Orchestrator, Worker, SampleStore
recipe = (
Orchestrator()
.split.grid()
.recognize(Worker.openai("gpt-4o").score(method="rendering"))
.export.samples(
store=SampleStore.fs(".scriva_samples"),
label_from="recognition", # "recognition" | "none" | Callable
embed=None, # ImageEmbedder | None — required if store has no EMBEDDED_INDEX
select=None, # set[str] | None — which Recognition fields to persist
)
)

label_from:

  • "recognition" (default) — the primary Worker’s text becomes the sample’s label. Right for “I’ll correct the wrong ones later” workflows.
  • "none" — leave label=None. The sample is unlabelled training data awaiting annotation.
  • A Callable[[Recognition], str | None] — application-specific logic (e.g. only label when confidence > 0.9).

The step participates in the event stream — one export/progress per persisted sample — and respects page_concurrency. See domains.annotation for the canonical annotation recipe that builds on it.

Writes a directory of per-region crops, raw Worker responses, and the full event log. Audit aid for “why did this run produce that result?” questions.

.export.debug(
"debug/",
include_crops=True,
include_responses=True,
include_events=True,
)

Hand the DocumentResult to your own function at the end of the run. The callback runs in the event stream just like any other export:

def archive(result):
db.save(result.to_dict())
recipe = recipe.export.callback(archive)

The schema is stable and versioned:

{
"scriva_version": "0.1.0",
"schema_version": 1,
"document": { "source": "scan.png", "pages": 1 },
"pages": [
{
"index": 0,
"size": [2480, 3508],
"regions": [
{
"id": "p0-r0",
"bbox": [10, 20, 200, 50],
"role": "data",
"kind": "text",
"grid": { "row": 0, "col": 0, "rowspan": 1, "colspan": 1 },
"recognition": {
"text": "Hello",
"confidence": 0.92,
"language": "en",
"kind": "text",
"source": "openai:gpt-4o",
"cache": null
}
}
]
}
]
}

Round-trips losslessly through DocumentResult.from_json().

Most export steps and result verbs take a select= argument that controls which Recognition fields make it into the output. Useful for sharing results without confidence debug data:

result.to_dict(select={"text", "confidence"})
.export.json("out.json", select={"text", "confidence"})

Subclass:

from scriva import Step, Context, ExportArtifact
class MyExporter(Step):
phase = "export"
name = "my-exporter"
async def run(self, ctx: Context) -> Context:
artifact = ExportArtifact.bytes(render(ctx.result), suffix=".bin")
ctx.artifacts.append(artifact)
return ctx

…or decorate a function:

from scriva import step, ExportArtifact
@step(phase="export", name="my-exporter")
async def my_exporter(ctx):
ctx.artifacts.append(ExportArtifact.bytes(render(ctx.result), suffix=".bin"))
return ctx
recipe = recipe.then(my_exporter)

The artifact can be a path (.path(...)), in-memory bytes (.bytes(...)), or a stream (.stream(...)). The Orchestrator does not care which.