スキャンした 1 ページを、確かめられる Markdown にする
画像を Markdown に変換し、data.values の本文、path キーの cells、review.flagged、元画像座標を使って確認する実装ガイド。
スキャンした書類を Markdown にしたい理由は、だいたい二つあります。そしてこの二つは求めるものが少し違います。
一つは公開。Wiki やドキュメントサイト、リポジトリに載せたい。そのとき見出しは見出しのままであってほしい。もう一つはモデルに読ませるため。多くの LLM パイプラインが Markdown を前提にしているのは、記法が構造を運ぶからです。## は「ここが節の切れ目だ」と伝え、パイプ表は「このセルたちは同じ行だ」と伝えます。素のテキストではその情報が消えます。
失敗の仕方も共通です。見出しが段落に潰れれば、ドキュメントサイトは一枚の壁になり、検索用のチャンクは変なところで切れます。そしてどちらの用途でも、返ってくるのはたいてい 1 本の Markdown 文字列だけで、この行はページのどこから来たのかを聞く手段がありません。
「レイアウト保存」が実際に守るべきもの
使える Markdown 変換は、独立した 4 つの判断を正しく行う必要があります。
- 読み順 — 多段組みで文章が入り混じらないこと。素の OCR 出力が最初に崩れるのがここで、エンジンは人が読む順ではなく検出順に段落を並べます。
- ブロック種別 — この行は見出しか、リスト項目か、引用か、ただの段落か。スキャンでは文字サイズだけでは判断できません。
- 表の構造 — どのセルが同じ行か、どの行がヘッダーか、セルが 2 行に折り返したときにどうなるか。
- 落とさないこと — 誰も気づかない失敗です。段落が静かに消えても、Markdown は「ちゃんとして見える」。
4 番目がいちばん厄介です。ページの 5% を落とした変換結果は、読む分には完璧に読めてしまいます。
1 回の呼び出し、4 つの応答レイヤー
POST /ocr/markdown は 1 枚のラスター画像を URL または base64 で受け取ります。構造・座標・確認 UI が必要なら、既定値でもある 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[] を持ちます。
座標は要素の中ではなく、flat な data.cells にあります。elements[0] は最初の要素、elements[2].cells[1] は 3 番目の要素が表ならその 2 番目のセルです。data.review.flagged[].path も同じ文法なので、確認項目からそのまま 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,
})));
}確認リストを原稿オーバーレイにする
独自のスコアしきい値ではなく、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 を見出し境界で chunk 化し、path を一緒に保存すると引用から原稿位置へ戻れます。ドキュメントサイトでは data.values.markdown を公開し、elements・cells・review・image を確認用 sidecar として保持します。
構造が不要なら 読み順を保つプレーンテキスト OCR、枠の実装は 元座標で OCR を確認する方法 を参照してください。
画像を確認可能な Markdown に変換する手順
- 1 ページを用意するURL または base64 のラスター画像を用意します。PDF は API 呼び出し前に 1 ページずつ画像化します。
- elements を要求する構造・座標・人の確認が必要なら includeElements: true で POST /ocr/markdown を呼びます。
- 応答レイヤーを保存する公開には data.values.markdown、構造処理には data.values.elements を使い、cells・review・image を確認用に保持します。
- flagged path を解決するdata.review.flagged を順に読み、data.cells[flag.path] の理由と box・quad を data.image 上に表示します。
- 完全性を確認して公開するdata.review.coverage と recovered blocks を確認し、必要なレビューを終えてから公開または chunk 化します。