何を確かめたか
PDFやWord、Webページを「AIに読ませて要約・採点させる」前処理では、ファイルからテキストを抽出します。このとき、人の目には見えない文字(白文字、極小文字、display:none など)が抽出結果に残るかどうかは、使うライブラリで変わります。
残れば、文書に埋め込まれた「隠し指示」がそのままプロンプトに入ります(間接プロンプトインジェクション。OWASP Top 10 for LLM Applications 2025 の LLM01)。残らなければ、その経路では入りません。どちらになるかは、ライブラリの仕組みを知らないと予想しにくいので、同じファイルを10通りの方法で抽出して比べました。
この記事は抽出方法ごとの挙動の違いにしぼった技術メモです。測った条件と結果の要約を先に、再現用のコードを後ろに置きます。
測った条件
| 項目 | 内容 |
|---|---|
| ファイル | 架空の提案書。PDF 10、HTML 17、Word(DOCX)9 の計36。見えない文字あり30、なし(対照)6 |
| PDFの抽出 | pypdf 6.11.0 / PyMuPDF 1.27.2 / poppler の pdftotext |
| HTMLの抽出 | BeautifulSoup 4.12.3 の get_text() / markdownify / macOS textutil / Chrome の document.body.innerText
|
| DOCXの抽出 | python-docx 1.2.0 / macOS textutil / word/document.xml の全文 |
| 判定 | 抽出したテキストに、埋め込んだ文が含まれていれば「残る」 |
| 環境 | 2026-10-09、macOS、Python 3.13。空のフォルダで2回実行し、結果ファイルがバイト単位で一致 |
結果の要約: ライブラリ別に「何が残るか」
| 隠し方 | pypdf | PyMuPDF | pdftotext |
|---|---|---|---|
| 白文字、ほぼ白(灰96%)、極小文字(0.5pt)、白い四角で覆う、透明テキスト(描画モード3) | 残る | 残る | 残る |
| ページ外に配置 | 残る | 残らない | 残らない |
| 文書プロパティ(件名・キーワード)、注釈 | 残らない | 残らない | 残らない |
PDFの抽出は「どこにどの文字があるか」を読む仕組みで、色・大きさ・重なり・透明度は見ていません。ページ外の文字だけは、ページ境界で切るかどうかの差が出ました。
HTML
| 隠し方 | BeautifulSoup | markdownify | textutil | Chrome innerText |
|---|---|---|---|---|
| font-size:0、白文字、画面外(left:-9999px)、opacity:0、不可視Unicodeタグ文字、ほぼ白(#fafafa)、height:0+overflow:hidden、transform:scale(0) | 残る | 残る | 残る | 残る |
display:none、<style> のクラスで非表示 |
残る | 残る | 残らない | 残らない |
| visibility:hidden | 残る | 残る | 残る | 残らない |
| 画像の alt 属性 | 残らない | 残る | 残らない | 残らない |
| HTMLコメント、title属性、meta description | 残らない | 残らない | 残らない | 残らない |
ポイントは2つです。
- CSSを解釈しない抽出(BeautifulSoup、markdownify)は、display:none も拾う。 HTMLをそのままテキスト化してRAGに取り込む構成では、非表示要素も入ります。
-
ブラウザの
innerTextでも、font-size:0 や opacity:0 や画面外の文字は残る。innerTextは「レイアウト上そこにある文字」を返すだけで、人に見えるかは判定しません。除外されたのは display:none と visibility:hidden だけでした。「画面で見て問題なかった」は、AIに渡る文字が安全という意味になりません。
DOCX
| 隠し方 | python-docx | textutil | XML全文 |
|---|---|---|---|
| 白文字、ほぼ白(F5F5F5)、極小文字(1pt)、Wordの「隠し文字」属性、テーマ色「背景1(白)」 | 残る | 残る | 残る |
| コメント、文書プロパティ | 残らない | 残らない | 残る |
Wordの「隠し文字」属性(w:vanish)は、Word上では表示をオフにすると消えますが、python-docx の paragraph.text も textutil も本文として返しました。
対照ファイル
「濃い帯の上の白文字」「7ptの注記」「閉じたメニュー(display:none)」のように、正当な理由で隠れている要素もあります。見えない文字=悪意、ではないので、検出は「警告」にとどめ、最終判断は人が行う前提にしました。
再現用コード
抽出(10通り)
import subprocess, zipfile, re
from pypdf import PdfReader
import fitz # PyMuPDF
from bs4 import BeautifulSoup
from markdownify import markdownify as md
from docx import Document
def pdf_pypdf(path):
return "\n".join((p.extract_text() or "") for p in PdfReader(path).pages)
def pdf_pymupdf(path):
with fitz.open(path) as doc:
return "\n".join(page.get_text() for page in doc)
def pdf_pdftotext(path):
return subprocess.run(["pdftotext", "-layout", path, "-"],
capture_output=True, text=True, check=True).stdout
def html_bs4(path):
return BeautifulSoup(open(path, encoding="utf-8").read(), "html.parser").get_text("\n")
def html_markdownify(path):
return md(open(path, encoding="utf-8").read())
def textutil_txt(path): # macOS。HTMLとDOCXの両方に使える
return subprocess.run(["textutil", "-convert", "txt", "-stdout", path],
capture_output=True, text=True, check=True).stdout
def docx_python_docx(path):
return "\n".join(p.text for p in Document(path).paragraphs)
def docx_xml(path):
xml = zipfile.ZipFile(path).read("word/document.xml").decode("utf-8")
return re.sub(r"<[^>]+>", " ", xml)
Chrome の innerText は Playwright で取りました。
from playwright.sync_api import sync_playwright
def html_innertext(path):
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto(f"file://{path}")
text = page.evaluate("document.body.innerText")
browser.close()
return text
テストファイルの作り方(例)
HTML は属性やスタイルを1つ変えるだけです。
<p>通常の本文です。</p>
<p style="font-size:0">ここに見えない文が入る</p>
<p style="color:#fff;background:#fff">白文字の例</p>
<p style="position:absolute;left:-9999px">画面外の例</p>
DOCX は python-docx で「隠し文字」や白文字を作れます。
from docx import Document
from docx.shared import RGBColor, Pt
doc = Document()
doc.add_paragraph("通常の本文です。")
run = doc.add_paragraph().add_run("隠し文字属性の例")
run.font.hidden = True # Wordの「隠し文字」
run2 = doc.add_paragraph().add_run("白文字の例")
run2.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
run3 = doc.add_paragraph().add_run("極小文字の例")
run3.font.size = Pt(1)
doc.save("case.docx")
PDF は reportlab で白文字を置く例です。
from reportlab.pdfgen import canvas
c = canvas.Canvas("case.pdf")
c.drawString(72, 750, "通常の本文です。")
c.setFillColorRGB(1, 1, 1) # 白
c.drawString(72, 700, "白文字の例")
c.save()
判定は、埋め込んだ文がそのまま抽出結果に含まれるかを in で見るだけにし、空白・改行の差で落ちないよう両方から空白類を除いてから比較しました。
実務でのポイント
- 自分のパイプラインが実際に使う抽出方法で測る。 同じPDFでも pypdf と pdftotext で結果が違いました。
- 抽出の前後で、人に見えにくい文字を機械的に洗い出す工程を入れる。 色・サイズ・透明度・位置・隠し文字属性を見る。本文抽出ライブラリは、この判定を代わりにやってくれません。
- 検出は警告にとどめる。 対照ファイルのように正当な理由で隠れている要素があるため、自動で捨てずに人が見る。
限界
- 測ったのは「抽出したテキストに残るか」まで。AIがその指示に従うかは測っていません。 各AIサービスが内部でどの抽出方法を使っているかも未確認です。
- ファイルは架空で、1つの隠し方につき1ファイル。実際の文書での発生頻度は分かりません。
- 画像内の文字、スキャンPDF、Excel・PowerPoint、JavaScriptで後から書き換えるページは対象外です。
参考
- OWASP Top 10 for LLM Applications 2025, LLM01: Prompt Injection
- Nikkei Asia, 2025-07-01: 'Positive review only': Researchers hide AI prompts in papers
30の隠し方×10の抽出方法の結果全体の要約は、Zennの記事(無料)にまとめています: https://zenn.dev/kagaage/articles/7f670b3a59a94d