Skip to content

添付ファイル ​

第9版作成 最終更新 (日本時間)
確認バージョン1.5.1.01.5.8.1

添付ファイル項目まわりのカスタマイズをまとめます。スクリプト/サーバスクリプトだけで済むもの(並び替え)、拡張スクリプト・拡張スタイル・拡張サーバスクリプトで全テーブルに適用するもの(一括削除、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 として並んでいます。この要素をファイル名で並べ替えます。

js
$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 を書き換えてください。

ファイル名順に並んだ添付ファイル(作業手順書.pdf、画面キャプチャ_1.png、画面キャプチャ_2.png)

一覧画面(サーバスクリプト) ​

サーバスクリプトの model.AttachmentsA には、添付ファイルの情報が JSON 文字列で入っています(画面表示の前に context.Log(JSON.stringify(model.AttachmentsA)) で確認できます)。

json
[
 {
  "Guid": "31DE9B93C26342D186646E723D7EB8E1",
  "Name": "log174_2503170830.xlsx",
  "Size": 11904,
  "HashCode": "xjugT/ALXg+G5dcjgs6CTPZ7DAeFDTXnZ8kavI8AQZY="
 }
]

一覧画面のセルは ol > li > a[href="/binaries/{guid}/download"] という単純なリストなので、並べ替えたリストを columns.AttachmentsA.RawText に入れて HTML ごと差し替えます。

js
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_ プレフィックスを付けて衝突を避けています。

js
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 を分けるとエディタの補完が効きます)。

json
{
    "BeforeOpeningPage": true,
    "Actions": ["index"],
    "TryCatch": true,
    "Body": "-- loaded from .json.js"
}
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 プレビュー(ページ送り・表示サイズ切替・別タブで開く)

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 リンクに付きます。

設定値 ​

js
// 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 デバウンスしてビューアーを作り直します。

js
// 編集画面・一覧画面のみ対象
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 が付いたもの(保存前の削除予定)は除外します。

js
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 属性で処理済みのリンクをスキップし、何度呼んでも重複しないようにしています。

js
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 フォントを使うときだけダウンロードされます。

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

表示サイズ。「ページ」モードのときだけ埋め込みビューアーの高さを aspect-ratio: 2 / 1 で固定し、ほかのモードでは解除します。canvas には height: auto が必要です(下記の修正 3)。

css
.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
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
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 リンクに付きます。

js
// 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 プレビューの修正版と同じパターンです。

js
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 で三角形の面が分かる表示にします。

js
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 つの平行光源で、裏面が暗くなりすぎないようにしています。

js
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 もモデルサイズに合わせて設定します。

js
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 に制限して真上・真下での反転を防ぎます。

js
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
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
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・リッチテキスト)に貼った画像はクリックするとプレビューモーダルで開くので、添付ファイルの画像も同じモーダルで開くようにします。

添付画像の虫眼鏡アイコンから開いたプレビューモーダル(Image 1 of 2)

標準の動作 ​

添付ファイルの 1 件は次の HTML で出力されます(HtmlControls.cs)。

html
<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)。添付ファイル用には、無ければスクリプトで作ります。

スクリプト ​

テーブルの管理の「スクリプト」(出力先は編集)に登録します。全サイトに適用するなら拡張スクリプトに置きます。

js
$(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 なので、置かなければ従来どおり)。

json
{
    "ScanMode": "Disabled",
    "Provider": "ClamAV",
    "ClamAvHost": "localhost",
    "ClamAvPort": 3310,
    "TimeoutMs": 30000,
    "MaxFileSizeMB": 100,
    "RejectOnScanError": false,
    "ScanImages": true
}
パラメータ説明
ScanModeAll(全アップロード経路)/ FormOnly(フォーム経由のみ)/ Disabled
Providerスキャンエンジン(現時点では ClamAV のみ)
ClamAvHost / ClamAvPortclamd のホスト名・ポート
TimeoutMsスキャンタイムアウト(ミリ秒)
MaxFileSizeMBスキャン対象の最大サイズ(超過時はスキップ)
RejectOnScanErrortrue: スキャンエラー時に拒否 / false: 許可
ScanImages画像アップロードもスキャン対象にするか
アップロード経路AllFormOnlyDisabled
BinariesController(認証ユーザー)スキャン--
FormBinariesController(フォーム)スキャンスキャン-
Api/BinariesController(API)スキャン--

