pdf.js でPDFの文字とリンクを位置つきで取り出すには、page.getTextContent() の各要素の transform、page.getAnnotations() の Link 注釈の rect を、どちらも viewport.convertToViewportPoint() で画面の座標に直してから、ページ幅・高さで割って 0〜1 の割合にする のが扱いやすいやり方です。割合にしておけば、ページ画像をどの大きさで表示しても、文字の位置やクリック範囲をそのまま重ねられます。
この記事では、Node.js で動く1ファイルのスクリプトとして、次の3つを実装します。
- 文字のかたまり(テキスト)と、その位置の箱
- PDFにもともと入っているリンク(URL・メール・電話・ページ内移動)と、そのクリック範囲
- 文字が1つも取れないページ(画像だけのページ)の判定
この処理が必要になった経緯
PDFを読み込んでページめくりのWEBカタログを作るツール(Web Catalog Studio)で、公開画面のテキスト検索と「PDFにあったリンクの再利用」を作るためにこの処理を書きました。
最初の版では pdfjs.getDocument({ data }) だけで読み込んでいて、日本語フォントを埋め込んでいないPDFを確認用に作って読ませたところ、文字の取り出しに問題が出ました。手元で再現すると、CMap(文字コードと文字の対応表)を渡さない場合、8つあるテキストのかたまりのうち取り出せたのは英字の1つだけ で、日本語の7つはすべて消えます。そこで CMap などの補助データを渡す形に直しました。この記事のコードは、その修正後の形を元にしています。
環境
| 項目 | バージョン |
|---|---|
| pdfjs-dist | 6.4.299 |
| Node.js | v23.9.0 |
| 実行環境 | macOS(Apple M3) |
npm init -y
npm i pdfjs-dist@6.4.299
Node.js では pdfjs-dist/legacy/build/pdf.mjs を読み込みます。ブラウザで使う場合は pdfjs-dist 本体を読み込み、GlobalWorkerOptions で worker を指定してください(この記事の関数自体はブラウザでもそのまま使えます)。
PDFの座標と画面の座標の違い
最初に押さえておくのは、PDFの座標は左下が原点で、y は上に向かって増える(単位はポイント)という点です。画面(canvas や HTML)は左上が原点で、y は下に向かって増えます。
確認用に、PyMuPDF で「上から230〜900ポイント」の範囲にリンクを置いた 800×1200 ポイントのページを作り、pdf.js で rect を読むとこうなります。
// getAnnotations() で返ってきた Link 注釈の rect([左, 下, 右, 上])
[60, 300, 740, 970]
// 1200 - 900 = 300、1200 - 230 = 970 = y が上下ひっくり返っている
この反転を自分で計算してもよいのですが、page.getViewport({ scale: 1 }) で作った viewport の convertToViewportPoint(x, y) を使えば、ページの回転(/Rotate)も含めて画面の座標に直してくれます。あとは viewport の幅・高さで割れば 0〜1 の割合になります。
const clamp01 = (v) => Math.min(1, Math.max(0, v));
const round4 = (v) => Math.round(v * 10000) / 10000;
/** PDF座標の点の集まり → ページに対する割合(0〜1)の箱 */
function toBox(viewport, points) {
const vps = points.map(([x, y]) => viewport.convertToViewportPoint(x, y));
const xs = vps.map((p) => p[0] / viewport.width);
const ys = vps.map((p) => p[1] / viewport.height);
const x = clamp01(Math.min(...xs));
const y = clamp01(Math.min(...ys));
return { x: round4(x), y: round4(y), w: round4(clamp01(Math.max(...xs)) - x), h: round4(clamp01(Math.max(...ys)) - y) };
}
点を複数受け取って最小・最大を取るのは、回転したページでは「左下と右上」が画面上で左下・右上にならないためです。どの向きでも、変換後の点を囲む箱を作れば正しい範囲になります。
テキストを位置つきで取り出す(getTextContent)
getTextContent() の items は、1つ1つが「同じ書式で続けて描かれた文字のかたまり」です。位置は transform(6つの数の行列 [a, b, c, d, e, f])で表されます。
-
e, f… かたまりの書き始めの点(PDF座標) -
a, b… 文字が進む向き(フォントの大きさを含む) -
c, d… 文字の高さの向き -
width, height… かたまりの幅と高さ(PDFの単位)
この情報から四隅の点を作り、先ほどの toBox で箱にします。
async function extractText(page) {
const viewport = page.getViewport({ scale: 1 });
const { items, styles } = await page.getTextContent();
const result = [];
for (const item of items) {
const str = item.str?.trim() ?? "";
if (str === "" || !item.transform || !item.width) continue; // 空・幅0は捨てる
const [a, b, c, d, e, f] = item.transform;
const ul = Math.hypot(a, b) || 1; // 文字の進む向きの長さ
const vl = Math.hypot(c, d) || 1; // 文字の高さ方向の長さ
const w = item.width;
const h = item.height || vl;
// 横書きは書き始めから右へ w・上へ h。縦書きは書き始めを上端の中央として、左右に w/2・下へ h
const vertical = styles[item.fontName]?.vertical === true;
const [u0, u1, v0, v1] = vertical ? [-w / 2, w / 2, -h, 0] : [0, w, 0, h];
const corners = [
[u0, v0],
[u1, v0],
[u0, v1],
[u1, v1],
].map(([u, v]) => [e + (a / ul) * u + (c / vl) * v, f + (b / ul) * u + (d / vl) * v]);
result.push({ str, ...toBox(viewport, corners) });
}
return result;
}
a, b と c, d を長さで割って「向きだけ」にしているのは、width・height がすでにフォントの大きさを含んだ値だからです。そのまま掛けると大きさを二重に掛けてしまいます。
縦書きは書き始めの位置が違う
縦書きのかたまりは、getTextContent() の戻り値の styles[item.fontName].vertical が true になります。このとき値の意味が横書きと変わります。
-
width… 字の幅(確認用PDFでは 28) -
height… 縦に進んだ長さ(同じく 196) -
e, f… 書き始めの点で、かたまりの上端の中央
横書きと同じく「書き始めから右へ width・上へ height」で箱を作ると、文字より上に箱ができてしまいます。最初はこの形で書いていて、確認用PDFの「縦書きの見出し」の箱が y: 0.1971 と文字の上に出ていました。上のコードのように縦書きだけ「左右に width / 2・下へ height」で四隅を作ると、y: 0.4299・h: 0.2328 になり、描画した文字にぴったり重なります。
items には空のかたまりが混ざる
確認用PDFの1行「ゴシック:新商品カタログ 2026年秋冬」(間に全角スペース)を読むと、items は次のように分かれて返ってきました。
"" // 幅0・高さ0
"ゴシック:新商品カタログ"
" " // 全角スペースの部分(高さ0)
"2026年秋冬"
PDF全体では items が15個あり、そのうち空文字と空白だけのものが7個でした。空のかたまりを捨てないと、検索結果に「何も書かれていない箱」が出てきます。 上のコードで trim() して空なら飛ばしているのはこのためです。逆に、1行が複数のかたまりに分かれることがあるので、「行の中の言葉」で検索したい場合は、同じページの str をつなげた文字列も別に持っておくと取りこぼしが減ります。
日本語は CMap を渡さないと消える
フォントを埋め込んでいない日本語PDF(Word などで「フォントを埋め込まない」で書き出したもの)は、PDFの中に「文字コード → 文字」の対応表がありません。pdf.js は cMapUrl から CMap を読んで対応を取るため、渡さないと日本語の文字が取り出せません。
const doc = await pdfjs.getDocument({
data,
cMapUrl: PDFJS + "cmaps/", // 日本語などの文字の対応表
cMapPacked: true,
standardFontDataUrl: PDFJS + "standard_fonts/", // 埋め込まれていない標準フォント
}).promise;
確認用PDF(横書きのUTF-16・Shift_JIS、縦書き、英字の Helvetica が混ざった1ページ)での結果です。
| 読み込み方 | 取り出せたテキストのかたまり |
|---|---|
| CMap を渡す | 8個(英字1・日本語7) |
| CMap を渡さない | 1個(英字の Helvetica だけ) |
ブラウザで file:// から開くページなど、cMapUrl を取りに行けない環境では、getDocument の BinaryDataFactory に「補助データを返すクラス」を渡し、ファイルを取りに行く代わりに手元のデータを返す方法もあります。
既存リンクを取り出す(getAnnotations)
PDFのリンクは「注釈(annotation)」として入っています。getAnnotations() で取り出し、subtype === "Link" のものだけを使います。Link 注釈には主に2種類あります。
| 種類 | 入っている値 | 例 |
|---|---|---|
| 外部へのリンク | url |
https://…、mailto:…、tel:…
|
| PDF内の移動 | dest |
名前(文字列)か、行き先の配列 |
dest は形が2通りあるので、ページ番号(1始まり)に直す関数を用意します。
async function destToPage(doc, dest) {
// 名前つきの行き先は getDestination で配列に直す
const explicit = typeof dest === "string" ? await doc.getDestination(dest) : dest;
if (!Array.isArray(explicit) || explicit.length === 0) return null;
const target = explicit[0];
// 先頭がページ番号(0始まり)の数値のこともある
if (Number.isInteger(target)) return target + 1;
try {
// 多くはページへの参照 { num, gen } なので getPageIndex で番号にする
return (await doc.getPageIndex(target)) + 1;
} catch {
return null;
}
}
確認用PDFの「6ページへ移動」リンクの dest は [{ num: 26, gen: 0 }, …] という参照の形で入っていて、getPageIndex で 5(0始まり)が返りました。
url は種類ごとに分けておきます。ftp: や javascript: などは使わないので捨てます。
function classifyUrl(url) {
if (/^mailto:/i.test(url)) return { type: "mail", email: decodeURIComponent(url.slice(7).split("?")[0]) };
if (/^tel:/i.test(url)) return { type: "tel", tel: decodeURIComponent(url.slice(4)) };
if (/^https?:\/\//i.test(url)) return { type: "url", url };
return null; // それ以外は扱わない
}
async function extractLinks(doc, page) {
const viewport = page.getViewport({ scale: 1 });
const annotations = await page.getAnnotations();
const links = await Promise.all(
annotations
.filter((a) => a.subtype === "Link" && Array.isArray(a.rect))
.map(async (a) => {
const [x1, y1, x2, y2] = a.rect;
const box = toBox(viewport, [[x1, y1], [x2, y2]]);
if (box.w === 0 || box.h === 0) return null; // 大きさのないリンクは押せない
if (a.url) {
const kind = classifyUrl(a.url);
return kind && { ...box, ...kind };
}
if (a.dest === undefined) return null;
const target = await destToPage(doc, a.dest);
return target === null || target > doc.numPages ? null : { ...box, type: "page", page: target };
}),
);
return links.filter(Boolean);
}
取り出したリンクは「候補」として扱う
自分のツールでは、取り出したリンクを自動では入れず、画面に候補として並べ、ユーザーが選んで追加する(1件ずつ、またはまとめて)形にしました。上の関数も「候補の一覧」を返すところまでにしてあり、採用するかどうかは呼び出し側で決めます。こうしておくと、自動で入れる使い方にも、選ばせる使い方にも同じ関数を使えます。
画像だけのページを判定する
スキャンしたPDFや、画像を貼り込んだだけのPDFは、getTextContent() の結果が空になります。文字が1つも取れなかったページには印を付けておき、検索用の文字は手で入れてもらう 形にしました(OCR はしていません)。
const text = await extractText(page);
const needsManualText = text.length === 0; // 画像だけのページ
なお、リンク注釈はページが画像かどうかに関係なく入れられるので、画像だけのページでもリンクは取れます。確認用PDFでも、テキスト0件のページからリンクが取り出せています。
まとめて動かす
ここまでの関数をまとめ、PDFのページごとに結果を出力します。上の関数(clamp01・round4・toBox・extractText・destToPage・classifyUrl・extractLinks)を同じファイルに置き、次を足してください。
// PDFの各ページから文字の位置と既存リンクを取り出す
import { readFile } from "node:fs/promises";
const PDFJS = new URL("./node_modules/pdfjs-dist/", import.meta.url).pathname;
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
// …ここに上の関数を置く…
const file = process.argv[2];
const doc = await pdfjs.getDocument({
data: new Uint8Array(await readFile(file)),
cMapUrl: PDFJS + "cmaps/",
cMapPacked: true,
standardFontDataUrl: PDFJS + "standard_fonts/",
}).promise;
for (let no = 1; no <= doc.numPages; no++) {
const page = await doc.getPage(no);
const text = await extractText(page);
const links = await extractLinks(doc, page);
console.log(JSON.stringify({ page: no, textItems: text.length, needsManualText: text.length === 0, text: text.slice(0, 2), links }));
page.cleanup(); // ページごとのメモリを手放す
}
node extract.mjs sample.pdf
実行結果
リンク入り・画像だけのPDF(7ページ)
画像を1ページずつ置き、2・3ページ目に計4件のリンク(URL 2件・メール1件・6ページへの移動1件)を入れたPDFです。3ページ目の出力は次のとおりです。
{"page":3,"textItems":0,"needsManualText":true,"text":[],"links":[
{"x":0.075,"y":0.8417,"w":0.4125,"h":0.1333,"type":"page","page":6},
{"x":0.5125,"y":0.8417,"w":0.4125,"h":0.1333,"type":"mail","email":"info@example.com"}
]}
作るときに置いた範囲(上から1010〜1170ポイント、ページの高さ1200)を割合にすると 0.8417〜0.975 で、出力の y: 0.8417・h: 0.1333 と一致しています。入れた4件はすべて取り出せ、全7ページで「文字なし(手入力が必要)」と判定されました。
日本語フォントを埋め込まないPDF(1ページ)
{"page":1,"textItems":8,"needsManualText":false,"text":[
{"str":"Helvetica: Autumn Catalog 2026","x":0.0807,"y":0.0523,"w":0.6748,"h":0.0333},
{"str":"ゴシック:新商品カタログ","x":0.0807,"y":0.1401,"w":0.484,"h":0.0285}
]}
処理時間(実測)
Apple M3・Node.js v23.9.0 で、それぞれ5回測った値です(getDocument の完了までと、全ページの getTextContent と getAnnotations の合計)。
| ページ数 | ファイルサイズ | 読み込み | 文字とリンクの取り出し(全ページ) | 結果 | |
|---|---|---|---|---|---|
| リンク入り・画像だけ | 7 | 約1.3MB | 約29〜31ms | 約7〜10ms | テキスト0件・リンク4件 |
| 日本語フォント非埋め込み | 1 | 約3KB | 約28〜29ms | 約40〜45ms | テキスト8件・リンク0件 |
1ページしかない日本語PDFの方が遅いのは、CMap の読み込みが入るためです。同じPDFを CMap なしで読むと取り出しは約14〜16ms(3回)で、差の約25〜30msが CMap の分です(ただし前述のとおり、CMap なしでは日本語が取り出せません)。ページを画像にする処理(canvas への描画)は含んでいないので、実際のアプリではこれに描画の時間が加わります。
まとめ
- 位置はすべて
viewport.convertToViewportPoint()で画面の座標に直し、ページの幅・高さで割って 0〜1 で持つ。y の反転とページの回転をまとめて吸収できる -
getTextContent()はtransformとwidth・heightから四隅を作って箱にする。縦書き(styles[fontName].vertical)は書き始めが上端の中央になるので作り方を変える。空のかたまりは捨てる - 日本語の非埋め込みフォントは
cMapUrlを渡さないと文字が消える - リンクは
getAnnotations()の Link 注釈。urlは種類で分け、destはgetDestinationとgetPageIndexでページ番号にする - 文字が0件のページは画像だけのページとして印を付ける。リンクは画像ページからも取れる
取り出した箱を、ページ画像の上に position: absolute と % 指定で重ねればクリック範囲になります。% 指定でクリック範囲を置く方法は「レスポンシブ対応のクリッカブルエリアジェネレーターをNext.jsで作った話」でも解説しています。
SEOスコアチェックツール: Direbase(ディレベース) — RINIAディレクターツール。
Web制作・SEO関連の技術情報サイト: CodeQuest.work