space ocr
GuidesArticlesPricingDocs
developer

Turning a scanned page into Markdown you can check

Convert images to Markdown with data.values.markdown, content-only elements, path-keyed cells, review.flagged, and source coordinates for review.

8 min read· 2026-08-31

There are two reasons to turn a scanned page into Markdown, and they pull in different directions.

The first is publishing: you want the document in a wiki, a docs site, or a repo, and you want the headings to still be headings. The second is feeding a model: Markdown is the format most LLM pipelines ingest, because the syntax carries structure that a flat text dump throws away — a ## tells the model this is a section boundary, a pipe table tells it these cells belong to the same row.

Both uses fail the same way. If the converter flattens a heading into a paragraph, your docs site gets one long wall of text and your retrieval chunks split in the wrong places. And in both cases you usually get back a single Markdown string with no way to ask where did this line come from?

What "layout-preserving" actually has to preserve

A useful Markdown conversion has to make four decisions correctly, and they're independent:

  1. Reading order — a two-column page must not interleave. This is where a naive OCR dump fails first: the raw engine emits paragraphs in detection order, not in the order a human reads them.
  2. Block type — is this line a heading, a list item, a quote, or just a paragraph? Font size alone is a bad signal on a scan.
  3. Table structure — which cells share a row, which row is the header, and what happens when a cell wraps to two lines.
  4. Nothing dropped — the failure nobody notices. If a paragraph quietly disappears from the output, the Markdown still looks fine.

The fourth one deserves the most attention, because it's invisible in the artifact. A converter that drops 5% of the page produces output that reads perfectly.

One call, four response layers

POST /ocr/markdown accepts one raster image as a URL or base64. Set includeElements: true when you need structure, coordinates, or a review UI; it is the default. The response separates the publishable Markdown from the metadata used to inspect it. See the API docs for request limits and the complete field table.

1
2
3
4
5
6
7
8
curl -X POST https://api.space-ocr.com/ocr/markdown \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "image": "https://example.com/report.jpg",
    "imageType": "url",
    "includeElements": 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
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
{
  "status": "success",
  "data": {
    "values": {
      "markdown": "# Quarterly report\n\nRevenue grew year over year.\n\n| Item | Amount |\n| --- | --- |\n| Revenue | 12,000 |",
      "elements": [
        {
          "type": "heading",
          "level": 1,
          "text": "Quarterly report"
        },
        {
          "type": "paragraph",
          "text": "Revenue grew year over year."
        },
        {
          "type": "table",
          "rows": 2,
          "cols": 2,
          "cells": [
            {
              "row": 0,
              "col": 0,
              "header": true,
              "text": "Item"
            },
            {
              "row": 0,
              "col": 1,
              "header": true,
              "text": "Amount"
            },
            {
              "row": 1,
              "col": 0,
              "header": false,
              "text": "Revenue"
            },
            {
              "row": 1,
              "col": 1,
              "header": false,
              "text": "12,000"
            }
          ]
        }
      ]
    },
    "cells": {
      "elements[0]": {
        "box": {
          "xmin": 36,
          "ymin": 21,
          "xmax": 314,
          "ymax": 39
        },
        "quad": [
          {
            "x": 36,
            "y": 21
          },
          {
            "x": 314,
            "y": 21
          },
          {
            "x": 314,
            "y": 39
          },
          {
            "x": 36,
            "y": 39
          }
        ],
        "verified": true,
        "review": null,
        "evidence": {
          "text_match": true,
          "source": "token_id"
        }
      },
      "elements[1]": {
        "box": {
          "xmin": 36,
          "ymin": 51,
          "xmax": 544,
          "ymax": 68
        },
        "quad": [
          {
            "x": 36,
            "y": 51
          },
          {
            "x": 544,
            "y": 51
          },
          {
            "x": 544,
            "y": 68
          },
          {
            "x": 36,
            "y": 68
          }
        ],
        "verified": true,
        "review": null,
        "evidence": {
          "text_match": true,
          "source": "token_id"
        }
      },
      "elements[2]": {
        "box": {
          "xmin": 36,
          "ymin": 86,
          "xmax": 387,
          "ymax": 137
        },
        "quad": [
          {
            "x": 36,
            "y": 86
          },
          {
            "x": 387,
            "y": 86
          },
          {
            "x": 387,
            "y": 137
          },
          {
            "x": 36,
            "y": 137
          }
        ],
        "verified": null,
        "review": null,
        "evidence": {}
      },
      "elements[2].cells[0]": {
        "box": {
          "xmin": 36,
          "ymin": 86,
          "xmax": 212,
          "ymax": 111
        },
        "quad": [
          {
            "x": 36,
            "y": 86
          },
          {
            "x": 212,
            "y": 86
          },
          {
            "x": 212,
            "y": 111
          },
          {
            "x": 36,
            "y": 111
          }
        ],
        "verified": true,
        "review": null,
        "evidence": {
          "text_match": true,
          "source": "token_id"
        }
      },
      "elements[2].cells[1]": {
        "box": {
          "xmin": 212,
          "ymin": 86,
          "xmax": 387,
          "ymax": 111
        },
        "quad": [
          {
            "x": 212,
            "y": 86
          },
          {
            "x": 387,
            "y": 86
          },
          {
            "x": 387,
            "y": 111
          },
          {
            "x": 212,
            "y": 111
          }
        ],
        "verified": false,
        "review": {
          "reasons": [
            "text_mismatch"
          ]
        },
        "evidence": {
          "text_match": false,
          "source": "token_id",
          "ocr_confidence": 0.71
        }
      },
      "elements[2].cells[2]": {
        "box": {
          "xmin": 36,
          "ymin": 111,
          "xmax": 212,
          "ymax": 137
        },
        "quad": [
          {
            "x": 36,
            "y": 111
          },
          {
            "x": 212,
            "y": 111
          },
          {
            "x": 212,
            "y": 137
          },
          {
            "x": 36,
            "y": 137
          }
        ],
        "verified": true,
        "review": null,
        "evidence": {
          "text_match": true,
          "source": "token_id"
        }
      },
      "elements[2].cells[3]": {
        "box": {
          "xmin": 212,
          "ymin": 111,
          "xmax": 387,
          "ymax": 137
        },
        "quad": [
          {
            "x": 212,
            "y": 111
          },
          {
            "x": 387,
            "y": 111
          },
          {
            "x": 387,
            "y": 137
          },
          {
            "x": 212,
            "y": 137
          }
        ],
        "verified": true,
        "review": null,
        "evidence": {
          "text_match": true,
          "source": "token_id"
        }
      }
    },
    "review": {
      "unit": "element",
      "total": 6,
      "boxed": 6,
      "verified": 5,
      "flagged": [
        {
          "path": "elements[2].cells[1]",
          "reasons": [
            "text_mismatch"
          ]
        }
      ],
      "by_reason": {
        "text_mismatch": 1
      },
      "coverage": {
        "recovered_blocks": 0,
        "vision_tokens": 40,
        "tokens_claimed": 40,
        "token_coverage": 1
      }
    },
    "image": {
      "width": 1654,
      "height": 2339
    }
  }
}

