4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

プリザンターの拡張機能だけでPDFプレビューを実装してみる

4
Posted at

はじめに

プリザンターの添付ファイル項目に PDF を添付することはよくあります。しかし、添付された PDF の中身を確認するには一度ダウンロードしてからビューアーで開く必要があり、ちょっとした確認でも手間がかかります。

この記事では、拡張スクリプトと拡張スタイルだけで以下の機能を実装します。

  • 編集画面:添付ファイル項目の下に PDF プレビューを埋め込み表示。複数の PDF がある場合はタブで切り替え。PDF がないときは何も表示しない
  • 一覧画面:PDF ファイルの横にプレビューボタンを追加。クリックするとモーダルで表示
  • 編集画面でもモーダルによる拡大表示に対応
  • 日本語フォントを含む PDF も正しくレンダリング

バージョン 1.5.1.0 以降を対象にしています

なお、今回の実装では PDF.js のファイルをプリザンターのサーバに直接配置するのではなく、CDN から読み込む方式を採用しています。日本語 PDF を正しく表示するには CMap(.bcmap)ファイルの読み込みが必要ですが、プリザンターの MIME 設定は .bcmap の配信に対応していないため、サーバに静的ファイルとして組み込む方法では日本語 PDF が正しく表示できません。CDN を経由することでこの制約を回避しています。仕組みの詳細は後述します。

PDF.js とは?

PDF.js は Mozilla が開発している JavaScript 製の PDF レンダリングライブラリです。ブラウザのネイティブ PDF ビューアーに依存せず、<canvas> 要素に PDF のページを描画できます。

今回は PDF.js を CDN 経由で動的に読み込み、拡張機能だけで完結する実装にします。

仕組みを整理する

編集画面の添付ファイル項目

編集画面の添付ファイル項目は次のような HTML 構造になっています。

<div id="Results_AttachmentsAField" class="field-wide">
  <p class="field-label"><label>添付ファイルA</label></p>
  <div class="field-control">
    <div class="container-normal">
      <input id="Results_AttachmentsA" class="control-attachments"
             data-name="AttachmentsA" type="hidden" value="[...]">
      <div id="AttachmentsA.items" class="control-attachments-items">
        <div id="{GUID}" class="control-attachments-item already-attachments">
          <a class="file-name" href="/binaries/{guid}/show" target="_blank">
            <span class="ui-icon ui-icon-circle-zoomin show-file"></span>
          </a>
          <a class="file-name" href="/binaries/{guid}/download">
            report.pdf (256.00 KB)
          </a>
          <div class="ui-icon ui-icon-circle-close delete-file" ...></div>
        </div>
      </div>
    </div>
  </div>
</div>

外側の div#Results_AttachmentsAField には、テーブルの管理→エディタで設定したフィールド CSS のクラスが追加されます。添付ファイル項目はデフォルトで field-wide(横幅いっぱい)になっているため、ここに pdf-viewer を追加するだけでビューアーの表示対象として認識させることができます。

ポイントは input.control-attachments の hidden input です。この value 属性には添付ファイルの情報が JSON 配列として格納されており、各オブジェクトに GuidNameExtension などのプロパティが含まれます。この JSON をパースすることで、DOM テキストの解析なしにファイル名とダウンロード URL(/binaries/{Guid}/download)を取得できます。拡張子が .pdf のものだけを対象にします。

一覧画面の添付ファイル項目

一覧画面では、添付ファイルはシンプルなリストで表示されます。

<ol>
  <li>
    <a href="/binaries/{guid}/download">report.pdf</a>
  </li>
  <li>
    <a href="/binaries/{guid}/download">photo.png</a>
  </li>
</ol>

/download リンクのテキストが .pdf で終わるものを見つけて、横にプレビューボタンを追加します。

PDF.js の読み込みと日本語対応

冒頭で触れたとおり、PDF.js のファイルをプリザンターのサーバに配置する方法は日本語 PDF で問題が生じます。日本語フォントを含む PDF を正しく表示するには CMap(Character Map)ファイル(.bcmap)の読み込みが必要ですが、プリザンターの Web サーバは .bcmap 拡張子の MIME タイプを認識しないため、ファイルの配信がブロックされてしまいます。

このため、PDF.js 本体と CMap ファイルはすべて CDN から取得する構成にしています。

PDF.js 本体    ← jsdelivr (pdfjs-dist パッケージ)
Worker         ← jsdelivr (pdfjs-dist パッケージ)
CMap ファイル  ← jsdelivr (pdfjs-dist パッケージ)
標準フォント   ← jsdelivr (pdfjs-dist パッケージ)

getDocument() のオプションで cMapUrlcMapPacked: true を指定すると、CJK フォントの CMap ファイルが必要に応じて自動的にダウンロードされます。

対象項目の設定

編集画面でどの添付ファイル項目に PDF プレビューを表示するかは、スクリプト冒頭の MODE 変数で切り替えられます。用途に応じて 3 つのモードから選択してください。

モード MODE の値 動作
個別指定 'include' フィールド CSS に pdf-viewer を追加した項目だけにビューアーを表示(既定)
全適用 'all' すべての添付ファイル項目にビューアーを表示
除外指定 'exclude' フィールド CSS に no-pdf-viewer を追加した項目を除くすべての添付ファイル項目にビューアーを表示

