🙄

TermuxでPDFビューアーを開発する

に公開

スマホブラウザで表示できたなら、ここからは “最小構成のPDFビューアー” を作っていきましょう。
以下は Vue (Vue CLI プロジェクト) + pdf.js を使った実装手順です。Termuxでも動きます。

──────────────────────────────────

1) 依存パッケージを入れる

──────────────────────────────────

  • プロジェクト直下で
cd my-vue-app
npm i pdfjs-dist
  • 開発サーバ再起動が安全(起動中なら一度 Ctrl+C)
npm run serve

──────────────────────────────────

2) 動作確認用のPDFを置く(任意)

──────────────────────────────────

──────────────────────────────────

3) PDFビューアーのコンポーネントを作成

──────────────────────────────────

  • src/components/PdfViewer.vue を新規作成
  • ※ pdf.js の worker はエントリを import する方式にしています(Vue CLI/webpack で安定しやすい)
<template>
  <div class="w-full h-full flex flex-col gap-2 p-2">
    <div class="flex items-center gap-2">
      <input type="file" accept="application/pdf" @change="onFileChange" />
      <input
        type="text"
        class="flex-1 border px-2 py-1 rounded"
        placeholder="PDFのURL(例: /sample.pdf や https://...)"
        v-model="url"
        @keyup.enter="loadFromUrl"
      />
      <button class="px-3 py-1 border rounded" @click="loadFromUrl">開く</button>
      <span class="ml-auto text-sm opacity-70">ページ: {{ pageNum }}/{{ numPages || '—' }}</span>
    </div>

    <div class="flex items-center gap-2">
      <button class="px-3 py-1 border rounded" @click="prevPage" :disabled="pageNum<=1">← 前</button>
      <button class="px-3 py-1 border rounded" @click="nextPage" :disabled="pageNum>=numPages">次 →</button>
      <button class="px-3 py-1 border rounded" @click="zoomOut" :disabled="scale<=0.5"></button>
      <span class="text-sm opacity-70">{{ Math.round(scale*100) }}%</span>
      <button class="px-3 py-1 border rounded" @click="zoomIn" :disabled="scale>=3"></button>
      <button class="px-3 py-1 border rounded" @click="rotate">回転</button>
      <button class="px-3 py-1 border rounded" @click="fitWidth">幅に合わせる</button>
      <button class="px-3 py-1 border rounded" @click="fitPage">全体に合わせる</button>
    </div>

    <div class="relative flex-1 overflow-auto bg-[#f5f5f5] rounded">
      <canvas ref="canvas" class="block mx-auto my-2 shadow" />
      <div v-if="loading" class="absolute inset-0 grid place-items-center text-sm opacity-70">
        読み込み中…
      </div>
      <div v-if="error" class="absolute inset-0 grid place-items-center text-sm text-red-600">
        {{ error }}
      </div>
    </div>
  </div>
</template>

<script>
import * as pdfjsLib from 'pdfjs-dist/build/pdf'
import 'pdfjs-dist/build/pdf.worker.entry'

