本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

概要

スキャン画像に OCR(optical character recognition、光学文字認識)の結果を「透明テキスト層」として重ねた検索可能 PDF を作っています。ある縦書き資料で、透明テキストが実際の文字位置とずれていることに気づきました。範囲選択やハイライトが文字に乗らず、テキスト抽出の読み順も乱れます。

調べたところ、原因は透明テキストを埋め込むスクリプトが元 PDF のページ回転(/Rotate)を考慮していなかったことでした。縦書き自体の問題というより、ページに /Rotate 270 が付いた PDF でだけ表面化する座標系の取り違えです。

この記事では、次を順にまとめます。

  • どのファイルが影響を受けているかを機械的に切り分ける検証方法
  • 制御実験による根本原因(座標系の二重回転)の特定
  • 再 OCR せずに透明テキスト層だけを直す方法

以下は修正前後の比較です。赤/緑の枠が透明テキスト行の位置で、左(Before)は横帯が縦列を横断し、右余白の空白部にまで枠が浮いています。右(After)は縦ボックスが各縦列に乗っています。

縦書きページの透明テキスト層の位置。Beforeは横長の枠が複数の縦列を横断し一部が右余白に浮いている。Afterは各縦列に沿った縦長の枠が重なっている

前提としているパイプライン

検索可能 PDF は、おおむね次の 3 段で作っていました。

  1. render.py … 元 PDF を PyMuPDF の get_pixmap() で 300dpi の画像にする
  2. ndlocr-lite … 画像を OCR し、行ごとの文字列と bounding box を出力する
  3. overlay.py(または make_searchable.py)… OCR の座標を元 PDF に透明テキスト(render_mode=3)として書き戻す

透明テキストを埋め込む部分は、行の bounding box の縦横比で縦書き / 横書きを判定していました。

# overlay.py(抜粋・簡略化): 行 box を元PDFに書き戻す
sx, sy = page.rect.width / img_w, page.rect.height / img_h
x = float(line.get("X")) * sx
y = float(line.get("Y")) * sy
w = float(line.get("WIDTH")) * sx
h = float(line.get("HEIGHT")) * sy
vertical = h > w * 1.5
if vertical:                       # 縦書き行: 上から下
    point = fitz.Point(x + w * 0.8, y)
    rot = 270
else:                              # 横書き行
    point = fitz.Point(x, y + h * 0.8)
    rot = 0
page.insert_text(point, text, fontname="japan", fontsize=fs,
                 render_mode=3, rotate=rot)

縦書き / 横書きの分岐自体は入っていて、他の縦書き資料では正しく機能していました。それでも一部の資料だけでずれが出ていた、というのが出発点です。

検証方法

1. 現象の確認(fonts / images / text)

まず対象 PDF の素性を確認します。poppler の CLI が便利です。

pdffonts  file.pdf     # 埋め込みフォント・エンコーディング
pdfimages -list file.pdf  # ページ内の画像(スキャン層)の有無・解像度
pdftotext -bbox file.pdf - # 各語の bounding box 付き抽出

対象の日記 PDF では、各ページに 300dpi の CCITT(bitonal)スキャン画像が 1 枚ずつ入っており、その上に透明テキストが乗っている構成でした。ここで pdftotext(レイアウト非保持)で本文を抽出すると、左段の本文と右段の注記が行単位で交互に出力され、読み順が乱れていました。

2. 透明テキストの位置を可視化する

数値だけでは分かりにくいので、透明テキスト行の bounding box をスキャン画像に重ねて描きます。ここで重要なのは、表示座標(ページ回転を適用した座標)で描くことです。PyMuPDF の page.get_text("dict") が返す bounding box はページの回転を適用していない「native 空間」の値なので、page.rotation_matrix を掛けて表示空間に変換してから描画します。

import fitz
from PIL import Image, ImageDraw

d = fitz.open(path); p = d[0]
m = p.rotation_matrix            # native -> 表示(display)
pix = p.get_pixmap(dpi=150)      # 表示向き(正立)でレンダリング
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
dr = ImageDraw.Draw(img); sc = 150 / 72
for b in p.get_text("dict")["blocks"]:
    for l in b.get("lines", []):
        r = fitz.Rect(l["bbox"]) * m           # 表示空間へ変換
        dr.rectangle([r.x0*sc, r.y0*sc, r.x1*sc, r.y1*sc], outline=(220,20,20), width=3)
img.save("overlay.png")

