なぜそのPDFは文字が変わってしまうのか?
はじめに
株式会社レトリバ CTO 武井です。ブログを書くのもなんだか久しぶりですね。
Dockerの話を書いたり、アルゴリズムの話を書いたり、節操のない記事ばかりを書いていますが、今回はPDFのテキスト化に関する話です。やっぱり節操がないですね。
さて、昨今、RAG(Retrieval Augmented Generation)だのなんだのと、PDFをテキスト化するニーズが高まっています。
PDFをテキスト化する際、「なにかちょっと見た目が違う文字に変わってしまったぞ?」という経験、ありませんか?
まぁ、でも見た目はだいたい同じだし、意味も通じるしLLMも賢いから、そのままでいっか、となっていませんか?
実際、LLMはだいぶ賢くなってきており、多少の文字化けであれば、文脈から意図をくみ取って正しい意味として扱ってくれることも多いです。
しかしながら、必ずしもすべてのLLMが正しく扱ってくれるわけではありませんし、トークナイズの結果が大きく変わってしまうこともあります。
原因を理解し、適切に対策を講じることは、LLMを活用するうえで重要です。
今回は、PDFのテキスト化、というか、PDFとフォントの関係に起因する、ちょっと変わった文字化けの話と、その影響、対策について書いてみようと思います。
なお、導入でお分かりかもですが、この記事は技術的にやや深掘りした内容となっています。
そのため、PDFからテキスト抽出するエンジニアや、RAG・全文検索の前処理に関わる方を主な対象読者としています。
が、軽い読み物としても楽しんでいただければ幸いです。
似て非なるUnicode文字たち
さて、突然ですが、ここに二つの「一石二鳥」があります。
一石二鳥
⼀⽯⼆⿃
ありますね。
実はこの二つ、全く違う文字列です。
ブラウザの検索は賢いので、どちらをコピーして検索しても両方がヒットすると思いますが、上下で一文字たりとも同じ文字はありません。
Unicodeのコードポイントで表すと、前者は以下のようになります。
U+4E00 U+77F3 U+4E8C U+9CE5
一方、後者は以下のようになります。
U+2F00 U+2F6F U+2F06 U+2FC3
前者は、いわゆる「普通の」漢字、Unicodeでは「CJK統合漢字」と呼ばれるブロックに含まれる文字です。
一方、後者は「康煕部首(こうきぶしゅ)」という、漢字の部首を表すために用意されたUnicodeブロックに含まれる文字です。
つまり、部首の「⼀」(七や丁などの部首)、部首の「⽯」(研の石偏や磨の石部)、部首の「⼆」(云や亜などの部首)、部首の「⿃」(鷦や鳳などの部首)が並んでいる、ということです。
部首のための漢字って何?と思われるかもしれませんが、たとえば「⽨(はつがしら)」や「⼵(えんにょう)」など、それ単体で漢字にならないような部首もあり、それらを表現するために用意されたブロックです。
前述の例では、「一」「石」「二」「鳥」それぞれ単体漢字としても存在するので、部首漢字と普通の漢字を兼用してしまえばいいじゃないか、と思うかもしれませんが、Unicodeは理念上「同じ形の文字でも意味が違うなら別のコードポイントを割り当てる」という方針を採用しているため、このようなことが起こっています。
さて、この部首漢字ですが、普通のテキストでは基本的に使われることはありません。
入力しろと言われても方法がわからない、という方も多いでしょうし、普通のIME(日本語入力システム)では変換候補にすら出てきません。
ところが、PDFを介したり、OCRを介したりすると、この「康煕部首」の文字が紛れ込んでしまうことがあります。
OCRは見た目が同じだから誤ってしまう、というのは理解できますが、デジタルデータのまま変換されるPDFで、なぜこんなことが起こるのでしょうか?
ちなみにですが、Wikipediaにも康煕部首の項目がありますので、他にどんな文字があるか気になる方は、こちらも参照してみてください。
Wikipedia: 康煕部首
一目でそれ部首だよねとわかる「⼇(なべぶた/けいさんかんむり)」みたいな字もあれば、そういえばこの漢字って部首にも使われるんだっけ?というような字もあるかと思います。
一度目を通してみると面白いかもしれません。
例えば、曜日を表す「月火水木金土日」は全部部首漢字としても存在しています。
水は「氵(さんずい)」ではと思われるかもですが、「永」や「氷」「泉」などの部首としての⽔部(すいぶ)として、この字形の文字が存在しています。
また、康煕部首は部首の「元となった字形」を収録しているのに対し、それを変形させたもの、例えば「⽔(すいぶ)」に対する「氵(さんずい)」のような字形は、「CJK部首補助」という別のブロックに収録されています。
こちらも、Wikipediaに項目がありますので、興味のある方は参照してみてください。
Wikipedia: CJK部首補助
PDFとフォントとGIDと
PDFは、テキストを表現する際、元の文字コードをそのまま保存するわけではありません。 [1]
まず、「文字に対してどのような形で表示するか」のためにフォントが必要となります。
フォントは、内部では文字コードではなく、GID(Glyph ID)という識別子で文字の形(グリフ)を管理しています。 [2]
GIDはフォント内で使われる独自のIDであり、文字の形(グリフ)に紐づく識別子です。
そして、文字コードからGIDへのマッピング(cmapと呼ばれる)がフォント内に定義されています。
PDFは、内部的にはGID(やそれに準ずる表現[3])としてテキストを保持し、表示時にフォントのマッピングを経て字形(グリフ)に変換します。
つまり、PDF内のテキストは、元の文字コードそのものではなく、フォント側のマッピングを介して字形(グリフ)に変換される前提の表現になっている、ということです。
このGIDが文字の字形(グリフ)に対応している、というのがポイントで、異なる文字コードの文字であっても、同じ字形(グリフ)を持つ文字は、同じGIDにマッピングされることがあります。
ちょっとPythonのコードを書いて、フォントのマッピング(cmap)を調べてみましょう。
以下のコードはPythonの依存として、 fonttools と pydantic を使っています。
PEP 723のInline script metadataに依存を書いているので uv コマンドが使える環境であれば、実行時に自動で依存解決され、そのまま実行できます。
#!/usr/bin/env -S uv run --script
#
# /// script
# requires-python = ">=3.12"
# dependencies = ["fonttools", "pydantic"]
# ///
import fileinput
from pathlib import Path
from fontTools.ttLib import TTFont
from pydantic import BaseModel, Field
class Options(BaseModel):
font_path: Path = Field(..., description="Path to the font file to be checked.")
font_index: int = Field(0, description="Index of the font in a collection.")
def convert_to_gid(options: Options, io: fileinput.FileInput):
def _escape(s: str) -> str:
return "".join(c if c.isprintable() else f"\\u{ord(c):04x}" for c in s)
# フォントコレクションファイル(.ttcなど)には複数フォントが格納されているため、fontNumberを指定する必要がある
if options.font_path.suffix.lower() in {".ttc", ".otc"}:
font = TTFont(options.font_path, fontNumber=options.font_index)
else:
font = TTFont(options.font_path)
# フォント内のcmapのうち一番「良い(=より新しい、広い範囲の文字コードに対応している)」cmapを取得する
cmap = font.getBestCmap()
for line in io:
for char in line:
code_point = ord(char)
# fontToolsのcmapはcode pointからglyph nameへのマッピングを提供する
glyph_name = cmap.get(code_point)
if glyph_name is not None:
gid = font.getGlyphID(glyph_name) # glyph nameからGIDを取得する
print(f"Char: {_escape(char)} (U+{code_point:04X}) -> GID: {gid}")
else:
print(f"Char: {_escape(char)} (U+{code_point:04X}) -> GID: Not found")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="Convert string to GID format using a specified font."
)
parser.add_argument(
"--font", "-f", type=Path, required=True, help="Path to the font file."
)
parser.add_argument(
"--font-index", type=int, default=0, help="Index of the font in a collection."
)
parser.add_argument(
"inputs",
nargs="*",
help="Input strings to convert. If none provided, reads from stdin.",
)
args = parser.parse_args()
options = Options(font_path=args.font, font_index=args.font_index)
with fileinput.input(args.inputs) as f:
convert_to_gid(options, f)
指定したフォントに対して、引数のテキストファイル、もしくは標準入力から読み込んだ文字列をGIDに変換して出力するスクリプトです。
ここでは試しに、Microsoft Wordの本文のデフォルトフォントである「游明朝体 (yumin.ttf)」を使って、先ほどの二つの「一石二鳥」を変換してみましょう。
実行環境はWSL2上のUbuntu 24.04を利用しています。
WSL2は /mnt/c/ で、Windows側のファイルにアクセスできるので、以下のように実行できます。
$ ./convert_to_gid.py --font /mnt/c/Windows/Fonts/yumin.ttf input.txt
Char: 一 (U+4E00) -> GID: 1202
Char: 石 (U+77F3) -> GID: 2678
Char: 二 (U+4E8C) -> GID: 3277
Char: 鳥 (U+9CE5) -> GID: 3033
Char: \u000a (U+000A) -> GID: Not found
Char: ⼀ (U+2F00) -> GID: 1202
Char: ⽯ (U+2F6F) -> GID: 2678
Char: ⼆ (U+2F06) -> GID: 3277
Char: ⿃ (U+2FC3) -> GID: 3033
Char: \u000a (U+000A) -> GID: Not found
※ すべてのフォントで同様のマッピングになるわけではありませんが、游明朝体のように部首字形を共用しているフォントでは、同様の挙動が確認できます。
改行文字は形のない文字なので Not found となっていますが、注目すべきは「康煕部首」の文字と「普通の漢字」の文字が、(このフォントでは)同じグリフに割り当てられており、結果として同じGIDになっている、という点です。
例えば、普通の漢字の「一」 (U+4E00)と部首漢字の「⼀」 (U+2F00)が、同じGID 1202 になっています。
文字コードとGIDが一対一に対応していない(単射ではない)ため、GIDから元の文字コードを一意に復元することができません。
これが、PDFを介したときに、文字が変わってしまう原因となっているわけです。
康煕部首の漢字なんて普通使わないのだから、「普通の漢字」に逆変換してよ、と言いたいところですが、そもそも、フォントの元々の役割は、印刷物であったり画面表示であったり、人間が直接見ても理解できないバイナリデータ(文字コード)を、人間が理解できる字形(グリフ)に変換することにあります。
そこからの逆変換は、そもそもフォントとしては想定していなかったわけです。
印刷して終わり、画面に表示できれば終わり、であればそれで問題はないのです。
ところが、そこに表示もできるし印刷もできるし、さらにはデジタルデータとしても扱える(=元に戻せると期待されてしまう)PDFと、字形が同じでも意味が違うなら別のコードポイントを割り当てるUnicodeの理念がぶつかってしまい、今回のような問題が発生してしまうわけです。
LLMとPDFのテキスト化
最近のLLMはだいぶ賢くなってきており、前述のような部首漢字が混入してしまった場合でも、文脈や文字に関する知識をもとに、意図をくみ取って正しい文字として扱ってくれることが多いです。
実際、ChatGPTに「一石二鳥の意味と語源を教えてください。」という質問を、部首漢字を使って尋ねても、手元の環境では正しく回答してくれました。
ただし、必ずしもすべてのLLMが正しく扱ってくれるわけではありません。
そもそも別の文字ですので、トークナイザーの結果は全く異なるものとなります。
2025年12月現在、OpenAIのGPT-4oの Tokenizer で、トークナイズすると、
通常の漢字版は [2432, 31984, 7779, 190957] に対し、
部首漢字版は [158, 120, 222, 158, 121, 107, 158, 120, 228, 158, 123, 225] となり、全く異なるトークン列となっています。
漢字4文字に対し、部首漢字版は12トークンになっており、UTF-8表現の1バイトがそのまま1トークンになっているような形です。
部首漢字の出現頻度の少なさが垣間見れますね。
パラメーター数の少ない軽量モデルなどでは、部首漢字を正しく理解できないことも多そうですし、トークン数が増えることで、コンテキスト長が増えてしまう、という問題もあります。
とくに最近は、ローカル環境下でクローズドなLLMを動かすケースも増えており、その場合、潤沢にGPUリソースが使えるわけではないため、パラメーター数の少ないモデルを使うことも多いでしょう。
あるいはGPUは十分にあっても、潤沢なGPUがあるならいろんなモデルを動かして連携させたい、といった理由で、軽量モデルを選択することもあるでしょう。
こういった場合、部首漢字の混入は、LLMの性能を大きく削いでしまう可能性があります。
テキストの正規化
PDFからどうにかして正確なテキストを抽出する、という方向で頑張るのも一つの手ですが、抽出したテキストを後処理で正規化する、というアプローチも考えられます。
そもそも、同じ字形でも意味が違うなら別のコードポイントを割り当てる、というのがUnicodeの理念からきているわけですから、Unicodeのレイヤーで対処する、というのは正着手とも言えそうです。
Unicodeの正規化(Normalization)は「見た目や意味で文字を同一視して揃える」ための仕組みで、用途に応じて4種類の方法が定義されています。
どの文字とどの文字を同一視するかのルールの違い(正準等価性と互換等価性)と、最終的な文字列の形態の違い(合成と分解)の組み合わせになっており、以下の4種です。
- NFC (Normalization Form Canonical Composition): 正準等価性に基づき、合成形態に正規化
- NFD (Normalization Form Canonical Decomposition): 正準等価性に基づき、分解形態に正規化
- NFKC (Normalization Form Compatibility Composition): 互換等価性に基づき、合成形態に正規化
- NFKD (Normalization Form Compatibility Decomposition): 互換等価性に基づき、分解形態に正規化
どれを使うべきかはケースバイケースですが、今回の部首漢字のケースでは互換等価性に基づく正規化を行うことで、部首漢字を通常の漢字に変換することができます。
Pythonでは、 unicodedata モジュールの normalize 関数を使うことで、簡単にUnicodeの正規化を行うことができます。
>>> import unicodedata
>>> s1 = "\u4E00\u77F3\u4E8C\u9CE5" # 通常漢字
>>> s2 = "\u2F00\u2F6F\u2F06\u2FC3" # 部首漢字
>>> print(s1, s2) # 見た目は同じ
一石二鳥 ⼀⽯⼆⿃
>>> s1 == s2 # でも違う文字列
False
>>> s2_normalized = unicodedata.normalize("NFKC", s2) # 部首漢字を互換等価性に基づき、合成形態に正規化
>>> s1 == s2_normalized # 正規化すると同じ文字列になる
True
>>> print([f"U+{ord(c):04X}" for c in s2_normalized]) # 正規化後のコードポイントを表示
['U+4E00', 'U+77F3', 'U+4E8C', 'U+9CE5']
ただし、互換等価性に基づく正規化は、部首漢字以外にも様々な文字を変換してしまうため、注意が必要です。
たとえば、全角英数字を半角英数字に変換したり、「㌀」のような組み文字を「アパート」のように分解したり、丸付き文字を通常の数字に変換したり、と影響の大きい変換が行われます。
場合によっては、意図しない変換が行われてしまう可能性もあるため、正規化を行う際は、影響範囲を把握したうえで適用範囲を設計する必要があります。
Unicodeの正規化の話をし始めると長くなってしまうので、詳細は割愛しますが(あるいは、別の記事で書くかもしれませんが)、ただライブラリやツールの出力をそのまま使うのではなく、前処理・後処理が重要である、ということは覚えておいて損はないと思います。
そのほかPDFにまつわるエトセトラ
PDFのテキスト抽出に関しては、部首漢字以外にも様々なトラブルが発生する可能性があります。いくつか例を挙げてみましょう。
重複した文字が出てくる
太字や文字の影などの修飾を表現するため、同じ文字を同じ場所に重ねて描画するソフトウェアがあり、テキスト抽出しようとすると重複した文字が出てくることがあります。
Googleなどの検索エンジンで「"おおおお" "知知知知" "らららら" "せせせせ"」のようなクエリで検索すると、こういったPDFの具体例を見つけることができます。
これも、最終成果物が印刷物であれば問題なかったのですが、仮想プリンタとしてのPDFが普及したことで、顕在化した問題です。
時々SNSで、「落ち着け」などと、ネタにされたりするやつですね。
文字列がバラバラになる
PDFはGIDの羅列の合間に数字を挟むことで、文字間隔を調整できるようになっています。
スペース文字なんかも、この数字で調整されていることがあります。
が、この調整方法では調整しきれないような文字の移動や、配置の変更をすると、元との文字列がバラバラになってしまうことがあります。
デザイン重視で文字単位で位置を調整しているようなPDFでよく見られる問題です。
そもそもテキスト情報が存在しない
前述のとおりPDFはフォントに依存した表現でテキストを保持するため、表示する側の環境で当該のフォントがないと字形を表示することができません。
フォントを埋め込む、という手段もありますが、フォントのライセンスによっては埋め込みが禁止されていることもありますし、そもそも埋め込み自体をサポートしていないフォントもあります。
そこで出てくるのが、アウトライン化(文字を表示したときの座標データに変換すること)ですが、アウトライン化してしまうと、もはやテキスト情報は存在しないため、テキスト抽出は不可能になります。
OCRを使うしかなくなりますが、OCRの精度次第では誤認識が発生することもありますし、処理コストも高くなります。
あとは、まぁ、同じような話で手書きした文書をスキャンしたPDFなんかもありますね。こちらはもうOCRを使うしか手はないわけですが、手書きなのでより難しくなりますね。
おわりに
PDFは非常に便利なフォーマットであり、印刷物や画面表示だけでなく、デジタルデータとしても扱えるため、広く普及しています。
昨今のRAGをはじめとした、社内文書活用のニーズの高まりにより、PDFからテキストを抽出するニーズも高まっています。
当然、様々なプラットフォームで、PDFのテキスト抽出を行うライブラリやツールも提供されています。
しかしながら、PDFの仕様上、フォントとGIDの関係に起因する文字化けなど、様々なトラブルが発生する可能性があります。
こと、部首漢字といった問題は漢字圏特有の問題であり、他言語圏で作られたツールなどでは見落とされがちな問題でもあります。
創業から全文検索エンジンの開発・提供を行ってきたレトリバでは、蓄積してきた自然言語処理のノウハウを生かし、AI時代の文書活用をしっかりと支援していきたいと考えています!
Discussion