export default {
  name: 'PdfViewer',
  data() {
    return {
      pdf: null,
      pageNum: 1,
      numPages: 0,
      scale: 1.0,
      rotation: 0, // 0/90/180/270
      url: '/sample.pdf', // ここをデフォルトにしておくと楽
      loading: false,
      error: '',
      lastViewportWidth: 0, // フィット用
    }
  },
  mounted() {
    // スマホ表示で幅に合わせたい場合は起動時に読み込む
    this.loadFromUrl()
    window.addEventListener('resize', this.onResize, { passive: true })
  },
  beforeUnmount() {
    window.removeEventListener('resize', this.onResize)
    this.cleanup()
  },
  methods: {
    async onFileChange(e) {
      const file = e.target.files?.[0]
      if (!file) return
      this.error = ''
      this.loading = true
      this.cleanup()

      try {
        const buf = await file.arrayBuffer()
        const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buf) })
        this.pdf = await loadingTask.promise
        this.numPages = this.pdf.numPages
        this.pageNum = 1
        await this.renderPage()
      } catch (err) {
        this.error = 'PDFの読み込みに失敗しました'
        console.error(err)
      } finally {
        this.loading = false
      }
    },

    async loadFromUrl() {
      if (!this.url) return
      this.error = ''
      this.loading = true
      this.cleanup()

      try {
        const loadingTask = pdfjsLib.getDocument(this.url)
        this.pdf = await loadingTask.promise
        this.numPages = this.pdf.numPages
        this.pageNum = 1
        await this.renderPage()
      } catch (err) {
        this.error = 'URLからPDFを読み込めませんでした'
        console.error(err)
      } finally {
        this.loading = false
      }
    },

    async renderPage() {
      if (!this.pdf) return
      this.loading = true
      try {
        const page = await this.pdf.getPage(this.pageNum)

        // ビューポート計算
        let viewport = page.getViewport({ scale: this.scale, rotation: this.rotation })

        // Canvas を取得
        const canvas = this.$refs.canvas
        const ctx = canvas.getContext('2d')

        // デバイスピクセル比を考慮(スマホで鮮明に)
        const dpr = window.devicePixelRatio || 1
        canvas.width = Math.floor(viewport.width * dpr)
        canvas.height = Math.floor(viewport.height * dpr)
        canvas.style.width = Math.floor(viewport.width) + 'px'
        canvas.style.height = Math.floor(viewport.height) + 'px'

        const renderContext = {
          canvasContext: ctx,
          viewport: viewport,
          transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : null
        }

        // 描画
        await page.render(renderContext).promise
        this.lastViewportWidth = viewport.width
      } catch (err) {
        this.error = 'ページの描画に失敗しました'
        console.error(err)
      } finally {
        this.loading = false
      }
    },

    async prevPage() {
      if (this.pageNum <= 1) return
      this.pageNum--
      await this.renderPage()
    },
    async nextPage() {
      if (this.pageNum >= this.numPages) return
      this.pageNum++
      await this.renderPage()
    },

    async zoomIn() {
      this.scale = Math.min(3, this.scale + 0.1)
      await this.renderPage()
    },
    async zoomOut() {
      this.scale = Math.max(0.5, this.scale - 0.1)
      await this.renderPage()
    },
    async rotate() {
      this.rotation = (this.rotation + 90) % 360
      await this.renderPage()
    },

    async fitWidth() {
      // コンテナ幅に合わせる
      const canvas = this.$refs.canvas
      const container = canvas.parentElement
      if (!container || !this.pdf) return

      const page = await this.pdf.getPage(this.pageNum)
      const viewport = page.getViewport({ scale: 1, rotation: this.rotation })
      const containerWidth = container.clientWidth - 16 // 余白分ちょい引く
      this.scale = Math.max(0.5, Math.min(3, containerWidth / viewport.width))
      await this.renderPage()
    },

    async fitPage() {
      // 高さ/幅の小さい方に合わせる
      const canvas = this.$refs.canvas
      const container = canvas.parentElement
      if (!container || !this.pdf) return

      const page = await this.pdf.getPage(this.pageNum)
      const base = page.getViewport({ scale: 1, rotation: this.rotation })
      const w = container.clientWidth - 16
      const h = container.clientHeight - 16
      const scaleW = w / base.width
      const scaleH = h / base.height
      this.scale = Math.max(0.5, Math.min(3, Math.min(scaleW, scaleH)))
      await this.renderPage()
    },

    async onResize() {
      // 幅に合わせている最中だけ自動リサイズ(必要に応じてカスタマイズ)
      const canvas = this.$refs.canvas
      const container = canvas?.parentElement
      if (!container || !this.pdf) return
      // 直前の幅から大きく変わったら再描画
      if (Math.abs(container.clientWidth - this.lastViewportWidth) > 20) {
        await this.fitWidth()
      }
    },

    cleanup() {
      if (this.pdf && this.pdf.cleanup) {
        try { this.pdf.cleanup() } catch (_) {}
      }
      this.pdf = null
      this.numPages = 0
      this.pageNum = 1
      this.error = ''
    }
  }
}
</script>

<style scoped>
/* 必要なら最小限の見た目 */
button:disabled { opacity: 0.5; }
</style>

──────────────────────────────────

4) 画面に組み込む(App.vue)

──────────────────────────────────

  • src/App.vue を編集してコンポーネントを表示
<template>
  <div id="app" style="height: 100vh;">
    <PdfViewer />
  </div>
</template>

<script>
import PdfViewer from './components/PdfViewer.vue'
export default { components: { PdfViewer } }
</script>

──────────────────────────────────

5) 使い方(スマホでの操作)

──────────────────────────────────

  • 画面上部の「ファイル選択」で端末内のPDFを開けます。
  • URL欄に /sample.pdf や https://... を入力して Enter/開く でもOK。
  • +/- で拡大縮小、回転、幅合わせ/全体合わせのボタンで見やすく調整。
  • スクロールはビューワの灰色エリア内で操作。

──────────────────────────────────

6) もしビルド周りでエラーが出たら

──────────────────────────────────

  • pdf.js の worker 設定で躓くことがあります。
    上記の「import 'pdfjs-dist/build/pdf.worker.entry'」方式で多くは解決します。
  • それでもダメなら代替として:
    1. CDNを使う(オフライン不可)
      pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist/build/pdf.worker.min.js'
    2. もしくは mjs 版を明示(webpack5想定)
      import * as pdfjsLib from 'pdfjs-dist/build/pdf.mjs'
      import workerSrc from 'pdfjs-dist/build/pdf.worker.min.mjs'
      pdfjsLib.GlobalWorkerOptions.workerSrc = workerSrc

──────────────────────────────────

7) 次の発展アイデア

──────────────────────────────────

  • 複数ページのサムネイル一覧(左カラム)を作る
  • 検索(テキストレイヤーを重ねて find/highlight)
  • ページ番号直接入力/ジャンプ
  • ピンチズーム対応(Hammer.js など導入、または pointer イベントで自作)
  • ダークモード、しおり/目次(outline)表示
  • PWA対応でオフライン閲覧

ここまで実装すれば、スマホ上でも実用的なPDFビューアーの骨格が完成します。
エラーや警告が出たら、そのログを貼ってください。原因を特定して直せるように手順を追加します。

Discussion