個別指定モード(include

特定の項目だけにビューアーを表示したい場合に使います。これが既定のモードです。

スクリプトの設定

var MODE = 'include';

設定手順

  1. テーブルの管理→エディタ を開く
  2. PDF プレビューを表示したい添付ファイル項目を選択して「詳細設定」を開く
  3. 「フィールド CSS」に pdf-viewer と入力して保存する

全適用モード(all

すべての添付ファイル項目にビューアーを表示したい場合に使います。フィールド CSS の設定は不要です。

スクリプトの設定

var MODE = 'all';

除外指定モード(exclude

大半の項目にビューアーを表示し、一部だけ除外したい場合に使います。

スクリプトの設定

var MODE = 'exclude';

設定手順

  1. テーブルの管理→エディタ を開く
  2. ビューアーを表示しない添付ファイル項目を選択して「詳細設定」を開く
  3. 「フィールド CSS」に no-pdf-viewer と入力して保存する

フィールド CSS に設定したクラスは、フィールドの外側の div に追加されます。添付ファイル項目はデフォルトで横幅いっぱいに表示されるため、ビューアーも広い領域を使ってプレビューを表示できます。

一覧画面のプレビューボタンはモード設定に関係なく、すべての PDF リンクに表示されます。

実装してみよう

拡張スクリプトと拡張スタイルの 2 ファイルで構成します。

拡張機能 役割
拡張スクリプト PDF.js 読み込み・ビューアー生成・モーダル制御
拡張スタイル 埋め込みビューアー・モーダルの見た目を定義

拡張スタイル

拡張スタイルとして App_Data/Parameters/ExtendedStyles/ に配置します。

ExtendedStyles/PdfViewer.css
/* ===== 埋め込みビューアー(編集画面) ===== */
.pdf-embed-field {
  padding-top: 0;
  min-height: 0;
}

.pdf-embed {
  border: 1px solid var(--base-border);
  border-radius: 4px;
  overflow: hidden;
}

.pdf-embed.collapsed .pdf-canvas-wrap,
.pdf-embed.collapsed .pdf-controls {
  display: none;
}

.pdf-embed .pdf-tabs {
  display: flex;
  align-items: center;
  border-bottom: 1px solid var(--base-border);
  background: var(--base-bg-light);
  overflow-x: auto;
}

.pdf-embed .pdf-tab {
  padding: 6px 14px;
  border: none;
  background: none;
  cursor: pointer;
  font-size: 12px;
  white-space: nowrap;
  border-bottom: 2px solid transparent;
  color: var(--scrollbar-thumb);
  transition: color 0.2s, border-color 0.2s;
}

.pdf-embed .pdf-tab:hover {
  color: var(--base-text);
}

.pdf-embed .pdf-tab.active {
  color: var(--primaryColor);
  border-bottom-color: var(--primaryColor);
}

.pdf-embed .pdf-canvas-wrap {
  position: relative;
  display: flex;
  justify-content: center;
  padding: 8px;
  background: var(--page-bg);
  overflow: auto;
}

.pdf-embed:not(.pdf-fit-width):not(.pdf-fit-height):not(.pdf-fit-zoom) .pdf-canvas-wrap {
  aspect-ratio: 2 / 1;
}

.pdf-embed canvas {
  max-width: 100%;
  box-shadow: 0 1px 6px var(--base-shadow);
}

/* ===== ローディング ===== */
.pdf-loader {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--base-dark-layer);
  z-index: 1;
  transition: opacity 0.2s;
}

.pdf-loader.hidden {
  opacity: 0;
  pointer-events: none;
}

.pdf-loader .pdf-spinner {
  width: 40px;
  height: 40px;
  border: 4px solid var(--scrollbar-thumb);
  border-right-color: transparent;
  border-radius: 50%;
  animation: pdf-rotate 1s linear infinite;
}

@keyframes pdf-rotate {
  to {
    transform: rotate(1turn);
  }
}

.pdf-embed .pdf-controls {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 4px 8px;
  background: var(--base-bg-light);
  border-top: 1px solid var(--base-border);
}

.pdf-ctrl-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px;
  border-radius: 4px;
  color: var(--base-text);
}

.pdf-ctrl-btn:hover {
  background: var(--btn-normal-hover);
}

.pdf-page-info {
  font-size: 13px;
  min-width: 60px;
  text-align: center;
}

.pdf-toggle-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px 8px;
  border-radius: 4px;
  color: var(--base-text);
  margin-left: auto;
  font-size: 12px;
}

.pdf-toggle-btn:hover {
  background: var(--btn-normal-hover);
}

.pdf-fit-group {
  display: inline-flex;
  border: 1px solid var(--control-border);
  border-radius: 4px;
  overflow: hidden;
}

.pdf-fit-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 4px;
  border: none;
  border-right: 1px solid var(--control-border);
  background: var(--base-bg);
  color: var(--base-text);
  cursor: pointer;
}

.pdf-fit-btn .material-symbols-outlined {
  font-size: 18px;
}

.pdf-fit-btn:last-child {
  border-right: none;
}

.pdf-fit-btn:hover {
  background: var(--btn-normal-hover);
}

.pdf-fit-btn.active {
  background: var(--primaryColor);
  color: var(--invert-text);
}

.pdf-zoom-group {
  display: inline-flex;
  align-items: center;
  gap: 2px;
}

.pdf-zoom-info {
  font-size: 12px;
  min-width: 40px;
  text-align: center;
  color: var(--base-text);
}

/* ===== プレビューボタン(一覧画面) ===== */
.pdf-preview-btn {
  font-size: 18px !important;
  vertical-align: middle;
  cursor: pointer;
  color: var(--warning-color);
  margin-left: 4px;
}

.pdf-preview-btn:hover {
  opacity: 0.8;
}

/* ===== モーダル ===== */
#pdf-modal {
  position: fixed;
  inset: 0;
  z-index: 9998;
  display: none;
  align-items: center;
  justify-content: center;
}

#pdf-modal .pdf-modal-overlay {
  position: fixed;
  inset: 0;
  background: var(--u-modal-bg);
}

