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.
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:
- 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.
- 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.
- Table structure — which cells share a row, which row is the header, and what happens when a cell wraps to two lines.
- 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.
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
}'{
"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.
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.
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
- Prepare one pageSend a raster image by URL or base64. Convert a PDF into one image per page before using the API endpoint.
- Request elementsCall POST /ocr/markdown with includeElements true when you need structural elements, source coordinates, or human review.
- Store the output layersUse data.values.markdown for publishing and data.values.elements for structural processing; keep data.cells, data.review, and data.image as the review sidecar.
- Resolve flagged pathsIterate data.review.flagged and open data.cells[flag.path]. Show the reasons and draw box or quad against the data.image coordinate frame.
- Check completeness and publishInspect data.review.coverage and recovered blocks, complete any required review, then publish or chunk the Markdown.