添付ファイル
添付ファイル項目まわりのカスタマイズをまとめます。スクリプト/サーバスクリプトだけで済むもの(並び替え)、拡張スクリプト・拡張スタイル・拡張サーバスクリプトで全テーブルに適用するもの(一括削除、PDF/STL プレビュー)、本体改変が必要なもの(ウイルススキャン)、外部ツール(一括ダウンロード)の順に紹介します。
| レシピ | 実装方法 | 対象バージョン |
|---|---|---|
| ファイル名順に並び替え | スクリプト / サーバスクリプト | 記載なし |
| 選択レコードの添付ファイル一括削除 | 拡張スクリプト + 拡張サーバスクリプト | 記載なし |
| PDF プレビュー | 拡張スクリプト + 拡張スタイル | 1.5.1.0 以降 |
| STL(3D モデル)プレビュー | 拡張スクリプト + 拡張スタイル | 1.5.1.0 以降 |
| 画像をプレビューモーダルで表示 | スクリプト(または拡張スクリプト) | 1.5.8.1 で確認 |
| ウイルススキャン | 本体コードの改変 | 1.5.1.0 |
| 一括ダウンロードツール | 外部ツール(.NET 8) | 1.4 系で動作 |
ファイル名順に並び替える
添付ファイル項目に複数のファイルをアップロードすると、アップロード時刻の昇順で並びます。これをファイル名順に変える方法です。編集画面はスクリプトで表示後の DOM を並べ替え、一覧画面はサーバスクリプトで描画前の HTML を書き換えます。
編集画面(スクリプト)
編集画面では、アップロード済みファイルが div.control-attachments-items の中に div#{ファイルのGUID}.control-attachments-item として並んでいます。この要素をファイル名で並べ替えます。
$p.events.on_editor_load = function() {
$('[id^="AttachmentsA"].control-attachments-items').html(
$('[id^="AttachmentsA"].control-attachments-items .control-attachments-item').sort(function(a, b) {
return $(a).find('.file-name').text() > $(b).find('.file-name').text() ?
1 : -1;
})
);
}- テーブルの管理 → スクリプトで、出力先「新規作成」「編集」を有効にして登録します。
- 対象は添付ファイル A です。他の項目を並べ替えるときは
AttachmentsAを書き換えてください。