実装はインターフェース IVirusScanService(ScanAsync(byte[] data, string fileName))と、nClam を使う ClamAvVirusScanService、無効時に常に Clean を返す NullVirusScanService に分け、Startup.cs の ConfigureServices で切り替えて DI 登録します。スキャン要否の判定は次のとおりです。

csharp
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
csharp
public interface IVirusScanService
{
    Task<VirusScanResult> ScanAsync(byte[] data, string fileName);
}
Libraries/Security/VirusScanResult.cs
csharp
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
csharp
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
csharp
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() でロード済みなので、この時点でアクセスできます。

csharp
// ウイルススキャンサービス登録
if (Parameters.VirusScan?.IsEnabled == true)
{
    services.AddSingleton<IVirusScanService, ClamAvVirusScanService>();
}
else
{
    services.AddSingleton<IVirusScanService, NullVirusScanService>();
}

チャンクアップロード

プリザンターは Content-Range ヘッダーによるチャンクアップロードに対応しています。途中のチャンクだけではパターンを検出できないため、最終チャンクが届いてファイル全体が揃った時点(contentRange == null || contentRange.To + 1 == contentRange.Length)でスキャンします。

ClamAV のセットアップ ​

bash
# 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-daemon

Windows では winget install ClamAV.ClamAV でインストールし、freshclam.exe で定義更新、clamd.exe でデーモンを起動します。clamd.conf の推奨設定は次のとおりです。

text
# TCP ソケットでリッスン
TCPSocket 3310
TCPAddr 127.0.0.1

# ストリームスキャンの最大サイズ
StreamMaxLength 100M

# 圧縮爆弾対策
MaxScanSize 100M
MaxFileSize 25M
MaxRecursion 16
MaxFiles 10000

エラー時の動作と運用上の注意 ​

状況RejectOnScanError: trueRejectOnScanError: 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 を直接操作する必要はありません。

bat
dotnet pleasanter-items-attachments-downloader.dll /Url:{URL} /SiteId:{SiteId} /ApiKey:{APIキー} /Path:{出力先} [/Skip:{除外する項目}] [/Target:{包含する項目}]
パラメータ説明
/Urlプリザンターの URL。サブディレクトリ運用ならサブディレクトリまで指定
/SiteIdサイト ID
/ApiKeyAPI キー。対象サイトに「読取り」「サイトの管理」の権限が必要
/Path出力先パス(実在すること)
/Skip除外する項目の物理名をカンマ区切りで(例: AttachmentsA,DescriptionC)。Enterprise Edition の拡張項目 001〜100 にも対応
/Target対象にする項目の物理名をカンマ区切りで。/Skip とは併用不可

出力は [Path]/[SiteId]サイト名/[ResultId または IssueId]レコードタイトル/[項目物理名]項目論理名/[添付ファイルID]ファイル名 の階層になります。

  • .NET 8 があれば Windows・macOS・Linux で動作し、プリザンター 1.4 系が動く環境であれば問題なく動作します。
  • 同じパスに再実行すると既存ファイルは上書きされます。プリザンター側で削除されたファイルがローカルから消えることはありません。
  • 例外処理は大まかで、1 つの処理が失敗すると以降は打ち切られます。例外内容は画面に出力されます。

関連ページ ​

変更履歴

第9版「添付ファイル」にスクリーンショットを追加
第8版履歴タブと復元・数値項目の通貨記号・画像プレビューモーダルの解説と、編集画面まわりの改修・設計メモを追加
第7版レートリミッターの解説と、フォーム投稿の制限・ウイルススキャン・拡張子制限・外部検索エンジン・管理画面設定の改修・設計メモを追加
第6版「拡張機能」「画面カスタマイズ集」に対応バージョンを表示
第5版本文から元記事や以前の版への言及を除き、正しい動作だけを書く形に整理
第4版画面カスタマイズ集のコードを 1.5.8.1 のソースで検証し、動かなかったサンプルを修正
第3版画面カスタマイズ集にコード全体を収録し、サイト画像とサイト種別アイコンを両立するレシピを追加
第2版記事のファイル名に並び順の番号を付け、元記事リンクを frontmatter の sources に移行
第1版「画面カスタマイズ集」セクションの記事を追加