PDFの中の文章を、コピペできるテキストとして取り出したい——議事録、論文、マニュアル、契約書。でも「コピーしたら改行がぐちゃぐちゃ」「そもそも選択できない」「PDFをどこかにアップロードするのは不安」で止まりがちです。
この記事では、pdf.js を使って、PDFをアップロードせずブラウザの中だけで本文テキストを抽出する方法をまとめます。ポイントは、getTextContent() で取れる断片を どう行に組み直すか、そしてスキャンPDFは抽出できないという線引きです。
ゴール:PDFを選ぶだけで、本文をブラウザ内でテキスト化して、コピーや保存ができるようになること。
pdf.js とは
pdf.js は Mozilla が開発しているブラウザ向けのPDFレンダラーです。もともとはPDFを画面に描画するライブラリですが、**テキスト情報を取り出すAPI(getTextContent)**も持っています。
- ブラウザ完結:PDFをサーバーに送らず、端末の中だけで処理できる。
- 追加コスト小:すでにPDFを表示している画面なら、ライブラリを増やさず本文抽出も足せる。
- ただしテキストPDF限定:文字が「文字」として埋まっているPDFが対象。スキャン画像のPDFは対象外(後述)。
基本:getDocument → getTextContent
まずは最小構成です。ファイルを Uint8Array にして読み込み、ページごとに getTextContent() を呼びます。
// worker のパスを最初に指定(自前ホストの場合)
pdfjsLib.GlobalWorkerOptions.workerSrc = "assets/vendor/pdf.worker.min.js";
async function extractText(file) {
const bytes = new Uint8Array(await file.arrayBuffer());
const pdf = await pdfjsLib.getDocument({ data: bytes }).promise;
let all = "";
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const tc = await page.getTextContent();
// items[].str に文字の断片が入っている
all += tc.items.map((it) => it.str).join("") + "\n\n";
}
return all;
}
これで文字は取れます。……が、join("") でつなぐだけだと 改行がまったく再現されません。ここからが本題です。
つまずき①:worker のパス設定を忘れる
pdf.js は重い処理を Web Worker に逃がす設計で、workerSrc を指定しないと動きません。CDNを使えない環境(CSPが厳しいなど)では、pdf.worker.min.js を自分のサイトに置いてパスを通します。
pdfjsLib.GlobalWorkerOptions.workerSrc = "assets/vendor/pdf.worker.min.js";
ここを忘れると「worker が読み込めない」系のエラーで止まります。最初に一度だけ設定しておきます。
つまずき②:行がバラバラになる → y座標で組み直す
getTextContent() が返す items は、「文字の見た目の断片」の配列です。1行がいくつもの str に分かれていたり、逆に改行の情報が薄かったりします。そこで、各断片の**位置情報(transform)**を使って行を復元します。
it.transform は6要素の配列で、transform[5] がその断片のY座標です。前の断片とY座標が大きく変わったら「改行した」とみなします。新しめのpdf.jsなら it.hasEOL(行末フラグ)も使えます。
async function extractLines(page) {
const tc = await page.getTextContent();
const lines = [];
let cur = "";
let lastY = null;
for (const it of tc.items) {
if (!("str" in it)) continue;
const y = it.transform ? it.transform[5] : null;
// Y座標が一定以上ずれたら改行とみなす
if (lastY !== null && y !== null && Math.abs(y - lastY) > 2) {
lines.push(cur);
cur = "";
}
cur += it.str;
// 行末フラグがあればそこでも改行
if (it.hasEOL) {
lines.push(cur);
cur = "";
}
lastY = y;
}
if (cur) lines.push(cur);
return lines.join("\n");
}
Math.abs(y - lastY) > 2 の しきい値(2) は、文字サイズや行間で調整します。小さすぎると同じ行を割ってしまい、大きすぎると別の行がつながります。等幅・行間の広い文書なら少し大きめが安定します。
これで、コピペできる自然な改行のテキストに近づきます。
ページ区切りを入れる・大きなPDFを固まらせない
複数ページをまとめるときは、ページの境目に区切りを入れておくと後で扱いやすいです。また、ページ数が多いPDFは同期的に回すとUIが固まるので、1ページごとに制御を返します。
async function extractAll(file, withMarks = true) {
const bytes = new Uint8Array(await file.arrayBuffer());
const pdf = await pdfjsLib.getDocument({ data: bytes }).promise;
const parts = [];
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const text = await extractLines(page);
parts.push(withMarks ? `--- ページ ${i} ---\n${text}` : text);
// UIをブロックしないよう一瞬だけ制御を返す
await new Promise((r) => setTimeout(r, 0));
}
return parts.join("\n\n");
}
スキャンPDFは抽出できない(ここが重要)
一番ハマりやすいのがこれです。「文字が画像として載っているPDF」(スキャンや写真から作ったPDF)は、getTextContent() では何も取れません。 文字データが無いからです。
抽出結果が空だったら、それを検知してユーザーに知らせるのが親切です。
const all = await extractAll(file);
const body = all.replace(/---[^\n]*---/g, "").trim(); // ページ区切りを除く
if (body.length === 0) {
// 中身が無い=スキャン/画像PDFの可能性が高い
alert("このPDFからは文字を抽出できませんでした。画像PDFの可能性があります(OCRが必要です)。");
}
この場合は本文抽出ではなく OCR(画像から文字認識) の出番です。用途がはっきり分かれるので、「テキストPDF=pdf.js」「画像PDF=OCR」と切り分けておくと迷いません。
つまずきやすいところ
-
パスワード付きPDF。 暗号化されていると
getDocumentで例外になります。エラーを拾って「先にパスワードを解除してください」と案内すると親切です(pdf.jsはpasswordオプションでの復号にも対応)。 - 段組み(2カラム)。 Y座標ベースの行復元は、左右の段が同じ高さにあると混ざりがちです。厳密にやるならX座標でカラム分けが要りますが、まずは単純な単カラム前提で十分実用になります。
-
表。 表はセルが別々の
strとして飛んでくるので、行・列の再構築は別途工夫が必要です。「本文の抽出」までと割り切るのが現実的。 -
strを持たない item。 稀にstrが無い要素が混ざるのでif (!("str" in it)) continue;で弾いておくと安全です。
まとめ
- pdf.js の
getTextContent()で、PDFの本文をブラウザ内だけで抽出できる(アップロード不要) - 最初に
workerSrcの設定を忘れない -
itemsはただの断片なので、transform[5](Y座標)やhasEOLで行を組み直すのがコツ - ページ区切りの挿入と、
setTimeout(r, 0)でのUIブロック回避 - スキャン/画像PDFは抽出できないので、空判定してOCRへ誘導する
- パスワード・段組み・表は個別対応が要る(本文抽出までで割り切ると楽)
「PDFの文章を、安全にサッとテキスト化したい」に、pdf.js × ブラウザ完結はよく効きます。参考になれば嬉しいです。