この重ね描きで、透明テキスト行が横帯として縦列を横断し、一部が余白に浮いていることがはっきり見えました(冒頭の Before 画像)。

3. 影響範囲を機械的に切り分ける

同じフォルダの他ファイルにも同様の破損がないかを確認します。「縦書き資料なのに、透明テキスト行が横ボックス優勢になっている」ものが疑わしい、という発想で、各 PDF のページ回転・MediaBox の向き・縦ボックス比率を一括測定しました。

# 各PDFで、行boxの縦横比から「縦ボックス比率」を測る(サンプルページ)
for b in page.get_text("dict")["blocks"]:
    for l in b.get("lines", []):
        x0, y0, x1, y1 = l["bbox"]
        w, h = x1 - x0, y1 - y0
        if h > w * 1.3: vert += 1
        elif w > h * 1.3: hor += 1

ただし、この比率だけでは判断を誤ります。理由は 2 つあります。

  • 横書きの資料(例: 技術書)は、正しく処理されていても横ボックスが優勢になる
  • 回転ページ/Rotate 270)では、native 空間の box は縦長(h > w)に見えるが、表示すると横帯になる

そこで比率で当たりを付けたうえで、疑わしいものと対照(正常なもの)を 表示空間の重ね描きで 1 枚ずつ目視確認しました。結果として、

  • 横ボックス優勢でも、横書き資料は枠が横行に正しく乗っており正常
  • 縦書き資料は縦ボックスが縦列に正しく乗っており正常
  • /Rotate 270+横長 MediaBox のファイルだけが、縦列を横帯が横断する破損

と切り分けられました。破損していたのは対象フォルダ内で 2 ファイル(同じ資料の 2 分冊)だけで、いずれも /Rotate 270 を持っていました。

根本原因: 座標系の二重回転

破損ファイルに共通する特徴は /Rotate 270(+横長 MediaBox)でした。ここから原因を絞り込みます。

PyMuPDF では、ページに回転が付いていると 2 つの座標系が食い違います。

  • page.rect … 回転を適用した表示(display)空間。/Rotate 270 の横長ページは、表示上は縦長(portrait)になる
  • page.insert_text() の座標 … 回転を適用しない native(未回転)空間。こちらは横長(landscape)のまま

パイプラインの流れを追うと、

  1. render.pyget_pixmap() は回転を適用して**正立(portrait)**の画像を出す。OCR はこの正立画像を見て、正立 portrait のピクセル座標で行 box を返す
  2. overlay.pypage.rect.width / img_w でスケールする。page.rect は表示(portrait)空間なので、得られる座標は表示空間の値になる
  3. しかし page.insert_text() はそれを native(landscape)空間の座標として書き込む
  4. さらに表示時に /Rotate 270 が掛かる

この結果、表示空間で正しく求めた縦列の座標が、native 空間に置かれたうえで回転が二重に掛かり、「変位した横帯」に化けます。

座標系の取り違えを3枚のパネルで示す概念図。①表示空間(portrait)でOCRが求めた縦長boxは正しい。②同じ数値がinsert_textでnative空間(landscape)に書かれる。③表示時に/Rotate 270が再度掛かり、文字は縦のままboxだけが変位した横帯になる

元 PDF の /Rotate が 0 なら native 空間と表示空間は一致するので、この取り違えは表面化しません。他の資料が正常だったのはそのためで、破損したのはスキャンが横長格納+/Rotate 270 だった 2 分冊だけでした。

制御実験で再現する

推測を確かめるため、/Rotate 270 の空ページを作り、パイプラインと同じ手順で「表示空間で求めた縦列座標」を書き込んで、実際にどこへ出るかを測りました。

import fitz
doc = fitz.open(); page = doc.new_page(width=729.6, height=516)  # 横長 native
page.set_rotation(270)                                           # 表示は portrait
# render.py 相当: get_pixmap は回転適用 -> portrait 画像(2150x3040)が出る
pix = page.get_pixmap(matrix=fitz.Matrix(300/72, 300/72))
# overlay.py 相当: 表示空間(page.rect=portrait)で求めた縦列の座標を、
# そのまま native 空間の insert_text に渡してしまう
page.insert_text(fitz.Point(466.5, 58.4), "平賀譲日記",
                 fontname="japan", fontsize=14, rotate=270)

結果は次の通りで、現象を正確に再現しました。

