0
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 をデモから深掘り 3: getPage メソッドの全体像

0
Posted at

image.png

はじめに

第 1 記事のデモでは、await loadingTask.promise によって pdf 変数を取得しました — この promise は worker が GetDoc メッセージを送り返した後、メインスレッドの GetDoc ハンドラによって resolve されます。詳細は第 13 記事を参照 — これは PDFDocumentProxy のインスタンスです。

第 1 記事のデモに戻り、8〜9 行目:

const page = await pdf.getPage(1);

getPage 呼び出し

今回の呼び出しで渡す数値引数は 1 つだけです:

パラメータ 値 意味
pageNumber 1 ページ番号。最初のページは 1 (1-based)

getPage は Promise を返し、PDFPageProxy オブジェクトに resolve します。本記事ではこの呼び出しから出発し、getPage の完全な実行チェーンを追いかけます。

api.js における PDFDocumentProxy の定義に基づくと、コードの実行フローは次のとおりです。

773〜775 行目: PDFDocumentProxy.getPage: 1 行の委譲

getPage(pageNumber) {
  return this._transport.getPage(pageNumber);
}

pdf オブジェクト自体は単なるファサードで、実際のロジックはすべて this._transport — つまり第 2 記事で作成された WorkerTransport インスタンス (task._transport) にあります。

以下のフローはすべて WorkerTransport.getPage の中で行われます。

3031〜3037 行目: パラメータの検証

if (!Number.isInteger(pageNumber) || pageNumber <= 0 || pageNumber > this.pagesMapper.pagesNumber) {
  return Promise.reject(new Error('Invalid page request.'));
}

3 つの条件にデモの値を順に代入します:

条件 pageNumber = 1 を代入 結果
!Number.isInteger(pageNumber) Number.isInteger(1) は true、否定して false 発火しない
pageNumber <= 0 1 <= 0 は false 発火しない
pageNumber > this.pagesMapper.pagesNumber pagesNumber は 756、1 > 756 は false 発火しない

pagesNumber は 756 で、ドキュメントの準備ができたときに pdfInfo.numPages から書き込まれます: ソースを見る. 本記事のデモ PDF は全 756 ページで、検証を通過します。0、負数、小数、またはページ数を超える値を渡すと、ここで直接 rejected promise が返されます。

Q: なぜ throw ではなく Promise.reject を使うのか?
A: 非同期のセマンティクスを一貫させるためです。getPage の呼び出し側は常に promise を await するため、引数エラーも同じエラーチャネルを通り、呼び出し側は同期の throw と非同期の reject の両方を処理する必要がありません。

3038〜3039 行目: ページ番号の換算

const pageIndex = pageNumber - 1; // 1 - 1 = 0
const newPageIndex = this.pagesMapper.getPageId(pageNumber) - 1; // 1 - 1 = 0

この 2 行で 3 系統のページ番号が生まれ、それぞれ異なる対象に使われます:

変数 視点 基数 本例の値 用途
pageNumber ユーザー 1-based 1 公開の getPage 引数
pageIndex メインスレッド 0-based 0 #pageCache / #pagePromises のキー
newPageIndex worker (元のドキュメント) 0-based 0 GetPage メッセージのペイロード

