こんにちは.ソーシャル経済メディア「NewsPicks」エンジニアの荒井です.
Web で PDF を表示する.
そんなこと簡単にできる──はずでした.
しかし実際にアプリ内 WebView での表示や,認証付き PDF,そして快適な閲覧体験を実現しようとすると,思わぬつまずきが次々と現れてきました.
この記事では,React × PDF.js を使って PDF ビューアを内製する過程や,その中で直面した課題と,そこをどう乗り越えたのかを紹介していきます.
はじめに
PDF を表示する方法はすでに多く存在しています.
たとえばブラウザが持つネイティブの PDF ビューアを使ったり,PDF を Web アプリケーション上に <iframe> や <object> で埋め込むだけで完結するケースもあります.また,Google Docs Viewer などの外部サービスに PDF 表示を委ねる方法も一般的です.
しかし、今回扱いたかった PDF には次のような要件がありました:
- iOS / Android のアプリ内 WebView で表示したい
- 認証付き PDF を扱いたい
これらの要件を既存の手段に当てはめてみると,それぞれ次の課題が発生しました:
-
ネイティブ PDF ビューア
→ アプリの WebView ではネイティブ PDF ビューアが同梱されていないことがあり,開こうとするとアプリ内ではなく,フォールバックして外部アプリで開いてしまうことがありました. -
<iframe>や<object>での埋め込み
→ 前項と同様の事情で,<iframe>や<object>での埋め込みをしても,真っ白になる,ダウンロード扱いになる,そもそも読み込まれない,といった問題が生じることがありました. -
Google Docs Viewer
→ 認証付きファイルの扱いに制限があるのと,公式 API ではないのでプロダクションで利用することは憚られます.
また,将来的に PDF に独自の付加情報を表示したい要件もあり,今回は PDF ビューアを内製することにしました.
PDF ビューアの実装
ここから PDF ビューアの実装の詳細に入ります.
NewsPicks の Web アプリケーションでは React を利用しているため,pdf.js のラッパーである react-pdf を採用することにしました.
PDF の表示
まずは,PDF を「ネイティブ PDF ビューアのように縦スクロールで表示」できる最低限のビューアを作ります.
react-pdf では Document と Page コンポーネントが提供されており,これを使うと比較的簡単に PDF を表示できます.
import React, { useEffect, useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js';
type PdfViewerProps = {
url: string;
};
export const PdfViewer: React.FC<PdfViewerProps> = ({ url }) => {
const [numPages, setNumPages] = useState<number>(0);
return (
<Document
file={url}
onLoadSuccess={(info) => setNumPages(info.numPages)}
>
{Array.from({ length: numPages }, (_, i) => (
<Page
key={i + 1}
pageNumber={i + 1}
renderTextLayer={false}
renderAnnotationLayer={false}
/>
))}
</Document>
);
};
表示だけならこの実装で完了です.しかし,この状態だと大きな PDF ファイルを表示したときにブラウザが落ちることがありました.
理由は,pdf.js では各ページが <canvas> で描画されており,全ページ分の <canvas> を同時にメモリに載せるとかなり重いためです.
そこで,ページのレンダリングに 仮想化を導入することにしました.
仮想化
仮想化とは,「実際に画面に見えている部分だけを描画して,見えていない要素は DOM から外すことで,メモリ使用量とレンダリングコストを抑える技術」です.
PDF の場合は,
- 画面に見えているページ+前後数ページだけをレンダリングする
- それ以外のページはアンマウントして
<canvas>を破棄する
ことで,ブラウザのメモリ使用量をかなり抑えることができます.
React で仮想化する代表的なライブラリには react-window や react-virtualized がありますが,今回は API がシンプルで,サイズも小さく,動作も軽い react-window を採用しました.
仮想化に対応すると以下のようになります.
import React, { useEffect, useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js';
type PdfPageItemProps = ListChildComponentProps<{
numPages: number;
}>;
const PdfPageItem: React.FC<PdfPageItemProps> = ({
index,
style,
data,
}) => {
const pageNumber = index + 1;
return (
<div style={style}>
<Page
pageNumber={pageNumber}
renderTextLayer={false}
renderAnnotationLayer={false}
/>
</div>
);
};
type PdfViewerProps = {
url: string;
};
export const PdfViewer: React.FC<PdfViewerProps> = ({ url }) => {
const [numPages, setNumPages] = useState<number>(0);
const pageHeight = 800;
return (
<Document
file={url}
onLoadSuccess={(info) => setNumPages(info.numPages)}
>
{numPages > 0 && (
<List
height={window.innerHeight}
width="100%"
itemCount={numPages}
itemSize={pageHeight}
itemData={{ numPages }}
>
{PdfPageItem}
</List>
)}
</Document>
);
};
※ 簡易的に pageHeight を固定にしていますが, onLoadSuccess の callback で受け取れる info から各ページの高さが取得できるので state として持っておくとより良いです.
仮想化することで,
- 多数ページの PDF でもブラウザが落ちない
- スクロールしても軽い
- メモリ使用量が安定する
といったメリットが得られました.
拡大・縮小機能
ここまでで,縦スクロールで閲覧できる PDF ビューアができましたが,特にモバイル端末での利用を考えると拡大・縮小機能(ズーム)は必須です.
まずはシンプルに「+」「ー」ボタンだけでズームできるようにします.
やり方としては,拡大縮小率(scale)を state で持って、ボタンで更新するだけです.
type PdfPageItemProps = ListChildComponentProps<{
numPages: number;
scale: number;
}>;
const PdfPageItem: React.FC<PdfPageItemProps> = ({
index,
style,
data,
}) => {
const pageNumber = index + 1;
return (
<div style={style}>
<Page
pageNumber={pageNumber}
scale={data.scale}
renderTextLayer={false}
renderAnnotationLayer={false}
/>
</div>
);
};
type PdfViewerProps = {
url: string;
};
export const PdfViewer: React.FC<PdfViewerProps> = ({ url }) => {
// ...
const [scale, setScale] = useState<number>(1.0);
const handleZoomIn = () => setScale((prev) => Math.min(prev + 0.25, 4));
const handleZoomOut = () => setScale((prev) => Math.max(prev - 0.25, 0.5));
const handleResetZoom = () => setScale(1.0);
return (
<div>
{/* 操作パネル */}
<div style={{ padding: '8px', display: 'flex', gap: '8px' }}>
<button onClick={handleZoomOut}>−</button>
<button onClick={handleZoomIn}>+</button>
<button onClick={handleResetZoom}>100%</button>
<span>倍率: {(scale * 100).toFixed(0)}%</span>
</div>
<Document
file={{ data }}
onLoadSuccess={(info) => setNumPages(info.numPages)}
>
{numPages > 0 && (
<List
height={window.innerHeight}
width="100%"
itemCount={numPages}
itemSize={pageHeight}
itemData={{ numPages, scale }}
>
{PdfPageItem}
</List>
)}
</Document>
</div>
);
};
これで,PC のような環境であれば十分なレベルになりますが,モバイル端末ではピンチイン・ピンチアウトによるズームのほうがより直感的です.
ピンチイン・ピンチアウト
ピンチイン・ピンチアウトの対応が今回いちばん苦労した部分です.
最初に思いつく実現方法は,
「ピンチ操作の最中に,拡大縮小率(scale)の state を直接更新して毎フレーム再レンダリングする」
という方法ですが,PDF の再レンダリングは重い,また,後述しますがスクロール位置を合わせる必要があるため,ピンチ中に毎回再描画するとカクカクになってしまいます.
そこで,
- ピンチ中は CSS の transform: scale(...) で見た目だけ拡大縮小する
- ピンチが終わったタイミングで本当の拡大縮小率(scale)を更新して PDF を再描画する
方針で実装することにしました.
上記の方針で実装することはそこまで難しくはないのですが, ピンチの中心を維持したまま拡大・縮小する ことに苦労しました.
ネイティブ PDF ビューアでは当たり前のように実現されていますが, 実際に実装してみると考えることが多かったです.
なぜ難しいのか?
まず,ピンチ操作をすると,指でつまんだ中心位置を軸に PDF が拡大・縮小してほしいわけですが,
- 拡大・縮小すると表示領域(描画領域)が足りなくなる
- 左右・上下に余白を動的に追加しないと中心がズレる
- PDF の実座標系と、余白を含めた「見た目の座標系」がずれる
という問題が発生します.
つまり,単に拡大縮小率を調整するだけではダメで,中心位置を正しく維持するための余白(オフセット)を計算したりスクロール位置を調整したりする必要があります.
どう解決したか?
- ピンチ開始時に「指2本の中心座標」を取得
- PDF の座標系に変換
- 拡大・縮小後の座標系でこの中心がどこに来るべきか計算
- 必要な上下左右のオフセット量とスクロール量を算出
- オフセットの付与・スクロール位置調整で「指の位置 = 拡大後の中心」を維持
という流れで中心を維持するようにしました.
さらに,スクロール位置の調整一つをとっても以下の工夫が必要でした.
スクロール位置調整での工夫
ピンチイン・アウトが終了したタイミングでやりたいことは:
- 拡大縮小率(scale)を更新する
- スクロール位置を更新して,中心がズレないようにする
- 必要なページをレンダリングし直す
ですが,単純に 拡大縮小率(scale)更新の直後にスクロール位置を更新の処理を呼び出すと, React の state 更新が非同期であるため,一瞬ズレが生じ,レイアウトがチラついてしまいます.
これを解決するために, useLayoutEffect でスクロール位置の更新処理を呼び出すようにしました.
useLayoutEffect は DOM のレイアウトが確定した直後・描画される直前に走るため,拡大縮小率(scale)更新とスクロール調整を同期させることができます.
また,仮想化と組み合わせることで以下のような課題もありました.
ピンチ終了後にスクロール位置を調整すると,その位置に対応するページがまだレンダリングされていないことがある
理由は:
- 仮想化は「見えている部分 + 前後数ページ」しか描画しない
- 拡大縮小で高さが変わると,見えるページが一時的に変わる
- スクロール位置を調整した瞬間には,その位置のページがまだ未描画のことがある
→ 結果,一瞬空白が見えてしまう / チラつく
という現象が起こったためです.
こちらはピンチ前後で見える可能性が高いページは事前に描画することによって回避しました.
具体的には,ピンチ終了直後には,ピンチ操作の中心となるページと,その前後数ページ(±2〜3)を描画するようにしつつ,それ以外のページも「高さだけ保持した空の要素」として描画するようにしました.
そうすることで,全てのページが描画済み扱いのため,スクロール位置を調整したときに未描画であることがなくなり,かつピンチ操作後に見えるであろうページは描画済みなのでチラつかないようになりました.
まとめ
これで,ネイティブの PDF ビューアと比較するとフル装備ではないですが,Web アプリケーションで PDF ビューアを実装することができました.
特に iOS や Android のアプリ内 WebView で PDF を表示するには制限があり,Web アプリケーションで PDF ビューアを実装することが解決手段の一つとしてあるかと思います.実装コストはかかるのでどのプロジェクトにも推奨ではありませんが,もし同じような境遇で PDF ビューアを実装する機会がある方がいらっしゃれば参考にしていただければと思います.