1
2

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

Posted at

GitHub アドレス:

連絡先メール:

yangrongwei1996@gmail.com

機能概要(pdf.jsプラグイン使用)

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

デモリンク:デモリンク

pdf.png

PDFコンポテンスの作成

<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を選んだ理由です。これにより、プログラムがより柔軟になり、拡張が容易になります。

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2