#pdf-modal .pdf-modal-dialog {
  position: relative;
  background: var(--base-bg);
  border-radius: 8px;
  width: 95vw;
  max-width: 1400px;
  height: 92vh;
  display: flex;
  flex-direction: column;
  box-shadow: 0 8px 32px var(--base-shadow);
  z-index: 1;
}

#pdf-modal .pdf-modal-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 16px;
  border-bottom: 1px solid var(--base-border);
}

#pdf-modal .pdf-modal-title {
  font-weight: bold;
  font-size: 14px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

#pdf-modal .pdf-modal-close {
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px;
  display: flex;
  color: var(--base-text);
}

#pdf-modal .pdf-modal-close:hover {
  opacity: 0.7;
}

#pdf-modal .pdf-modal-body {
  flex: 1;
  overflow: auto;
  display: flex;
  justify-content: center;
  padding: 12px;
  background: var(--page-bg);
}

#pdf-modal .pdf-modal-body canvas {
  max-width: 100%;
  box-shadow: 0 2px 8px var(--base-shadow);
}

#pdf-modal .pdf-modal-controls {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  padding: 8px 16px;
  border-top: 1px solid var(--base-border);
}

#pdf-modal .pdf-modal-controls button {
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px;
  display: flex;
  color: var(--base-text);
  border-radius: 4px;
}

#pdf-modal .pdf-modal-controls button:hover {
  background: var(--btn-normal-hover);
}

/* ===== レスポンシブ対応 ===== */
@media (max-width: 1024px) {
  .pdf-embed-field > .field-label {
    display: none;
  }
}

@media (max-width: 768px) {
  #pdf-modal .pdf-modal-dialog {
    width: 100vw;
    height: 100vh;
    max-width: none;
    border-radius: 0;
  }

  #pdf-modal .pdf-modal-header {
    padding: 8px 12px;
  }

  #pdf-modal .pdf-modal-body {
    padding: 4px;
  }

  #pdf-modal .pdf-modal-controls {
    padding: 6px 8px;
  }

  .pdf-embed .pdf-tab {
    padding: 4px 10px;
    font-size: 11px;
  }
}

拡張スクリプト

拡張スクリプトとして App_Data/Parameters/ExtendedScripts/ に配置します。

