はじめに
プリザンターの添付ファイル項目に 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 配列として格納されており、各オブジェクトに Guid・Name・Extension などのプロパティが含まれます。この 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() のオプションで cMapUrl と cMapPacked: true を指定すると、CJK フォントの CMap ファイルが必要に応じて自動的にダウンロードされます。
対象項目の設定
編集画面でどの添付ファイル項目に PDF プレビューを表示するかは、スクリプト冒頭の MODE 変数で切り替えられます。用途に応じて 3 つのモードから選択してください。
| モード |
MODE の値 |
動作 |
|---|---|---|
| 個別指定 | 'include' |
フィールド CSS に pdf-viewer を追加した項目だけにビューアーを表示(既定) |
| 全適用 | 'all' |
すべての添付ファイル項目にビューアーを表示 |
| 除外指定 | 'exclude' |
フィールド CSS に no-pdf-viewer を追加した項目を除くすべての添付ファイル項目にビューアーを表示 |
個別指定モード(include)
特定の項目だけにビューアーを表示したい場合に使います。これが既定のモードです。
スクリプトの設定
var MODE = 'include';
設定手順
- テーブルの管理→エディタ を開く
- PDF プレビューを表示したい添付ファイル項目を選択して「詳細設定」を開く
- 「フィールド CSS」に
pdf-viewerと入力して保存する
全適用モード(all)
すべての添付ファイル項目にビューアーを表示したい場合に使います。フィールド CSS の設定は不要です。
スクリプトの設定
var MODE = 'all';
除外指定モード(exclude)
大半の項目にビューアーを表示し、一部だけ除外したい場合に使います。
スクリプトの設定
var MODE = 'exclude';
設定手順
- テーブルの管理→エディタ を開く
- ビューアーを表示しない添付ファイル項目を選択して「詳細設定」を開く
- 「フィールド CSS」に
no-pdf-viewerと入力して保存する
フィールド CSS に設定したクラスは、フィールドの外側の div に追加されます。添付ファイル項目はデフォルトで横幅いっぱいに表示されるため、ビューアーも広い領域を使ってプレビューを表示できます。
一覧画面のプレビューボタンはモード設定に関係なく、すべての PDF リンクに表示されます。
実装してみよう
拡張スクリプトと拡張スタイルの 2 ファイルで構成します。
| 拡張機能 | 役割 |
|---|---|
| 拡張スクリプト | PDF.js 読み込み・ビューアー生成・モーダル制御 |
| 拡張スタイル | 埋め込みビューアー・モーダルの見た目を定義 |
拡張スタイル
拡張スタイルとして App_Data/Parameters/ExtendedStyles/ に配置します。
/* ===== 埋め込みビューアー(編集画面) ===== */
.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/ に配置します。
$(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: both と width: 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-items を forEach で回しているためです。
プレビューの表示切替
タブバーの左端にあるトグルボタン(▼ / ◀)で、埋め込みビューアーの表示・非表示を切り替えられます。トグルボタンには margin-left: auto を設定しており、ファイルタブは右側に寄せて表示されます。クリックすると .pdf-embed に collapsed クラスが付与され、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-wrap に position: 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-src や connect-src に cdn.jsdelivr.net を追加してください。
実際に動かしてみる
では、最後に実際に動かしてみましょう。
編集画面
一覧画面
まとめ
プリザンターの拡張スクリプトと拡張スタイルだけで、添付ファイルの 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、モバイルではフルスクリーン)
-
cMapUrlとstandardFontDataUrlの設定で日本語フォントを含む PDF も正しくレンダリング - ファイルの追加・削除や更新操作の後も
ajaxCompleteでビューアーを自動再構築 - モーダルはキーボード操作(矢印キーでページ送り、Escape で閉じる)に対応
- PDF 読み込み中はプリザンター標準と同じデザインのローディングスピナーを表示
- 配色にはテーマ CSS 変数(
--primaryColor、--base-border等)を使用しており、テーマ変更に自動追従



