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

スキャン画像に透明なOCRテキストを重ねた、ある史料集のPDF群(32ファイル・合計約870MB)について、次の2点の相談を受けました。

  1. ページによって用紙サイズが異なる
  2. 非力なPCでは印刷できないことがある

これを一括で直す作業を行ったのですが、途中で「よかれと思って画像を再圧縮すると、透明テキスト(OCR層)がわずかに欠ける」という落とし穴に気づきました。この記事では、原因の切り分けと、テキストを1文字も壊さずに済ませた変換方法を残しておきます。

まず状態を確認する

PDFの素性は pdfinfopdfimages(poppler)で分かります。ページサイズのばらつきは、全ページのサイズを列挙して種類を数えます。

# ページサイズの種類を数える
pdfinfo -f 1 -l 9999 input.pdf | grep "Page.*size:" \
  | awk '{print $4" x "$6}' | sort | uniq -c
 338 380.16 x 576
  24 396.72 x 594.72
  13 515.52 x 729.36
   1 419.58 x 595.38

1つのPDFの中に4種類のサイズが混在していました。本文・前付け・巻末の正誤表・末尾に追加されたページで、それぞれ元のスキャンや編集の経緯が違うためのようです。

画像の圧縮方式は pdfimages -list で分かります。

pdfimages -list input.pdf | tail -n +3 \
  | awk '{print $6, $9, $13"dpi"}' | sort | uniq -c | sort -rn
 338 gray jbig2 400dpi
  35 gray ccitt 200dpi
   2 gray jpeg 200dpi

本文の大半が JBIG2 で圧縮されていました。JBIG2は2値画像を非常に高い圧縮率で格納できる方式で、ファイルサイズは小さくなります(この本は376ページで16MB程度)。一方で展開時の計算コストが高く、環境によってはプリンタドライバやビューアが処理しきれずに印刷が止まることがあります。「サイズが大きいから印刷できない」と言われていた原因は、ファイルの容量ではなく、この展開負荷だろうと見当をつけました。

32ファイルを一通り調べると、傾向がはっきりしました。

  • JBIG2を使っているのは一部のファイルのみ(32件中9件)
  • 残りは image(Flate)や jpegccitt で、JBIG2は不使用
  • ほぼ全ファイルでページサイズが2〜5種類混在(あるファイルは138種類)
  • 全ファイルに透明テキスト(OCR層)がある

方針は「全ファイルでページサイズを統一」「JBIG2の9件だけ印刷しやすい方式へ再圧縮」の2つに決まりました。ただし、全ファイルにOCR層があるため、テキストを壊さないことが絶対条件になります。

落とし穴:Ghostscriptはテキストも作り直す

最初は Ghostscript の pdfwrite で、サイズ変更と画像再圧縮をまとめて行おうとしました。よく使われる方法です。

gs -o output.pdf -sDEVICE=pdfwrite -dCompatibilityLevel=1.6 \
  -dDEVICEWIDTHPOINTS=595 -dDEVICEHEIGHTPOINTS=842 \
  -dFIXEDMEDIA -dPDFFitPage input.pdf

ページは揃い、JBIG2もCCITTに変わります。ところが、変換前後で透明テキストの文字数を比べると、目に見えて減っていました。

# 空白を除いた文字数で比較
pdftotext input.pdf  - | tr -d '[:space:]' | wc -m   # 498335
pdftotext output.pdf - | tr -d '[:space:]' | wc -m   # 484935

約2.7%(1.3万字)が失われています。減っていたのは、古い綴りの欧文に見られる省略記号付きの語(合字・特殊グリフの類)が中心でした。Ghostscriptの pdfwrite はPDFを丸ごと再構築するため、フォントのサブセット化・再エンコードの過程で、埋め込みフォントに含まれない特殊なグリフの情報が一部落ちてしまうようです。

見た目の画像は綺麗になっても、検索・引用のためのテキスト層が変質するのは、史料としては受け入れられません。どの工程が原因かを切り分けます。

# ページサイズ統一だけ(Ghostscriptを使わず pypdf で実施)
pdftotext normalized_by_pypdf.pdf - | tr -d '[:space:]' | wc -m   # 498336

