Getting plain text out of a scan — in the order a human would read it
Raw OCR gives you every word on the page in detection order, which is not the order anyone reads. A developer's guide to plain-text extraction with reading order restored, per-block coordinates, and a check that the text was not rewritten.
"Just give me the text" sounds like the easy request. It's the one most OCR integrations get subtly wrong.
A vision OCR engine returns every word it found, grouped into paragraphs, in detection order — roughly top-to-bottom, left-to-right across the whole page. On a single-column memo that happens to match how you'd read it. On a two-column article, an invoice with a sidebar, or a page scanned at a slight angle, it doesn't. You get the left column's first paragraph, then the right column's first paragraph, then back to the left. The words are all correct. The document is nonsense.
If that text is going into a search index, an embedding, or a diff, the damage is quiet: nothing errors, results just get worse in ways nobody traces back to the OCR step.
Three things plain text still needs
Even with no structure to preserve, useful plain-text extraction has to answer three questions:
- What order? Blocks have to come out in reading order, and a sentence broken across a line wrap has to be rejoined rather than left as two fragments.
- From where? If you index a paragraph and later need to show a human why a document matched, you need the coordinates that paragraph came from — a page number isn't enough.
- Is it what the page says? A model that transcribes a page can also normalize it. Silent "improvements" are worse than errors, because they read as correct.
One call
POST /ocr/text takes an image and returns the document's text. Ask for includeBlocks and you also get each block with its coordinates.
curl -X POST https://api.space-ocr.com/ocr/text \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image": "https://example.com/page.jpg",
"imageType": "url",
"includeBlocks": true
}'{
"status": "success",
"data": {
"text": "Sakura Trading Co.\nInvoice\nTotal 1,451",
"source": "llm",
"image_size": { "width": 1654, "height": 2339 },
"review_summary": {
"unit": "block",
"total": 12,
"boxed": 12,
"text_verified": 11,
"text_mismatch": 1,
"needs_review": 1,
"flagged": [{ "path": "blocks[7]", "reason": "text_mismatch" }],
"recovered_blocks": 0,
"token_coverage": 1.0
},
"blocks": [
{
"text": "Sakura Trading Co.",
"bbox": { "xmin": 60, "ymin": 48, "xmax": 470, "ymax": 92 },
"vertices": [{ "x": 60, "y": 48 }, "…"],
"bbox_source": "token_id",
"text_verified": true,
"needs_review": false
}
]
}
}text is the whole document as one string, blocks joined by newlines. blocks[] is the same content split into the units that were actually located, each with bbox and vertices on a 0–1000 normalized grid.
The division of labour is worth being explicit about: the vision OCR is the authority on characters and coordinates; the model is only asked for order and grouping. It decides which block comes next and which fragments belong together — it does not get to invent the coordinates, and it doesn't get the last word on the characters either.
text_verified, and the case where it's false
Each block carries text_verified. The engine takes the word tokens a block claims, looks up what the vision OCR read at those coordinates, and compares after normalization. true means both reads agree. false means the transcription differs from the page — the text is still returned, flagged instead of silently wrong.
This is not an accuracy percentage. It answers a narrower and more useful question: was this checked against the pixels, and did it match? For an indexing pipeline, one natural policy is to index everything but keep the flag, so that when a human is eventually shown a match, the uncertain blocks are marked rather than presented as fact.
Two failure modes are handled explicitly rather than hidden. If the model pass fails outright — no key, quota exhausted, retries spent — the endpoint does not error: it falls back to the vision transcription and says so, with source: "vision" and a warning. And any run of page tokens that no block claimed is appended back as a recovered block (bbox_source: "unclaimed_tokens"), so a dropped paragraph shows up in the output instead of disappearing from it.
When to turn the model off
useLlm: false gives you the vision-only transcription: instant, no model cost, but blocks come back in raw OCR order. That's the right trade for a single-column page where order is trivially correct, or for a bulk pass where you only need keyword presence.
Keep it on when order carries meaning — multi-column layouts, forms with sidebars, anything scanned at an angle, or text you're going to show a person rather than only match against.
And if you want the structure rather than the prose — headings as headings, tables as tables — that's a different endpoint: POST /ocr/markdown returns layout-preserving Markdown with the same per-element coordinates and the same text_verified flag.