一覧画面(サーバスクリプト)
サーバスクリプトの model.AttachmentsA には、添付ファイルの情報が JSON 文字列で入っています(画面表示の前に context.Log(JSON.stringify(model.AttachmentsA)) で確認できます)。
[
{
"Guid": "31DE9B93C26342D186646E723D7EB8E1",
"Name": "log174_2503170830.xlsx",
"Size": 11904,
"HashCode": "xjugT/ALXg+G5dcjgs6CTPZ7DAeFDTXnZ8kavI8AQZY="
}
]一覧画面のセルは ol > li > a[href="/binaries/{guid}/download"] という単純なリストなので、並べ替えたリストを columns.AttachmentsA.RawText に入れて HTML ごと差し替えます。
if (model.AttachmentsA) {
var files = JSON.parse(model.AttachmentsA);
files.sort((a, b) => a.Name > b.Name ? 1 : -1);
var ol = "";
files.forEach(file => {
ol += `<li><a href="/binaries/${file.Guid}/download">${file.Name}</a></li>`
});
columns.AttachmentsA.RawText = `<ol>${ol}</ol>`;
}- 条件「行表示の前」で登録します。一覧画面全体に適用されるため、リンクの一覧にも効きます。
columns.{項目}.RawTextはセルの HTML を直接書き換えられるので、ほかの表示カスタマイズにも応用できます。
選択したレコードの添付ファイルを一括削除する
一覧画面でチェックしたレコードについて、指定した添付ファイル項目の中身をダウンロードしてからまとめて削除します。$p.selectedIds・$p.apiGet・$p.apiUpdate を組み合わせ、拡張スクリプトと拡張サーバスクリプトで実装するので、テーブルごとの設定は不要です。
処理の流れ
図を読み込み中…
先頭レコードからテーブルに存在する添付ファイル項目を検出し、jQuery UI のモーダルダイアログで削除対象の項目を選ばせたあと、レコードごとにダウンロード → Deleted: true での削除を行います。
ダウンロードファイル名は {SiteId}_{レコードID}_{添付ファイル項目名}_{元のファイル名} になり、ファイル名に使えない文字(\ / : * ? " < > |)は _ に置き換えます。
AttachmentsHash と API バージョンの注意
$p.apiGetの応答のAttachmentsHashには、添付ファイル項目名をキーにGuid・Name・Size・HashCodeの配列が入ります。- ただし API バージョンが
1.1未満(App_Data/Parameters/Api.jsonのVersionを1.0にしている環境など。既定値は1.1)のときはAttachmentsHashがnullになり、AttachmentsA〜AttachmentsZ(項目拡張時はAttachments001〜)の個別プロパティに JSON 文字列で入ります(_BaseApiModel.cs の判定、AttachmentsHash を null にする箇所)。 - リクエストで
ApiVersion: 1.1を指定すればAttachmentsHash形式で取得できますが、Api.jsonのCompatibility_1_3_12がtrueの環境ではApiKeyを含むリクエストでしかApiVersionが反映されません。$p.apiGetはセッション認証でApiKeyを送らないため、この指定は無視されます。 - そのため、下のスクリプトは
da_getAttachmentsHashで両方の形式に対応しています。
Deleted の値は true
本体の Attachment.cs では Deleted は bool? 型で、Deleted == true で判定しています。公式マニュアルにある 1 でも Newtonsoft.Json の変換で動作しますが、型としては true が適切です。
拡張スクリプト
App_Data/Parameters/ExtendedScripts/ に配置します。拡張スクリプトはグローバルスコープで読み込まれるため、関数名に da_ プレフィックスを付けて衝突を避けています。
function da_sanitizeFileName(name) {
return name.replace(/[\\/:*?"<>|]/g, '_');
}
function da_downloadFile(guid, fileName) {
return fetch('/binaries/' + guid + '/download', {
credentials: 'include'
})
.then(function (response) {
return response.blob();
})
.then(function (blob) {
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fileName;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(a.href);
document.body.removeChild(a);
});
}
function da_downloadFieldFiles(siteId, recordId, fieldName, attachments) {
var chain = Promise.resolve();
attachments.forEach(function (file) {
chain = chain.then(function () {
var fileName = da_sanitizeFileName(
siteId + '_' + recordId + '_' + fieldName + '_' + file.Name
);
return da_downloadFile(file.Guid, fileName);
});
});
return chain;
}
function da_getAttachmentsHash(record) {
var hash = record.AttachmentsHash;
if (hash && Object.keys(hash).length > 0) return hash;
hash = {};
Object.keys(record).forEach(function (key) {
if (!/^Attachments([A-Z]|\d{3})$/.test(key)) return;
var val = record[key];
if (!val) return;
if (typeof val === 'string') {
try { val = JSON.parse(val); } catch (e) { return; }
}
if (Array.isArray(val) && val.length > 0) hash[key] = val;
});
return hash;
}
function da_discoverFields(firstId, callback) {
$p.apiGet({
id: firstId,
data: { ApiVersion: 1.1 },
done: function (data) {
var hash = da_getAttachmentsHash(data.Response.Data[0]);
callback(Object.keys(hash).sort());
},
fail: function () {
callback([]);
}
});
}
function da_showFieldSelectionDialog(fields, onConfirm) {
if (fields.length === 0) {
$p.clearMessage();
$p.setMessage('#Message', JSON.stringify({
Css: 'alert-warning',
Text: '選択したレコードに添付ファイル項目がありません'
}));
return;
}
var $dialog = $('<div title="削除対象の添付ファイル項目を選択"></div>');
var $list = $('<div style="margin: 10px 0;"></div>');
fields.forEach(function (field) {
$list.append(
'<label style="display: block; margin: 5px 0; cursor: pointer;">'
+ '<input type="checkbox" value="' + field + '" checked> '
+ field
+ '</label>'
);
});
$dialog.append($list);
$dialog.dialog({
modal: true,
width: 480,
open: function () {
$(this).css('margin-bottom', 0);
$(this).next('.ui-dialog-buttonpane').css('margin-top', 0);
},
buttons: {
'ダウンロードして削除': function () {
var selected = [];
$dialog.find('input:checked').each(function () {
selected.push($(this).val());
});
$(this).dialog('close');
$(this).remove();
if (selected.length > 0) onConfirm(selected);
},
'キャンセル': function () {
$(this).dialog('close');
$(this).remove();
}
}
});
}
function da_deleteSelectedAttachments() {
var ids = $p.selectedIds();
if (ids.length === 0) {
alert('レコードが選択されていません');
return;
}
da_discoverFields(ids[0], function (fields) {
da_showFieldSelectionDialog(fields, function (selectedFields) {
da_processRecords(ids, selectedFields);
});
});
}
function da_processRecords(ids, selectedFields) {
var siteId = $p.siteId();
var completed = 0;
var failed = 0;
var index = 0;
function processNext() {
// 全件処理したら結果を表示して一覧を再読み込み
if (index >= ids.length) {
alert('添付ファイルの一括削除が完了しました(成功: ' + completed
+ ' 件、失敗: ' + failed + ' 件)');
location.reload();
return;
}
var id = ids[index++];
$p.apiGet({
id: id,
data: { ApiVersion: 1.1 },
done: function (data) {
var hash = da_getAttachmentsHash(data.Response.Data[0]);
da_processFields(siteId, id, hash, selectedFields, 0, function (success) {
if (success) completed++; else failed++;
processNext();
});
},
fail: function () {
failed++;
processNext();
}
});
}
processNext();
}
function da_processFields(siteId, id, hash, selectedFields, fieldIndex, callback) {
if (fieldIndex >= selectedFields.length) {
callback(true);
return;
}
var fieldName = selectedFields[fieldIndex];
var attachments = hash[fieldName];
if (!attachments || attachments.length === 0) {
da_processFields(siteId, id, hash, selectedFields, fieldIndex + 1, callback);
return;
}
da_downloadFieldFiles(siteId, id, fieldName, attachments)
.then(function () {
var updateHash = {};
updateHash[fieldName] = attachments.map(function (file) {
return { Guid: file.Guid, Deleted: true };
});
$p.apiUpdate({
id: id,
data: { AttachmentsHash: updateHash },
done: function () {
da_processFields(siteId, id, hash, selectedFields, fieldIndex + 1, callback);
},
fail: function () {
callback(false);
}
});
})
.catch(function () {
// ダウンロードに失敗したレコードは削除せず、失敗として次へ進む
callback(false);
});
}da_processRecordsのprocessNextは、indexがids.lengthに達した時点で結果を表示し、location.reload()で一覧を再読み込みして終了します。この終了判定がないと、全件処理後もids[index]がundefinedのまま$p.apiGetを呼び続けてしまいます。また、ダウンロードが失敗したときに処理が止まらないよう.catchを付けています。- ダウンロードは
Promiseチェーンで 1 件ずつ順番に実行します。ブラウザが同時に多数のダウンロードをブロックすることがあるためです。 - ダウンロードはセッション認証を使うため
credentials: 'include'を指定しています。 - ダイアログは閉じるときに
remove()で DOM から削除するので、繰り返し実行できます。
ボタンの追加(拡張サーバスクリプト)
context.AddResponse の Append で一覧画面のコマンドバーにボタンを追加します。App_Data/Parameters/ExtendedServerScripts/ に 2 ファイルを置きます(設定 .json と本文 .json.js を分けるとエディタの補完が効きます)。
{
"BeforeOpeningPage": true,
"Actions": ["index"],
"TryCatch": true,
"Body": "-- loaded from .json.js"
}context.AddResponse(
'Append',
'#MainCommands:has(#BulkDeleteCommand)',
'<button id="deleteAttachments" class="button button-icon" type="button" onclick="da_deleteSelectedAttachments()">'
+ '<span class="ui-icon ui-icon-trash"></span>'
+ '<span>添付ファイル一括削除</span>'
+ '</button>'
);Actionsをindex(一覧画面)に限定しています。SiteIdListを省略するとすべてのテーブルが対象です。限定するときは"SiteIdList": [12345, 67890]のように指定します。UserIdList・GroupIdList・DeptIdListで表示するユーザを絞ることもできます。- 一括削除ボタン(
#BulkDeleteCommand)は削除権限があるときだけ描画されます。セレクタに:has(#BulkDeleteCommand)を付けることで、削除権限がないユーザにはボタンを出しません。 - 拡張サーバスクリプトのエラーは全体の処理に影響するため、
"TryCatch": trueを指定しておくと安全です。
PDF をプレビューする 1.5.1.0 以降
拡張スクリプトと拡張スタイルだけで、添付された PDF をブラウザ上でプレビューします。ここでは不具合を修正した版の内容を載せます。
- 編集画面: 添付ファイル項目の下にビューアーを埋め込み表示。複数 PDF はタブで切り替え、PDF がなければ何も表示しない
- 一覧画面: PDF ファイル名の横にプレビューボタンを追加し、クリックでモーダル表示
- 編集画面からもモーダルで拡大表示できる
- フィットモード(ページ・幅・高さ・%)とズーム、Esc / 矢印キー操作、テーマ CSS 変数による配色

PDF.js を CDN から読み込む理由
PDF.js 本体・Worker・CMap・標準フォントはすべて jsDelivr(pdfjs-dist パッケージ)から読み込みます。日本語フォントを含む PDF の表示には CMap ファイル(.bcmap)が必要ですが、プリザンターの Web サーバは .bcmap の MIME タイプを認識せず配信がブロックされるため、サーバに静的ファイルとして置く方式では日本語 PDF が正しく表示できないためです。
対象項目の指定
スクリプト冒頭の MODE 変数で、編集画面でビューアーを付ける添付ファイル項目を選びます。
MODE | 動作 |
|---|---|
'include'(既定) | フィールド CSS に pdf-viewer を設定した項目だけに表示 |
'all' | すべての添付ファイル項目に表示 |
'exclude' | フィールド CSS に no-pdf-viewer を設定した項目を除いて表示 |
フィールド CSS は、テーブルの管理 → エディタ → 対象の添付ファイル項目の詳細設定で入力します。設定したクラスはフィールドの外側の div(div#Results_AttachmentsAField など)に付きます。一覧画面のプレビューボタンは MODE に関係なくすべての PDF リンクに付きます。
設定値
// PDF.js の設定(バージョンを固定して動作の安定性を確保)
var PDFJS_VER = '4.10.38';
var DIST = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@' + PDFJS_VER;
var MODE = 'include'; // 'include' | 'all' | 'exclude'
var CSS_INCLUDE = 'pdf-viewer';
var CSS_EXCLUDE = 'no-pdf-viewer';
var DEFAULT_COLLAPSED = false; // true = 初期状態で折りたたみ
var DEFAULT_FIT = 'page'; // 'page' | 'width' | 'height' | 'zoom'
var DEFAULT_ZOOM = 100; // 「%」モードの初期倍率
var ZOOM_STEP = 25; // +/-ボタンの増減幅(25%~500%)スクリプトの骨格
App_Data/Parameters/ExtendedScripts/PdfViewer.js と App_Data/Parameters/ExtendedStyles/PdfViewer.css の 2 ファイルで構成します。全文は下の「コード全体」に載せます。ここでは要点を抜粋します。
読み込みと初期化(修正版)。import() で ES モジュールの PDF.js を動的に読み込み、ajaxComplete を 300ms デバウンスしてビューアーを作り直します。
// 編集画面・一覧画面のみ対象
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);
// 追加読み込み(gridrows)後にアイコンを再付与
// モーダル編集画面の埋込ビューワを初期化
var listTimer = null;
$(document).ajaxComplete(function () {
clearTimeout(listTimer);
listTimer = setTimeout(function () {
initList(pdfjsLib);
initEdit(pdfjsLib);
}, 300);
});
}
})
.catch(function (e) {
console.error('PDF.js の読み込みに失敗しました:', e);
});PDF の検出。編集画面の添付ファイル項目には hidden input(input.control-attachments)があり、value に添付ファイル情報の JSON 配列(Guid・Name・Deleted など)が入っています。DOM のテキストを解析せずに PDF を拾えます。Deleted が付いたもの(保存前の削除予定)は除外します。
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;
}initEdit() は既存の .pdf-embed-field を破棄してから、各 .control-attachments について closest('[id$="Field"]') でフィールドの div をたどり、MODE に応じて判定したうえでビューアーを作ります。ビューアーはフィールドの直後に field-wide pdf-embed-field クラスの wrapper(空の field-label と field-control > container-normal を持つ標準レイアウトの再現)として挿入するので、添付ファイル項目内部の float の影響を受けません。
一覧画面のプレビューボタン(修正版)。一覧画面には hidden input がないため、/download リンクのテキストが .pdf で終わるかで判定します。data-pdf-icon 属性で処理済みのリンクをスキップし、何度呼んでも重複しないようにしています。
function initList(pdfjsLib) {
document
.querySelectorAll('a[href*="/binaries/"][href*="/download"]')
.forEach(function (a) {
if (!/\.pdf$/i.test(a.textContent.trim())) return;
if (a.dataset.pdfIcon) return;
a.dataset.pdfIcon = '1';
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 の表示。getDocument() に CMap と標準フォントの URL を指定します。CMap は PDF が CJK フォントを使うときだけダウンロードされます。
pdfjsLib.getDocument({
url: pdfUrl,
cMapUrl: DIST + '/cmaps/',
cMapPacked: true,
standardFontDataUrl: DIST + '/standard_fonts/'
})| オプション | 説明 |
|---|---|
cMapUrl | CMap ファイルのベース URL。CJK フォントの文字マッピングに使用 |
cMapPacked | true でバイナリ形式(.bcmap)を使用 |
standardFontDataUrl | 標準 PDF フォントデータの URL |
表示サイズ。「ページ」モードのときだけ埋め込みビューアーの高さを aspect-ratio: 2 / 1 で固定し、ほかのモードでは解除します。canvas には height: auto が必要です(下記の修正 3)。
.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%;
height: auto;
box-shadow: 0 1px 6px var(--base-shadow);
}コード全体
修正版のスクリプトとスタイルの全体です。
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);
// 追加読み込み(gridrows)後にアイコンを再付与
// モーダル編集画面の埋込ビューワを初期化
var listTimer = null;
$(document).ajaxComplete(function () {
clearTimeout(listTimer);
listTimer = setTimeout(function () {
initList(pdfjsLib);
initEdit(pdfjsLib);
}, 300);
});
}
})
.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;
if (a.dataset.pdfIcon) return;
a.dataset.pdfIcon = '1';
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 body = modal.querySelector('.pdf-modal-body');
// フィットモード UI リセット
body.classList.remove('pdf-fit-width', 'pdf-fit-height', 'pdf-fit-zoom');
if (DEFAULT_FIT === 'width') body.classList.add('pdf-fit-width');
if (DEFAULT_FIT === 'height') body.classList.add('pdf-fit-height');
if (DEFAULT_FIT === 'zoom') body.classList.add('pdf-fit-zoom');
modal.querySelectorAll('.pdf-fit-btn').forEach(function (b) {
b.classList.toggle('active', b.dataset.fit === DEFAULT_FIT);
});
var zoomGroup = modal.querySelector('.pdf-zoom-group');
zoomGroup.style.display = DEFAULT_FIT === 'zoom' ? '' : 'none';
modal.querySelector('.pdf-zoom-info').textContent = DEFAULT_ZOOM + '%';
modal.querySelector('.pdf-loader').classList.remove('hidden');
var vs = {
idx: 0,
page: startPage || 1,
pages: 0,
doc: null,
busy: false,
fit: DEFAULT_FIT,
zoom: DEFAULT_ZOOM,
files: [file],
canvas: modal.querySelector('canvas'),
wrap: body,
loader: modal.querySelector('.pdf-loader'),
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 class="pdf-loader"><div class="pdf-spinner"></div></div>' +
'</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 class="pdf-fit-group"></div>' +
'<div class="pdf-zoom-group">' +
'<button type="button" class="pdf-ctrl-btn pdf-zoom-out">' +
'<span class="material-symbols-outlined">remove</span>' +
'</button>' +
'<span class="pdf-zoom-info">' + DEFAULT_ZOOM + '%</span>' +
'<button type="button" class="pdf-ctrl-btn pdf-zoom-in">' +
'<span class="material-symbols-outlined">add</span>' +
'</button>' +
'</div>' +
'</div>' +
'</div>';
document.body.appendChild(m);
// フィットモードボタン生成
var fitGroup = m.querySelector('.pdf-fit-group');
var fitModes = [
['page', 'fit_screen', 'ページ'],
['width', 'width', '幅'],
['height', 'height', '高さ'],
['zoom', 'percent', '%']
];
fitModes.forEach(function (mode) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'pdf-fit-btn' + (mode[0] === DEFAULT_FIT ? ' active' : '');
var ico = document.createElement('span');
ico.className = 'material-symbols-outlined';
ico.textContent = mode[1];
b.appendChild(ico);
b.title = mode[2];
b.dataset.fit = mode[0];
fitGroup.appendChild(b);
});
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);
};
// フィットモード切替
var body = m.querySelector('.pdf-modal-body');
var zoomGroup = m.querySelector('.pdf-zoom-group');
zoomGroup.style.display = DEFAULT_FIT === 'zoom' ? '' : 'none';
fitGroup.onclick = function (e) {
var btn = e.target.closest('.pdf-fit-btn');
if (!btn || !m._vs) return;
fitGroup.querySelectorAll('.pdf-fit-btn').forEach(function (b) {
b.classList.toggle('active', b === btn);
});
m._vs.fit = btn.dataset.fit;
body.classList.remove('pdf-fit-width', 'pdf-fit-height', 'pdf-fit-zoom');
if (m._vs.fit === 'width') body.classList.add('pdf-fit-width');
if (m._vs.fit === 'height') body.classList.add('pdf-fit-height');
if (m._vs.fit === 'zoom') body.classList.add('pdf-fit-zoom');
zoomGroup.style.display = m._vs.fit === 'zoom' ? '' : 'none';
render(m._vs);
};
// ズーム操作
m.querySelector('.pdf-zoom-in').onclick = function () {
if (!m._vs) return;
m._vs.zoom = Math.min(m._vs.zoom + ZOOM_STEP, 500);
m.querySelector('.pdf-zoom-info').textContent = m._vs.zoom + '%';
if (m._vs.fit === 'zoom') render(m._vs);
};
m.querySelector('.pdf-zoom-out').onclick = function () {
if (!m._vs) return;
m._vs.zoom = Math.max(m._vs.zoom - ZOOM_STEP, 25);
m.querySelector('.pdf-zoom-info').textContent = m._vs.zoom + '%';
if (m._vs.fit === 'zoom') render(m._vs);
};
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;
}
});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%;
height: auto;
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);
position: relative;
}
#pdf-modal .pdf-modal-body canvas {
max-width: 100%;
height: auto;
box-shadow: 0 2px 8px var(--base-shadow);
}
#pdf-modal .pdf-modal-body.pdf-fit-zoom canvas,
#pdf-modal .pdf-modal-body.pdf-fit-height canvas {
max-width: none;
}
/* ===== モーダルコントロール ===== */
#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;
}
}初版からの修正点
初版のコードには次の不具合があり、修正版で直っています。初版を使っている場合は差し替えてください。
| 不具合 | 原因 | 修正 |
|---|---|---|
一覧画面をスクロールして追加読み込み(action=gridrows)された行にプレビューボタンが付かない | initList() を初回ロード時に 1 回しか呼んでいない | 一覧画面にも ajaxComplete で initList() を再実行。data-pdf-icon で重複防止 |
| 一覧画面からモーダルで開いた編集画面に埋め込みビューアーが出ない | モーダル表示ではページの action が index のままで initEdit() が呼ばれない | 一覧画面の ajaxComplete でも initEdit() を呼ぶ(編集フォームがなければ何もしない) |
| 横長 PDF の縦横比が崩れる | canvas は max-width だけでは高さが連動しない | canvas の CSS に height: auto を追加 |
あわせて、モーダルビューアーにも埋め込みビューアーと同じフィットモード切替・ズーム・ローディング表示が追加されました(buildModal() に UI を追加し、openModal() で vs に fit・zoom・wrap・loader を渡す。CSS で .pdf-modal-body に position: relative、ズーム・高さモードでは canvas の max-width を解除)。
注意点
- 配色はテーマの CSS 変数(
--primaryColor、--base-borderなど)を使っています。旧テーマ(base/start等)ではこれらの変数が定義されていないため色が反映されません。cerulean(既定)やgreen-teaなどの新テーマで使ってください。 - アイコンは
material-symbols-outlinedクラス(Material Symbols)を使っています。 - CSP(Content Security Policy)を設定している環境では CDN からの読み込みがブロックされることがあります。
script-srcやconnect-srcにcdn.jsdelivr.netを追加してください。
STL(3D モデル)をプレビューする 1.5.1.0 以降
PDF プレビューと同じ構成で、3D プリンタや CAD データ交換で使われる STL ファイルを Three.js と STLLoader で 3D 表示します。STLLoader は ASCII / バイナリ形式を自動判別して BufferGeometry に変換します。
図を読み込み中…
- 編集画面: 埋め込みビューアー(タブ切替、折りたたみ)。一覧画面: プレビューボタンからモーダル表示
- ドラッグで回転、Shift+ドラッグ / 右ドラッグでパン、ホイール・ボタンでズーム、リセットボタン
- 表示設定パネル(
tuneアイコン): ワイヤーフレーム、フラットシェーディング、6 色のカラープリセット - 読み込み後にモデル全体が収まるようカメラを自動調整
設置と設定
App_Data/Parameters/ExtendedScripts/StlViewer.js と App_Data/Parameters/ExtendedStyles/StlViewer.css の 2 ファイルで構成します。全文は下の「コード全体」に載せます。
対象項目の指定は PDF プレビューと同じ MODE 方式で、クラス名は stl-viewer / no-stl-viewer です。一覧画面のプレビューボタンはモードに関係なくすべての STL リンクに付きます。
// Three.js の設定(バージョンを固定して動作の安定性を確保)
var THREE_VER = '0.172.0';
var THREE_URL =
'https://cdn.jsdelivr.net/npm/three@' + THREE_VER + '/+esm';
var STL_LOADER_URL =
'https://cdn.jsdelivr.net/npm/three@' +
THREE_VER +
'/examples/jsm/loaders/STLLoader.js';
var MODE = 'include'; // 'include' | 'all' | 'exclude'
var CSS_INCLUDE = 'stl-viewer';
var CSS_EXCLUDE = 'no-stl-viewer';
var DEFAULT_COLLAPSED = false;
var MODEL_COLOR = 0x4fc3f7; // モデルの色実装の要点
読み込み。Three.js 本体(jsDelivr の +esm エンドポイント)と STLLoader を並行して import() します。初期化と ajaxComplete による再構築(編集画面)・アイコン再付与(一覧画面)は PDF プレビューの修正版と同じパターンです。
Promise.all([import(THREE_URL), import(STL_LOADER_URL)])
.then(function (modules) {
var THREE = modules[0];
var STLLoader = modules[1].STLLoader;
var libs = { THREE: THREE, STLLoader: STLLoader };
// action に応じて initEdit(libs) / initList(libs) を呼び、ajaxComplete で再実行
});STL の読み込みとマテリアル。STL には色情報がないため、スクリプト側で MeshPhongMaterial を設定します。side: THREE.DoubleSide で裏面も描画し、flatShading: true で三角形の面が分かる表示にします。
var stlLoader = new libs.STLLoader();
stlLoader.load(
vs.files[idx].url,
function (geometry) {
var material = new THREE.MeshPhongMaterial({
color: MODEL_COLOR,
specular: 0x222222,
shininess: 40,
side: THREE.DoubleSide,
flatShading: true
});
var mesh = new THREE.Mesh(geometry, material);
vs.mesh = mesh;
vs.scene.add(mesh);
resetCamera(vs, THREE);
vs.renderer.render(vs.scene, vs.camera);
buildDisplayPanel(vs, libs);
// 三角形数を表示(省略)
},
undefined,
function () {
vs.info.textContent = '読み込み失敗';
}
);ライティング。環境光と、反対方向からの 2 つの平行光源で、裏面が暗くなりすぎないようにしています。
scene.add(new THREE.AmbientLight(0x606060));
var dirLight1 = new THREE.DirectionalLight(0xffffff, 1.0);
dirLight1.position.set(1, 2, 3);
scene.add(dirLight1);
var dirLight2 = new THREE.DirectionalLight(0xffffff, 0.3);
dirLight2.position.set(-1, -1, -2);
scene.add(dirLight2);カメラの自動フィッティング。Box3 でバウンディングボックスを求め、対角線の長さからカメラ距離を決めます。near / far もモデルサイズに合わせて設定します。
var box = new THREE.Box3().setFromObject(vs.mesh);
var center = box.getCenter(new THREE.Vector3());
var size = box.getSize(new THREE.Vector3()).length();
var dist = size * 1.5;
vs.camera.position.set(
center.x + dist * 0.5,
center.y + dist * 0.5,
center.z + dist
);
vs.camera.lookAt(center);
vs.camera.near = size * 0.001;
vs.camera.far = size * 100;
vs.camera.updateProjectionMatrix();マウス操作。OrbitControls はモジュール依存関係の解決が複雑になるため、球面座標(target・radius・theta・phi)による簡易的な軌道カメラを自前で実装しています。PointerEvent(pointerdown / pointermove)を使うのでマウス・タッチ・ペンを同じハンドラで扱え、setPointerCapture でキャンバス外にドラッグしても操作が続きます。phi は 0.1〜π - 0.1 に制限して真上・真下での反転を防ぎます。
camera.position.set(
target.x + radius * Math.sin(phi) * Math.sin(theta),
target.y + radius * Math.cos(phi),
target.z + radius * Math.sin(phi) * Math.cos(theta)
);
camera.lookAt(target);コード全体
ExtendedScripts/StlViewer.js
$(function () {
// Three.js の設定(バージョンを固定して動作の安定性を確保)
var THREE_VER = '0.172.0';
var THREE_URL =
'https://cdn.jsdelivr.net/npm/three@' + THREE_VER + '/+esm';
var STL_LOADER_URL =
'https://cdn.jsdelivr.net/npm/three@' +
THREE_VER +
'/examples/jsm/loaders/STLLoader.js';
// モード設定: 'include' | 'all' | 'exclude'
// include : フィールドCSSに stl-viewer がある項目だけ対象(既定)
// all : すべての添付ファイル項目を対象
// exclude : フィールドCSSに no-stl-viewer がある項目を除外
var MODE = 'include';
var CSS_INCLUDE = 'stl-viewer';
var CSS_EXCLUDE = 'no-stl-viewer';
// 表示モードの初期状態: true = 折りたたみ / false = 展開(既定)
var DEFAULT_COLLAPSED = false;
// モデルの色
var MODEL_COLOR = 0x4fc3f7;
// 編集画面・一覧画面のみ対象
var action = $p.action();
if (action !== 'edit' && action !== 'index') return;
// Three.js モジュールを CDN から動的に読み込む
Promise.all([import(THREE_URL), import(STL_LOADER_URL)])
.then(function (modules) {
var THREE = modules[0];
var STLLoader = modules[1].STLLoader;
var libs = { THREE: THREE, STLLoader: STLLoader };
if (action === 'edit') {
initEdit(libs);
var timer = null;
$(document).ajaxComplete(function () {
clearTimeout(timer);
timer = setTimeout(function () {
initEdit(libs);
}, 300);
});
}
if (action === 'index') {
initList(libs);
// 追加読み込み(gridrows)後にアイコンを再付与
var listTimer = null;
$(document).ajaxComplete(function () {
clearTimeout(listTimer);
listTimer = setTimeout(function () {
initList(libs);
}, 300);
});
}
})
.catch(function (e) {
console.error('STL ビューアーの読み込みに失敗しました:', e);
});
/* ========== 編集画面 ========== */
function initEdit(libs) {
document.querySelectorAll('.stl-embed-field').forEach(function (el) {
if (el._renderer) {
el._renderer.dispose();
el._renderer = null;
}
el.remove();
});
document
.querySelectorAll('.control-attachments')
.forEach(function (input) {
var field = input.closest('[id$="Field"]');
if (!shouldAttach(field)) return;
var files = findStlFiles(input);
if (files.length === 0) return;
buildEmbedViewer(field, files, libs);
});
}
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);
}
// hidden input の JSON から STL だけを抽出する
function findStlFiles(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 || !/\.stl$/i.test(att.Name)) return;
files.push({
name: att.Name,
url: '/binaries/' + att.Guid + '/download'
});
});
return files;
}
function buildEmbedViewer(field, stlFiles, libs) {
var THREE = libs.THREE;
// 添付ファイル項目の直後に field-wide の wrapper を作成
var wrapper = document.createElement('div');
wrapper.className = 'field-wide stl-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);
var el = document.createElement('div');
el.className = 'stl-embed';
// タブバー
var tabBar = document.createElement('div');
tabBar.className = 'stl-tabs';
var btnToggle = document.createElement('button');
btnToggle.type = 'button';
btnToggle.className = 'stl-toggle-btn';
btnToggle.textContent = DEFAULT_COLLAPSED ? '◀' : '▼';
btnToggle.title = 'プレビューの表示切替';
// キャンバス
var wrap = document.createElement('div');
wrap.className = 'stl-canvas-wrap';
var canvas = document.createElement('canvas');
var loader = document.createElement('div');
loader.className = 'stl-loader';
loader.innerHTML = '<div class="stl-spinner"></div>';
wrap.append(canvas, loader);
// コントロールバー
var ctrl = document.createElement('div');
ctrl.className = 'stl-controls';
var btnZoomOut = iconBtn('zoom_out');
btnZoomOut.title = '縮小';
var btnZoomIn = iconBtn('zoom_in');
btnZoomIn.title = '拡大';
var btnReset = iconBtn('restart_alt');
btnReset.title = 'デフォルト表示に戻す';
var info = document.createElement('span');
info.className = 'stl-info';
info.textContent = 'ドラッグで回転';
var btnDisplay = iconBtn('tune');
btnDisplay.title = '表示設定';
var btnOpen = iconBtn('open_in_new');
btnOpen.title = '別ウィンドウで表示';
ctrl.append(btnZoomOut, btnZoomIn, btnReset, info, btnDisplay, btnOpen);
el.append(tabBar, wrap, ctrl);
if (DEFAULT_COLLAPSED) el.classList.add('collapsed');
cn.appendChild(el);
field.after(wrapper);
// Three.js のセットアップ
var w = wrap.clientWidth || 400;
var h = wrap.clientHeight || 200;
var renderer = new THREE.WebGLRenderer({
antialias: true,
canvas: canvas
});
renderer.setSize(w, h);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setClearColor(0x1e1e1e);
wrapper._renderer = renderer;
var scene = new THREE.Scene();
var camera = new THREE.PerspectiveCamera(45, w / h, 0.01, 100000);
// ライティング
scene.add(new THREE.AmbientLight(0x606060));
var dirLight1 = new THREE.DirectionalLight(0xffffff, 1.0);
dirLight1.position.set(1, 2, 3);
scene.add(dirLight1);
var dirLight2 = new THREE.DirectionalLight(0xffffff, 0.3);
dirLight2.position.set(-1, -1, -2);
scene.add(dirLight2);
var vs = {
idx: 0,
files: stlFiles,
scene: scene,
camera: camera,
renderer: renderer,
wrap: wrap,
loader: loader,
info: info,
mesh: null,
orbit: null,
displayPanel: null
};
stlFiles.forEach(function (f, i) {
var tab = document.createElement('button');
tab.type = 'button';
tab.className = 'stl-tab' + (i === 0 ? ' active' : '');
tab.textContent = f.name;
tab.onclick = function () {
tabBar.querySelectorAll('.stl-tab').forEach(function (t, j) {
t.classList.toggle('active', j === i);
});
loadStl(vs, i, libs);
};
tabBar.appendChild(tab);
});
tabBar.appendChild(btnToggle);
btnToggle.onclick = function () {
var collapsed = el.classList.toggle('collapsed');
btnToggle.textContent = collapsed ? '◀' : '▼';
if (!collapsed) {
resizeRenderer(vs);
vs.renderer.render(vs.scene, vs.camera);
}
};
btnZoomOut.onclick = function () {
vs.orbit.radius *= 1.3;
vs.updateCamera();
};
btnZoomIn.onclick = function () {
vs.orbit.radius *= 0.7;
vs.orbit.radius = Math.max(0.01, vs.orbit.radius);
vs.updateCamera();
};
btnReset.onclick = function () {
resetCamera(vs, libs.THREE);
vs.renderer.render(vs.scene, vs.camera);
};
btnDisplay.onclick = function () {
if (vs.displayPanel) vs.displayPanel.classList.toggle('hidden');
};
btnOpen.onclick = function () {
openModal(vs.files[vs.idx], libs);
};
// マウス操作(回転・ズーム・パン)
addOrbitControls(vs, libs.THREE);
// ウィンドウリサイズ対応
window.addEventListener('resize', function () {
resizeRenderer(vs);
vs.renderer.render(vs.scene, vs.camera);
});
loadStl(vs, 0, libs);
}
/* ========== 一覧画面 ========== */
function initList(libs) {
document
.querySelectorAll('a[href*="/binaries/"][href*="/download"]')
.forEach(function (a) {
if (!/\.stl$/i.test(a.textContent.trim())) return;
if (a.dataset.stlIcon) return;
a.dataset.stlIcon = '1';
var btn = document.createElement('span');
btn.className = 'material-symbols-outlined stl-preview-btn';
btn.textContent = 'preview';
btn.title = 'STL 3D プレビュー';
btn.onclick = function (e) {
e.preventDefault();
e.stopPropagation();
openModal(
{ name: a.textContent.trim(), url: a.getAttribute('href') },
libs
);
};
a.after(btn);
});
}
/* ========== STL 読み込み・描画 ========== */
function loadStl(vs, idx, libs) {
var THREE = libs.THREE;
vs.idx = idx;
if (vs.mesh) {
vs.scene.remove(vs.mesh);
vs.mesh.geometry.dispose();
vs.mesh.material.dispose();
vs.mesh = null;
}
if (vs.loader) vs.loader.classList.remove('hidden');
vs.info.textContent = '読み込み中...';
var stlLoader = new libs.STLLoader();
stlLoader.load(
vs.files[idx].url,
function (geometry) {
var material = new THREE.MeshPhongMaterial({
color: MODEL_COLOR,
specular: 0x222222,
shininess: 40,
side: THREE.DoubleSide,
flatShading: true
});
var mesh = new THREE.Mesh(geometry, material);
vs.mesh = mesh;
vs.scene.add(mesh);
resetCamera(vs, THREE);
vs.renderer.render(vs.scene, vs.camera);
buildDisplayPanel(vs, libs);
// 三角形数を表示
var triCount = geometry.index
? geometry.index.count / 3
: geometry.attributes.position.count / 3;
vs.info.textContent =
Math.round(triCount).toLocaleString() + ' triangles';
},
undefined,
function () {
vs.info.textContent = '読み込み失敗';
}
);
if (vs.loader) {
setTimeout(function () {
vs.loader.classList.add('hidden');
}, 500);
}
}
/* ========== カメラ制御 ========== */
function resetCamera(vs, THREE) {
if (!vs.mesh) return;
var box = new THREE.Box3().setFromObject(vs.mesh);
var center = box.getCenter(new THREE.Vector3());
var size = box.getSize(new THREE.Vector3()).length();
var dist = size * 1.5;
vs.camera.position.set(
center.x + dist * 0.5,
center.y + dist * 0.5,
center.z + dist
);
vs.camera.lookAt(center);
vs.camera.near = size * 0.001;
vs.camera.far = size * 100;
vs.camera.updateProjectionMatrix();
if (vs.orbit) {
vs.orbit.target = center.clone();
vs.orbit.radius = dist;
vs.orbit.theta = Math.atan2(0.5, 1);
vs.orbit.phi = Math.acos(0.5 / Math.sqrt(1.5));
}
}
function addOrbitControls(vs, THREE) {
var el = vs.renderer.domElement;
var dragging = false;
var panning = false;
var lastX = 0;
var lastY = 0;
var orbit = {
target: new THREE.Vector3(),
radius: 10,
theta: Math.PI / 4,
phi: Math.PI / 3
};
vs.orbit = orbit;
function updateCamera() {
var sinPhi = Math.sin(orbit.phi);
vs.camera.position.set(
orbit.target.x + orbit.radius * sinPhi * Math.sin(orbit.theta),
orbit.target.y + orbit.radius * Math.cos(orbit.phi),
orbit.target.z + orbit.radius * sinPhi * Math.cos(orbit.theta)
);
vs.camera.lookAt(orbit.target);
vs.renderer.render(vs.scene, vs.camera);
}
vs.updateCamera = updateCamera;
el.addEventListener('pointerdown', function (e) {
if (e.button === 1 || e.button === 2 || e.shiftKey) {
panning = true;
} else {
dragging = true;
}
lastX = e.clientX;
lastY = e.clientY;
el.setPointerCapture(e.pointerId);
});
el.addEventListener('pointermove', function (e) {
if (!dragging && !panning) return;
var dx = e.clientX - lastX;
var dy = e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
if (panning) {
vs.camera.updateMatrixWorld();
var panSpeed = orbit.radius * 0.002;
var right = new THREE.Vector3();
var up = new THREE.Vector3();
right.setFromMatrixColumn(vs.camera.matrixWorld, 0);
up.setFromMatrixColumn(vs.camera.matrixWorld, 1);
orbit.target.addScaledVector(right, -dx * panSpeed);
orbit.target.addScaledVector(up, dy * panSpeed);
} else {
orbit.theta -= dx * 0.01;
orbit.phi = Math.max(
0.1,
Math.min(Math.PI - 0.1, orbit.phi + dy * 0.01)
);
}
updateCamera();
});
el.addEventListener('pointerup', function () {
dragging = false;
panning = false;
});
el.addEventListener('contextmenu', function (e) {
e.preventDefault();
});
el.addEventListener(
'wheel',
function (e) {
e.preventDefault();
orbit.radius *= e.deltaY > 0 ? 1.1 : 0.9;
orbit.radius = Math.max(0.01, orbit.radius);
updateCamera();
},
{ passive: false }
);
}
function resizeRenderer(vs) {
var w = vs.wrap.clientWidth;
var h = vs.wrap.clientHeight;
if (w <= 0 || h <= 0) return;
vs.renderer.setSize(w, h);
vs.camera.aspect = w / h;
vs.camera.updateProjectionMatrix();
}
/* ========== モーダル ========== */
function openModal(file, libs) {
var THREE = libs.THREE;
var modal = document.getElementById('stl-modal');
if (!modal) modal = buildModal(libs);
modal.querySelector('.stl-modal-title').textContent = file.name;
modal.style.display = 'flex';
var body = modal.querySelector('.stl-modal-body');
var oldPanel = body.querySelector('.stl-display-panel');
if (oldPanel) oldPanel.remove();
var canvas = body.querySelector('canvas');
if (!canvas) {
canvas = document.createElement('canvas');
body.appendChild(canvas);
}
var loader = body.querySelector('.stl-loader');
if (loader) loader.classList.remove('hidden');
var w = body.clientWidth || 800;
var h = body.clientHeight || 600;
var renderer = new THREE.WebGLRenderer({
antialias: true,
canvas: canvas
});
renderer.setSize(w, h);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setClearColor(0x1e1e1e);
var scene = new THREE.Scene();
var camera = new THREE.PerspectiveCamera(45, w / h, 0.01, 100000);
scene.add(new THREE.AmbientLight(0x606060));
var dl1 = new THREE.DirectionalLight(0xffffff, 1.0);
dl1.position.set(1, 2, 3);
scene.add(dl1);
var dl2 = new THREE.DirectionalLight(0xffffff, 0.3);
dl2.position.set(-1, -1, -2);
scene.add(dl2);
var vs = {
idx: 0,
files: [file],
scene: scene,
camera: camera,
renderer: renderer,
wrap: body,
loader: loader,
info: modal.querySelector('.stl-info'),
mesh: null,
orbit: null,
displayPanel: null
};
modal._vs = vs;
modal._renderer = renderer;
addOrbitControls(vs, THREE);
vs.info.textContent = '読み込み中...';
loadStl(vs, 0, libs);
var onKey = function (e) {
if (e.key === 'Escape') closeModal();
};
document.addEventListener('keydown', onKey);
modal._onKey = onKey;
var onResize = function () {
var mw = body.clientWidth;
var mh = body.clientHeight;
if (mw <= 0 || mh <= 0) return;
renderer.setSize(mw, mh);
camera.aspect = mw / mh;
camera.updateProjectionMatrix();
renderer.render(scene, camera);
};
window.addEventListener('resize', onResize);
modal._onResize = onResize;
setTimeout(onResize, 100);
}
function buildModal(libs) {
var m = document.createElement('div');
m.id = 'stl-modal';
m.innerHTML =
'<div class="stl-modal-overlay"></div>' +
'<div class="stl-modal-dialog">' +
'<div class="stl-modal-header">' +
'<span class="stl-modal-title"></span>' +
'<button type="button" class="stl-modal-close">' +
'<span class="material-symbols-outlined">close</span>' +
'</button>' +
'</div>' +
'<div class="stl-modal-body">' +
'<div class="stl-loader"><div class="stl-spinner"></div></div>' +
'</div>' +
'<div class="stl-modal-controls">' +
'<button type="button" class="stl-modal-zoom-out" title="縮小">' +
'<span class="material-symbols-outlined">zoom_out</span>' +
'</button>' +
'<button type="button" class="stl-modal-zoom-in" title="拡大">' +
'<span class="material-symbols-outlined">zoom_in</span>' +
'</button>' +
'<button type="button" class="stl-modal-reset" title="デフォルト表示に戻す">' +
'<span class="material-symbols-outlined">restart_alt</span>' +
'</button>' +
'<span class="stl-info">ドラッグで回転</span>' +
'<button type="button" class="stl-modal-display" title="表示設定">' +
'<span class="material-symbols-outlined">tune</span>' +
'</button>' +
'</div>' +
'</div>';
document.body.appendChild(m);
m.querySelector('.stl-modal-overlay').onclick = closeModal;
m.querySelector('.stl-modal-close').onclick = closeModal;
m.querySelector('.stl-modal-zoom-out').onclick = function () {
var vs = m._vs;
if (!vs || !vs.orbit) return;
vs.orbit.radius *= 1.3;
vs.updateCamera();
};
m.querySelector('.stl-modal-zoom-in').onclick = function () {
var vs = m._vs;
if (!vs || !vs.orbit) return;
vs.orbit.radius *= 0.7;
vs.orbit.radius = Math.max(0.01, vs.orbit.radius);
vs.updateCamera();
};
m.querySelector('.stl-modal-reset').onclick = function () {
var vs = m._vs;
if (!vs) return;
resetCamera(vs, libs.THREE);
vs.renderer.render(vs.scene, vs.camera);
};
m.querySelector('.stl-modal-display').onclick = function () {
var vs = m._vs;
if (vs && vs.displayPanel) vs.displayPanel.classList.toggle('hidden');
};
return m;
}
function buildDisplayPanel(vs, libs) {
var THREE = libs.THREE;
var existing = vs.wrap.querySelector('.stl-display-panel');
if (existing) existing.remove();
vs.displayPanel = null;
if (!vs.mesh) return;
var panel = document.createElement('div');
panel.className = 'stl-display-panel hidden';
var header = document.createElement('div');
header.className = 'stl-display-header';
header.innerHTML = '<span>表示設定</span>';
var resetBtn = document.createElement('button');
resetBtn.type = 'button';
resetBtn.textContent = 'リセット';
resetBtn.onclick = function () {
vs.mesh.material.wireframe = false;
vs.mesh.material.flatShading = true;
vs.mesh.material.color.setHex(MODEL_COLOR);
vs.mesh.material.needsUpdate = true;
cbWire.checked = false;
cbFlat.checked = true;
swatches.forEach(function (sw) {
sw.classList.toggle('active', sw.dataset.color === '0x' + MODEL_COLOR.toString(16));
});
vs.renderer.render(vs.scene, vs.camera);
};
header.appendChild(resetBtn);
panel.appendChild(header);
var list = document.createElement('div');
list.className = 'stl-display-list';
// ワイヤーフレーム切替
var wireItem = document.createElement('label');
wireItem.className = 'stl-display-item';
var cbWire = document.createElement('input');
cbWire.type = 'checkbox';
cbWire.checked = false;
cbWire.onchange = function () {
vs.mesh.material.wireframe = cbWire.checked;
vs.renderer.render(vs.scene, vs.camera);
};
var wireLabel = document.createElement('span');
wireLabel.textContent = 'ワイヤーフレーム';
wireItem.append(cbWire, wireLabel);
list.appendChild(wireItem);
// フラットシェーディング切替
var flatItem = document.createElement('label');
flatItem.className = 'stl-display-item';
var cbFlat = document.createElement('input');
cbFlat.type = 'checkbox';
cbFlat.checked = true;
cbFlat.onchange = function () {
vs.mesh.material.flatShading = cbFlat.checked;
vs.mesh.material.needsUpdate = true;
vs.renderer.render(vs.scene, vs.camera);
};
var flatLabel = document.createElement('span');
flatLabel.textContent = 'フラットシェーディング';
flatItem.append(cbFlat, flatLabel);
list.appendChild(flatItem);
// カラープリセット
var colorRow = document.createElement('div');
colorRow.className = 'stl-color-row';
var colorLabel = document.createElement('span');
colorLabel.textContent = 'カラー';
colorLabel.style.marginRight = '4px';
colorRow.appendChild(colorLabel);
var colors = [
{ hex: 0x4fc3f7, name: 'ライトブルー' },
{ hex: 0x81c784, name: 'グリーン' },
{ hex: 0xffb74d, name: 'オレンジ' },
{ hex: 0xe57373, name: 'レッド' },
{ hex: 0xbdbdbd, name: 'グレー' },
{ hex: 0xfff176, name: 'イエロー' }
];
var swatches = [];
colors.forEach(function (c) {
var sw = document.createElement('span');
sw.className = 'stl-color-swatch';
sw.style.background = '#' + c.hex.toString(16).padStart(6, '0');
sw.title = c.name;
sw.dataset.color = '0x' + c.hex.toString(16);
if (c.hex === MODEL_COLOR) sw.classList.add('active');
sw.onclick = function () {
vs.mesh.material.color.setHex(c.hex);
vs.renderer.render(vs.scene, vs.camera);
swatches.forEach(function (s) {
s.classList.remove('active');
});
sw.classList.add('active');
};
swatches.push(sw);
colorRow.appendChild(sw);
});
list.appendChild(colorRow);
panel.appendChild(list);
vs.wrap.appendChild(panel);
vs.displayPanel = panel;
}
function closeModal() {
var m = document.getElementById('stl-modal');
if (!m) return;
m.style.display = 'none';
if (m._onKey) {
document.removeEventListener('keydown', m._onKey);
}
if (m._onResize) {
window.removeEventListener('resize', m._onResize);
}
if (m._renderer) {
m._renderer.dispose();
m._renderer = null;
}
}
/* ========== ユーティリティ ========== */
function iconBtn(name) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'stl-ctrl-btn';
var s = document.createElement('span');
s.className = 'material-symbols-outlined';
s.textContent = name;
b.appendChild(s);
return b;
}
});ExtendedStyles/StlViewer.css
/* ===== 埋め込みビューアー(編集画面) ===== */
.stl-embed-field {
padding-top: 0;
min-height: 0;
}
.stl-embed {
border: 1px solid var(--base-border);
border-radius: 4px;
overflow: hidden;
}
.stl-embed.collapsed .stl-canvas-wrap,
.stl-embed.collapsed .stl-controls {
display: none;
}
.stl-embed .stl-tabs {
display: flex;
align-items: center;
border-bottom: 1px solid var(--base-border);
background: var(--base-bg-light);
overflow-x: auto;
}
.stl-embed .stl-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;
}
.stl-embed .stl-tab:hover {
color: var(--base-text);
}
.stl-embed .stl-tab.active {
color: var(--primaryColor);
border-bottom-color: var(--primaryColor);
}
.stl-embed .stl-canvas-wrap {
position: relative;
display: flex;
justify-content: center;
padding: 0;
background: #1e1e1e;
aspect-ratio: 2 / 1;
overflow: hidden;
}
.stl-embed canvas {
width: 100%;
height: 100%;
display: block;
}
/* ===== ローディング ===== */
.stl-loader {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
z-index: 10;
transition: opacity 0.2s;
}
.stl-loader.hidden {
opacity: 0;
pointer-events: none;
}
.stl-loader .stl-spinner {
width: 40px;
height: 40px;
border: 4px solid var(--scrollbar-thumb);
border-right-color: transparent;
border-radius: 50%;
animation: stl-rotate 1s linear infinite;
}
@keyframes stl-rotate {
to {
transform: rotate(1turn);
}
}
.stl-embed .stl-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);
}
.stl-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);
}
.stl-ctrl-btn:hover {
background: var(--btn-normal-hover);
}
.stl-info {
font-size: 13px;
min-width: 50px;
text-align: center;
}
.stl-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;
}
.stl-toggle-btn:hover {
background: var(--btn-normal-hover);
}
/* ===== 表示設定パネル ===== */
.stl-display-panel {
position: absolute;
top: 0;
right: 0;
width: 220px;
max-height: 100%;
background: rgba(30, 30, 30, 0.92);
border-left: 1px solid rgba(255, 255, 255, 0.15);
display: flex;
flex-direction: column;
z-index: 5;
color: #ccc;
font-size: 12px;
}
.stl-display-panel.hidden {
display: none;
}
.stl-display-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
font-weight: bold;
}
.stl-display-header button {
background: none;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 3px;
color: #ccc;
font-size: 11px;
cursor: pointer;
padding: 2px 6px;
}
.stl-display-header button:hover {
background: rgba(255, 255, 255, 0.15);
}
.stl-display-list {
overflow-y: auto;
flex: 1;
}
.stl-display-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
cursor: pointer;
transition: background 0.15s;
}
.stl-display-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.stl-display-item input[type='checkbox'] {
accent-color: var(--primaryColor, #4285f4);
margin: 0;
}
.stl-display-item span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stl-color-row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
}
.stl-color-swatch {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: border-color 0.15s;
}
.stl-color-swatch:hover,
.stl-color-swatch.active {
border-color: #fff;
}
/* ===== プレビューボタン(一覧画面) ===== */
.stl-preview-btn {
font-size: 18px !important;
vertical-align: middle;
cursor: pointer;
color: var(--warning-color);
margin-left: 4px;
}
.stl-preview-btn:hover {
opacity: 0.8;
}
/* ===== モーダル ===== */
#stl-modal {
position: fixed;
inset: 0;
z-index: 9998;
display: none;
align-items: center;
justify-content: center;
}
#stl-modal .stl-modal-overlay {
position: fixed;
inset: 0;
background: var(--u-modal-bg);
}
#stl-modal .stl-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;
}
#stl-modal .stl-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--base-border);
}
#stl-modal .stl-modal-title {
font-weight: bold;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#stl-modal .stl-modal-close {
background: none;
border: none;
cursor: pointer;
padding: 4px;
display: flex;
color: var(--base-text);
}
#stl-modal .stl-modal-close:hover {
opacity: 0.7;
}
#stl-modal .stl-modal-body {
position: relative;
flex: 1;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
background: #1e1e1e;
}
#stl-modal .stl-modal-body canvas {
width: 100%;
height: 100%;
display: block;
}
#stl-modal .stl-modal-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 8px 16px;
border-top: 1px solid var(--base-border);
}
#stl-modal .stl-modal-controls button {
background: none;
border: none;
cursor: pointer;
padding: 4px;
display: flex;
color: var(--base-text);
border-radius: 4px;
}
#stl-modal .stl-modal-controls button:hover {
background: var(--btn-normal-hover);
}
/* ===== レスポンシブ対応 ===== */
@media (max-width: 1024px) {
.stl-embed-field > .field-label {
display: none;
}
}
@media (max-width: 768px) {
#stl-modal .stl-modal-dialog {
width: 100vw;
height: 100vh;
max-width: none;
border-radius: 0;
}
#stl-modal .stl-modal-header {
padding: 8px 12px;
}
#stl-modal .stl-modal-body {
padding: 0;
}
#stl-modal .stl-modal-controls {
padding: 6px 8px;
}
.stl-embed .stl-tab {
padding: 4px 10px;
font-size: 11px;
}
.stl-display-panel {
width: 180px;
}
}注意点
- WebGL 非対応のブラウザでは動作しません。
- 三角形数が多い大規模な STL では描画負荷が高く、操作のレスポンスが落ちることがあります。
- 3D ビューポートの背景色(
#1e1e1e)は視認性のため固定です。コントロールバーはテーマの CSS 変数を使うため、旧テーマ(base/start等)では色が反映されません。 - CSP を設定している環境では
script-srcにcdn.jsdelivr.netを追加してください。 - Three.js は MIT ライセンスです。CDN から読み込む場合も、サービスのライセンス表記に著作権表示を含めることが推奨されています。
画像をプレビューモーダルで表示する
添付ファイル項目の虫眼鏡アイコンは、ファイルを別タブで開くだけです。説明項目(Markdown・リッチテキスト)に貼った画像はクリックするとプレビューモーダルで開くので、添付ファイルの画像も同じモーダルで開くようにします。

標準の動作
添付ファイルの 1 件は次の HTML で出力されます(HtmlControls.cs)。
<div id="{guid}" class="control-attachments-item">
<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">{ファイル名} ({サイズ})</a>
<div class="ui-icon ui-icon-circle-close delete-file" ...></div>
</div>- 虫眼鏡のリンクは
/binaries/{guid}/show、アップロード直後で未保存のファイルは/binaries/{guid}/showtempです(Locations.cs)。 showは、BinaryStorage.jsonのBrowserAllowMimeTypes(既定は PDF・テキスト・JPEG・PNG・GIF・WebP)に含まれる形式ならブラウザで表示し、それ以外はダウンロードさせます(BinariesController.cs)。- ファイル名のリンクの文字列は「ファイル名 + 全角スペース + (サイズ)」です。拡張子で判定するときはサイズの部分を除きます。
プレビューモーダル(image-viewer-modal)
説明項目のプレビューは <image-viewer-modal> というカスタム要素です(imageViewerModal.ts)。
show(img, imgs)にimg要素を渡すと、そのsrcを読み込んで表示します。2 枚以上のimgsを渡すと前後の移動ボタンと枚数が付きます。srcの?thumbnail以降は外して原寸を読み込みます。- 共通のスクリプト(
generals/index.ts)で読み込まれていて(index.ts)、読み込みはテーマで分かれていません(HtmlScripts.cs)。 - Markdown 項目は
General.jsonのEnableLightBoxがtrue(既定)のときだけ要素を作ってbodyに置きます(HtmlControls.cs)。添付ファイル用には、無ければスクリプトで作ります。
スクリプト
テーブルの管理の「スクリプト」(出力先は編集)に登録します。全サイトに適用するなら拡張スクリプトに置きます。
$(function () {
var imageExt = /\.(jpe?g|png|gif|webp|bmp|svg)$/i;
function getModal() {
var modal = document.querySelector('image-viewer-modal');
if (!modal) {
modal = document.createElement('image-viewer-modal');
document.body.appendChild(modal);
}
return modal;
}
// 「ファイル名 (サイズ)」からファイル名を取り出す
function fileName($item) {
return $item.find('a.file-name').last().text().trim()
.replace(/ \([^)]*\)$/, '');
}
$(document).on('click', '.control-attachments-item .show-file', function (e) {
var $item = $(this).closest('.control-attachments-item');
if (!imageExt.test(fileName($item))) return; // 画像以外は標準どおり別タブ
e.preventDefault();
var current = null;
var imgs = [];
$item.closest('.control-attachments-items')
.find('.control-attachments-item')
.each(function () {
var $i = $(this);
if (!imageExt.test(fileName($i))) return;
var img = document.createElement('img');
img.src = $i.find('.show-file').closest('a').attr('href');
imgs.push(img);
if ($i.is($item)) current = img;
});
getModal().show(current, imgs);
});
});同じ添付ファイル項目にある画像をまとめて渡すので、モーダル内で前後の画像に移れます。
注意点
- 拡張子で判定しているので、拡張子の無いファイルや拡張子と中身が違うファイルは判定できません。
BrowserAllowMimeTypesに無い形式(BMP・SVG など)はshowがダウンロードとして返すので、モーダルに表示できません。使う形式に合わせて正規表現かBrowserAllowMimeTypesを調整します。- 本体を改修するなら、
AttachmentItemに MIME タイプを渡してdata-content-type属性を出し、それで判定する方法があります。AttachmentItemの呼び出し元すべてに引数を足すことになります。
ウイルススキャンを追加する(本体改変)
フォーム機能(Form.Enabled)を有効にすると、認証なしで外部からファイルを受け付けることになります。プリザンター標準のチェックでは拡張子偽装を防げないため、ClamAV によるスキャンを組み込む設計を紹介します。
本体コードの改変が必要です
この方法はプリザンター本体のコードを改変してビルドする必要があります(バージョン 1.5.1.0 が対象)。ビルド環境は公式リポジトリの CONTRIBUTING.md を参照してください。
スキャンエンジンや組み込み方(リバースプロキシ・プラグインなど)の比較、1.5.8.1 での挿入箇所、API・画像アップロードへの適用、運用上のリスクは添付ファイルのウイルススキャン(設計メモ)にまとめています。
標準のファイルチェックと限界
| バリデーション | メソッド | 内容 |
|---|---|---|
| 件数制限 | OverLimitQuantity() | カラムごとの LimitQuantity(デフォルト 30) |
| 個別サイズ制限 | OverLimitSize() | カラムの LimitSize MB |
| 合計サイズ制限 | OverTotalLimitSize() | TotalLimitSize |
| テナントストレージ制限 | OverTenantStorageSize() | ContractSettings.StorageSize(GB) |
| ファイル名検証 | IsValidFileName() | null 文字、..、/、\、: を禁止 |
| 拡張子ブロックリスト | IsAllowedExtension() | 除外リストに該当する拡張子を拒否 |
| 画像フォーマット検証 | OnUploadingSiteImage() | ImageSharp で画像フォーマット検証 |
| MD5 ハッシュ検証 | ValidateFileHash() | アップロード後のファイル整合性チェック |
- ファイルの中身(バイナリ)を検査する仕組みはありません。
- ファイル名検証と拡張子ブロックリストは
BinaryValidators.OnValidatingFormUpload()の中でif (!context.IsForm) return Error.Types.None;となっており、フォーム経由のアップロードにだけ適用されます(該当コード)。 - 除外拡張子は
App_Data/Parameters/Form.jsonのAttachmentExcludedExtensionsで定義され、.exe・.dll・.bat・.ps1・.js・.php・.sh・.py・.jarなどがブロックされます。malware.txt.exeのような二重拡張子もドットで分割して全パーツを判定します(該当コード)。 - ただし許可された拡張子(
.docx・.pdf・.zipなど)への偽装、マクロ付き Office ファイルのリネームなどは検出できません。
スキャン手法の選択
| 手法 | クロスプラットフォーム | 無償 | パフォーマンス | 検出精度 |
|---|---|---|---|---|
ClamAV(clamd デーモン) | 対応 | 対応 | 高速 | 実用的 |
ClamAV(clamscan コマンド) | 対応 | 対応 | 低速 | 実用的 |
| Windows Defender(AMSI) | 非対応 | 対応 | 高速 | 高い |
Windows Defender(MpCmdRun.exe) | 非対応 | 対応 | 普通 | 高い |
| YARA ルールスキャン | 対応 | 対応 | 高速 | ルール依存 |
| VirusTotal API | 対応 | 制限あり | 低速 | 非常に高い |
Linux でも動かすことを考え、ClamAV(clamd を TCP で呼ぶストリームスキャン)と .NET ライブラリ nClam(MIT)の組み合わせを採用しています。
設計
既存のバリデーション(サイズ・拡張子)を通過したあと、BinaryUtilities.UploadFile の流れの中でスキャンを行い、結果が Clean なら保存、Infected ならエラー、スキャンエラーなら設定に応じて許可/拒否します。
図を読み込み中…
設定はプリザンターのパラメータの形式に合わせて App_Data/Parameters/VirusScan.json に置きます(既定は Disabled なので、置かなければ従来どおり)。
{
"ScanMode": "Disabled",
"Provider": "ClamAV",
"ClamAvHost": "localhost",
"ClamAvPort": 3310,
"TimeoutMs": 30000,
"MaxFileSizeMB": 100,
"RejectOnScanError": false,
"ScanImages": true
}| パラメータ | 説明 |
|---|---|
ScanMode | All(全アップロード経路)/ FormOnly(フォーム経由のみ)/ Disabled |
Provider | スキャンエンジン(現時点では ClamAV のみ) |
ClamAvHost / ClamAvPort | clamd のホスト名・ポート |
TimeoutMs | スキャンタイムアウト(ミリ秒) |
MaxFileSizeMB | スキャン対象の最大サイズ(超過時はスキップ) |
RejectOnScanError | true: スキャンエラー時に拒否 / false: 許可 |
ScanImages | 画像アップロードもスキャン対象にするか |
| アップロード経路 | All | FormOnly | Disabled |
|---|---|---|---|
BinariesController(認証ユーザー) | スキャン | - | - |
FormBinariesController(フォーム) | スキャン | スキャン | - |
Api/BinariesController(API) | スキャン | - | - |
実装はインターフェース IVirusScanService(ScanAsync(byte[] data, string fileName))と、nClam を使う ClamAvVirusScanService、無効時に常に Clean を返す NullVirusScanService に分け、Startup.cs の ConfigureServices で切り替えて DI 登録します。スキャン要否の判定は次のとおりです。
private static bool ShouldScan(bool isFormUpload)
{
var mode = Parameters.VirusScan?.ScanMode ?? VirusScanMode.Disabled;
return mode switch
{
VirusScanMode.All => true,
VirusScanMode.FormOnly => isFormUpload,
_ => false
};
}ClamAvVirusScanService の中核は _client.SendAndScanFileAsync(data) の結果を ClamScanResults.Clean / VirusDetected / それ以外(エラー)に振り分ける処理です。各クラスの全コードは次のとおりです(パスは Implem.Pleasanter/ からの相対パスです)。
Libraries/Security/IVirusScanService.cs
public interface IVirusScanService
{
Task<VirusScanResult> ScanAsync(byte[] data, string fileName);
}Libraries/Security/VirusScanResult.cs
public class VirusScanResult
{
public bool IsClean { get; set; }
public bool IsError { get; set; }
public string VirusName { get; set; }
public string ErrorMessage { get; set; }
public static VirusScanResult Clean()
=> new VirusScanResult { IsClean = true };
public static VirusScanResult Infected(string virusName)
=> new VirusScanResult { IsClean = false, VirusName = virusName };
public static VirusScanResult Error(string message)
=> new VirusScanResult { IsClean = false, IsError = true, ErrorMessage = message };
}Libraries/Security/ClamAvVirusScanService.cs
using nClam;
public class ClamAvVirusScanService : IVirusScanService
{
private readonly ClamClient _client;
private readonly int _maxFileSizeBytes;
public ClamAvVirusScanService()
{
var host = Parameters.VirusScan?.ClamAvHost ?? "localhost";
var port = Parameters.VirusScan?.ClamAvPort ?? 3310;
_maxFileSizeBytes = (Parameters.VirusScan?.MaxFileSizeMB ?? 100) * 1024 * 1024;
_client = new ClamClient(host, port)
{
MaxStreamSize = _maxFileSizeBytes
};
}
public async Task<VirusScanResult> ScanAsync(byte[] data, string fileName)
{
if (data == null || data.Length == 0)
return VirusScanResult.Clean();
if (data.Length > _maxFileSizeBytes)
return VirusScanResult.Clean(); // サイズ超過はスキップ
try
{
var result = await _client.SendAndScanFileAsync(data);
return result.Result switch
{
ClamScanResults.Clean => VirusScanResult.Clean(),
ClamScanResults.VirusDetected => VirusScanResult.Infected(
result.InfectedFiles?.FirstOrDefault()?.VirusName ?? "Unknown"),
_ => VirusScanResult.Error(
$"ClamAV scan returned: {result.Result}")
};
}
catch (Exception ex)
{
return VirusScanResult.Error($"ClamAV connection error: {ex.Message}");
}
}
}Libraries/Security/NullVirusScanService.cs
public class NullVirusScanService : IVirusScanService
{
public Task<VirusScanResult> ScanAsync(byte[] data, string fileName)
{
return Task.FromResult(VirusScanResult.Clean());
}
}DI 登録は Startup.cs の ConfigureServices に次のように書きます。Parameters は Startup のコンストラクタ内の Initializer.Initialize() でロード済みなので、この時点でアクセスできます。
// ウイルススキャンサービス登録
if (Parameters.VirusScan?.IsEnabled == true)
{
services.AddSingleton<IVirusScanService, ClamAvVirusScanService>();
}
else
{
services.AddSingleton<IVirusScanService, NullVirusScanService>();
}チャンクアップロード
プリザンターは Content-Range ヘッダーによるチャンクアップロードに対応しています。途中のチャンクだけではパターンを検出できないため、最終チャンクが届いてファイル全体が揃った時点(contentRange == null || contentRange.To + 1 == contentRange.Length)でスキャンします。
ClamAV のセットアップ
# Docker(最も手軽。ウイルス定義も自動ダウンロード)
docker run -d --name clamav -p 3310:3310 clamav/clamav:latest
# Ubuntu/Debian
sudo apt-get install clamav clamav-daemon
sudo freshclam # ウイルス定義更新
sudo systemctl start clamav-daemonWindows では winget install ClamAV.ClamAV でインストールし、freshclam.exe で定義更新、clamd.exe でデーモンを起動します。clamd.conf の推奨設定は次のとおりです。
# TCP ソケットでリッスン
TCPSocket 3310
TCPAddr 127.0.0.1
# ストリームスキャンの最大サイズ
StreamMaxLength 100M
# 圧縮爆弾対策
MaxScanSize 100M
MaxFileSize 25M
MaxRecursion 16
MaxFiles 10000エラー時の動作と運用上の注意
| 状況 | RejectOnScanError: true | RejectOnScanError: false |
|---|---|---|
| ウイルス検出 | 拒否 | 拒否 |
clamd 接続失敗 | 拒否 | 許可(ログ記録) |
| タイムアウト | 拒否 | 許可(ログ記録) |
| ファイルサイズ超過 | スキップ(許可) | スキップ(許可) |
falseだとclamdが止まっていてもアップロードが通るため、本番環境ではRejectOnScanError: trueを推奨します。clamdはlocalhostか専用ネットワーク内に置き、freshclamの自動更新を有効にして状況を監視します。- スキャンは多層防御の一要素です。拡張子チェック・サイズ制限と組み合わせて使います。
- 動作確認には EICAR テストファイルを使います。
.docxなどにリネームしてもEicar-Signatureとして検出されるので、拡張子偽装への耐性も確認できます。
サイト単位で一括ダウンロードする(ツール)
添付ファイルのバックアップなどで実ファイルをまとめて取り出したいときのコマンドラインツール pleasanter-items-attachments-downloader です(MIT License、GitHub の Release からバイナリを入手)。サイト ID を指定するだけで、添付ファイル項目に加えて内容・説明・コメントに貼り付けた画像もダウンロードできます(v1.0.1 以降)。
添付ファイルの保存先が Database(Binaries テーブルに BASE64 で保存)でも Local(実体はファイルシステム、ファイル名は GUID)でも、DB を直接操作する必要はありません。
dotnet pleasanter-items-attachments-downloader.dll /Url:{URL} /SiteId:{SiteId} /ApiKey:{APIキー} /Path:{出力先} [/Skip:{除外する項目}] [/Target:{包含する項目}]| パラメータ | 説明 |
|---|---|
/Url | プリザンターの URL。サブディレクトリ運用ならサブディレクトリまで指定 |
/SiteId | サイト ID |
/ApiKey | API キー。対象サイトに「読取り」「サイトの管理」の権限が必要 |
/Path | 出力先パス(実在すること) |
/Skip | 除外する項目の物理名をカンマ区切りで(例: AttachmentsA,DescriptionC)。Enterprise Edition の拡張項目 001〜100 にも対応 |
/Target | 対象にする項目の物理名をカンマ区切りで。/Skip とは併用不可 |
出力は [Path]/[SiteId]サイト名/[ResultId または IssueId]レコードタイトル/[項目物理名]項目論理名/[添付ファイルID]ファイル名 の階層になります。
- .NET 8 があれば Windows・macOS・Linux で動作し、プリザンター 1.4 系が動く環境であれば問題なく動作します。
- 同じパスに再実行すると既存ファイルは上書きされます。プリザンター側で削除されたファイルがローカルから消えることはありません。
- 例外処理は大まかで、1 つの処理が失敗すると以降は打ち切られます。例外内容は画面に出力されます。