把一页扫描件变成可以核对的 Markdown
把图像转成 Markdown,并用 data.values、path 键控的 cells、review.flagged 与来源坐标核对结果的开发指南。
把扫描件转成 Markdown,通常出于两个理由,而这两者要的东西并不完全一样。
一是发布:你想把文档放进 Wiki、文档站或仓库,并且希望标题还是标题。二是喂给模型:多数 LLM 流水线以 Markdown 为输入,因为语法本身承载结构——## 告诉模型这里是章节边界,管道表格告诉它这些单元格属于同一行。纯文本会把这些信息丢掉。
失败的方式也一样。标题被压成段落,文档站就成了一堵墙,检索用的分块也会在错误的位置断开。而且在这两种用途里,你拿到的通常只是一整串 Markdown,没有办法追问这一行来自页面的哪里。
“保留版式”真正要保留的东西
一个可用的 Markdown 转换,需要正确完成四个彼此独立的判断:
- 阅读顺序 —— 多栏排版不能交错。这是朴素 OCR 输出最先崩掉的地方:引擎按检测顺序输出段落,而不是人阅读的顺序。
- 块类型 —— 这一行是标题、列表项、引用,还是普通段落。在扫描件上,仅凭字号并不可靠。
- 表格结构 —— 哪些单元格属于同一行,哪一行是表头,单元格折成两行时怎么处理。
- 不丢东西 —— 没人会注意到的失败。段落悄悄消失了,Markdown 看上去依然完好。
第四点最棘手,因为它在产物里是隐形的:丢了 5% 的转换结果,读起来完全通顺。
一次调用,四层响应
POST /ocr/markdown 接收一张 URL 或 base64 栅格图像。需要结构、坐标或复核界面时,请使用默认值 includeElements: true。可发布的 Markdown 与用于核对的元数据在响应中彼此分开。请求限制和完整字段表请见 API 文档。
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": "# 季度报告\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 是只含内容的元素数组,可表示标题、段落、列表项、引用、代码块、分隔线和表格。表格保留 rows、cols 以及只含内容的 cells[]。
坐标不在元素内部,而在扁平的 data.cells 映射中。elements[0] 指第一个元素;若第三个元素是表格,elements[2].cells[1] 就指它的第二个单元格。data.review.flagged[].path 使用同一种路径语法,因此复核项可以直接查表。
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 开始。读取每项的 path 与 reasons,在 data.cells[path] 中显示 review.reasons。倾斜轮廓用 quad,需要轴对齐计算时用 box。
两种坐标都位于 0–1000 网格。请按 data.image.width 与 height 换算成像素;它们描述的是经过方向与服务器处理后实际读取的页面。verified: false 表示存在复核原因,true 表示未触发原因且已运行交叉核对,null 表示未触发原因但没有可核对内容。evidence 只是诊断依据,并不保证所选文字在业务语义上就是正确字段。
内容遗漏也有明确的检查信号。 data.review.coverage 提供 vision_tokens、tokens_claimed、token_coverage 和 recovered_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 的步骤
- 准备单页图像通过 URL 或 base64 提供栅格图像。PDF 应在调用 API 前逐页转成图像。
- 请求结构元素需要结构、来源坐标或人工复核时,以 includeElements: true 调用 POST /ocr/markdown。
- 分层保存响应发布使用 data.values.markdown,结构处理使用 data.values.elements,并保留 cells、review、image 作为复核旁路数据。
- 解析 flagged path遍历 data.review.flagged,打开 data.cells[flag.path],显示原因,并按 data.image 框架绘制 box 或 quad。
- 检查完整性后发布检查 data.review.coverage 与 recovered blocks,完成必要复核后再发布或切分 Markdown。