yomitoku OCR変換と画像表示改善 - 日本語PDF112ページを3分でMarkdown化する実践記録
book-knowledge-baseプロジェクトで、日本語特化AI OCRツール「yomitoku」を使って「創業手帳 Ver.99」(112ページ)をMarkdown変換した。既にGemini APIでOCR済みのデータ(sogyo-techo)があったが、図表が全て失われてしまう問題があり、yomitokuによる図表保持付きの変換を別ID(sogyo-techo-md)で共存させるアプローチを採用した。
変換自体は3分で完了したが、その後の画像表示改善に大半の時間を費やした。日本語ファイル名、Nuxt 4のpublicディレクトリ問題、OCR出力の不要な<br>タグ、装飾アイコンのフィルタリングなど、実運用で直面する課題を一つずつ潰していった記録。
背景: なぜyomitokuが必要だったか
Gemini API OCRの限界
先行して実装した/import-bookスラッシュコマンドでは、Gemini APIを使ってPDFをテキスト化していた。テキスト抽出の精度は高いが、図表が全て失われるという致命的な問題があった。
書籍のビューアとして使う場合、図表がないと内容の理解が大きく損なわれる。特に「創業手帳」のような実用書では、フローチャートや表がコンテンツの重要な構成要素になっている。
yomitokuの特徴
yomitokuは日本語に特化したAI OCRライブラリで、以下の特徴がある:
- ローカルGPU(CUDA)で処理: APIコストなし
- 図表を画像ファイルとして抽出:
--figureオプション - 表をMarkdownテーブルに変換: 構造を保持
- 図内テキストもMarkdownに含める:
--figure_letterオプション - CLI対応:
uv run yomitokuで実行可能
| 比較項目 | Gemini API | yomitoku |
|---|---|---|
| 処理場所 | クラウド(API) | ローカルGPU |
| コスト | APIトークン消費 | 無料(電気代のみ) |
| 図表保持 | 不可(テキストのみ) | 画像として抽出 |
| 表の変換 | テキスト化 | Markdownテーブル |
| 処理速度(112p) | 数分 | 約3分(GPU) |
Step 1: yomitoku環境の確認とスラッシュコマンド作成
既存環境
yomitokuはC:\Users\numbe\Git_repo\yomitoku-ocrにuv環境として構築済みだった。
cd C:/Users/numbe/Git_repo/yomitoku-ocr && uv run yomitoku --help
/yomitokuスラッシュコマンドの設計
Claude Codeのスラッシュコマンドとして/yomitokuを作成した。処理の流れ:
- PDF/画像パスを引数に受け取る
- 冒頭ページから書籍メタデータ(タイトル、著者等)を判定
- yomitoku CLIでMarkdown変換
- 生成されたMarkdownをページ分割してSQLiteのchunksテーブルに格納
- 抽出された図画像をフロントエンド用ディレクトリに配置
Step 2: --combineオプションの罠
最初の実行: --combine付き
最初は--combineオプションを付けて実行した。これは全ページの出力を1つのMarkdownファイルに結合するオプション。
cd C:/Users/numbe/Git_repo/yomitoku-ocr && uv run yomitoku "C:/path/to/sogyo-techo.pdf" \
-f md \
-o "C:/Users/numbe/Git_repo/book-knowledge-base/data/md/sogyo-techo-md" \
--figure --figure_letter --combine -v
問題: ページ区切りがない
--combineで生成された単一のMarkdownファイルにはページ区切りの情報が含まれていなかった。書籍ビューアではページ単位で表示する設計のため、どこでページが変わるのか判定できない。
再実行: --combineなし
--combineなしで再実行すると、1ファイル=1ページで出力される。これなら自然にページ分割できる。
cd C:/Users/numbe/Git_repo/yomitoku-ocr && uv run yomitoku "C:/path/to/sogyo-techo.pdf" \
-f md \
-o "C:/Users/numbe/Git_repo/book-knowledge-base/data/md/sogyo-techo-md" \
--figure --figure_letter -v
処理結果
- 112ページを約3分(155秒)で処理
- 478枚の図画像を抽出
- 112個のMarkdownファイル(各ページ1ファイル)
figures/ディレクトリに抽出画像を配置- OCR/レイアウト可視化画像も出力(
-vオプション)
教訓: --combineは使わない。 1ファイル=1ページの方がDBへのインポートが自然で、ページ単位の操作も容易。
Step 3: Gemini版との共存
既にGemini APIで処理済みのsogyo-techo(テキストのみ)が存在するため、yomitoku版は別ID sogyo-techo-md として共存させた。
data/
books.db # SQLiteデータベース
md/
sogyo-techo/ # Gemini版(テキストのみ)
sogyo-techo-md/ # yomitoku版(図表付き)
*.md # 112個のMarkdownファイル
figures/ # 478枚の抽出画像
DBには2つのbook_idで登録:
| book_id | ソース | 図表 | チャンク数 |
|---|---|---|---|
sogyo-techo | Gemini API | なし | 112 |
sogyo-techo-md | yomitoku | 478枚 | 112 |
Step 4: 画像ファイル名の問題
日本語ファイル名
yomitokuが出力する図画像のファイル名は、PDFのメタデータに基づく日本語ファイル名だった。さらにWindowsのファイルシステムではShift-JISエンコーディングの影響を受ける場合がある。
figures/
創業手帳_p1_図1.png
創業手帳_p1_図2.png
...
WebのURL上で日本語ファイル名はエンコーディング問題を引き起こしやすい。ブラウザやサーバーによってパーセントエンコーディングの扱いが異なるため、画像が404になるリスクがある。
英数字名にリネーム
全画像ファイルを p{N}_fig{M}.png の命名規則にリネームした。
cd "data/md/sogyo-techo-md/figures" && ls *.png | while IFS= read -r f; do
page=$(echo "$f" | grep -oP 'p\d+' | head -1)
if [ -z "$page" ]; then
page="p0"
fi
idx=0
newname="${page}_fig${idx}.png"
while [ -e "$newname" ] && [ "$newname" != "$f" ]; do
idx=$((idx + 1))
newname="${page}_fig${idx}.png"
done
[ "$f" != "$newname" ] && mv "$f" "$newname"
done
リネーム後:
figures/
p1_fig0.png
p1_fig1.png
p2_fig0.png
...
同時にDB内のMarkdownコンテンツの画像参照パスも更新した。
Step 5: Nuxt 4のpublicディレクトリ問題
最初のアプローチ: publicに配置
Nuxt 4のweb/public/figures/sogyo-techo-md/に画像を配置し、/figures/sogyo-techo-md/p1_fig0.pngでアクセスできると想定した。
問題: 404
しかし画像が404になった。Nuxt 4ではpublicディレクトリの扱いがNuxt 3と異なる可能性があり、あるいはディレクトリ構造がdev serverに認識されていなかった。
解決: APIルート経由で画像配信
publicディレクトリの問題を回避するため、NuxtのサーバーAPIルート経由で画像を配信する方式に切り替えた。
// server/api/figures/[...path].get.ts
import { readFileSync } from 'fs'
import { join } from 'path'
export default defineEventHandler((event) => {
const path = getRouterParam(event, 'path')
if (!path) {
throw createError({ statusCode: 400, message: 'Path required' })
}
const filePath = join(process.cwd(), '..', 'data', 'md', path)
try {
const data = readFileSync(filePath)
// Content-Typeを画像に設定
setHeader(event, 'Content-Type', 'image/png')
return data
} catch {
throw createError({ statusCode: 404, message: 'Image not found' })
}
})
DBに格納するMarkdown内の画像パスを /api/figures/sogyo-techo-md/figures/p1_fig0.png 形式に書き換え:
import re
content = re.sub(
r'<img\s+src="(?:\./)?figures/([^"]+)"',
lambda m: f'<img src="/api/figures/{book_id}/figures/{m.group(1)}"',
content
)
これで画像が正しく表示されるようになった。
Step 6: 不要な<br>タグの除去
OCR出力の改行問題
yomitokuはPDFのレイアウトを忠実に再現するため、PDF上の見た目の改行位置に<br>タグを挿入する。しかしこれが問題を引き起こす。
事業の成功は、ボウリングでストライクを<br>取るようなもの。そのた<br>めには、センターピンを倒すことが必要不可欠です。
単語の途中で<br>が入るため、ブラウザ上で不自然な改行が発生する。
対処方針
全ての<br>を機械的に除去すると、意図的な改行まで消えてしまう。そこで以下のルールを適用した:
- 残す: 句読点(。、!?)の後の
<br>、画像やテーブルの前の<br> - 除去: それ以外の文中の
<br>
PageContent.vueでの実装
フロントエンド側(表示時)に<br>除去ロジックを組み込んだ。
const removeMidSentenceBr = (text: string): string => {
// 句点の後の<br>は段落区切りとして残す
// それ以外の<br>は除去
return text
.replace(/(?<![。!?」』\n])<br\s*\/?>/g, '')
.replace(/<br\s*\/?>\s*(?=<img|<table)/g, '\n')
}
見出し内の<br>
本文だけでなく、Markdownの見出し行(# ...)やchapter/sectionフィールドにも<br>が含まれていた。
# 経営者には必須!<br>「お金の流れを見極める力」をつけよう
これらは以下の箇所で対処:
- PageContent.vue: 本文レンダリング時に見出し行の
<br>を除去 - 中カラム(
[page].vue): chapterとsectionの表示で<br>を除去するヘルパー関数を追加
// 見出し行内の<br>を除去
const cleanHeadingBr = (md: string): string => {
return md.replace(/^(#{1,6}\s+.+?)<br\s*\/?>/gm, '$1')
}
// chapter/section表示用
const stripBr = (text: string): string => {
return text.replace(/<br\s*\/?>/g, '')
}
修正後: 「経営者には必須!「お金の流れを見極める力」をつけよう」が一行で自然に表示されるようになった。
Step 7: 装飾アイコン画像のフィルタリング
問題: 大量の装飾アイコン
yomitokuは図画像を忠実に抽出するため、ページ上の装飾アイコンやロゴも全て画像として抽出してしまう。478枚の画像のうち相当数が、本文とは無関係な装飾要素だった。
閾値の試行錯誤
最初はピクセル幅で判定を試みた:
| 閾値 | 結果 |
|---|---|
| 100px以下 | 76枚の極小アイコンは消えたが、100-200px台のアイコンが残る |
| 200px以下 | 多くのアイコンが消えたが、281x184px等の複合アイコンが残る |
| 300px以下 | ほぼ消えるが、有意義な画像まで巻き込むリスク |
幅だけでは装飾アイコンと有意義な画像の境界が曖昧だった。
面積ベースのフィルタリング
幅と高さの**面積(width x height)**で判定するアプローチに切り替えた。
実データの分析結果:
装飾アイコンの最大: 281 x 184 = 51,704 px²
有意義画像の最小: 280 x 278 = 77,840 px²
65,000px²を閾値に設定することで、両者を明確に分離できた。
フロントエンドでの実装
PageContent.vueで画像のonloadイベントを監視し、面積が閾値未満の場合は非表示にする:
const AREA_THRESHOLD = 65000 // px²
const hideSmallImages = () => {
const images = contentRef.value?.querySelectorAll('img') ?? []
images.forEach((img: HTMLImageElement) => {
const area = img.naturalWidth * img.naturalHeight
if (area > 0 && area < AREA_THRESHOLD) {
img.style.display = 'none'
}
})
}
onMounted(() => {
hideSmallImages()
})
onUpdated(() => {
hideSmallImages()
})
注意点として、watchではなくonMountedとonUpdatedを使用した。画像の読み込みタイミングとVueのリアクティビティの関係で、watchが発火しないケースがあったため。
結果
- 装飾アイコンが全て非表示になり、クリーンな表示に
- 有意義な画像(写真、チャート、フローチャート等)はそのまま表示
- 一部のバッジ画像(289x154=44,506px²)も非表示になったが、装飾的なので問題なし
Step 8: /yomitokuスラッシュコマンドの改善
実際の運用で判明した問題点を全てスラッシュコマンドに反映した。
更新内容
--combine不使用を明記: ページ区切りが出力されないため、1ファイル=1ページで処理する- 日本語ファイル名リネーム手順追加:
p{N}_fig{M}.pngへの変換スクリプト - APIルート経由の画像パス書き換え:
/api/figures/<book_id>/figures/形式への変換 - フロントエンド自動処理の注意書き:
<br>除去と装飾アイコン非表示は自動で行われる旨の記載
コマンドの最終形
# yomitoku実行(--combineなし)
cd C:/Users/numbe/Git_repo/yomitoku-ocr && uv run yomitoku "<PDF>" \
-f md \
-o "C:/Users/numbe/Git_repo/book-knowledge-base/data/md/<book_id>" \
--figure --figure_letter -v
オプション解説:
| オプション | 説明 |
|---|---|
-f md | Markdown形式で出力 |
--figure | 図・画像を画像ファイルとして抽出 |
--figure_letter | 図内のテキスト情報もMarkdownに含める |
-v | 解析結果の可視化画像を出力 |
コンテキストトークン量の考察
PDFの処理パターンによるClaude側のトークン消費量を比較した。
Claudeの画像トークン計算
画像トークンは (幅 x 高さ) / 750 で算出される(1568px上限にリサイズ後)。書籍ページ画像の場合、1ページあたり約2,000トークン。
3パターンの比較(112ページの場合)
| Gemini API | yomitoku(ローカル) | Claude直読み | |
|---|---|---|---|
| 画像送信先 | Gemini | ローカルGPU | Claude |
| Claude入力トークン | 約5K | 約5K | 約224K |
| Claude出力トークン | 約30K | 約30K | 約80K |
| Claude合計 | 約35K | 約35K | 約300K超 |
| 外部コスト | Geminiトークン | 電気代のみ | なし |
Claude直読みは1冊でコンテキストウィンドウ(200K)を超えるため、1セッションでは処理不可能。
推奨: ハイブリッドアプローチ
- yomitokuで全ページをMarkdown化(CLIコマンド操作のみ: 約35Kトークン)
- ビューアで確認しながら、必要なページだけClaudeに画像を送って修正依頼
- 1ページ送信で約2Kトークン。20ページ確認しても 35K + 40K = 約75K
全画像送信の1/4以下で済む。
まとめ: 実運用で直面した課題の全体像
[PDF 112ページ]
|
v
[yomitoku OCR] ← --combineの罠(1回目)→ 再実行(--combineなし)
|
+-- 112個のMarkdownファイル
+-- 478枚の図画像
| |
| +-- 日本語ファイル名問題 → p{N}_fig{M}.pngにリネーム
| +-- Nuxt 4 publicディレクトリ問題 → APIルート経由で配信
| +-- 装飾アイコンの混入 → 面積ベースフィルタリング(65,000px²)
|
+-- Markdown内の<br>タグ問題 → 文中の不要な<br>を表示時に除去
|
v
[SQLite DB] → [Nuxt 4 フロントエンド] → きれいな書籍ビューア
処理時間の内訳(体感)
| 作業 | 時間 |
|---|---|
| yomitoku OCR実行 | 約3分 |
| --combineの罠発見と再実行 | 約10分 |
| 画像ファイル名リネーム | 約5分 |
| publicディレクトリ問題の調査と回避 | 約20分 |
<br>タグ除去の実装と調整 | 約30分 |
| 装飾アイコンフィルタリングの試行錯誤 | 約40分 |
| /yomitokuスラッシュコマンドの更新 | 約10分 |
OCR変換は3分。後処理に2時間。 これは今回のみの初期コストで、2冊目以降はスラッシュコマンドとフロントエンドの自動処理により大幅に短縮される。
今後の改善ポイント
- yomitokuの
<br>除去をインポート時(DB格納前)に行うオプション - 面積閾値の書籍ごとの調整機能
- 図画像のalt属性に
--figure_letterの情報を自動挿入 - 表のMarkdownテーブル品質の検証と修正フロー