• #yomitoku
  • #OCR
  • #Markdown
  • #PDF変換
  • #Nuxt4
  • #日本語OCR
  • #GPU
  • #画像処理
開発book-knowledge-baseメモ

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 APIyomitoku
処理場所クラウド(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を作成した。処理の流れ:

  1. PDF/画像パスを引数に受け取る
  2. 冒頭ページから書籍メタデータ(タイトル、著者等)を判定
  3. yomitoku CLIでMarkdown変換
  4. 生成されたMarkdownをページ分割してSQLiteのchunksテーブルに格納
  5. 抽出された図画像をフロントエンド用ディレクトリに配置

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-techoGemini APIなし112
sogyo-techo-mdyomitoku478枚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>「お金の流れを見極める力」をつけよう

これらは以下の箇所で対処:

  1. PageContent.vue: 本文レンダリング時に見出し行の<br>を除去
  2. 中カラム([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ではなくonMountedonUpdatedを使用した。画像の読み込みタイミングとVueのリアクティビティの関係で、watchが発火しないケースがあったため。

結果

  • 装飾アイコンが全て非表示になり、クリーンな表示に
  • 有意義な画像(写真、チャート、フローチャート等)はそのまま表示
  • 一部のバッジ画像(289x154=44,506px²)も非表示になったが、装飾的なので問題なし

Step 8: /yomitokuスラッシュコマンドの改善

実際の運用で判明した問題点を全てスラッシュコマンドに反映した。

更新内容

  1. --combine不使用を明記: ページ区切りが出力されないため、1ファイル=1ページで処理する
  2. 日本語ファイル名リネーム手順追加: p{N}_fig{M}.pngへの変換スクリプト
  3. APIルート経由の画像パス書き換え: /api/figures/<book_id>/figures/形式への変換
  4. フロントエンド自動処理の注意書き: <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 mdMarkdown形式で出力
--figure図・画像を画像ファイルとして抽出
--figure_letter図内のテキスト情報もMarkdownに含める
-v解析結果の可視化画像を出力

コンテキストトークン量の考察

PDFの処理パターンによるClaude側のトークン消費量を比較した。

Claudeの画像トークン計算

画像トークンは (幅 x 高さ) / 750 で算出される(1568px上限にリサイズ後)。書籍ページ画像の場合、1ページあたり約2,000トークン。

3パターンの比較(112ページの場合)

Gemini APIyomitoku(ローカル)Claude直読み
画像送信先GeminiローカルGPUClaude
Claude入力トークン約5K約5K約224K
Claude出力トークン約30K約30K約80K
Claude合計約35K約35K約300K超
外部コストGeminiトークン電気代のみなし

Claude直読みは1冊でコンテキストウィンドウ(200K)を超えるため、1セッションでは処理不可能。

推奨: ハイブリッドアプローチ

  1. yomitokuで全ページをMarkdown化(CLIコマンド操作のみ: 約35Kトークン)
  2. ビューアで確認しながら、必要なページだけClaudeに画像を送って修正依頼
  3. 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テーブル品質の検証と修正フロー