pagesMapper.getPageId(1) は 1 を返します。デモではページの移動/削除/コピーを行っていないため、マッピングは恒等 (#pageNumberToId は null) であり、newPageIndex === pageIndex === 0 となります。ドキュメントが編集された場合にのみ、3 つは分岐します。

PagesMapper: ユーザーのページ番号と元のページ順の間で翻訳する

PagesMapper は第 2 記事のパラメータ解析時に new PagesMapper() で作成されました: ソースを見る. 外部に公開しているのは 2 つだけです:

  • pagesNumber: 現在の総ページ数。一度代入されると、内部の #pageNumberToId / #prevPageNumbers は null にリセットされ、恒等マッピングに戻ります;
  • getPageId(pageNumber) / getPageNumber(pageId): 双方向の翻訳 (L462-L464, L443-L454). 恒等マッピングでは入力をそのまま返し、実際にページの並べ替えが起きたときだけテーブルを構築します。

Q: なぜ PagesMapper を介する必要があるのか?

A: ページの並べ替えをサポートするためです。エディタのシナリオでは、ユーザーはページを移動、削除、コピーでき、その後の「3 ページ目」は元のドキュメントの 3 ページ目とは限りません。worker 側は元のドキュメントのページ順しか認識しないため、worker に送るのは newPageIndex です。一方、メインスレッドの 2 つのキャッシュはユーザー視点の pageIndex をキーにします。恒等マッピングでは両者は同じで、無視できます。注意: PagesMapper は PDF ファイルとは関係ありません。PDF ファイル内にこのデータは存在せず、純粋に編集時に状態を保持するためのものです

一言で区別すると: pageIndex はメインスレッドのキャッシュのキー、newPageIndex は worker に送るキーです。

3041〜3044 行目: キャッシュの問い合わせ

const cachedPromise = this.#pagePromises.get(0); // pageIndex = 0
if (cachedPromise) {
  return cachedPromise;
}

#pagePromises は Map<pageIndex, Promise<PDFPageProxy>> です。初回の呼び出しではヒットせず、先に進みます。

Q: キャッシュの効果は?

A: 実際に await pdf.getPage(1) を 2 回連続で呼ぶと、返されるのは同じ PDFPageProxy オブジェクトです (page === page2 は true)。2 回目の呼び出しはキャッシュされた promise に直接ヒットするため、worker にメッセージを送りません。

3 層のキャッシュ: それぞれ別のものを保存し、それぞれの役割を担う

WorkerTransport はクラスフィールドで 3 つの Map を一度に宣言しています: ソースを見る

#pageCache = new Map();
#pagePromises = new Map();
#pageRefCache = new Map();
フィールド キー → 値 責務 主な使用箇所
#pageCache pageIndex → PDFPageProxy 作成済みページオブジェクトのインデックス StartRenderPage イベントがインデックスでオブジェクトを取得 (api.js#L2805)、startCleanup が走査 (api.js#L3227)
#pagePromises pageIndex → Promise<PDFPageProxy> in-flight リクエストと重複呼び出しの重複排除 getPage の入口で読み取り (本記事の 3041 行目)、出口で書き込み (3067 行目)
#pageRefCache refStr → pageIndex オブジェクト参照からページ番号への逆引き getPage が書き込み (3054 行目)、cachedPageNumber が読み取り (api.js#L3245-L3255)

3 つはライフサイクルが同じで、transport と一緒に破棄されます: destroy() が順番に 3 つのテーブルを clear() します (api.js#L2611-L2616).

Q: #pageCache にすでにページオブジェクトがあるのに、なぜ #pagePromises が必要なのか?

A: 2 つは異なる問題を解決します。#pagePromises はメッセージが送信される前に書き込まれ、「同時に到着した複数の getPage が GetPage メッセージを 1 つだけ送る」ことを担います。#pageCache は応答が到着してから書き込まれ、transport 内部のイベントがページインデックスでオブジェクトを取得するために使われます。page ではなく promise をキャッシュするのは、まさに応答が到着するまでの時間帯をカバーするためです。

3045〜3048 行目: worker へ GetPage メッセージを送信

const promise = this.messageHandler
  .sendWithPromise('GetPage', {
    pageIndex: newPageIndex, // 0
  })
  .then(pageInfo => {
    // 応答の処理は次節で
  });

messageHandler は第 2 記事で作成された MessageHandler で、sendWithPromise の通信メカニズムは第 6 記事で展開しています。ここでは、メッセージを worker スレッドに送り、worker の応答を待つ promise を返す、とだけ知っておけば十分です。

worker 側が GetPage を受け取ったとき: ソースを見る

handler.on('GetPage', async function ({ pageIndex }) {
  const page = await pdfManager.getPage(pageIndex);
  const [rotate, ref, userUnit, view] = await Promise.all([
    pdfManager.ensure(page, 'rotate'),
    pdfManager.ensure(page, 'ref'),
    pdfManager.ensure(page, 'userUnit'),
    pdfManager.ensure(page, 'view'),
  ]);
  return {
    rotate,
    ref,
    refStr: ref?.toString() ?? null,
    userUnit,
    view,
  };
});

worker 側の pdfManager.getPage によるページ解析は第 14 記事で展開します。デモの 1 ページ目の応答は次のとおりです:

pageInfo = {
  rotate: 0,
  ref: { num: 4, gen: 0 }, // スレッド間の構造化複製の後、Ref インスタンスは普通のオブジェクトに退化
  refStr: '4R', // worker 側での ref.toString() の結果
  userUnit: 1,
  view: [0, 0, 595, 842],
};
フィールド 本例の値 意味 対応する PDFPageProxy の getter
rotate 0 時計回りの回転角度 (度) rotate
ref { num: 4, gen: 0 } オブジェクト参照 (つまり PDF ファイル内での、このページの入口 Ref オブジェクト) ref
refStr "4R" 参照の文字列形式。キャッシュのキーとして使用 なし (内部使用のみ)
userUnit 1 ユーザー単位の換算係数 userUnit
view [0, 0, 595, 842] 可視領域 (PDF ユーザー空間) view

view の 595 × 842 はちょうど A4 用紙のサイズ (単位は 1/72 インチ) で、デモの PDF の仕様と一致しています。

Q: ref: { num: 4, gen: 0 } とは何か? なぜ Ref インスタンスではないのか?

A: これは PDF 仕様の間接参照 (Indirect Reference) です — PDF ファイル内のすべてのオブジェクトには一意の (num, gen) 番号があり、ページオブジェクトも例外ではありません。num = 4 は「4 番オブジェクト」を意味し、gen = 0 は世代番号です (新しい PDF では通常 0 で、増分更新された場合にのみ増加します)。

worker 側が持っていたのは本来、本物の Ref インスタンスです: ソースを見る で Ref.get(4, 0) によって作成され、内部に #str = '4R' がキャッシュされています。しかし、それはスレッドを越えられません: Web Worker の postMessage は構造化複製 (Structured Clone) を使い、列挙可能な固有プロパティしかコピーしません。Ref の #str はプライベートフィールドでコピーできず、クラスのインスタンスも plain object に退化し、public な num と gen の 2 つだけが残ります。だからメインスレッドが受け取るのは { num: 4, gen: 0 } です。

メインスレッドの PDFPageProxy.ref の getter (api.js#L1399) はこの plain object をそのまま返し、Ref インスタンスを再構築しません — メインスレッドは num / gen を読むだけで、それを worker に送り返すことはないからです。後続の GetPageIndex やエディタの SaveDocument などのメッセージで PDF のオブジェクトを参照する必要があるときは、この 2 つのフィールドから組み立て直します。

なぜ refStr を worker 側で事前に計算しておくのか? まさにスレッドを越えた後に Ref#toString() が失われるからです — メインスレッドが得るのは plain object で、toString を呼べません。そこで worker は return の中で先に refStr: ref?.toString() ?? null を計算し、一緒にメインスレッドに返します。2 つのフィールドで 1 組: ref は num / gen の分割が必要な呼び出し側へ、refStr は文字列キーが必要なキャッシュの参照へ。

Q: なぜ refStr は "4 0 R" ではなく "4R" なのか?

A: Ref#toString は内部にキャッシュされたコンパクトな文字列を返します。gen がゼロのときは numR、非ゼロのときは numRgen です: ソースを見る. 逆引き側の cachedPageNumber も ${ref.num}R でキーを組み立てるため、両側のフォーマットは一致している必要があります。

Q: なぜこの 4 つのプロパティだけを取るのか?

A: これらはすべてページのメタデータだからです: 回転角度、オブジェクト参照、単位換算係数、可視領域。これらを読むにはページオブジェクトの辞書を解析するだけでよく、ページのコンテンツストリームには触れません。本当のコンテンツ解析 (描画命令) は render 時の GetOperatorList メッセージまで遅延されます。これが getPage が素早く返せる理由です。

この 4 つのプロパティは、その後 PDFPageProxy の getter に一対一で対応します: rotate、ref、userUnit、view。次の記事の getViewport はそのうちの 2 つを使います。

3049〜3068 行目: 応答の処理と PDFPageProxy の生成

応答が到着すると、.then コールバックに入ります:

.then(pageInfo => {
  if (this.destroyed) {
    throw new Error("Transport destroyed");
  }
  if (pageInfo.refStr) {
    this.#pageRefCache.set(pageInfo.refStr, newPageIndex); // "4R" -> 0
  }

  const page = new PDFPageProxy(
    pageIndex,          // 0
    pageInfo,
    this,
    this.pagesMapper,
    this._params.pdfBug // false
  );
  this.#pageCache.set(pageIndex, page);
  return page;
});
this.#pagePromises.set(pageIndex, promise); // 3067 行目
return promise;                             // 3068 行目

順番に 4 つのことを行います:

  1. destroyed チェック: 応答を待つ間にドキュメントがすでに破棄されている可能性がある (loadingTask.destroy())。その場合はエラーを投げる;
  2. #pageRefCache の逆引きキャッシュ: "4R" → 0. cachedPageNumber(ref) の逆引きに使われます — 例えばアウトラインのジャンプ時、PDF 内部ではオブジェクト参照 (4 0 R) でページを特定するため、ページ番号への逆引きが必要です;
  3. PDFPageProxy を生成して #pageCache に書き込む;
  4. promise 自体を #pagePromises に書き込む。

Q: なぜ #pageRefCache は pageIndex ではなく newPageIndex を保存するのか?

A: それが記録するのは「このオブジェクト参照が元のドキュメントのどのページを指すか」であり、ユーザーの視点とは関係ないからです。逆引きの際、cachedPageNumber はまず元のページインデックスを取得し、次に pagesMapper.getPageNumber(pageIndex + 1) でユーザーのページ番号に翻訳し直します (api.js#L3245-L3255). 恒等マッピングでは両者はたまたま等しくなりますが、意味的には入れ替えられません。

PDFPageProxy のコンストラクタ: 代入のみ、解析なし

ソースを見る

constructor(pageIndex, pageInfo, transport, pagesMapper, pdfBug = false) {
  this._pageIndex = pageIndex;                  // 0
  this._pageInfo = pageInfo;                    // 本記事で受け取った pageInfo
  this._transport = transport;
  this._stats = pdfBug ? new StatTimer() : null; // false のためパフォーマンス統計は無効
  this._pdfBug = pdfBug;
  this.commonObjs = transport.commonObjs;       // ドキュメント全体で共有するリソースコンテナ (フォントなど)
  this.objs = new PDFObjects();                 // このページ専有のリソースコンテナ
  this._intentStates = new Map();               // レンダリングインテントの状態キャッシュ。第 5 記事の render で使用
  this.destroyed = false;
  this.recordedBBoxes = null;                   // エディタシナリオの記録済み領域
  this.#pagesMapper = pagesMapper;
  this.imageCoordinates = null;                 // 画像座標のマッピング。レンダリング後にのみ値を持つ
}

コンストラクタ全体に await は一度もなく、解析動作もありません。参照の受け渡しとプレースホルダーの設置だけです:

  • _pageIndex は getter pageNumber を通じて _pageIndex + 1 として外部に公開されます;
  • _pageInfo は上記の pageInfo で、4 つの getter はすべてここから読み取ります;
  • commonObjs と objs は後のレンダリング時のリソースコンテナで、PDFObjects は別の記事で展開します;
  • recordedBBoxes / imageCoordinates は初回レンダリングまで null のままです。

これも getPage のメインスレッド側のコストが極めて小さい理由です: 複雑な仕事はすべて worker スレッドにあり、メインスレッドはオブジェクトの組み立てだけを担当します。

これで getPage(1) の promise は page に resolve し、デモの await がそれを受け取ります。

まとめ

本節ではデモに沿って getPage(1) の完全な呼び出しを追いました:

  1. PDFDocumentProxy.getPage は 1 行で WorkerTransport.getPage に委譲;
  2. パラメータ検証: 1 は有効なページ番号 (総ページ数 756);
  3. ページ番号の換算: pageNumber(1) → pageIndex(0) → newPageIndex(0)。恒等マッピングでは 3 つとも一致;
  4. #pagePromises キャッシュを問い合わせ、初回はヒットせず;
  5. worker へ GetPage メッセージを送り、rotate / ref / refStr / userUnit / view の 5 つのフィールドを受け取る;
  6. #pageRefCache に書き込み、PDFPageProxy を生成し、#pageCache に書き込む;
  7. promise を #pagePromises に書き込んで返し、デモの await が page を得る。

page を手に入れた後、デモの次のステップは page.getViewport({ scale: 0.5 }) です — これは本記事の pageInfo にある view と userUnit を使います。次の記事で展開します。


私について: StreamPDF の創作者で、AI と PDF 領域の研究開発に従事しています。

元記事リンク: PDF.js をデモから深掘り 3: getPage メソッドの全体像

0
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
0
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?