ExtendedScripts/PdfViewer.js
$(function () {
  // PDF.js の設定(バージョンを固定して動作の安定性を確保)
  var PDFJS_VER = '4.10.38';
  var DIST = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@' + PDFJS_VER;

  // モード設定: 'include' | 'all' | 'exclude'
  //   include : フィールドCSSに pdf-viewer がある項目だけ対象(既定)
  //   all     : すべての添付ファイル項目を対象
  //   exclude : フィールドCSSに no-pdf-viewer がある項目を除外
  var MODE = 'include';
  var CSS_INCLUDE = 'pdf-viewer';
  var CSS_EXCLUDE = 'no-pdf-viewer';

  // 表示モードの初期状態: true = 折りたたみ / false = 展開(既定)
  var DEFAULT_COLLAPSED = false;

  // フィットモード: 'page' | 'width' | 'height' | 'zoom'
  //   page   : ページ全体を収める(既定)
  //   width  : 横幅にフィット
  //   height : 縦幅にフィット
  //   zoom   : 指定倍率で表示
  var DEFAULT_FIT = 'page';

  // ズーム倍率の初期値(%)と増減幅
  var DEFAULT_ZOOM = 100;
  var ZOOM_STEP = 25;

  // 編集画面・一覧画面のみ対象
  var action = $p.action();
  if (action !== 'edit' && action !== 'index') return;

  // PDF.js を CDN から動的に読み込む
  import(DIST + '/build/pdf.min.mjs')
    .then(function (pdfjsLib) {
      pdfjsLib.GlobalWorkerOptions.workerSrc =
        DIST + '/build/pdf.worker.min.mjs';

      if (action === 'edit') {
        initEdit(pdfjsLib);
        // ファイル追加・削除・更新後にビューアーを再構築
        var timer = null;
        $(document).ajaxComplete(function () {
          clearTimeout(timer);
          timer = setTimeout(function () {
            initEdit(pdfjsLib);
          }, 300);
        });
      }
      if (action === 'index') initList(pdfjsLib);
    })
    .catch(function (e) {
      console.error('PDF.js の読み込みに失敗しました:', e);
    });

  /* ========== 編集画面 ========== */

  function initEdit(pdfjsLib) {
    // 既存のビューアーを除去して再構築
    document.querySelectorAll('.pdf-embed-field').forEach(function (el) {
      var embed = el.querySelector('.pdf-embed');
      if (embed && embed._vs && embed._vs.doc) embed._vs.doc.destroy();
      el.remove();
    });

    document
      .querySelectorAll('.control-attachments')
      .forEach(function (input) {
        // モードに応じて対象項目を判定
        var field = input.closest('[id$="Field"]');
        if (!shouldAttach(field)) return;

        var files = findPdfFiles(input);
        if (files.length === 0) return;
        buildEmbedViewer(field, files, pdfjsLib);
      });
  }

  // モードに応じて対象項目かどうかを判定する
  function shouldAttach(field) {
    if (!field) return false;
    if (MODE === 'all') return true;
    if (MODE === 'exclude') return !field.classList.contains(CSS_EXCLUDE);
    return field.classList.contains(CSS_INCLUDE); // include(既定)
  }

  // hidden input の JSON から PDF だけを抽出する
  function findPdfFiles(input) {
    var files = [];
    try {
      var list = JSON.parse(input.value || '[]');
    } catch (e) {
      return files;
    }
    list.forEach(function (att) {
      if (att.Deleted) return;
      if (!att.Name || !/\.pdf$/i.test(att.Name)) return;
      files.push({
        name: att.Name,
        url: '/binaries/' + att.Guid + '/download'
      });
    });
    return files;
  }

  // 埋め込みビューアーを生成する
  function buildEmbedViewer(field, pdfFiles, pdfjsLib) {
    // 添付ファイル項目の直後に field-wide の wrapper を作成
    var wrapper = document.createElement('div');
    wrapper.className = 'field-wide pdf-embed-field';
    // 空の field-label でラベル幅を揃える
    var label = document.createElement('p');
    label.className = 'field-label';
    label.appendChild(document.createElement('label'));
    wrapper.appendChild(label);
    var fc = document.createElement('div');
    fc.className = 'field-control';
    var cn = document.createElement('div');
    cn.className = 'container-normal';
    fc.appendChild(cn);
    wrapper.appendChild(fc);

    var el = document.createElement('div');
    el.className = 'pdf-embed';

    // タブバー(ファイルタブ+トグルボタン)
    var tabBar = document.createElement('div');
    tabBar.className = 'pdf-tabs';

    var btnToggle = document.createElement('button');
    btnToggle.type = 'button';
    btnToggle.className = 'pdf-toggle-btn';
    btnToggle.textContent = DEFAULT_COLLAPSED ? '' : '';
    btnToggle.title = 'プレビューの表示切替';

    // キャンバス
    var wrap = document.createElement('div');
    wrap.className = 'pdf-canvas-wrap';
    var canvas = document.createElement('canvas');
    // ローディング
    var loader = document.createElement('div');
    loader.className = 'pdf-loader';
    loader.innerHTML = '<div class="pdf-spinner"></div>';
    wrap.append(canvas, loader);

    // コントロールバー
    var ctrl = document.createElement('div');
    ctrl.className = 'pdf-controls';
    var btnPrev = iconBtn('chevron_left');
    var btnNext = iconBtn('chevron_right');
    var info = document.createElement('span');
    info.className = 'pdf-page-info';
    var btnOpen = iconBtn('open_in_new');
    btnOpen.title = '別ウィンドウで表示';

    // フィットモードボタン
    var fitGroup = document.createElement('div');
    fitGroup.className = 'pdf-fit-group';
    var fitModes = [
      ['page', 'fit_screen', 'ページ'],
      ['width', 'width', ''],
      ['height', 'height', '高さ'],
      ['zoom', 'percent', '%']
    ];
    fitModes.forEach(function (m) {
      var b = document.createElement('button');
      b.type = 'button';
      b.className = 'pdf-fit-btn' + (m[0] === DEFAULT_FIT ? ' active' : '');
      var ico = document.createElement('span');
      ico.className = 'material-symbols-outlined';
      ico.textContent = m[1];
      b.appendChild(ico);
      b.title = m[2];
      b.dataset.fit = m[0];
      fitGroup.appendChild(b);
    });

    // ズームコントロール
    var zoomGroup = document.createElement('div');
    zoomGroup.className = 'pdf-zoom-group';
    var btnZoomOut = iconBtn('remove');
    btnZoomOut.title = '縮小';
    var zoomInfo = document.createElement('span');
    zoomInfo.className = 'pdf-zoom-info';
    zoomInfo.textContent = DEFAULT_ZOOM + '%';
    var btnZoomIn = iconBtn('add');
    btnZoomIn.title = '拡大';
    zoomGroup.append(btnZoomOut, zoomInfo, btnZoomIn);
    zoomGroup.style.display = DEFAULT_FIT === 'zoom' ? '' : 'none';

    ctrl.append(btnPrev, info, btnNext, fitGroup, zoomGroup, btnOpen);

    el.append(tabBar, wrap, ctrl);
    if (DEFAULT_COLLAPSED) el.classList.add('collapsed');
    cn.appendChild(el);
    field.after(wrapper);

    // ビューアーの状態管理
    var vs = {
      idx: 0,
      page: 1,
      pages: 0,
      doc: null,
      busy: false,
      fit: DEFAULT_FIT,
      zoom: DEFAULT_ZOOM,
      files: pdfFiles,
      canvas: canvas,
      wrap: wrap,
      loader: loader,
      el: el,
      info: info
    };
    el._vs = vs;

    // タブ生成
    pdfFiles.forEach(function (f, i) {
      var tab = document.createElement('button');
      tab.type = 'button';
      tab.className = 'pdf-tab' + (i === 0 ? ' active' : '');
      tab.textContent = f.name;
      tab.onclick = function () {
        tabBar.querySelectorAll('.pdf-tab').forEach(function (t, j) {
          t.classList.toggle('active', j === i);
        });
        loadDoc(vs, i, pdfjsLib);
      };
      tabBar.appendChild(tab);
    });
    tabBar.appendChild(btnToggle);

    // ボタンイベント
    btnPrev.onclick = function () {
      turnPage(vs, -1);
    };
    btnNext.onclick = function () {
      turnPage(vs, 1);
    };
    btnOpen.onclick = function () {
      openModal(vs.files[vs.idx], vs.page, pdfjsLib);
    };
    btnToggle.onclick = function () {
      var collapsed = el.classList.toggle('collapsed');
      btnToggle.textContent = collapsed ? '' : '';
    };
    // フィットモードボタン
    fitGroup.onclick = function (e) {
      var btn = e.target.closest('.pdf-fit-btn');
      if (!btn) return;
      fitGroup.querySelectorAll('.pdf-fit-btn').forEach(function (b) {
        b.classList.toggle('active', b === btn);
      });
      vs.fit = btn.dataset.fit;
      el.classList.remove('pdf-fit-width', 'pdf-fit-height', 'pdf-fit-zoom');
      if (vs.fit === 'width') el.classList.add('pdf-fit-width');
      if (vs.fit === 'height') el.classList.add('pdf-fit-height');
      if (vs.fit === 'zoom') el.classList.add('pdf-fit-zoom');
      zoomGroup.style.display = vs.fit === 'zoom' ? '' : 'none';
      render(vs);
    };
    // ズーム操作
    btnZoomIn.onclick = function () {
      vs.zoom = Math.min(vs.zoom + ZOOM_STEP, 500);
      zoomInfo.textContent = vs.zoom + '%';
      if (vs.fit === 'zoom') render(vs);
    };
    btnZoomOut.onclick = function () {
      vs.zoom = Math.max(vs.zoom - ZOOM_STEP, 25);
      zoomInfo.textContent = vs.zoom + '%';
      if (vs.fit === 'zoom') render(vs);
    };
    // 初期フィットモードを適用
    if (DEFAULT_FIT === 'width') el.classList.add('pdf-fit-width');
    if (DEFAULT_FIT === 'height') el.classList.add('pdf-fit-height');
    if (DEFAULT_FIT === 'zoom') el.classList.add('pdf-fit-zoom');

    // 最初の PDF を読み込む
    loadDoc(vs, 0, pdfjsLib);
  }

  /* ========== 一覧画面 ========== */

  function initList(pdfjsLib) {
    document
      .querySelectorAll('a[href*="/binaries/"][href*="/download"]')
      .forEach(function (a) {
        if (!/\.pdf$/i.test(a.textContent.trim())) return;
        var btn = document.createElement('span');
        btn.className = 'material-symbols-outlined pdf-preview-btn';
        btn.textContent = 'preview';
        btn.title = 'PDF プレビュー';
        btn.onclick = function (e) {
          e.preventDefault();
          e.stopPropagation();
          openModal(
            { name: a.textContent.trim(), url: a.getAttribute('href') },
            1,
            pdfjsLib
          );
        };
        a.after(btn);
      });
  }

  /* ========== PDF 読み込み・描画 ========== */

  function loadDoc(vs, idx, pdfjsLib) {
    if (vs.doc) {
      vs.doc.destroy();
      vs.doc = null;
    }
    vs.idx = idx;
    vs.page = 1;
    vs.info.textContent = '読み込み中...';
    if (vs.loader) vs.loader.classList.remove('hidden');

    pdfjsLib
      .getDocument({
        url: vs.files[idx].url,
        cMapUrl: DIST + '/cmaps/',
        cMapPacked: true,
        standardFontDataUrl: DIST + '/standard_fonts/'
      })
      .promise.then(function (pdf) {
        vs.doc = pdf;
        vs.pages = pdf.numPages;
        render(vs);
      })
      .catch(function () {
        vs.info.textContent = '読み込み失敗';
      });
  }

  function render(vs) {
    if (!vs.doc || vs.busy) return;
    vs.busy = true;
    vs.info.textContent = vs.page + ' / ' + vs.pages;

    vs.doc.getPage(vs.page).then(function (pg) {
      var vp = pg.getViewport({ scale: 1 });
      var wrapEl = vs.wrap || vs.canvas.parentElement;
      var maxW = wrapEl.clientWidth - 16;
      var maxH = wrapEl.clientHeight - 16;
      var scale;

      switch (vs.fit) {
        case 'width':
          scale = maxW / vp.width;
          break;
        case 'height':
          scale = maxH > 0 ? maxH / vp.height : 1;
          break;
        case 'zoom':
          scale = (vs.zoom || 100) / 100;
          break;
        default: // page
          scale = Math.min(maxW / vp.width, maxH > 0 ? maxH / vp.height : 1.5);
          break;
      }
      vp = pg.getViewport({ scale: scale });
      vs.canvas.width = vp.width;
      vs.canvas.height = vp.height;

      pg.render({
        canvasContext: vs.canvas.getContext('2d'),
        viewport: vp
      }).promise.then(function () {
        vs.busy = false;
        if (vs.loader) vs.loader.classList.add('hidden');
      });
    });
  }

  function turnPage(vs, delta) {
    var n = vs.page + delta;
    if (n >= 1 && n <= vs.pages) {
      vs.page = n;
      render(vs);
    }
  }

  /* ========== モーダル ========== */

  function openModal(file, startPage, pdfjsLib) {
    var modal = document.getElementById('pdf-modal');
    if (!modal) modal = buildModal();

    modal.querySelector('.pdf-modal-title').textContent = file.name;
    modal.style.display = 'flex';

    var vs = {
      idx: 0,
      page: startPage || 1,
      pages: 0,
      doc: null,
      busy: false,
      files: [file],
      canvas: modal.querySelector('canvas'),
      info: modal.querySelector('.pdf-page-info')
    };
    modal._vs = vs;

    pdfjsLib
      .getDocument({
        url: file.url,
        cMapUrl: DIST + '/cmaps/',
        cMapPacked: true,
        standardFontDataUrl: DIST + '/standard_fonts/'
      })
      .promise.then(function (pdf) {
        vs.doc = pdf;
        vs.pages = pdf.numPages;
        render(vs);
      })
      .catch(function () {
        vs.info.textContent = '読み込み失敗';
      });

    // キーボード操作
    var onKey = function (e) {
      if (e.key === 'Escape') closeModal();
      if (e.key === 'ArrowLeft') turnPage(vs, -1);
      if (e.key === 'ArrowRight') turnPage(vs, 1);
    };
    document.addEventListener('keydown', onKey);
    modal._onKey = onKey;
  }

  function buildModal() {
    var m = document.createElement('div');
    m.id = 'pdf-modal';
    m.innerHTML =
      '<div class="pdf-modal-overlay"></div>' +
      '<div class="pdf-modal-dialog">' +
      '<div class="pdf-modal-header">' +
      '<span class="pdf-modal-title"></span>' +
      '<button type="button" class="pdf-modal-close">' +
      '<span class="material-symbols-outlined">close</span>' +
      '</button>' +
      '</div>' +
      '<div class="pdf-modal-body"><canvas></canvas></div>' +
      '<div class="pdf-modal-controls">' +
      '<button type="button" class="pdf-modal-prev">' +
      '<span class="material-symbols-outlined">chevron_left</span>' +
      '</button>' +
      '<span class="pdf-page-info"></span>' +
      '<button type="button" class="pdf-modal-next">' +
      '<span class="material-symbols-outlined">chevron_right</span>' +
      '</button>' +
      '</div>' +
      '</div>';

    document.body.appendChild(m);

    m.querySelector('.pdf-modal-overlay').onclick = closeModal;
    m.querySelector('.pdf-modal-close').onclick = closeModal;
    m.querySelector('.pdf-modal-prev').onclick = function () {
      turnPage(m._vs, -1);
    };
    m.querySelector('.pdf-modal-next').onclick = function () {
      turnPage(m._vs, 1);
    };
    return m;
  }

  function closeModal() {
    var m = document.getElementById('pdf-modal');
    if (!m) return;
    m.style.display = 'none';
    if (m._vs && m._vs.doc) {
      m._vs.doc.destroy();
      m._vs.doc = null;
    }
    if (m._onKey) {
      document.removeEventListener('keydown', m._onKey);
    }
  }

  /* ========== ユーティリティ ========== */

  function iconBtn(name) {
    var b = document.createElement('button');
    b.type = 'button';
    b.className = 'pdf-ctrl-btn';
    var s = document.createElement('span');
    s.className = 'material-symbols-outlined';
    s.textContent = name;
    b.appendChild(s);
    return b;
  }
});

