【vue3-jp-admin】Vue 3でのPDF表示の最適解: pdf.js

## GitHub アドレス:
https://github.com/yangrongwe/vue3-jp-admin

## 連絡先メール:
yangrongwei1996@gmail.com

### 機能概要（pdf.jsプラグイン使用）
1.複数ページまたは単一ページのPDFをプレビューできます
2.PDFをズームイン、ズームアウト、ドラッグ操作できます
3.PDFをダウンロードできます
4.PDF内のテキストを取得できます

デモリンク：[デモリンク](https://wxcorer.com/components/pdf)

![pdf.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3833523/257175a7-b6a3-4d56-0b5a-09752f2bf018.png)

###  PDFコンポテンスの作成

```vue
<template>
  <div class="tw-container tw-mx-auto tw-p-4">
    <div class="tw-flex tw-justify-between tw-mb-4">
      <div>
        <v-btn @click="prevPage" class="tw-btn" :disabled="isAllPages">
          {{ $t('views.pdf.prevPage') }}
        </v-btn>
        <v-btn @click="nextPage" class="tw-btn" :disabled="isAllPages">
          {{ $t('views.pdf.nextPage') }}
        </v-btn>
        <v-btn @click="toggleView" class="tw-btn">
          {{
            isAllPages
              ? $t('views.pdf.singlePageView')
              : $t('views.pdf.allPagesView')
          }}
        </v-btn>
      </div>
      <div class="tw-flex tw-items-center">
        <v-btn @click="zoomOut" :disabled="zoomOutDisabled" class="tw-btn">
          {{ $t('views.pdf.zoomOut') }}
        </v-btn>
        <input
          type="number"
          v-model.number="zoomPercentage"
          @change="updateZoom"
          class="tw-mx-2 tw-text-center tw-w-20"
          min="10"
          step="1"
        />
        <v-btn @click="zoomIn" :disabled="zoomInDisabled" class="tw-btn">
          {{ $t('views.pdf.zoomIn') }}
        </v-btn>
      </div>
      <div>
        <v-btn @click="downloadPDF" class="tw-btn">{{
          $t('views.pdf.download')
        }}</v-btn>
        <v-btn @click="reset" class="tw-btn tw-bg-red-500">{{
          $t('views.pdf.reset')
        }}</v-btn>

        <v-btn @click="rotateLeft" class="tw-btn">{{
          $t('views.pdf.rotateLeft')
        }}</v-btn>
        <v-btn @click="rotateRight" class="tw-btn">{{
          $t('views.pdf.rotateRight')
        }}</v-btn>
        <v-btn @click="alertText" class="tw-btn">{{
          $t('views.pdf.alertText')
        }}</v-btn>
      </div>
    </div>
    <div
      ref="pdfContainer"
      class="tw-relative tw-overflow-auto tw-border tw-border-gray-300"
      @mousedown="startDrag"
      @mouseup="stopDrag"
      @mousemove="drag"
    >
      <canvas ref="pdfCanvas" class="tw-canvas"></canvas>
    </div>

    <div class="tw-text-center tw-mt-4">
      <template v-if="isAllPages">
        {{ $t('views.pdf.totalPages', { count: totalPages }) }}
      </template>
      <template v-else>
        {{
          $t('views.pdf.pageInfo', { current: currentPage, total: totalPages })
        }}
      </template>
    </div>
    <div ref="textLayer" class="tw-text-layer" v-show="false"></div>
  </div>
</template>

<script lang="ts" setup>
import { ref, onMounted, computed } from 'vue';
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist';
import { useResizeObserver } from '@vueuse/core';
import { TextLayerBuilder } from 'pdfjs-dist/web/pdf_viewer.mjs';
import 'pdfjs-dist/web/pdf_viewer.css';

// PDF.jsのワーカーのパスを設定
GlobalWorkerOptions.workerSrc =
  '../../../node_modules/pdfjs-dist/build/pdf.worker.mjs';

// コンポーネントのプロパティを定義
const props = defineProps({
  pdfUrl: {
    type: String,
    default: '',
  },
  pdfName: {
    type: String,
    default: 'test.pdf',
  },
  initialZoom: {
    type: Number,
    default: 150,
  },
  initialRotation: {
    type: Number,
    default: 0,
  },
});

const pdfUrl = ref(props.pdfUrl);
const pdfCanvas = ref<HTMLCanvasElement | null>(null);
const pdfContainer = ref<HTMLDivElement | null>(null);
const currentPage = ref(1);
const totalPages = ref(0);
const scale = ref(props.initialZoom / 100);
const zoomPercentage = ref(props.initialZoom);
const maxZoom = 4.0;
const minZoom = 0.1;
const rotation = ref(props.initialRotation);

const isAllPages = ref(false);
const initialScale = 1.5;
const initialZoomPercentage = 150;
const initialOffsetX = 0;
const initialOffsetY = 0;

const zoomInDisabled = computed(() => scale.value >= maxZoom);
const zoomOutDisabled = computed(() => scale.value <= minZoom);

let renderTask: Promise<void> | null = null;
let pendingRender = false;
let isDragging = false;
let startX = 0;
let startY = 0;
let offsetX = initialOffsetX;
let offsetY = initialOffsetY;
const textLayer = ref<HTMLDivElement | null>(null);

// PDFをレンダリングする関数
const renderPDF = async (pageNum?: number) => {
  if (pdfCanvas.value && textLayer.value) {
    if (renderTask) {
      await renderTask;
    }

    if (pendingRender) {
      return;
    }

    const context = pdfCanvas.value.getContext('2d');
    if (context) {
      context.clearRect(0, 0, pdfCanvas.value.width, pdfCanvas.value.height);
    }

    pendingRender = true;
    const loadingTask = getDocument(pdfUrl.value);
    const pdf = await loadingTask.promise;
    totalPages.value = pdf.numPages;

    if (isAllPages.value) {
      let totalHeight = 0;
      let maxWidth = 0;
      for (let i = 1; i <= totalPages.value; i++) {
        const page = await pdf.getPage(i);
        const viewport = page.getViewport({ scale: scale.value });
        totalHeight += viewport.height;
        maxWidth = Math.max(maxWidth, viewport.width);
      }

      pdfCanvas.value.height = totalHeight * window.devicePixelRatio;
      pdfCanvas.value.width = maxWidth * window.devicePixelRatio;
      context?.scale(window.devicePixelRatio, window.devicePixelRatio);

      for (let i = 1; i <= totalPages.value; i++) {
        const page = await pdf.getPage(i);
        const viewport = page.getViewport({ scale: scale.value });
        const renderContext = {
          canvasContext: context,
          viewport: viewport,
        };

        renderTask = page.render(renderContext).promise;
        await renderTask;

        context.translate(0, viewport.height);
      }
    } else {
      const page = await pdf.getPage(pageNum || 1);
      const viewport = page.getViewport({ scale: scale.value });
      pdfCanvas.value.height = viewport.height * window.devicePixelRatio;
      pdfCanvas.value.width = viewport.width * window.devicePixelRatio;
      context?.scale(window.devicePixelRatio, window.devicePixelRatio);

      const renderContext = {
        canvasContext: context,
        viewport: viewport,
      };

      renderTask = page.render(renderContext).promise;
      await renderTask;
    }

    // 以前のテキストレイヤーの内容をクリア
    if (textLayer.value) {
      textLayer.value.innerHTML = '';
    }

    if (isAllPages.value) {
      for (let i = 1; i <= totalPages.value; i++) {
        const page = await pdf.getPage(i);
        const viewport = page.getViewport({ scale: scale.value });
        const textLayerBuilder = new TextLayerBuilder({
          pdfPage: page,
          highlighter: null,
          accessibilityManager: null,
          enablePermissions: false,
        });
        textLayerBuilder.div = textLayer.value!;
        await textLayerBuilder.render(viewport);
      }
    } else {
      const page = await pdf.getPage(pageNum || 1);
      const viewport = page.getViewport({ scale: scale.value });
      const textLayerBuilder = new TextLayerBuilder({
        pdfPage: page,
        highlighter: null,
        accessibilityManager: null,
        enablePermissions: false,
      });
      textLayerBuilder.div = textLayer.value!;
      await textLayerBuilder.render(viewport);
    }

    pendingRender = false;
    centerCanvas();
  }
};

// ズームインの処理
const zoomIn = () => {
  if (scale.value < maxZoom) {
    scale.value *= 1.1;
    zoomPercentage.value = Math.round(scale.value * 100);
    renderPDF(isAllPages.value ? undefined : currentPage.value);
  }
};

// ズームアウトの処理
const zoomOut = () => {
  if (scale.value > minZoom) {
    scale.value /= 1.1;
    zoomPercentage.value = Math.round(scale.value * 100);
    renderPDF(isAllPages.value ? undefined : currentPage.value);
  }
};

// ズームパーセンテージの更新処理
const updateZoom = () => {
  if (zoomPercentage.value < 10) {
    zoomPercentage.value = 10;
  } else if (zoomPercentage.value > 400) {
    zoomPercentage.value = 400;
  }
  scale.value = zoomPercentage.value / 100;
  renderPDF(isAllPages.value ? undefined : currentPage.value);
};

// 前のページに移動する処理
const prevPage = () => {
  if (currentPage.value > 1) {
    currentPage.value--;
    renderPDF();
  }
};

// 次のページに移動する処理
const nextPage = () => {
  if (currentPage.value < totalPages.value) {
    currentPage.value++;
    renderPDF();
  }
};

// 単一ページ表示と全ページ表示の切り替え処理
const toggleView = () => {
  isAllPages.value = !isAllPages.value;
  currentPage.value = 1;
  renderPDF();
};

// PDFのダウンロード処理
const downloadPDF = () => {
  const link = document.createElement('a');
  link.href = pdfUrl.value;
  link.download = props.pdfName;
  link.click();
};

// PDFのリセット処理
const reset = () => {
  scale.value = initialScale;
  zoomPercentage.value = initialZoomPercentage;
  rotation.value = 0;
  offsetX = initialOffsetX;
  offsetY = initialOffsetY;
  renderPDF(isAllPages.value ? undefined : currentPage.value);
};

// PDFの回転処理（左回転）
const rotateLeft = () => {
  rotation.value -= 90;
  if (rotation.value < 0) rotation.value = 270;
  renderPDF(isAllPages.value ? undefined : currentPage.value);
};

// PDFの回転処理（右回転）
const rotateRight = () => {
  rotation.value += 90;
  if (rotation.value >= 360) rotation.value = 0;
  renderPDF(isAllPages.value ? undefined : currentPage.value);
};

// テキストのアラート処理
const alertText = () => {
  alert('テキスト機能はまだ未実装です。');
};

// ドラッグ操作の開始
const startDrag = (event: MouseEvent) => {
  isDragging = true;
  startX = event.clientX;
  startY = event.clientY;
};

// ドラッグ操作の停止
const stopDrag = () => {
  isDragging = false;
};

// ドラッグ操作の処理
const drag = (event: MouseEvent) => {
  if (isDragging) {
    const dx = event.clientX - startX;
    const dy = event.clientY - startY;
    offsetX += dx;
    offsetY += dy;
    pdfContainer.value!.scrollLeft -= dx;
    pdfContainer.value!.scrollTop -= dy;
    startX = event.clientX;
    startY = event.clientY;
  }
};

// キャンバスの中央揃え
const centerCanvas = () => {
  if (pdfCanvas.value && pdfContainer.value) {
    const canvasWidth = pdfCanvas.value.width;
    const containerWidth = pdfContainer.value.clientWidth;
    const canvasHeight = pdfCanvas.value.height;
    const containerHeight = pdfContainer.value.clientHeight;
    const left = (containerWidth - canvasWidth) / 2;
    const top = (containerHeight - canvasHeight) / 2;
    pdfCanvas.value.style.left = `${left}px`;
    pdfCanvas.value.style.top = `${top}px`;
  }
};

// サイズ変更オブザーバーの設定
onMounted(() => {
  if (pdfContainer.value) {
    const { stop } = useResizeObserver(pdfContainer, () => {
      renderPDF(isAllPages.value ? undefined : currentPage.value);
    });
    onUnmounted(stop);
  }
});

// 初回レンダリング
onMounted(() => {
  renderPDF(isAllPages.value ? undefined : currentPage.value);
});
</script>

<style scoped>
.tw-container {
  max-width: 100%;
  margin: auto;
}

.tw-btn {
  margin: 0 4px;
  padding: 8px 16px;
  font-size: 14px;
}

.tw-canvas {
  border: 1px solid #ddd;
  background-color: #fff;
}

.tw-text-layer {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

.tw-mx-2 {
  margin-left: 8px;
  margin-right: 8px;
}

.tw-text-center {
  text-align: center;
}

.tw-w-20 {
  width: 80px;
}

.tw-bg-red-500 {
  background-color: #f56565;
}
</style>

```
まとめ: pdf.jsは市場で最も人気のあるPDF関連のプラグインであり、ユーザー層が広く、機能が充実しており、無料で利用できます。これが私がpdf.jsを選んだ理由です。これにより、プログラムがより柔軟になり、拡張が容易になります。