Exporters
Exporters
Section titled “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 respectpage_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.
Result serialisation verbs
Section titled “Result serialisation verbs”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 textdata = result.to_dict() # dict; same schema as .export.jsondf = result.to_dataframe() # pandas — one row per regionpath = result.to_excel("out.xlsx", confidence_thresholds=(0.6, 0.8))md = result.to_markdown() # strhocr = result.to_hocr() # strxml = result.to_alto() # strpdf = 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 fileresult.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().
.export.* steps
Section titled “.export.* steps”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.
| Method | Output | Extra | Notes |
|---|---|---|---|
.export.xlsx(path) | .xlsx | excel | Grid layout, merged cells, confidence colouring |
.export.json(path) | .json | core | Full result dump; stable schema; the default |
.export.csv(path) | .csv | core | One row per cell, with confidence |
.export.markdown(path) | .md | core | Reading-order text + optional table rendering |
.export.html(path) | .html | core | HTML with embedded crops and confidence styling |
.export.parquet(path) | .parquet | parquet | Columnar; for downstream analytics |
.export.hocr(path) | .hocr (HTML) | core | W3C hOCR; consumable by hOCR viewers / pdf-merge |
.export.alto(path) | .xml | core | ALTO XML; library-archive friendly |
.export.jsonl(path) | .jsonl | core | One region per line; right for ML training sets |
.export.pdf(path) | .pdf | pdf | Searchable PDF — text overlay on source image |
.export.debug(dir) | directory | core | Per-region crops + raw responses + events |
.export.samples(store) | side effect | core | Writes one Sample per region to a SampleStore |
.export.callback(fn) | side effect | core | Hand the DocumentResult to your code |
.export.null() | nothing | core | For test runs and dry-runs |
.export.xlsx(...)
Section titled “.export.xlsx(...)”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)Per-cell confidence colouring
Section titled “Per-cell confidence colouring”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.
Embedded crop thumbnails
Section titled “Embedded crop thumbnails”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.
.export.pdf(...)
Section titled “.export.pdf(...)”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").export.samples(...)
Section titled “.export.samples(...)”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’slabel. Right for “I’ll correct the wrong ones later” workflows."none"— leavelabel=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.
.export.debug(...)
Section titled “.export.debug(...)”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,).export.callback(fn)
Section titled “.export.callback(fn)”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).export.json schema
Section titled “.export.json schema”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().
Selecting fields
Section titled “Selecting fields”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"})Writing your own export step
Section titled “Writing your own export step”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.
What to read next
Section titled “What to read next”- Orchestrator › Export — how export slots into a recipe.
- Results — full reference for
DocumentResultand itsto_*verbs. - Samples — what
.export.samples(...)writes into.