各処理のポイント

PDF.js の動的読み込み

import() を使って PDF.js を CDN から動的に読み込んでいます。ES モジュール形式の .mjs ファイルを読み込むため、<script> タグではなく動的インポートを使用します。import() は主要ブラウザ(Chrome 63+、Firefox 67+、Safari 11.1+、Edge 79+)でサポートされており、通常の利用環境であれば問題なく動作します。読み込みに失敗した場合(ネットワークエラーなど)は catch でエラーをコンソールに出力して処理を終了します。

import(DIST + '/build/pdf.min.mjs')
  .then(function (pdfjsLib) {
    pdfjsLib.GlobalWorkerOptions.workerSrc =
      DIST + '/build/pdf.worker.min.mjs';
    // 初期化処理...
  });

表示対象の絞り込み

MODE 変数で 3 つの対象パターンを切り替えられます。shouldAttach() 関数が各添付ファイル項目のフィールド div を受け取り、ビューアーを生成するかどうかを返します。

// モードに応じて対象項目かどうかを判定する
function shouldAttach(field) {
  if (!field) return false;
  if (MODE === 'all') return true;
  if (MODE === 'exclude') return !field.classList.contains(CSS_EXCLUDE);
  return field.classList.contains(CSS_INCLUDE); // include(既定)
}
モード 判定ロジック
include フィールド CSS に pdf-viewerある項目だけにビューアーを表示(既定)
all すべての添付ファイル項目にビューアーを表示(フィールド CSS の設定不要)
exclude フィールド CSS に no-pdf-viewerない項目にビューアーを表示