pypdfによるサイズ統一だけなら、文字数は原本(498335)とほぼ完全に一致しました(差1字)。つまりテキストを壊しているのはGhostscriptの再構築で、ページの拡縮そのものは無害だと分かります。

理屈は単純で、ページの拡縮は座標変換行列を1つ挿入するだけなので、テキスト描画命令(どの文字をどこに置くか)には手を触れません。一方でGhostscriptはコンテンツストリームもフォントも作り直します。

テキストを触らずに画像だけ再圧縮する

そこで、次の2段構成にしました。どちらもテキスト層には手を触れません。

flowchart LR
  A[元PDF] --> B{JBIG2画像あり?}
  B -->|あり| C["pikepdf<br/>画像だけCCITTに再圧縮"]
  B -->|なし| D[そのまま]
  C --> E["pypdf<br/>ページサイズを統一"]
  D --> E
  E --> F[変換後PDF]

1. pikepdfで画像オブジェクトだけを差し替える

pikepdf(qpdfのPythonバインディング)は、PDF内の個々のオブジェクトを、他を触らずに書き換えられます。JBIG2の画像だけをデコードし、CCITT G4(FAX由来の2値圧縮。枯れていてほぼどの環境でも軽く展開できる)へ再エンコードして書き戻します。テキストのコンテンツストリームは一切読み書きしません。

JBIG2のデコードには jbig2dec が必要です(brew install jbig2dec)。CCITT G4の生ストリームは、Pillowで1度TIFF(group4)に保存し、そのストリップを取り出して得ます。このとき、G4ストリームは複数ストリップに分割されると単純連結では壊れるので、ストリップサイズを大きくして必ず1ストリップにするのが要点です。

import io
import pikepdf
from pikepdf import Name, Dictionary, PdfImage
from PIL import Image, TiffImagePlugin
from PIL.TiffImagePlugin import STRIPOFFSETS, STRIPBYTECOUNTS

# G4は複数ストリップだと連結で壊れる。必ず1ストリップにする
TiffImagePlugin.STRIP_SIZE = 2 ** 31


def pil_1bit_to_ccitt_g4(im):
    if im.mode != "1":
        im = im.convert("1")
    buf = io.BytesIO()
    im.save(buf, format="TIFF", compression="group4")
    data = buf.getvalue()
    tif = Image.open(io.BytesIO(data))
    offsets = tif.tag_v2[STRIPOFFSETS]
    counts = tif.tag_v2[STRIPBYTECOUNTS]
    assert len(offsets) == 1, f"strip split: {len(offsets)}"
    return data[offsets[0]:offsets[0] + counts[0]]


def recompress_jbig2(src, dst):
    pdf = pikepdf.open(src)
    n = 0
    for page in pdf.pages:
        xobjs = (page.get("/Resources") or {}).get("/XObject") or {}
        for _, xobj in list(xobjs.items()):
            if xobj.get("/Subtype") != Name("/Image"):
                continue
            if "JBIG2" not in str(xobj.get("/Filter")):
                continue
            w, h = int(xobj.Width), int(xobj.Height)
            im = PdfImage(xobj).as_pil_image()          # jbig2dec でデコード
            raw = pil_1bit_to_ccitt_g4(im)
            xobj.write(
                raw,
                filter=Name("/CCITTFaxDecode"),
                decode_parms=Dictionary(K=-1, Columns=w, Rows=h, BlackIs1=True),
            )
            n += 1
    pdf.save(dst)
    pdf.close()
    return n

K=-1 がG4(2次元符号化)を表します。極性(BlackIs1)には注意が必要です。CCITTFaxDecodeの既定 BlackIs1=False はPDFの通常規約「0ビット=黒、1ビット=白」を意味します。一方、PdfImage で得た2値画像をPillowのgroup4で符号化した生ストリームは、この規約のままだと白黒が反転してしまいました。そこでCCITTFaxDecode側に BlackIs1=True を指定して向きを合わせています。

極性は、使うデコード経路や符号化ライブラリの組み合わせで反転し得るため、値を思い込みで決めず、変換後に必ず数ページ描画して、白地に黒文字になっているか(平均輝度が白側にあるか)を機械的にも確認するのが確実です。実際、この記事の初稿では極性を取り違えており、生成物のスキャンページが全て黒地に反転していました。目視だけの確認では見落としやすく、輝度の数値チェックを併用して初めて気づけました。

