MENU

問い合わせ


    【Nuxtで作るPDF管理システム 第6回】ブラウザでPDFをプレビューする

    PDF管理システムで、ファイルを開くたびにダウンロードして別のアプリで確認するのは手間がかかります。今回は、登録したPDFをシステムの画面内でそのままプレビュー(=中身の確認表示)できるようにします。日本語のPDFを文字化け・文字抜けさせずに表示する設定と、ページ送り・拡大縮小も実装します。

    前回の第5回:権限管理と「認可つきダウンロード」では、ロールによる権限管理と、ログイン・権限を確認してからPDFを返すダウンロードAPIを作りました。今回はそのAPIをプレビューの読み込み元として使います。

    「PDFってブラウザで普通に開けますよね?わざわざプレビュー機能を作る必要があるの?」

    結論から言うと、「見るだけ」ならブラウザ標準の表示で足りますが、PDFの上に注釈やスタンプを置く編集機能まで作るなら、PDFを自前で描画する仕組みが必要です。この連載では第8回で「プレビュー上をクリックした位置に文字や印影を置く」機能を作るため、Mozilla の PDF.js(npm パッケージ名 pdfjs-dist、執筆時点で 6 系)を使って、PDFのページを画面に描画します。

    目次

    PDFプレビューの仕組み:PDF.js でページを Canvas に描く

    ブラウザ標準の表示と PDF.js の違い

    方式手軽さできることできないこと
    ブラウザ標準(<iframe> などで表示)非常に手軽表示・印刷・ブラウザの機能での検索見た目の統一、ページ上の座標の取得、独自の注釈の重ね合わせ
    PDF.js で自前描画(今回)設定が必要表示を完全に制御、ページ上の座標の取得、注釈の重ね合わせ何もしなければ文字の選択・検索もできない(別途テキスト層が必要)

    PDF.js は、Firefox に内蔵されているPDFビューアの中身として開発されているライブラリです。ブラウザの Canvas(=JavaScriptで絵を描ける領域)にPDFのページを1枚の絵として描きます。

    今回できるようになること

    • 一覧のファイル名をクリックすると詳細画面(/files/[id])が開き、PDFが表示される
    • ページ送り(前へ・次へ)と、50%〜300%の拡大縮小
    • 日本語のPDF(フォントが埋め込まれていないものを含む)が文字抜けせずに表示される
    • パスワード付きPDFは「プレビューできません」と案内する

    構成

    部品役割ファイル
    ファイル情報API1件分の情報(名前・ページ数・タグ等)server/api/files/[id].get.ts
    ダウンロードAPI(第5回)PDF本体を認可つきで返す。?inline=1 で表示用server/api/files/[id]/download.get.ts
    補助ファイルの配信日本語用の文字対応表(cMap)などを /pdfjs/ で配信nuxt.config.ts
    ビューア部品PDF.js で描画、ページ送り・拡大縮小app/components/PdfViewer.client.vue
    詳細画面ファイル情報とビューアを並べるapp/pages/files/[id].vue

    pdfjs-dist を入れて、補助ファイルを配信する

    npm install --save-exact pdfjs-dist@6.3.289

    PDF.js は、PDFの解析に使う「ワーカー」(=画面の動きを止めないよう、裏で別に動く処理)のファイルと、日本語の表示などに使う補助ファイルを、実行時にURLで読み込みます。補助ファイルは pdfjs-dist のパッケージに含まれているので、Nitro の publicAssets(=指定したフォルダをそのまま公開する設定)で配信します。

    nuxt.config.ts(追加部分)

    import { createRequire } from 'node:module'
    import { dirname, join } from 'node:path'
    
    // pdfjs-dist のインストール先(cMap・標準フォント・wasm をブラウザに配信するため)
    const pdfjsDir = dirname(createRequire(import.meta.url).resolve('pdfjs-dist/package.json'))
    
    export default defineNuxtConfig({
      // …
      nitro: {
        // PDF.js が実行時に読み込む補助ファイルを /pdfjs/ 以下で配信する(node_modules からコピー)
        publicAssets: ['cmaps', 'standard_fonts', 'wasm', 'iccs'].map((name) => ({
          dir: join(pdfjsDir, name),
          baseURL: `/pdfjs/${name}`,
          maxAge: 60 * 60 * 24 * 7,
        })),
      },
    })

    📰 出典:Nitro 公式ドキュメント Configuration(publicAssets)

    フォルダ中身必要になる場面
    cmaps定義済みCMap(=文字コードと文字の対応表)169ファイル日本語など、フォントを埋め込まずに作られたPDF
    standard_fonts標準フォントの代替データHelvetica など、PDFの標準フォントを埋め込んでいないPDF
    wasm画像デコーダー(WebAssembly)JPEG 2000 などの画像を含むPDF
    iccs色の定義(ICCプロファイル)色空間の指定があるPDF

    ビルドすると .output/public/pdfjs/ にコピーされ(筆者の環境で約4MB)、本番サーバーからそのまま配信されます。これらはPDFの中身ではなく共通の部品なので、ログインなしで取得できても問題ありません。

    ビューア部品を作る

    app/components/PdfViewer.client.vue(スクリプト部分の抜粋)

    <script setup lang="ts">
    import { GlobalWorkerOptions, getDocument } from 'pdfjs-dist'
    import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from 'pdfjs-dist'
    // ?url … Vite の機能。ワーカーのファイルをビルド成果物に含め、その URL を受け取る
    import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
    
    const props = defineProps<{
      /** PDFのURL(同じオリジンのAPI。Cookie が送られ、サーバー側で認可される) */
      src: string
    }>()
    
    // PDF.js の重い処理(解析)はワーカー(別スレッド)で行う
    GlobalWorkerOptions.workerSrc = workerUrl
    
    const canvas = ref<HTMLCanvasElement | null>(null)
    const pageNumber = ref(1)
    const pageCount = ref(0)
    const scale = ref(1.25)
    const loading = ref(true)
    const errorMessage = ref('')
    
    // PDF.js のオブジェクトは Vue のリアクティブ(Proxy)にしない。
    // 内部で private フィールドを使っており、Proxy 越しに触るとエラーになるため
    let loadingTask: PDFDocumentLoadingTask | null = null
    let pdf: PDFDocumentProxy | null = null
    let renderTask: RenderTask | null = null
    /** 描画の呼び出し番号。ページ送りの連打で古い描画が後から始まらないようにする */
    let renderSeq = 0
    
    async function load() {
      loading.value = true
      errorMessage.value = ''
      try {
        loadingTask = getDocument({
          url: props.src,
          // 日本語PDFで使われる定義済みCMap(文字コード→文字の対応表)の置き場所
          cMapUrl: '/pdfjs/cmaps/',
          cMapPacked: true,
          // PDFに埋め込まれていない標準14フォント(Helvetica 等)の代替フォント
          standardFontDataUrl: '/pdfjs/standard_fonts/',
          // JPEG 2000 などの画像デコーダー(WebAssembly)
          wasmUrl: '/pdfjs/wasm/',
          iccUrl: '/pdfjs/iccs/',
        })
        pdf = await loadingTask.promise
        pageCount.value = pdf.numPages
        pageNumber.value = 1
        await render()
      } catch (err: unknown) {
        const name = (err as { name?: string }).name
        errorMessage.value = name === 'PasswordException'
          ? 'パスワード付きのPDFはプレビューできません。ダウンロードして開いてください。'
          : 'PDFを表示できませんでした。'
      } finally {
        loading.value = false
      }
    }

    ポイントは3つです。

    • ワーカーの指定(GlobalWorkerOptions.workerSrc):ワーカーのファイルは、Vite の ?url 付きインポートでURLを受け取っています。?url を付けると、そのファイルがビルド成果物に含まれ、公開URL(本番では /_nuxt/pdf.worker.min.<ハッシュ>.mjs)が返ります。開発サーバーと本番ビルドの両方で、この書き方でワーカーが読み込まれることを確認しました。
    • getDocument の補助ファイル設定:cMapUrl・cMapPacked・standardFontDataUrl・wasmUrl は、インストールした pdfjs-dist 6 の型定義(getDocument の引数の説明)で名前と意味を確認しました。いずれも末尾の / が必要です。
    • PDF.js のオブジェクトを ref に入れない:Vue の ref や reactive はオブジェクトを Proxy(=アクセスを横取りする仕組み)で包みます。JavaScript のプライベートフィールドは Proxy 越しに読めないため、PDF.js のオブジェクトを包むとエラーの原因になります。画面の表示に使う値(ページ番号など)だけを ref にしています。

    📰 出典:Vite 公式ドキュメント 静的アセットの取り扱い(Explicit URL Imports)

    📰 出典:MDN Proxy(プライベートフィールドは転送されない)

    PDFのURLには、第5回のダウンロードAPIを ?inline=1 付きで渡します。同じオリジン(=同じドメイン)への読み込みなので、ログインのCookieが自動で送られ、サーバー側で権限が確認されます。プレビュー用に別の公開URLを作る必要はありません。

    ページを描画する

    app/components/PdfViewer.client.vue(描画部分)

    async function render() {
      if (!pdf || !canvas.value) return
      const seq = ++renderSeq
      const page = await pdf.getPage(pageNumber.value)
      // ページの取得を待つ間に次の描画が呼ばれていたら、こちらは描かない
      if (seq !== renderSeq) return
      // 前の描画が終わっていなければ取り消す(同じCanvasに同時に描くとエラーになる)
      renderTask?.cancel()
    
      const viewport = page.getViewport({ scale: scale.value })
      // 高解像度ディスプレイではCanvasを実寸より大きく描いてにじみを防ぐ
      const outputScale = window.devicePixelRatio || 1
      const el = canvas.value
      el.width = Math.floor(viewport.width * outputScale)
      el.height = Math.floor(viewport.height * outputScale)
      el.style.width = `${Math.floor(viewport.width)}px`
      el.style.height = `${Math.floor(viewport.height)}px`
    
      renderTask = page.render({
        canvas: el,
        viewport,
        transform: outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : undefined,
      })
      try {
        await renderTask.promise
      } catch (err: unknown) {
        // cancel() による中断は正常な動作なので無視する
        if ((err as { name?: string }).name !== 'RenderingCancelledException') throw err
      }
    }
    
    watch([pageNumber, scale], () => {
      render()
    })
    
    function destroy() {
      renderTask?.cancel()
      // ワーカー側のメモリも解放する
      loadingTask?.destroy()
      loadingTask = null
      pdf = null
    }
    
    onMounted(load)
    onBeforeUnmount(destroy)
    • getViewport({ scale }) で、拡大率に応じたページの大きさを求めます。
    • 高解像度の画面(スマートフォンや高精細ノートPC)では、Canvas を表示サイズより大きく描いて縮小表示することで、文字のにじみを防ぎます。PDF.js 公式のサンプルと同じ考え方です。
    • PDF.js は「同じ Canvas に同時に2つの描画をする」とエラーにします。ページ送りを連打したときに備え、古い描画を cancel() してから次を始めます。
    • 画面を離れるときは loadingTask.destroy() でワーカー側のメモリも解放します。

    📰 出典:PDF.js 公式 Examples

    テンプレート部分は、ツールバー(前のページ・次のページ・縮小・拡大と現在のページ・倍率)と <canvas> を並べただけなので省略します(全体はサンプルコードを参照してください)。

    ファイル名を .client.vue にする理由

    Nuxt はページをサーバーでも描画しますが、サーバーには Canvas もワーカーもありません。ファイル名を PdfViewer.client.vue にすると、この部品はブラウザでだけ描画され、サーバー描画では空の場所だけが確保されます。

    📰 出典:Nuxt 公式ドキュメント components ディレクトリ(.client コンポーネント)

    詳細画面とファイル情報API

    app/pages/files/[id].vue(抜粋)

    <script setup lang="ts">
    const route = useRoute()
    const id = computed(() => String(route.params.id))
    
    const { data: file, error } = await useFetch(() => `/api/files/${id.value}`)
    useHead({ title: () => `${file.value?.originalName ?? 'ファイル'} | PDF管理システム` })
    
    // プレビューはダウンロードAPIを inline 指定で読む(認可はダウンロードと同じ)
    const previewUrl = computed(() => `/api/files/${id.value}/download?inline=1`)
    const downloadUrl = computed(() => `/api/files/${id.value}/download`)
    </script>
    
    <template>
      <section>
        <p><NuxtLink to="/files">← ファイル一覧</NuxtLink></p>
        <p v-if="error">{{ error.statusCode === 404 ? 'ファイルが見つかりません。' : 'ファイル情報を取得できませんでした。' }}</p>
        <template v-else-if="file">
          <h1>{{ file.originalName }}</h1>
          <!-- ページ数・サイズ・登録者・タグ(省略) -->
          <p><a :href="downloadUrl">ダウンロード</a></p>
          <p v-if="file.isEncrypted">パスワード付きのPDFのため、プレビューできない場合があります。</p>
          <PdfViewer :src="previewUrl" />
        </template>
      </section>
    </template>

    ファイル1件分の情報を返す server/api/files/[id].get.ts は、一覧APIと同じく requireRole(event, 'viewer') で権限を確認し、IDを zod で検証したうえで、登録者名・タグ・canDelete を付けて返します。一覧画面のファイル名は、この詳細画面へのリンクにしました。

    動作確認の方法

    npm run build
    node .output/server/index.mjs

    今回は、自動操作できるブラウザ(ヘッドレスの Chrome)を使って、ログインからプレビューまでを画面上で確認しました。確認用のPDFは、日本語の文字を「フォントを埋め込まず、定義済みCMap(UniJIS-UCS2-H)で指定する」形式で3ページ作成しました。業務で受け取るPDFにもよくある作り方で、cMap の設定がないと正しく表示できない種類です。

    確認内容結果
    未ログインで詳細画面を開く → ログイン/login?redirect=/files/… に移動し、ログイン後に詳細画面へ戻る
    日本語PDFのプレビュー見出し・本文の日本語、埋め込みなしの Helvetica の英字が表示される
    読み込まれたファイルワーカー(/_nuxt/pdf.worker.min.….mjs)、PDF本体(download?inline=1)、UniJIS-UCS2-H.bcmap と Adobe-Japan1-UCS2.bcmap
    cMap の配信を止めた状態で同じPDFを表示日本語の行がすべて表示されず、英字の行だけが描画される
    次のページ・拡大2 / 3 ページ、150% で再描画
    縮小ボタンの連打 → 前のページ50% で止まり、1 / 3 ページをエラーなく再描画
    パスワード付きPDF(AES-128で暗号化)「パスワード付きのPDFはプレビューできません」と表示
    /pdfjs/cmaps/UniJIS-UCS2-H.bcmap を直接取得200(ログイン不要の共通部品)
    開発サーバー(npm run dev)で同じ操作本番ビルドと同じくワーカー・cMap が読み込まれて表示される

    確認に使ったのは Chromium 系のブラウザのみで、Firefox・Safari、スマートフォン、スキャンした大容量PDFでの表示は筆者の環境では確認していません。お手元の実際の業務PDFでの確認をおすすめします。

    つまずきやすい点・注意点

    • 日本語が表示されない・文字化けする:多くは cMap の設定漏れです。cMapUrl の末尾の / 忘れ、配信パスの間違いでも同じ症状になります。ブラウザの開発者ツールで .bcmap の読み込みが 200 になっているかを確認します。
    • フォントを埋め込んでいないPDFは、見る側のフォントで表示される:確認用PDFの日本語は、PDFに指定された書体(平成角ゴシック)がないため、端末にある別の日本語フォントで表示されました。端末に日本語フォントがない環境では、見た目が変わったり表示できなかったりする可能性があります。
    • ワーカーの読み込みに失敗する:ワーカーのファイルとライブラリ本体のバージョンが食い違うとエラーになります。今回のように同じパッケージからURLを取れば、バージョンは常にそろいます。
    • プレビューでは文字を選択・検索できない:Canvas に絵として描いているためです。必要ならPDF.js のテキスト層(文字を透明に重ねる仕組み)を追加しますが、今回は扱っていません。
    • 大きなPDFは表示に時間がかかる:スキャンPDFは1ページが大きな画像のため、特にスマートフォンでは重くなります。サムネイルを事前に作る、表示する解像度を抑えるといった工夫が別途必要になることがあります。
    • パスワード付きPDF:開くためのパスワードが設定されたPDFは、今回の実装ではプレビューしません。ダウンロードして手元で開く運用にしています。

    発注者向けメモ:「表示できる」を確かめるには、実物のPDFが一番

    「PDFをブラウザで見られるようにしたい」という要望は簡単そうに見えますが、PDFは作り方(どのソフトで作ったか、スキャンか、フォントを埋め込んでいるか)によって、表示の難しさが大きく変わります。

    • 実際の業務PDFをサンプルとして早めに渡す:取引先から届く見積書、社内の申請書、スキャンした契約書など、種類ごとに数ファイルあると、開発側が表示の問題を早い段階で見つけられます。個人情報を含む場合は、黒塗りしたものやダミーデータで作り直したもので構いません。
    • 「見るだけ」か「その上で何かするか」を伝える:閲覧だけならブラウザ標準の表示で安く済む場合があります。注釈・スタンプ・署名の位置指定など、PDFの上で操作する予定があるなら、最初からその前提で作る方が手戻りが少なくなります。
    • 利用する端末を伝える:スマートフォン・タブレットで大きなPDFを開く必要があるかどうかで、必要な工夫と確認の範囲が変わります。
    • 文字の検索・コピーが必要か:プレビュー上で文字を選択・検索したいなら、追加の実装が必要です。

    打ち合わせでは、次のように聞いてみてください。

    • 「フォントが埋め込まれていない日本語PDFも、文字抜けせずに表示できますか?どのPDFで確認しましたか?」
    • 「スキャンした数十ページのPDFを、スマートフォンで開いたときの速さはどのくらいですか?」
    • 「プレビューのためにPDFを公開URLに置いていませんか?権限の確認はダウンロードと同じですか?」
    • 「プレビュー上で文字の選択や検索はできますか?必要な場合の追加作業はどのくらいですか?」

    まとめと次回予告

    第6回では、pdfjs-dist を使ってPDFをブラウザ上にプレビューできるようにしました。

    • PDF.js はPDFのページを Canvas に描画する。解析はワーカーで行い、URLは Vite の ?url で取得する
    • 日本語PDFのために、cMap などの補助ファイルを publicAssets で配信し、getDocument に場所を渡す
    • PDF.js のオブジェクトは Vue のリアクティブにしない。描画の取り消しと後片付けも忘れずに
    • PDF本体は第5回の認可つきAPIから読み込み、プレビュー専用の公開URLは作らない

    次回は「PDFの結合と分割」です。pdf-lib を使って複数のPDFを1つにまとめたり、ページ範囲を指定して切り出したりする機能を作ります。元のファイルは上書きせず、結果を「新しい版」として保存する仕組みもここで導入します。

    この連載の記事一覧

    この記事は連載「Nuxtで作るPDF管理システム」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次