initEdit() の中で、各 .control-attachments-items の祖先要素にあたるフィールドの div(ID が Field で終わる要素)を closest() でたどり、shouldAttach() で判定しています。

var field = container.closest('[id$="Field"]');
if (!shouldAttach(field)) return;

この仕組みにより、用途に応じて柔軟に対象項目を制御できます。たとえば all モードに設定すれば、画像専用の添付ファイルも含めすべての項目にビューアーが生成されます(PDF がなければ何も表示されません)。exclude モードなら、画像専用の項目だけに no-pdf-viewer を設定して除外できます。

埋め込みビューアーの配置

埋め込みビューアーは、添付ファイル項目の Field div([id$="Field"])の直後に field-wide pdf-embed-field クラスを持つ wrapper div を挿入し、その中に .pdf-embed を配置しています。field-wide はプリザンター標準のフィールドレイアウトクラスで、clear: bothwidth: 100% が設定されているため、添付ファイル項目内部の float の影響を受けずに自然な位置に配置されます。

var wrapper = document.createElement('div');
wrapper.className = 'field-wide pdf-embed-field';
var label = document.createElement('p');
label.className = 'field-label';
label.appendChild(document.createElement('label'));
wrapper.appendChild(label);
var fc = document.createElement('div');
fc.className = 'field-control';
var cn = document.createElement('div');
cn.className = 'container-normal';
fc.appendChild(cn);
wrapper.appendChild(fc);
cn.appendChild(el);
field.after(wrapper);

field-wide の標準レイアウトを再現するため、空の field-label(中に空の <label> を含む)と field-control > container-normal の構造を作成しています。これにより、ビューアーの左端が添付ファイル項目のコンテンツ領域と揃います。レスポンシブ(1024px 以下)では空のラベルが不要なスペースになるため、CSS で非表示にしています。

埋め込みビューアーのサイズ制御

埋め込みビューアーの高さは、「ページ」フィットモード時のみ CSS の aspect-ratio: 2 / 1 で横幅の 50% に固定しています。「幅」「高さ」「%」フィットモードでは aspect-ratio の制約が外れ、キャンバスの実サイズに応じた表示になります。

.pdf-embed:not(.pdf-fit-width):not(.pdf-fit-height):not(.pdf-fit-zoom) .pdf-canvas-wrap {
  aspect-ratio: 2 / 1;
}

