space ocr
GuidesArticlesPricingDocs
developer

Getting plain text out of a scan — in the order a human would read it

Use POST /ocr/text to extract reading-order plain text, optional content-only blocks, path-keyed source coordinates, and an explicit review queue.

7 min read· 2026-08-31

"Just give me the text" sounds like the easy OCR request. It is also the one that quietly breaks search indexes. A vision OCR pass can find the right words but return them in detection order: the first line from the left column, then one from the right, then back again. Nothing throws an error, yet the resulting document no longer reads like the page.

POST /ocr/text separates that problem from field extraction and Markdown conversion. With its default useLlm: true, it reorders blocks into human reading order and rejoins wrapped lines. The characters and source geometry are still checked against the Vision observations instead of treating a language model's transcription as unquestioned truth.

The two request switches

useLlm defaults to true. Keep it on for multi-column layouts, sidebars, skewed scans, or any text a person will read. Set it to false for a Vision-only transcription in raw OCR order; that path skips the LLM work but still returns the document-level verification object.

includeBlocks defaults to false. Leave it off when all you need is data.values.text. Turn it on for block-level chunking, source highlights, or a review UI. It adds content-only entries at data.values.blocks, metadata at keys such as data.cells["blocks[0]"], and block paths in data.review.flagged.

Make one request

This example enables blocks so a flagged paragraph can be traced back to the image. Authentication and the full parameter reference are in the API documentation.

1
2
3
4
5
6
7
8
9
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",
    "useLlm": true,
    "includeBlocks": true
  }'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
{
  "status": "success",
  "data": {
    "values": {
      "text": "Sakura Trading Co.\nInvoice\nTotal 1,451",
      "blocks": [
        {
          "text": "Sakura Trading Co."
        },
        {
          "text": "Invoice\nTotal 1,451"
        }
      ]
    },
    "cells": {
      "blocks[0]": {
        "box": {
          "xmin": 60,
          "ymin": 48,
          "xmax": 470,
          "ymax": 92
        },
        "quad": [
          {
            "x": 60,
            "y": 48
          },
          {
            "x": 470,
            "y": 48
          },
          {
            "x": 470,
            "y": 92
          },
          {
            "x": 60,
            "y": 92
          }
        ],
        "verified": true,
        "review": null,
        "evidence": {
          "text_match": true,
          "source": "token_id"
        }
      },
      "blocks[1]": {
        "box": {
          "xmin": 58,
          "ymin": 190,
          "xmax": 510,
          "ymax": 274
        },
        "quad": [
          {
            "x": 58,
            "y": 190
          },
          {
            "x": 510,
            "y": 190
          },
          {
            "x": 510,
            "y": 274
          },
          {
            "x": 58,
            "y": 274
          }
        ],
        "verified": false,
        "review": {
          "reasons": [
            "text_mismatch"
          ]
        },
        "evidence": {
          "text_match": false,
          "source": "char_matcher_fallback"
        }
      }
    },
    "review": {
      "unit": "block",
      "total": 2,
      "boxed": 2,
      "verified": 1,
      "flagged": [
        {
          "path": "blocks[1]",
          "reasons": [
            "text_mismatch"
          ]
        }
      ],
      "by_reason": {
        "text_mismatch": 1
      },
      "coverage": {
        "recovered_blocks": 0,
        "vision_tokens": 9,
        "tokens_claimed": 9,
        "token_coverage": 1
      }
    },
    "image": {
      "width": 1654,
      "height": 2339
    },
    "source": "llm"
  }
}

Read the response by responsibility

  • data.values.text is the full plain-text document. With blocks enabled, data.values.blocks contains the same content split into { text } units, with no geometry mixed into the content.
  • data.cells[path] is the source sidecar. box and quad use a 0–1000 page grid; use data.image.width and height to project it onto the processed image. verified says whether the block matched the Vision text at that location after normalization. It is a cross-check verdict, not an accuracy percentage.
  • data.review.flagged is the work queue. Each path directly indexes data.cells, and cells[path].review.reasons explains why inspection is recommended.
  • data.source is llm when the reading-order pass produced the transcription and vision on the Vision path.

An indexer can consume values.text, while a reviewer consumes review and cells without parsing coordinates out of the text payload.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const { data } = await response.json();

indexDocument(data.values.text);

for (const flag of data.review.flagged) {
  const cell = data.cells?.[flag.path];
  queueForReview({
    path: flag.path,
    reasons: flag.reasons,
    box: cell?.box,
    quad: cell?.quad,
    image: data.image,
  });
}
✓ Verified

Fallback is visible, not silent. If the LLM reading-order pass fails, the endpoint returns a Vision transcription instead of turning that failure into an OCR error. In that response data.source is "vision" and data.warning carries the reason. Unclaimed Vision tokens can also be appended as recovered blocks with cells[path].evidence.source: "unclaimed_tokens", so omitted text can be surfaced rather than quietly discarded.

Plain text or Markdown?

Choose plain text for full-text search, embeddings, diffing, accessibility feeds, and archives where headings and tables do not need their own types. If structure matters, use POST /ocr/markdown; the image-to-Markdown guide explains its element-oriented response. For more on geometry and review metadata, see OCR source coordinates.

  1. Choose the reading-order path
    Send the image to POST /ocr/text. Keep the default useLlm:true when reading order matters; use false only when raw Vision order is acceptable.
  2. Request blocks when you need provenance
    Set includeBlocks:true for values.blocks, path-keyed cells, and block-level review metadata; otherwise consume values.text alone.
  3. Process the explicit review queue
    Iterate data.review.flagged, resolve each item with data.cells[flag.path], and draw its box or quad over the frame described by data.image.
  4. Record which transcription path ran
    Store data.source with the text, and surface data.warning when source is vision after an automatic fallback.
Does /ocr/text correct reading order by default?
Yes. useLlm defaults to true and reorders blocks and rejoins wrapped lines. Set useLlm:false only when a Vision-only transcription in raw OCR order is preferable.
Do I have to request blocks?
No. includeBlocks defaults to false, so data.values.text is enough for a simple index. Enable it for values.blocks, cells keyed by blocks[n], and block-level review paths.
Does verified:true guarantee that a block is correct?
No. It means the block matched the Vision text at the linked coordinates after normalization. It is not an accuracy score, and two readings can still agree on the same mistake.
What happens if the reading-order model fails?
The endpoint falls back to Vision transcription, sets data.source to vision, and includes data.warning. The response still includes the document-level review object.
Related