はじめに
第 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 つのことを行います:
-
destroyedチェック: 応答を待つ間にドキュメントがすでに破棄されている可能性がある (loadingTask.destroy())。その場合はエラーを投げる; -
#pageRefCacheの逆引きキャッシュ:"4R"→0.cachedPageNumber(ref)の逆引きに使われます — 例えばアウトラインのジャンプ時、PDF 内部ではオブジェクト参照 (4 0 R) でページを特定するため、ページ番号への逆引きが必要です; -
PDFPageProxyを生成して#pageCacheに書き込む; - 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は getterpageNumberを通じて_pageIndex + 1として外部に公開されます; -
_pageInfoは上記のpageInfoで、4 つの getter はすべてここから読み取ります; -
commonObjsとobjsは後のレンダリング時のリソースコンテナで、PDFObjectsは別の記事で展開します; -
recordedBBoxes/imageCoordinatesは初回レンダリングまでnullのままです。
これも getPage のメインスレッド側のコストが極めて小さい理由です: 複雑な仕事はすべて worker スレッドにあり、メインスレッドはオブジェクトの組み立てだけを担当します。
これで getPage(1) の promise は page に resolve し、デモの await がそれを受け取ります。
まとめ
本節ではデモに沿って getPage(1) の完全な呼び出しを追いました:
-
PDFDocumentProxy.getPageは 1 行でWorkerTransport.getPageに委譲; - パラメータ検証:
1は有効なページ番号 (総ページ数756); - ページ番号の換算:
pageNumber(1)→pageIndex(0)→newPageIndex(0)。恒等マッピングでは 3 つとも一致; -
#pagePromisesキャッシュを問い合わせ、初回はヒットせず; - worker へ
GetPageメッセージを送り、rotate/ref/refStr/userUnit/viewの 5 つのフィールドを受け取る; -
#pageRefCacheに書き込み、PDFPageProxyを生成し、#pageCacheに書き込む; - promise を
#pagePromisesに書き込んで返し、デモのawaitがpageを得る。
page を手に入れた後、デモの次のステップは page.getViewport({ scale: 0.5 }) です — これは本記事の pageInfo にある view と userUnit を使います。次の記事で展開します。
私について: StreamPDF の創作者で、AI と PDF 領域の研究開発に従事しています。