PDF のページサイズがビューアー領域より大きい場合は overflow: auto でスクロールできます。aspect-ratio の値を変えることで「ページ」モード時の表示サイズを調整できます。たとえば 3 / 2 にすると横幅の約 67% の高さになります。

テーマ対応

ビューアーの配色にはプリザンターのテーマ CSS 変数を使用しています。テーマを切り替えると自動的にビューアーの色も追従します。主な対応は以下のとおりです。

用途 CSS 変数
ボーダー --base-border / --control-border
背景(タブバー・コントロール) --base-bg-light
背景(キャンバス・モーダル本体) --page-bg
背景(ボタン・ダイアログ) --base-bg
テキスト・アイコン --base-text
アクティブタブ・選択中ボタン --primaryColor
選択中ボタンのテキスト --invert-text
ホバー背景 --btn-normal-hover
--base-shadow
モーダルオーバーレイ --u-modal-bg
プレビューボタン(一覧画面) --warning-color
ローディングスピナー --scrollbar-thumb

旧テーマ(base / start 等)ではこれらの CSS 変数が定義されていないため、ビューアーの色が反映されません。cerulean(既定)や green-tea などの新テーマを使用してください。

ファイル操作後の再構築

編集画面ではファイルの追加・削除や更新ボタンの押下といった操作が Ajax で処理されます。画面遷移を伴わないため、初期ロード時に生成したビューアーは添付ファイルの実態と食い違う可能性があります。

これに対応するため、$(document).ajaxComplete() でページ上のすべての Ajax 完了を監視し、300ms のデバウンスを挟んでビューアーを再構築しています。

var timer = null;
$(document).ajaxComplete(function () {
  clearTimeout(timer);
  timer = setTimeout(function () {
    initEdit(pdfjsLib);
  }, 300);
});

initEdit() は呼ばれるたびに既存の .pdf-embed を除去してから再生成するため、ファイルが追加・削除された場合でもビューアーの内容が常に最新の状態になります。

添付ファイルからの PDF 検出

編集画面では、添付ファイル項目の hidden input(input.control-attachments)の value 属性に格納されている JSON 配列をパースして PDF ファイルを抽出しています。各オブジェクトには Name(ファイル名)、Guid(UUID)、Deleted(削除フラグ)などのプロパティが含まれており、DOM テキストを解析する必要がありません。ダウンロード URL は Guid から /binaries/{Guid}/download の形式で組み立てます。

function findPdfFiles(input) {
  var files = [];
  try {
    var list = JSON.parse(input.value || '[]');
  } catch (e) {
    return files;
  }
  list.forEach(function (att) {
    if (att.Deleted) return;
    if (!att.Name || !/\.pdf$/i.test(att.Name)) return;
    files.push({
      name: att.Name,
      url: '/binaries/' + att.Guid + '/download'
    });
  });
  return files;
}

Deleted フラグが true のファイルは削除予定(更新保存前)のため、ビューアーの対象から除外しています。

一覧画面では hidden input がないため、従来どおり DOM のダウンロードリンクからファイル名を取得しています。リンクテキストがファイル名そのままなので、.pdf で終わるかどうかだけを確認しています。

埋め込みビューアーのタブ切り替え

1つの添付ファイル項目に複数の PDF がある場合、タブバーを表示してファイルを切り替えられるようにしています。タブをクリックすると loadDoc() で新しい PDF を読み込み直します。PDF が1つだけの場合もタブバーは表示されるため、どのファイルがプレビュー対象かをひと目で確認できます。

複数の添付ファイル項目がある場合は、それぞれの項目ごとに独立したビューアーが生成されます。initEdit().control-attachments-itemsforEach で回しているためです。

プレビューの表示切替

タブバーの左端にあるトグルボタン(▼ / ◀)で、埋め込みビューアーの表示・非表示を切り替えられます。トグルボタンには margin-left: auto を設定しており、ファイルタブは右側に寄せて表示されます。クリックすると .pdf-embedcollapsed クラスが付与され、CSS でキャンバスとコントロールバーを非表示にします。

.pdf-embed.collapsed .pdf-canvas-wrap,
.pdf-embed.collapsed .pdf-controls {
  display: none;
}
btnToggle.onclick = function () {
  var collapsed = el.classList.toggle('collapsed');
  btnToggle.textContent = collapsed ? '' : '';
};

デフォルトの開閉状態は DEFAULT_COLLAPSED 変数で切り替えられます。true にすると初期状態でプレビューが折りたたまれ、タブバーだけが表示されます。必要なときにボタンをクリックして展開する運用に適しています。

// true = 折りたたみ(プレビュー非表示) / false = 展開(既定)
var DEFAULT_COLLAPSED = false;

モーダルビューアー

編集画面のコントロールバーにある open_in_new ボタンと一覧画面のプレビューボタンは、同じ openModal() 関数を呼び出します。モーダルはビューポートの 95%(最大 1400px)の幅と 92% の高さで表示されるため、PDF の内容を大きく確認できます。モバイル端末(768px 以下)ではフルスクリーン表示に切り替わります。モーダルの DOM は初回表示時に1つだけ生成して使い回します。キーボード操作にも対応しています。

キー 動作
Escape モーダルを閉じる
(左矢印) 前のページへ
(右矢印) 次のページへ

モーダルを閉じるときは PDF ドキュメントの destroy() を呼んでメモリを解放し、キーボードイベントリスナーも解除しています。

ローディング表示

PDF の読み込み中は、プリザンター標準のローディングスピナーと同じデザイン(円形のボーダー回転アニメーション)のローダーをキャンバス領域に表示します。loadDoc() でローダーを表示し、render() の描画完了後に非表示にします。

.pdf-loader {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--base-dark-layer);
}