data.values.markdown is the assembled string. data.values.elements is a content-only array: headings, paragraphs, list items, blockquotes, code blocks, thematic breaks, and tables. A table keeps rows, cols, and content-only cells[] entries.

Coordinates do not sit inside those elements. They live in the flat data.cells map. elements[0] addresses the first element; elements[2].cells[1] addresses the second cell of the third element when that element is a table. The same path grammar is used by data.review.flagged[].path, so a review item is a direct map lookup.

review-markdown-elements.js
1
2
3
4
5
6
7
8
9
10
11
12
const { data } = body;

for (const flag of data.review.flagged) {
  const cell = data.cells?.[flag.path];
  if (!cell) continue;

  console.log(flag.path, flag.reasons, cell.review?.reasons);
  drawQuad(cell.quad.map(({ x, y }) => ({
    x: (x / 1000) * data.image.width,
    y: (y / 1000) * data.image.height,
  })));
}

Turn the review list into a source overlay

Start with data.review.flagged, not a home-made score threshold. Each item carries path and a reasons array. Resolve the path in data.cells, display its review.reasons, and draw quad for a tilted outline or box for axis-aligned calculations.

Both coordinate forms use a 0–1000 grid. Convert them against data.image.width and data.image.height, which describe the page as it was actually read after orientation and any server-side processing. A cell's verified value is the verdict: false means review reasons exist, true means no reason was raised and a cross-check ran, and null means nothing was raised but there was nothing to check. Evidence is diagnostic support, not a guarantee that the chosen text has the right business meaning.

✓ Verified

Dropped content has an explicit signal. data.review.coverage reports vision_tokens, tokens_claimed, token_coverage, and recovered_blocks. Tokens not claimed by an element are recovered as a trailing paragraph whose cell uses evidence.source: "unclaimed_tokens". Inspect coverage and recovered blocks as a completeness check; do not invent a pass/fail threshold the API does not define.

When to turn elements off

includeElements defaults to true. Set it to false only when the assembled data.values.markdown string is all you need. In that mode data.values.elements and the per-element data.cells map are omitted, so you cannot build element-level highlights from that response.

Where Markdown OCR fits

For RAG, chunk data.values.elements on heading boundaries and keep each path beside the chunk so a citation can reopen the source region. For a docs site, publish data.values.markdown and retain the elements, cells, review summary, and image frame as a review sidecar.

If structure is unnecessary, use the reading-order plain-text workflow. For an overlay implementation, see validating OCR with source coordinates.

How to convert an image to reviewable Markdown

  1. Prepare one page
    Send a raster image by URL or base64. Convert a PDF into one image per page before using the API endpoint.
  2. Request elements
    Call POST /ocr/markdown with includeElements true when you need structural elements, source coordinates, or human review.
  3. Store the output layers
    Use data.values.markdown for publishing and data.values.elements for structural processing; keep data.cells, data.review, and data.image as the review sidecar.
  4. Resolve flagged paths
    Iterate data.review.flagged and open data.cells[flag.path]. Show the reasons and draw box or quad against the data.image coordinate frame.
  5. Check completeness and publish
    Inspect data.review.coverage and recovered blocks, complete any required review, then publish or chunk the Markdown.
Where are Markdown and elements returned?
The assembled string is data.values.markdown. With includeElements enabled, content-only elements are in data.values.elements and their coordinates and verification metadata are in the flat data.cells map.
How do I find the source of a flagged table cell?
Read flag.path from data.review.flagged and use it directly as data.cells[flag.path]. Table-cell paths look like elements[2].cells[1]. Draw that cell's quad or box against the data.image frame.
What does includeElements: false change?
includeElements defaults to true. false keeps the assembled Markdown output but omits data.values.elements and per-element data.cells, so use it only when you do not need structure or source overlays.
Can I send a PDF directly?
The OCR endpoint accepts raster images: JPEG, PNG, GIF, BMP, TIFF, or WebP. Convert each PDF page to an image before calling the API; the web app can rasterize dropped PDFs for you.
How is Markdown OCR priced?
Check /pricing for the current per-page credit and plan policy. Failed processing is not charged; do not assume CSV or other exports share the OCR call's policy.
Related