1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

pdf.jsでPDFの文字と既存リンクを位置つきで取り出す|getTextContent・getAnnotationsをviewport座標に変換

1
Last updated at Posted at 2026-10-26

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)を同じファイルに置き、次を足してください。

extract.mjs
// 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 の合計)。

PDF ページ数 ファイルサイズ 読み込み 文字とリンクの取り出し(全ページ) 結果
リンク入り・画像だけ 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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?