.pdf-loader .pdf-spinner {
  width: 40px;
  height: 40px;
  border: 4px solid var(--scrollbar-thumb);
  border-right-color: transparent;
  border-radius: 50%;
  animation: pdf-rotate 1s linear infinite;
}

.pdf-canvas-wrapposition: relative を設定し、ローダーを position: absolute; inset: 0 でキャンバス全体にオーバーレイ表示しています。描画が完了すると hidden クラスを付与してフェードアウトさせます。

日本語 PDF の表示

PDF.js で日本語フォントを正しく表示するには、getDocument() のオプションで CMap ファイルの場所を指定する必要があります。

pdfjsLib.getDocument({
  url: pdfUrl,
  cMapUrl: DIST + '/cmaps/',
  cMapPacked: true,
  standardFontDataUrl: DIST + '/standard_fonts/'
})
オプション 説明
cMapUrl CMap ファイルのベース URL。CJK フォントの文字マッピングに使用
cMapPacked true でバイナリ形式(.bcmap)を使用。転送サイズが小さくなる
standardFontDataUrl 標準 PDF フォントデータの URL

CMap ファイルは PDF が CJK フォントを使用している場合にのみダウンロードされるため、日本語を含まない PDF では追加の通信は発生しません。

ページ描画のスケーリング

render() 関数では、フィットモード(vs.fit)に応じてスケールを計算しています。コントロールバーのボタンでモードを切り替えると、その場で再描画されます。

アイコン モード 動作
fit_screen ページ ページ全体がビューアーに収まるように縮小(既定)
width ページの横幅をビューアーの横幅に合わせる。aspect-ratio の制約を解除し、縦方向はスクロールで確認
height 高さ ページの高さをビューアーの高さに合わせる。aspect-ratio の制約を解除し、横方向はスクロールで確認
percent % 指定倍率で表示。removeadd ボタンで 25% 刻み(25%~500%)にズーム操作が可能
switch (vs.fit) {
  case 'width':
    scale = maxW / vp.width;
    break;
  case 'height':
    scale = maxH > 0 ? maxH / vp.height : 1;
    break;
  case 'zoom':
    scale = (vs.zoom || 100) / 100;
    break;
  default: // page
    scale = Math.min(maxW / vp.width, maxH > 0 ? maxH / vp.height : 1.5);
    break;
}

「ページ」モードでは aspect-ratio: 2 / 1 が適用され、ビューアー領域のサイズが固定されます。「幅」「高さ」「%」モードでは CSS クラスによって aspect-ratio の制約が解除され、キャンバスの実サイズに応じた表示になります。

.pdf-embed:not(.pdf-fit-width):not(.pdf-fit-height):not(.pdf-fit-zoom) .pdf-canvas-wrap {
  aspect-ratio: 2 / 1;
}

デフォルトのフィットモードは DEFAULT_FIT 変数で変更できます。

// 'page' | 'width' | 'height' | 'zoom'
var DEFAULT_FIT = 'page';

「%」モードでは DEFAULT_ZOOM(初期倍率)と ZOOM_STEP(増減幅)でズーム動作をカスタマイズできます。

var DEFAULT_ZOOM = 100; // 初期倍率(%)
var ZOOM_STEP = 25;     // +/-ボタンの増減幅

埋め込みビューアーでは親要素の幅に収まるサイズ、モーダルではビューポートの 95%(最大 1400px)の幅で表示されます。

プリザンターサーバに Content Security Policy(CSP)が設定されている環境では、CDN からのスクリプト読み込みがブロックされることがあります。その場合は CSP の script-srcconnect-srccdn.jsdelivr.net を追加してください。

実際に動かしてみる

では、最後に実際に動かしてみましょう。

編集画面

image.png
image.png

一覧画面

image.png
image.png

まとめ

プリザンターの拡張スクリプトと拡張スタイルだけで、添付ファイルの PDF をブラウザ上でプレビューできる機能を実装しました。

  • PDF.js を CDN から動的インポートで読み込むため、サーバ側の設定は不要
  • MODE 変数で対象項目の制御パターンを 3 つ選択可能(include:個別指定、all:全適用、exclude:除外指定)
  • include モードではフィールド CSS に pdf-viewer を追加した項目にだけ、exclude モードでは no-pdf-viewer を追加した項目を除くすべてにビューアーを表示
  • 埋め込みビューアーの高さは aspect-ratio で横幅の 50% に設定し、スクロールで全体を確認可能
  • フィットモード(ページ・幅・高さ・%)をコントロールバーのボタンで切替可能。「%」モードでは removeadd ボタンでズーム操作も可能。DEFAULT_FIT / DEFAULT_ZOOM / ZOOM_STEP で初期値を制御
  • 複数の PDF がある場合はタブで切り替え、複数の添付ファイル項目にはそれぞれ独立したビューアーを配置
  • 一覧画面では PDF ファイルの横にプレビューボタンを追加し、クリックでモーダル表示
  • 埋め込みビューアーはトグルボタンで表示・非表示を切替可能。DEFAULT_COLLAPSED で初期状態を制御
  • 編集画面からもモーダルによる拡大表示が可能(ビューポートの 95%/最大 1400px、モバイルではフルスクリーン)
  • cMapUrlstandardFontDataUrl の設定で日本語フォントを含む PDF も正しくレンダリング
  • ファイルの追加・削除や更新操作の後も ajaxComplete でビューアーを自動再構築
  • モーダルはキーボード操作(矢印キーでページ送り、Escape で閉じる)に対応
  • PDF 読み込み中はプリザンター標準と同じデザインのローディングスピナーを表示
  • 配色にはテーマ CSS 変数(--primaryColor--base-border 等)を使用しており、テーマ変更に自動追従
4
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
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?