スキャンした 1 ページを、確かめられる Markdown にする
スキャンを Markdown にする処理は、雑にやると見出しが潰れ、表が崩れ、どの行がどこから来たのか分からなくなります。要素ごとの座標と text_verified を返す、レイアウト保存 Markdown OCR の実装ガイド。
スキャンした書類を Markdown にしたい理由は、だいたい二つあります。そしてこの二つは求めるものが少し違います。
一つは公開。Wiki やドキュメントサイト、リポジトリに載せたい。そのとき見出しは見出しのままであってほしい。もう一つはモデルに読ませるため。多くの LLM パイプラインが Markdown を前提にしているのは、記法が構造を運ぶからです。## は「ここが節の切れ目だ」と伝え、パイプ表は「このセルたちは同じ行だ」と伝えます。素のテキストではその情報が消えます。
失敗の仕方も共通です。見出しが段落に潰れれば、ドキュメントサイトは一枚の壁になり、検索用のチャンクは変なところで切れます。そしてどちらの用途でも、返ってくるのはたいてい 1 本の Markdown 文字列だけで、この行はページのどこから来たのかを聞く手段がありません。
「レイアウト保存」が実際に守るべきもの
使える Markdown 変換は、独立した 4 つの判断を正しく行う必要があります。
- 読み順 — 多段組みで文章が入り混じらないこと。素の OCR 出力が最初に崩れるのがここで、エンジンは人が読む順ではなく検出順に段落を並べます。
- ブロック種別 — この行は見出しか、リスト項目か、引用か、ただの段落か。スキャンでは文字サイズだけでは判断できません。
- 表の構造 — どのセルが同じ行か、どの行がヘッダーか、セルが 2 行に折り返したときにどうなるか。
- 落とさないこと — 誰も気づかない失敗です。段落が静かに消えても、Markdown は「ちゃんとして見える」。
4 番目がいちばん厄介です。ページの 5% を落とした変換結果は、読む分には完璧に読めてしまいます。
1 回の呼び出しと、返ってくるもの
POST /ocr/markdown は画像を受け取り、組み立て済みの Markdown 文字列と、その材料になった要素の両方を返します。
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"
}'{
"status": "success",
"data": {
"markdown": "# 四半期レポート\n\n売上は前年同期比で増加した。\n\n| 項目 | 金額 |\n| --- | --- |\n| 売上 | 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": "四半期レポート",
"bbox": { "xmin": 60, "ymin": 48, "xmax": 520, "ymax": 92 },
"vertices": [{ "x": 60, "y": 48 }, "…"],
"bbox_source": "token_id",
"text_verified": true
}
]
}
}markdown はそのままドキュメントサイトに貼れる文字列です。結果を確かめられるようにしているのは elements の方です。
すべての要素、そして表の 1 セルずつに bbox と vertices が 0〜1000 の正規化座標で付きます。出力中の見出しが、元画像のどの矩形から来たのかをそのまま辿れます。正規化座標である点は実務で効きます — リサイズしても位置がずれないので、サムネイルを作った後でも枠が合います。
要素の種別は素直です。heading(level 付き)、paragraph、list_item、blockquote、code_block、thematic_break、table。表は rows / cols と cells[] を持ち、各セルが自分の row / col / header / text と座標を持つので、パイプ記法を再パースせずに独自のグリッドを描けます。
text_verified — 書き換えを捕まえるフラグ
ページを読む言語モデルは、静かに「直して」しまうことがあります。日付を正規化し、誤字と思ったものを修正し、略語を展開する。要約用途なら構いません。しかし保存する文書では、それは有能そうに見えるデータ破損です。
そこで各要素に text_verified が付きます。エンジンはその要素が主張する単語トークンを取り、その座標で Vision OCR が実際に読んだ文字を引き、正規化した上で突き合わせます。true は独立した 2 つの読みが一致したという意味。false は転写された文字がページ上の文字と違うという意味で、値は返しつつ黙って間違える代わりに印を付けます。null は比較する材料がなかった場合です。
これは精度スコアではありませんし、そう読むべきでもありません。答えているのは一点だけです — これは画素と突き合わせたのか?
静かな失敗にはカウンタがあります。 review_summary.token_coverage は、回収処理の前にモデルが主張したページトークンの割合です。どの要素も主張しなかったトークン区間は bbox_source: "unclaimed_tokens" の段落として末尾に戻され、review_summary.recovered_blocks がその回数を数えます。段落を落とした変換は、消えた本文ではなく数字に現れます。
パイプラインのどこに置くか
RAG の取り込みでは、文字列より elements の方が使えます。文字数ではなく heading の境界でチャンクを切れば、文書自身の節に沿ったチャンクになります。各チャンクに bbox を添えておけば、引用がページ番号ではなく元ページ上の矩形を指せます。
ドキュメントサイトや Wiki なら markdown をそのまま使い、要素はレビュー用のサイドカーとして持っておく。数字に異議が出たとき、議論する代わりにスキャン上に枠を出せます。
構造が要らない用途(索引・検索・差分)なら、Markdown 記法はむしろノイズです。そのための POST /ocr/text があります — 同じページを読み順を直したプレーンテキストで返し、ブロックごとに同じ text_verified が付きます。