把一頁掃描件變成可以核對的 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。