2. pypdfでページサイズを統一する

各ページを目標サイズ(例:A4)に、縦横比を保って拡縮し中央に配置します。add_transformation は座標変換行列を挿入するだけなので、画像も透明テキストも同じ倍率で一緒に動き、両者の位置関係(=OCRの当たり位置)も保たれます。

from pypdf import PdfReader, PdfWriter, Transformation
from pypdf.generic import RectangleObject


def normalize(src, dst, W, H):
    reader, writer = PdfReader(src), PdfWriter()
    for page in reader.pages:
        box = page.mediabox
        w, h = float(box.width), float(box.height)
        s = min(W / w, H / h)                      # 縦横比維持
        tx = (W - w * s) / 2 - float(box.left) * s  # 中央寄せ
        ty = (H - h * s) / 2 - float(box.bottom) * s
        page.add_transformation(Transformation().scale(s, s).translate(tx, ty))
        rect = RectangleObject([0, 0, W, H])
        page.mediabox = page.cropbox = rect
        writer.add_page(page)
    with open(dst, "wb") as f:
        writer.write(f)

テキストが本当に無傷かを確認する

「文字数が同じ」だけでは不十分です。pdftotext はページ形状が変わると、行の折り返し位置が変わり、単語の切れ目や空白の出方も変わります。実際、変換前後で単語単位のdiffを取ると大量に差が出ますが、その中身は (bronckados)金(bronckados)金 に分かれたような、折り返し由来の見かけの差でした。

そこで、単語ではなく1文字ごとの出現頻度を比べます。テキスト描画命令を触っていないなら、文字の多重集合(どの文字が何回出るか)はほぼ完全に一致するはずです。

import subprocess, collections

def char_freq(path):
    out = subprocess.run(["pdftotext", path, "-"], capture_output=True).stdout
    text = "".join(out.decode("utf-8", "replace").split())  # 空白類を全除去
    return collections.Counter(text)

a, b = char_freq("input.pdf"), char_freq("output.pdf")
diff = {c: (a[c], b[c]) for c in set(a) | set(b) if a[c] != b[c]}
print("差のある文字種:", len(diff))
print("差の総和:", sum(abs(x - y) for x, y in diff.values()))

もっとも差が大きかったファイルでも、約28万文字のうち差があったのは26文字種・合計29字(0.010%)だけでした。しかも「の」が13004→13003のように各文字±1で全体に散らばっており、これは pdftotext が変わった紙面から座標復元する際の誤差の範囲です(テキスト量が最大のファイル(約55万文字)では差0字でした)。Ghostscript版で見られた「特定の特殊グリフが2.7%まとめて消える」系統的な欠落とは、パターンがまったく異なります。この差なら、テキスト層は実質無傷と判断できます。

参考までに、両方式の比較です。

ページ統一JBIG2→CCITTOCRテキスト
Ghostscript pdfwriteできるできる約2.7%が系統的に欠落
pikepdf + pypdfできるできる(9件)実質無変化(最大でも0.010%の復元誤差、各文字±1)

まとめ

  • スキャンPDFの「印刷できない」は、ファイル容量ではなくJBIG2などの展開負荷が原因のことがある。pdfimages -list で圧縮方式を確認する
  • 透明テキスト(OCR層)付きのPDFでは、Ghostscriptの pdfwrite はテキストも作り直すため、特殊グリフが一部欠けることがある
  • ページの拡縮は座標変換の挿入だけで済み、テキストを壊さない(pypdf)。画像の再圧縮も、pikepdfで画像オブジェクトだけを差し替えればテキストは無傷
  • 検証は文字数や単語diffではなく、1文字単位の頻度で行うと、折り返し由来の見かけの差と本当の欠落を区別できる
  • 2値画像の再圧縮では極性(白黒反転)を取り違えやすい。目視は見落とすことがあるので、変換後に各ページの平均輝度を測り、白地側(明るい側)にあることを機械的に確認する

同じ道具立て(pikepdf / pypdf / poppler)は、透明テキストを保ったままの結合・分割・回転・軽量化にも使えます。「画像やテキストの中身は触らず、必要なオブジェクトだけを差し替える」という考え方が、OCR済み史料PDFを扱うときの安全側の基本になりそうです。