🙄
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を置く(任意)
──────────────────────────────────
- public/ にサンプルPDFを置く(例: public/sample.pdf)
- ※ あとで http://127.0.0.1:8080/sample.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'」方式で多くは解決します。 - それでもダメなら代替として:
- CDNを使う(オフライン不可)
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist/build/pdf.worker.min.js' - もしくは 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
- CDNを使う(オフライン不可)
──────────────────────────────────
7) 次の発展アイデア
──────────────────────────────────
- 複数ページのサムネイル一覧(左カラム)を作る
- 検索(テキストレイヤーを重ねて find/highlight)
- ページ番号直接入力/ジャンプ
- ピンチズーム対応(Hammer.js など導入、または pointer イベントで自作)
- ダークモード、しおり/目次(outline)表示
- PWA対応でオフライン閲覧
ここまで実装すれば、スマホ上でも実用的なPDFビューアーの骨格が完成します。
エラーや警告が出たら、そのログを貼ってください。原因を特定して直せるように手順を追加します。
Discussion