意図(表示 portrait 空間): 点 (466, 58) = 右上・縦書き下向き
実際の native bbox      : [463, 58, 485, 188]  (縦長ボックス)
実際の display bbox     : [58, 245, 188, 267]  (= 左・中段の横帯)

「右上の縦列」が「左中段の横帯」に化けます。実ファイルで見た現象と一致しました。

スクリプトの問題だったか

はい。overlay.py / make_searchable.py に、次の 2 つの潜在バグがありました。

  1. ページ回転の未対応(主因)… 座標を page.rect(表示空間)基準で計算しながら、page.insert_text(native 空間)へそのまま渡していた。/Rotate が 0 の資料では顕在化しないので、回転付き PDF を入力するまで気づけませんでした。
  2. 行原点のずれ(後述)… 縦書き行の挿入点に x + w * 0.8 を使っていたため、文字が約 1 列ぶん右へ寄っていた。1 は表示上すぐ分かりますが、2 は文字単位で重ね描きするまで気づけませんでした。

縦書き / 横書きの判定ロジック自体は正しく、縦書きが原因というより「回転ページの座標変換漏れ」と「行原点のオフセット」が原因でした。

対処: 再OCRなしで透明テキスト層だけを直す

再 OCR は不要でした。理由は、壊れた PDF が正しい OCR 文字列を保持しており、各行の native bbox が本来意図していた表示空間の box と数値的に一致している(バグの裏返し)からです。実際、破損ファイルから各行の native bbox を読むと、題字「平賀譲日記」は [383, 115, 398, 423] で、これを portrait(516×729.6)座標として読むと、実画像の題字(右寄り・上部の縦列)の位置と一致します。

そこで、次の手順で直しました。

  1. 既存の(誤配置の)透明テキストを削除し、スキャン画像は保持する
  2. 各行を「表示空間の目標 box」に、回転を打ち消して再挿入する
    • 挿入点を page.derotation_matrix で native 空間へ変換する
    • insert_textrotate を「見た目の向き + page.rotation」にする(縦書き下向き 270/Rotate 270 なら 540 % 360 = 180

回転を打ち消す rotate 値は、/Rotate 270 のページで候補を総当たりして、縦書き下向きが表示上の目標位置に出るものを選びました(rotate=180)。

import fitz

FONT = fitz.Font("japan")

def place_line(page, D, t, R, derot):
    """行 box D(表示空間)に、回転を打ち消して 1 行を配置する。"""
    w, h = D.width, D.height
    unit = FONT.text_length(t, fontsize=1)
    if unit <= 0:
        return
    if h > w:  # 縦書き(表示上は上から下)
        fs = min(h / unit, w * 1.05)         # 送り合計を列長 h に合わせる
        disp_pt = fitz.Point(D.x0, D.y0)     # 列の左上(x に +w*0.8 しない)
        vis_rot = 270
    else:      # 横書き
        fs = min(w / unit, h * 1.05)
        disp_pt = fitz.Point(D.x0, D.y0 + h * 0.8)
        vis_rot = 0
    page.insert_text(disp_pt * derot, t, fontname="japan", fontsize=fs,
                     render_mode=3, rotate=(vis_rot + R) % 360)

def fix(inp, outp):
    doc = fitz.open(inp)
    for page in doc:
        R = page.rotation
        if R == 0:
            continue                     # 回転なしは元々正しいので触らない
        derot = page.derotation_matrix
        # 各行を native 空間で収集(= 本来意図の表示空間 box)
        lines = []
        for b in page.get_text("dict")["blocks"]:
            for l in b.get("lines", []):
                t = "".join(s["text"] for s in l["spans"]).strip()
                if t:
                    lines.append((fitz.Rect(l["bbox"]), t))
        # 既存の透明テキストだけ削除(画像・線画は保持)
        page.add_redact_annot(page.rect, fill=None)
        page.apply_redactions(images=fitz.PDF_REDACT_IMAGE_NONE,
                              graphics=fitz.PDF_REDACT_LINE_ART_NONE)
        for D, t in lines:
            place_line(page, D, t, R, derot)
    # clean=True で content stream を統合・圧縮する
    doc.save(outp, garbage=4, deflate=True, clean=True)

この方法はスキャン画像を再ラスタ化しないので、画質は原本のまま、ファイルサイズも 979,030 バイト → 788,278 バイトと原本以下に収まりました。既存テキストの削除には apply_redactions を使い、images / graphics..._NONE にして画像と線画を保持しました(fill=None で赤塗りも出しません)。

なお、既存 PDF から画像だけを流用して正立(/Rotate 0)のページに組み直す方法もあります。回転計算が要らず単純ですが、get_pixmap で画像を再ラスタ化するため bitonal のスキャンが RGB になり、ファイルサイズが数倍(実測で約 3.2 倍)になりました。画質・サイズを維持したい場合は、上記の「元ページのまま透明テキストだけ差し替える」方法が向いています。

文字単位で検証すると、もう一つのずれが見つかった

行 box を重ね描きした段階では枠が縦列に乗っており、直ったように見えました。しかし念のため文字単位の bounding boxget_text("rawdict") の各 charbbox)をスキャンに重ねると、透明文字が実際の黒文字より約 1 列ぶん右へ寄っていることが分かりました。

