space ocr
指南文章价格文档
developer

把一页扫描件变成可以核对的 Markdown

把图像转成 Markdown,并用 data.values、path 键控的 cells、review.flagged 与来源坐标核对结果的开发指南。

8 分钟阅读· 2026-08-31

把扫描件转成 Markdown,通常出于两个理由,而这两者要的东西并不完全一样。

一是发布:你想把文档放进 Wiki、文档站或仓库,并且希望标题还是标题。二是喂给模型:多数 LLM 流水线以 Markdown 为输入,因为语法本身承载结构——## 告诉模型这里是章节边界,管道表格告诉它这些单元格属于同一行。纯文本会把这些信息丢掉。

失败的方式也一样。标题被压成段落,文档站就成了一堵墙,检索用的分块也会在错误的位置断开。而且在这两种用途里,你拿到的通常只是一整串 Markdown,没有办法追问这一行来自页面的哪里

“保留版式”真正要保留的东西

一个可用的 Markdown 转换,需要正确完成四个彼此独立的判断:

  1. 阅读顺序 —— 多栏排版不能交错。这是朴素 OCR 输出最先崩掉的地方:引擎按检测顺序输出段落,而不是人阅读的顺序。
  2. 块类型 —— 这一行是标题、列表项、引用,还是普通段落。在扫描件上,仅凭字号并不可靠。
  3. 表格结构 —— 哪些单元格属于同一行,哪一行是表头,单元格折成两行时怎么处理。
  4. 不丢东西 —— 没人会注意到的失败。段落悄悄消失了,Markdown 看上去依然完好。

第四点最棘手,因为它在产物里是隐形的:丢了 5% 的转换结果,读起来完全通顺。

一次调用,四层响应

POST /ocr/markdown 接收一张 URL 或 base64 栅格图像。需要结构、坐标或复核界面时,请使用默认值 includeElements: true。可发布的 Markdown 与用于核对的元数据在响应中彼此分开。请求限制和完整字段表请见 API 文档

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": "# 季度报告\n\n营收同比增长。\n\n| 项目 | 金额 |\n| --- | --- |\n| 营收 | 12,000 |",
      "elements": [
        {
          "type": "heading",
          "level": 1,
          "text": "季度报告"
        },
        {
          "type": "paragraph",
          "text": "营收同比增长。"
        },
        {
          "type": "table",
          "rows": 2,
          "cols": 2,
          "cells": [
            {
              "row": 0,
              "col": 0,
              "header": true,
              "text": "项目"
            },
            {
              "row": 0,
              "col": 1,
              "header": true,
              "text": "金额"
            },
            {
              "row": 1,
              "col": 0,
              "header": false,
              "text": "营收"
            },
            {
              "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 是组装好的字符串,data.values.elements 是只含内容的元素数组,可表示标题、段落、列表项、引用、代码块、分隔线和表格。表格保留 rowscols 以及只含内容的 cells[]

坐标不在元素内部,而在扁平的 data.cells 映射中。elements[0] 指第一个元素;若第三个元素是表格,elements[2].cells[1] 就指它的第二个单元格。data.review.flagged[].path 使用同一种路径语法,因此复核项可以直接查表。

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,
  })));
}

把复核清单连接到原图叠加层

不要自行设置信心分数阈值,应从 data.review.flagged 开始。读取每项的 pathreasons,在 data.cells[path] 中显示 review.reasons。倾斜轮廓用 quad,需要轴对齐计算时用 box

两种坐标都位于 0–1000 网格。请按 data.image.widthheight 换算成像素;它们描述的是经过方向与服务器处理后实际读取的页面。verified: false 表示存在复核原因,true 表示未触发原因且已运行交叉核对,null 表示未触发原因但没有可核对内容。evidence 只是诊断依据,并不保证所选文字在业务语义上就是正确字段。

✓ Verified

内容遗漏也有明确的检查信号。 data.review.coverage 提供 vision_tokenstokens_claimedtoken_coveragerecovered_blocks。未被任何元素认领的 token 会作为末尾段落补回,其 cell 的 evidence.source"unclaimed_tokens"。请把这些值用于完整性检查,不要杜撰 API 没有定义的合格阈值。

何时关闭 elements

includeElements 默认为 true。只有在仅需完整 data.values.markdown 字符串时才设为 false。此时 data.values.elements 和元素级 data.cells 会被省略,因此无法用该响应制作元素级原图高亮。

在流水线中的用法

做 RAG 时,按标题边界切分 data.values.elements,并把 path 与分块一起保存,引用即可重新打开原图区域。用于文档站时,发布 data.values.markdown,同时把 elements、cells、review、image 作为复核旁路数据保留。

如果不需要结构,请用阅读顺序纯文本 OCR;叠加层实现可参考用来源坐标验证 OCR

把图像转成可复核 Markdown 的步骤

  1. 准备单页图像
    通过 URL 或 base64 提供栅格图像。PDF 应在调用 API 前逐页转成图像。
  2. 请求结构元素
    需要结构、来源坐标或人工复核时,以 includeElements: true 调用 POST /ocr/markdown。
  3. 分层保存响应
    发布使用 data.values.markdown,结构处理使用 data.values.elements,并保留 cells、review、image 作为复核旁路数据。
  4. 解析 flagged path
    遍历 data.review.flagged,打开 data.cells[flag.path],显示原因,并按 data.image 框架绘制 box 或 quad。
  5. 检查完整性后发布
    检查 data.review.coverage 与 recovered blocks,完成必要复核后再发布或切分 Markdown。
Markdown 与元素返回在哪里?
组装后的字符串在 data.values.markdown。启用 includeElements 后,只含内容的元素在 data.values.elements,坐标与验证元数据在扁平 data.cells 映射中。
怎样定位被标记的表格单元格?
把 data.review.flagged 中的 path 直接用于 data.cells[flag.path]。表格单元格路径形如 elements[2].cells[1],再按 data.image 框架绘制 quad 或 box。
includeElements: false 会改变什么?
默认值是 true。设为 false 后仍保留组装好的 Markdown,但会省略 data.values.elements 和元素级 data.cells。
可以直接发送 PDF 吗?
OCR API 接收 JPEG、PNG、GIF、BMP、TIFF、WebP 栅格图像。请先把 PDF 每页转成图像;Web 应用可以把拖入的 PDF 栅格化。
怎样确认费用?
当前每页积分和套餐政策以 /pricing 为准。处理失败不收费;导出政策应与 OCR 调用分开确认。
相关文章