space ocr
GuidesArticlesPricingDocs
developer

Turning a scanned page into Markdown you can check

Converting a scan to Markdown is easy to do badly: headings flatten, tables become mush, and nothing tells you which line came from where. A developer's look at layout-preserving Markdown OCR that returns per-element coordinates and a text_verified flag.

7 min read· 2026-07-28

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, and what comes back

POST /ocr/markdown takes an image and returns both the assembled Markdown string and the elements it was assembled from.

1
2
3
4
5
6
7
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"
  }'
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
{
  "status": "success",
  "data": {
    "markdown": "# Quarterly report\n\nRevenue grew year over year.\n\n| Item | Amount |\n| --- | --- |\n| Revenue | 12,000 |",
    "review_summary": {
      "unit": "element",
      "total": 8,
      "boxed": 8,
      "text_verified": 7,
      "text_mismatch": 1,
      "needs_review": 1,
      "flagged": [{ "path": "elements[3].cells[1]", "reason": "text_mismatch" }],
      "recovered_blocks": 0,
      "token_coverage": 1.0
    },
    "elements": [
      {
        "type": "heading",
        "level": 1,
        "text": "Quarterly report",
        "bbox": { "xmin": 60, "ymin": 48, "xmax": 520, "ymax": 92 },
        "vertices": [{ "x": 60, "y": 48 }, "…"],
        "bbox_source": "token_id",
        "text_verified": true
      }
    ]
  }
}

The markdown field is what you paste into your docs site. The elements array is what makes the result checkable.

Every element — and every individual table cell — carries bbox and vertices on a 0–1000 normalized grid, so a heading in the output maps back to a rectangle on the source image. Normalized coordinates matter here: they survive a resize, so the box still lines up after you've generated a thumbnail.

Element types are the ones you'd expect: heading (with level), paragraph, list_item, blockquote, code_block, thematic_break, and table. A table carries rows, cols, and a cells[] array where each cell has its own row, col, header, text, and coordinates — so you can render your own grid instead of parsing the pipe syntax back out.

text_verified: the flag that catches rewriting

A language model reading a page can quietly improve it — normalizing a date, fixing what it thinks is a typo, expanding an abbreviation. For a summary that's fine. For a document you're going to store, it's data corruption that reads as competence.

So each element carries text_verified. The engine takes the word tokens that element claims, looks up the text the vision OCR actually detected at those coordinates, and compares after normalization. true means the two independent reads agree. false means the transcribed text differs from what was read on the page — the value is still returned, but flagged rather than silently wrong. null means there were no tokens to compare against.

It is not an accuracy score, and it shouldn't be read as one. It answers one narrow question: did anyone check this against the pixels?

✓ Verified

The quiet failure has a counter. review_summary.token_coverage is the share of page tokens the model claimed before any recovery step. Any run of tokens no element claimed is appended back as a paragraph with bbox_source: "unclaimed_tokens", and review_summary.recovered_blocks counts how many times that happened. A conversion that dropped a paragraph is visible in the numbers instead of only in the missing text.

Where this fits in a pipeline

For RAG ingestion, the elements array is more useful than the string. Chunk on heading boundaries instead of a character count, and you get chunks that match the document's own sections. Keep each chunk's bbox alongside its text, and a citation can point at a rectangle on the original page rather than at a page number.

For a docs site or a wiki, take markdown directly and keep the elements as a sidecar for review. When someone disputes a number, you can put the box on the scan instead of arguing about it.

If you don't need structure at all — indexing, search, diffing — Markdown syntax is noise. That's what POST /ocr/text is for: the same page as plain text with reading order restored, and the same text_verified flag on each block.

Related