修正前後の文字単位の位置比較。左は各文字の枠が黒文字の1列右にずれ、題字「平賀譲日記」の右に空の枠が並ぶ。右は各文字の枠が黒文字に重なっている

原因の切り分けとして、まず目標 box D 自体(OCR 座標)をスキャンに重ねると、各列にぴったり乗っていました(下図)。つまり OCR 座標は正しく、ずれは「D の中に文字を置くときの原点」の問題でした。

目標box D(OCR座標)を正立スキャンに重ねた図。各縦列の黒文字を青い矩形がちょうど囲っており、OCR座標自体は正確であることを示す

犯人は、縦書き行の挿入点に使っていた x + w * 0.8(列幅の 8 割だけ右へ寄せる)でした。/Rotate 270 ページで候補位置を実測して較正したところ、正しい挿入点は列の左端 x = D.x0 で、w * 0.8 を外すと文字が黒文字にほぼ一致しました(誤差 2pt 程度)。上記コードの disp_pt = fitz.Point(D.x0, D.y0) がこの修正です。

行単位で入れる(文字単位で入れない)理由

各文字を 1 つずつ insert_text する(セルに 1 文字ずつ置く)と、位置は完璧に合いますが、複数文字の検索が効かなくなりました。PyMuPDF の抽出が 1 文字ごとに別行として扱うため、page.search_for("教養委員会") のような連続語がヒットしなくなります。

# 文字単位で配置した PDF での search_for 結果
'教養委員会': []
'前田家婚儀': []
'軍人会館':   []

そこで、位置は行単位配置(fs = 送り合計に合わせる + 原点 D.x0)で十分な精度が出ることを確認し、行単位のまま採用しました。これで語のまとまりが保たれ、検索も戻ります。

# 行単位で配置し直した PDF での search_for 結果
'教養委員会': [(3, 1)]
'前田家婚儀': [(3, 1)]
'軍人会館':   [(3, 2)]

結果

修正後は、透明テキストが各縦列の黒文字に(文字単位で)乗り、pdftotext の抽出も段の混線が解消して連続した本文として読め、複数文字の search_for も効くようになりました。同一資料の全文テキスト(別途 OCR 行順から生成していた .txt)はもともと読み順が正しかったため、修正対象は PDF 2 本だけで済みました。

まとめ

  • 検索可能 PDF の透明テキストがずれる場合、**元 PDF のページ回転(/Rotate)**を最初に疑うと切り分けが早いです。page.rect(表示空間)と page.insert_text(native 空間)の座標系の違いを、回転付きページで取り違えると起きます。
  • 影響範囲の切り分けは、縦横比の一括測定で当たりを付け、表示空間での重ね描きで目視確認するのが確実でした。比率だけでは横書き資料や回転ページを誤判定します。
  • 透明テキストの位置がずれているだけで、OCR 文字列自体が正しければ、再 OCR せずに座標を打ち直すだけで直せます。page.derotation_matrix で挿入点を native 空間へ変換し、rotatepage.rotation を足して回転を打ち消すのがポイントです。
  • 行 box の枠が合っていても、文字単位の bbox まで確認しないと、行の中で文字が寄っているずれ(今回の x + w * 0.8)は見逃します。search_for で複数文字がヒットするかも、実用上の確認になります。
  • 位置精度を優先して 1 文字ずつ配置すると完璧に合いますが、抽出が 1 文字ごとに分かれて複数文字検索が効かなくなるため、行単位配置のまま原点を直すほうが実用的でした。
  • スクリプト側の恒久対応としては、透明テキスト埋め込み時に page.rotation != 0 を扱えるようにしておくと、回転付き PDF が混ざっても破損しません。