Skip to content

CAD・Excel ファイルのプレビュー ​

第7版作成 最終更新 (日本時間)
対応バージョンPleasanter 1.5.1.0 以降確認バージョン1.5.1.01.5.8.1

添付ファイル項目に添付された DXF(2D CAD 図面)、STEP(3D CAD モデル)、Excel(.xlsx / .xlsm)を、ダウンロードせずにブラウザ上でプレビューします。どれも拡張スクリプトと拡張スタイルの 2 ファイルだけで実装でき、ライブラリは CDN(jsDelivr)から動的に読み込むので、サーバ側の追加設定は要りません。

作り方は 添付ファイル の PDF / STL プレビューと同じです(編集画面は埋め込みビューアー、一覧画面はプレビューボタンを押すとモーダルが開く、MODE 変数で対象項目を選ぶ)。対象はバージョン 1.5.1.0 以降です。

形式ごとの比較 ​

DXF プレビューSTEP プレビューExcel プレビュー
対象の拡張子.dxf.stp / .step.xlsx / .xlsm
内容2D 図面(テキスト形式)3D モデル(テキスト形式、B-Rep)表(OpenXML 形式)
ライブラリ(CDN)dxf-parser 1.1.2occt-import-js 0.0.23(WASM)+ Three.js 0.172.0ExcelJS 4.4.0(MIT)
読み込み方法<script> タグの動的挿入(UMD)occt-import-js は <script> タグ、Three.js は import()(+esm)<script> タグの動的挿入(UMD)
描画Canvas 2D でベクター描画Three.js(WebGL)で 3D メッシュ描画スタイル付き HTML の table
操作ズーム(拡大・縮小・全体表示)回転・パン・ズーム、パーツの表示切替ファイルタブ・シートタブの切替、スクロール
拡張スクリプトExtendedScripts/DxfViewer.jsExtendedScripts/StepViewer.jsExtendedScripts/XlsxViewer.js
拡張スタイルExtendedStyles/DxfViewer.cssExtendedStyles/StepViewer.cssExtendedStyles/XlsxViewer.css
フィールド CSS(include / exclude)dxf-viewer / no-dxf-viewerstep-viewer / no-step-viewerxlsx-viewer / no-xlsx-viewer
CDN から読み込むサイズ(目安)約 50KB約 13MB(WASM)約 400KB
主な制約DWG は非対応。対応エンティティのみ描画WASM が大きい。WebGL が必要。大規模モデルは重い.xls は非対応。グラフは非対応。条件付き書式は一部だけ

参考として、PDF・DXF・STEP の比較も載せます(PDF の詳細は 添付ファイル を参照)。

比較項目PDF プレビューDXF プレビューSTEP プレビュー
次元2D2D3D
パーサーPDF.jsdxf-parserocct-import-js(WASM)
レンダラーCanvas 2DCanvas 2DThree.js(WebGL)
描画方式ページ単位ラスタライズベクター描画3D メッシュ描画
マウス操作ページ送りズーム回転・ズーム・パン
WASM不要不要必要(約 13MB)

Word・PowerPoint などの Office 文書 ​

PDF.js のように「サーバー不要・見た目の再現度が高い・OSS」の 3 つをそろえた Office 文書のビューアーはありません。方式は 3 つに分かれます。

方式例サーバー再現度
ブラウザで解析して描画するdocx-preview、mammoth.js、SheetJS、ExcelJS、x-spreadsheet不要中〜低
文書サーバーで変換するONLYOFFICE Document Server、Collabora Online、Univer必要高
外部サービスに埋め込むMicrosoft Office Online、Google Docs Viewer不要(クラウドに依存)最高
ライブラリ・サービスDOCXXLSXPPTX旧形式(DOC・XLS・PPT)ライセンス
docx-preview○MIT
mammoth.js○(意味構造の HTML に変換。見た目は再現しない)BSD-2-Clause
SheetJS(Community Edition)○(データのみ)XLSApache-2.0
ONLYOFFICE Document Server○○○○AGPL-3.0(Community Edition は同時接続 20 まで)/商用
Collabora Online○○○○MPL-2.0/商用
Microsoft Office Online○○○○プロプライエタリ(閲覧は無料)
Google Docs Viewer○○○プロプライエタリ(25MB まで)

プリザンターの添付ファイルは、ログインしたユーザーしか取得できません。Microsoft Office Online と Google Docs Viewer は、インターネットから直接取得できる公開 URL のファイルしか表示できないので、そのままでは使えません。文書サーバー型は、サーバーの構築・運用(ONLYOFFICE は RAM 4GB 以上が目安)とライセンスの確認が必要です。

そのため、拡張機能だけで入れるなら、このページと同じくブラウザで解析する OSS を形式ごとに選ぶのが現実的です。

形式候補
PDFPDF.js(添付ファイル)
DOCXdocx-preview(MIT)。renderAsync(blob, 要素) で、取得したファイルの Blob をそのまま描画できる
XLSXExcelJS(このページ)。データだけでよければ SheetJS
PPTXブラウザで描画できる成熟した OSS は無い。サーバー側で LibreOffice などを使って PDF・画像に変換し、PDF.js などで表示する

共通の仕組み ​

3 つのビューアーは同じ構造です。

図を読み込み中…

添付ファイルの検出 ​

  • 編集画面: 添付ファイル項目には hidden input(input.control-attachments)があり、value 属性に添付ファイル情報の JSON 配列(Guid・Name・Deleted など)が入っています。これをパースして、対象の拡張子で Deleted でないものだけを拾い、ダウンロード URL /binaries/{Guid}/download を組み立てます。DOM のテキストを解析する必要はありません。
  • 一覧画面: hidden input がないため、a[href*="/binaries/"][href*="/download"] のリンクテキストが対象の拡張子で終わるかどうかで判定します。

編集画面の添付ファイル項目の HTML は次のような構造です(Excel プレビューの例)。

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

一覧画面のセルは ol > li > a[href="/binaries/{guid}/download"] の単純なリストです。

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

フィールドの div(input.closest('[id$="Field"]'))の直後に、field-wide と各ビューアーのクラス(dxf-embed-field など)を持つ wrapper を挿入します。wrapper の中身は空の field-label(中に空の label)と field-control > container-normal で、field-wide の標準レイアウトを再現しているため、ビューアーの左端が添付ファイル項目のコンテンツ領域と揃います。1024px 以下では空のラベルが余計な余白になるので、CSS で非表示にしています。

initEdit() は呼ばれるたびに既存の wrapper を削除してから作り直すので、何度呼んでも安全です。

対象項目の指定(MODE) ​

スクリプト冒頭の MODE 変数で、編集画面でビューアーを付ける添付ファイル項目を選びます。

MODE動作
'include'(既定)フィールド CSS に dxf-viewer / step-viewer / xlsx-viewer を設定した項目だけに表示
'all'すべての添付ファイル項目に表示(フィールド CSS の設定は不要)
'exclude'フィールド CSS に no-dxf-viewer / no-step-viewer / no-xlsx-viewer を設定した項目を除いて表示

フィールド CSS の設定手順は次のとおりです。

  1. テーブルの管理 → エディタを開きます。
  2. 対象の添付ファイル項目を選んで「詳細設定」を開きます。
  3. 「フィールド CSS」にクラス名(include なら xlsx-viewer など、exclude なら表示しない項目に no-xlsx-viewer など)を入力して保存します。

設定したクラスはフィールドの外側の div(div#Results_AttachmentsAField など)に付きます。

一覧画面のプレビューボタン

一覧画面のプレビューボタンは MODE に関係なく、対象の拡張子のすべてのリンクに付きます。

Ajax 完了後の再構築 ​

ファイルの追加・削除・更新のあとにビューアーを作り直すため、$(document).ajaxComplete() を 300ms デバウンスして再実行します。ただし、このページのコードは形式によって一覧画面の扱いが違います。

画面DXFSTEPExcel
編集画面:ajaxComplete で initEdit() を再実行ありありあり
一覧画面:追加読み込み(action=gridrows)後にプレビューボタンを再付与なし(初回のみ)ありあり
一覧画面:処理済みリンクに印を付けて重複を防止なしdata-step-icondata-xlsx-icon
一覧画面からモーダルで開いた編集画面に埋め込みビューアーを表示なしなしあり(ajaxComplete で initEdit() も呼ぶ)

モーダルで開いた編集画面ではページの action が index のままなので、initEdit() が呼ばれません。Excel 版は一覧画面の ajaxComplete で initList() と initEdit() の両方を呼んでこれに対応しています(PDF プレビューの修正版と同じ方法。添付ファイルを参照)。

DXF 版の一覧画面

DXF 版のコードは一覧画面で initList() を初回ロード時に 1 回しか呼んでいません。PDF プレビューの初版と同じく、スクロールで追加読み込みされた行にはプレビューボタンが付きません。必要なら、Excel 版と同じように一覧画面にも ajaxComplete を設定し、data-* 属性で処理済みのリンクを飛ばす処理を足してください。

共通の注意点 ​

  • 配色にはテーマの CSS 変数(--primaryColor、--base-border、--base-bg-light など)を使っています。旧テーマ(base / start 等)ではこれらの変数が定義されていないため、色が反映されません。cerulean(既定)や green-tea などの新テーマで使ってください。
  • DXF と STEP の描画領域の背景色(#1e1e1e)は、図面・モデルを見やすくするために固定しています。
  • アイコンは material-symbols-outlined クラス(Material Symbols)を使っています。
  • モーダルはビューポートの 95%(最大 1400px)の幅と 92% の高さで表示し、768px 以下では全画面になります。DOM は最初に表示するときに 1 つだけ作って使い回し、Esc キーまたはオーバーレイのクリックで閉じます。
  • CSP(Content Security Policy)を設定している環境では CDN からの読み込みがブロックされることがあります。script-src に cdn.jsdelivr.net を追加してください(STEP は connect-src も必要。後述)。

DXF(2D 図面)をプレビューする ​

dxf-parser で DXF のテキストを JavaScript オブジェクトに変換し、Canvas 2D API で自前で描画します。PDF.js のような専用のレンダリングエンジンは使いません。

図を読み込み中…

  • 編集画面: 埋め込みビューアー(複数ファイルはタブ切替、折りたたみ)。一覧画面: プレビューボタンからモーダル表示。編集画面からも「別ウィンドウで表示」ボタンでモーダルを開けます。
  • ズームは 25%〜500%(ZOOM_STEP ずつ)と、全体表示(DEFAULT_ZOOM に戻す)です。

DWG は対象外 ​

CAD でよく使われる形式のうち、拡張機能だけで扱えるのは DXF です。

形式拡張子開発元データ形式ブラウザ対応
DXF.dxfAutodeskテキストJavaScript でパース可能
DWG.dwgAutodeskバイナリ(プロプライエタリ)専用ライブラリが必要
IFC.ifcbuildingSMARTテキスト(STEP 形式)専用ライブラリが必要
STEP.stp / .stepISOテキスト3D 描画が必要(このページの STEP の節を参照)

DWG はバイナリかつ仕様非公開のため、JavaScript で直接パースするのは現実的ではありません。WASM ベースの MLight CAD-Viewer は Vue 3 前提の構成で、拡張機能として組み込むのは困難です。DWG をプレビューしたい場合の選択肢は次のとおりです。

方法概要制約
事前に DXF へ変換ODA File Converter などで DWG → DXF に変換してからアップロード手動変換が必要
サーバサイドで変換バックグラウンドサーバスクリプトで変換処理を実行変換ツールのインストールが必要
MLight CAD-ViewerWASM ベースのクライアントサイド DWG ビューアーVue 3 前提。拡張機能としては複雑
Autodesk Platform ServicesAutodesk の API で DWG をレンダリングAPI キーが必要。外部サービス依存

いちばん簡単なのは「プリザンターには DXF 形式でアップロードする」という運用ルールにすることです。AutoCAD、Fusion、FreeCAD など多くの CAD ソフトは DXF の書き出しに対応しています。

設定値 ​

js
var DXFPARSER_VER = '1.1.2';
var MODE = 'include';          // 'include' | 'all' | 'exclude'
var CSS_INCLUDE = 'dxf-viewer';
var CSS_EXCLUDE = 'no-dxf-viewer';
var DEFAULT_COLLAPSED = false; // true = 初期状態で折りたたみ
var DEFAULT_ZOOM = 100;        // ズーム倍率の初期値(%)
var ZOOM_STEP = 25;            // +/-ボタンの増減幅
var DEFAULT_COLOR = '#FFFFFF'; // 色が解決できないときの線の色

実装の要点 ​

読み込み。dxf-parser は CommonJS モジュールのため、import() ではなく <script> タグの動的挿入で UMD ビルド(dist/dxf-parser.min.js)を読み込みます。読み込むとグローバルに DxfParser クラスが使えるようになります。同じ src の script が既にあれば読み込みをスキップします。

取得とパース。DXF はテキスト形式なので、fetch のあと response.text() で文字列として取得し、new DxfParser().parseSync(text) に渡します。パース結果の主なプロパティは次のとおりです。

プロパティ内容
entities図形エンティティの配列(LINE, CIRCLE, ARC など)
blocksブロック定義(INSERT で参照される図形グループ)
tables.layer.layersレイヤー情報(色、表示/非表示など)

座標変換。DXF は Y 軸が上向き、Canvas は Y 軸が下向きです。全エンティティ(INSERT が参照するブロックの中身も含む)からバウンディングボックスを求め、図面の中心がキャンバスの中心に来るように平行移動したうえで、ctx.scale(scale, -scale) で Y 軸の反転とスケーリングを同時に行います。これで DXF の座標をそのまま ctx.lineTo(x, y) などに渡せます。

js
ctx.translate(
  w / 2 - ((bounds.minX + bounds.maxX) / 2) * scale,
  h / 2 + ((bounds.minY + bounds.maxY) / 2) * scale
);
ctx.scale(scale, -scale);

対応エンティティ。

エンティティ描画の要点
LINE2 頂点を結ぶ
CIRCLEctx.arc() で全周
ARCDXF の角度はデグリー単位なのでラジアンに変換。Y 軸反転で向きが逆になるため角度を負にし、counterclockwise を false にする
LWPOLYLINE / POLYLINE頂点の bulge 値(正は反時計回り、負は時計回りの円弧)から円弧の中心と半径を求めて描画。shape なら閉じる
ELLIPSE長軸端点と axisRatio から半径・回転を求める(パラメータはラジアン単位)
POINT小さな円で塗りつぶし
SOLID / 3DFACE頂点を結んで塗りつぶし
TEXT / MTEXTY 軸反転で文字が上下逆になるため、描画時に ctx.scale(1, -1) で戻してから回転を適用
INSERTブロック定義を位置・回転・スケールで変換して再帰的に描画

色の解決。エンティティの色は ACI(AutoCAD Color Index)で指定されます。colorIndex が未設定か BYLAYER(256)ならレイヤーの色を使い、1〜9 の基本色だけを対応表で変換します。それ以外は DEFAULT_COLOR になります。背景が暗い色なので、ACI 7(通常は白または黒)は白 #FFFFFF にしています(AutoCAD のダーク背景と同じ表示)。

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

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

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

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

.dxf-embed .dxf-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;
}

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

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

.dxf-embed .dxf-canvas-wrap {
  position: relative;
  display: flex;
  justify-content: center;
  padding: 8px;
  background: #1e1e1e;
  aspect-ratio: 2 / 1;
  overflow: auto;
}

.dxf-embed canvas {
  max-width: 100%;
}

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

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

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

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

.dxf-embed .dxf-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);
}

.dxf-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);
}

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

.dxf-zoom-info {
  font-size: 13px;
  min-width: 50px;
  text-align: center;
}

.dxf-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;
}

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

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

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

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

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

#dxf-modal .dxf-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;
}

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

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

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

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

#dxf-modal .dxf-modal-body {
  flex: 1;
  overflow: auto;
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 12px;
  background: #1e1e1e;
}

#dxf-modal .dxf-modal-body canvas {
  max-width: 100%;
}

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

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

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

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

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

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

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

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

  .dxf-embed .dxf-tab {
    padding: 4px 10px;
    font-size: 11px;
  }
}
ExtendedScripts/DxfViewer.js
js
$(function () {
  // dxf-parser の設定(バージョンを固定して動作の安定性を確保)
  var DXFPARSER_VER = '1.1.2';
  var DXFPARSER_URL =
    'https://cdn.jsdelivr.net/npm/dxf-parser@' +
    DXFPARSER_VER +
    '/dist/dxf-parser.min.js';

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

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

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

  // ACI(AutoCAD Color Index)の基本色マッピング
  var ACI = [
    null,      // 0: BYBLOCK
    '#FF0000', // 1: Red
    '#FFFF00', // 2: Yellow
    '#00FF00', // 3: Green
    '#00FFFF', // 4: Cyan
    '#0000FF', // 5: Blue
    '#FF00FF', // 6: Magenta
    '#FFFFFF', // 7: White
    '#808080', // 8: Gray
    '#C0C0C0'  // 9: Light gray
  ];
  var DEFAULT_COLOR = '#FFFFFF';

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

  // dxf-parser を CDN から動的に読み込む
  loadScript(DXFPARSER_URL)
    .then(function () {
      if (action === 'edit') {
        initEdit();
        var timer = null;
        $(document).ajaxComplete(function () {
          clearTimeout(timer);
          timer = setTimeout(initEdit, 300);
        });
      }
      if (action === 'index') initList();
    })
    .catch(function (e) {
      console.error('dxf-parser の読み込みに失敗しました:', e);
    });

  // スクリプトを動的に読み込む
  function loadScript(src) {
    return new Promise(function (resolve, reject) {
      if (document.querySelector('script[src="' + src + '"]')) {
        resolve();
        return;
      }
      var s = document.createElement('script');
      s.src = src;
      s.onload = resolve;
      s.onerror = reject;
      document.head.appendChild(s);
    });
  }

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

  function initEdit() {
    document.querySelectorAll('.dxf-embed-field').forEach(function (el) {
      el.remove();
    });

    document
      .querySelectorAll('.control-attachments')
      .forEach(function (input) {
        var field = input.closest('[id$="Field"]');
        if (!shouldAttach(field)) return;

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

  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 から DXF だけを抽出する
  function findDxfFiles(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 || !/\.dxf$/i.test(att.Name)) return;
      files.push({
        name: att.Name,
        url: '/binaries/' + att.Guid + '/download'
      });
    });
    return files;
  }

  function buildEmbedViewer(field, dxfFiles) {
    // 添付ファイル項目の直後に field-wide の wrapper を作成
    var wrapper = document.createElement('div');
    wrapper.className = 'field-wide dxf-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 = 'dxf-embed';

    // タブバー
    var tabBar = document.createElement('div');
    tabBar.className = 'dxf-tabs';

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

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

    // コントロールバー
    var ctrl = document.createElement('div');
    ctrl.className = 'dxf-controls';
    var btnZoomOut = iconBtn('remove');
    btnZoomOut.title = '縮小';
    var zoomInfo = document.createElement('span');
    zoomInfo.className = 'dxf-zoom-info';
    zoomInfo.textContent = DEFAULT_ZOOM + '%';
    var btnZoomIn = iconBtn('add');
    btnZoomIn.title = '拡大';
    var btnFit = iconBtn('fit_screen');
    btnFit.title = '全体表示';
    var btnOpen = iconBtn('open_in_new');
    btnOpen.title = '別ウィンドウで表示';

    ctrl.append(btnZoomOut, zoomInfo, btnZoomIn, btnFit, btnOpen);

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

    var vs = {
      idx: 0,
      zoom: DEFAULT_ZOOM,
      dxf: null,
      files: dxfFiles,
      canvas: canvas,
      wrap: wrap,
      loader: loader,
      el: el,
      zoomInfo: zoomInfo
    };

    dxfFiles.forEach(function (f, i) {
      var tab = document.createElement('button');
      tab.type = 'button';
      tab.className = 'dxf-tab' + (i === 0 ? ' active' : '');
      tab.textContent = f.name;
      tab.onclick = function () {
        tabBar.querySelectorAll('.dxf-tab').forEach(function (t, j) {
          t.classList.toggle('active', j === i);
        });
        loadDxf(vs, i);
      };
      tabBar.appendChild(tab);
    });
    tabBar.appendChild(btnToggle);

    btnToggle.onclick = function () {
      var collapsed = el.classList.toggle('collapsed');
      btnToggle.textContent = collapsed ? '◀' : '▼';
    };
    btnZoomIn.onclick = function () {
      vs.zoom = Math.min(vs.zoom + ZOOM_STEP, 500);
      zoomInfo.textContent = vs.zoom + '%';
      render(vs);
    };
    btnZoomOut.onclick = function () {
      vs.zoom = Math.max(vs.zoom - ZOOM_STEP, 25);
      zoomInfo.textContent = vs.zoom + '%';
      render(vs);
    };
    btnFit.onclick = function () {
      vs.zoom = DEFAULT_ZOOM;
      zoomInfo.textContent = vs.zoom + '%';
      render(vs);
    };
    btnOpen.onclick = function () {
      openModal(vs.files[vs.idx]);
    };

    loadDxf(vs, 0);
  }

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

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

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

  function loadDxf(vs, idx) {
    vs.idx = idx;
    vs.dxf = null;
    if (vs.loader) vs.loader.classList.remove('hidden');

    fetch(vs.files[idx].url)
      .then(function (res) {
        return res.text();
      })
      .then(function (text) {
        var parser = new DxfParser();
        vs.dxf = parser.parseSync(text);
        render(vs);
      })
      .catch(function () {
        vs.zoomInfo.textContent = '読み込み失敗';
      });
  }

  function render(vs) {
    if (!vs.dxf) return;
    var canvas = vs.canvas;
    var ctx = canvas.getContext('2d');
    var wrapEl = vs.wrap || canvas.parentElement;
    var w = wrapEl.clientWidth - 16;
    var h = wrapEl.clientHeight - 16;
    if (w <= 0 || h <= 0) return;

    // すべてのエンティティからバウンディングボックスを計算
    var bounds = getBounds(vs.dxf.entities, vs.dxf.blocks);
    var bw = bounds.maxX - bounds.minX || 1;
    var bh = bounds.maxY - bounds.minY || 1;

    // ズーム倍率を適用してスケールを計算
    var baseScale = Math.min(w / bw, h / bh);
    var scale = baseScale * (vs.zoom / 100);

    canvas.width = w;
    canvas.height = h;

    // 背景を塗りつぶし
    ctx.fillStyle = '#1e1e1e';
    ctx.fillRect(0, 0, w, h);

    ctx.save();
    // 中央に配置し、Y 軸を反転
    ctx.translate(
      w / 2 - ((bounds.minX + bounds.maxX) / 2) * scale,
      h / 2 + ((bounds.minY + bounds.maxY) / 2) * scale
    );
    ctx.scale(scale, -scale);

    // エンティティを描画
    drawEntities(ctx, vs.dxf.entities, vs.dxf, scale);

    ctx.restore();
    if (vs.loader) vs.loader.classList.add('hidden');
  }

  /* ========== バウンディングボックス ========== */

  function getBounds(entities, blocks) {
    var b = {
      minX: Infinity,
      minY: Infinity,
      maxX: -Infinity,
      maxY: -Infinity
    };
    collectBounds(entities, b, blocks);
    if (!isFinite(b.minX)) {
      b.minX = 0;
      b.minY = 0;
      b.maxX = 100;
      b.maxY = 100;
    }
    return b;
  }

  function collectBounds(entities, b, blocks) {
    if (!entities) return;
    entities.forEach(function (e) {
      if (e.vertices) {
        e.vertices.forEach(function (v) {
          updateBound(b, v.x, v.y);
        });
      }
      if (e.center) {
        var r = e.radius || 0;
        updateBound(b, e.center.x - r, e.center.y - r);
        updateBound(b, e.center.x + r, e.center.y + r);
      }
      if (e.position) {
        updateBound(b, e.position.x, e.position.y);
      }
      if (e.startPoint) {
        updateBound(b, e.startPoint.x, e.startPoint.y);
      }
      if (e.type === 'INSERT' && e.name && blocks && blocks[e.name]) {
        var block = blocks[e.name];
        if (block.entities) collectBounds(block.entities, b, blocks);
      }
    });
  }

  function updateBound(b, x, y) {
    if (x < b.minX) b.minX = x;
    if (x > b.maxX) b.maxX = x;
    if (y < b.minY) b.minY = y;
    if (y > b.maxY) b.maxY = y;
  }

  /* ========== エンティティ描画 ========== */

  function drawEntities(ctx, entities, dxf, scale) {
    if (!entities) return;
    entities.forEach(function (e) {
      ctx.strokeStyle = getColor(e, dxf);
      ctx.fillStyle = ctx.strokeStyle;
      ctx.lineWidth = 1 / scale;
      drawEntity(ctx, e, dxf, scale);
    });
  }

  function drawEntity(ctx, e, dxf, scale) {
    switch (e.type) {
      case 'LINE':
        drawLine(ctx, e);
        break;
      case 'CIRCLE':
        drawCircle(ctx, e);
        break;
      case 'ARC':
        drawArc(ctx, e);
        break;
      case 'LWPOLYLINE':
      case 'POLYLINE':
        drawPolyline(ctx, e);
        break;
      case 'ELLIPSE':
        drawEllipse(ctx, e);
        break;
      case 'POINT':
        drawPoint(ctx, e, scale);
        break;
      case 'SOLID':
      case '3DFACE':
        drawSolid(ctx, e);
        break;
      case 'TEXT':
      case 'MTEXT':
        drawText(ctx, e, scale);
        break;
      case 'INSERT':
        drawInsert(ctx, e, dxf, scale);
        break;
    }
  }

  function drawLine(ctx, e) {
    if (!e.vertices || e.vertices.length < 2) return;
    ctx.beginPath();
    ctx.moveTo(e.vertices[0].x, e.vertices[0].y);
    ctx.lineTo(e.vertices[1].x, e.vertices[1].y);
    ctx.stroke();
  }

  function drawCircle(ctx, e) {
    if (!e.center) return;
    ctx.beginPath();
    ctx.arc(e.center.x, e.center.y, e.radius || 0, 0, Math.PI * 2);
    ctx.stroke();
  }

  function drawArc(ctx, e) {
    if (!e.center) return;
    // DXF の角度はデグリー単位。Y 軸反転のため角度を反転
    var startRad = (-e.startAngle * Math.PI) / 180;
    var endRad = (-e.endAngle * Math.PI) / 180;
    ctx.beginPath();
    ctx.arc(e.center.x, e.center.y, e.radius || 0, startRad, endRad, false);
    ctx.stroke();
  }

  function drawPolyline(ctx, e) {
    if (!e.vertices || e.vertices.length < 2) return;
    ctx.beginPath();
    ctx.moveTo(e.vertices[0].x, e.vertices[0].y);
    for (var i = 1; i < e.vertices.length; i++) {
      if (e.vertices[i].bulge && e.vertices[i].bulge !== 0) {
        drawBulgeSegment(
          ctx,
          e.vertices[i - 1],
          e.vertices[i],
          e.vertices[i - 1].bulge
        );
      } else {
        ctx.lineTo(e.vertices[i].x, e.vertices[i].y);
      }
    }
    if (e.shape) ctx.closePath();
    ctx.stroke();
  }

  function drawBulgeSegment(ctx, p1, p2, bulge) {
    // Bulge 値から円弧を計算
    var dx = p2.x - p1.x;
    var dy = p2.y - p1.y;
    var dist = Math.sqrt(dx * dx + dy * dy);
    if (dist === 0) return;

    var sagitta = (bulge * dist) / 2;
    var radius = (dist / 2 / Math.abs(bulge) + Math.abs(sagitta)) / 2;

    var midX = (p1.x + p2.x) / 2;
    var midY = (p1.y + p2.y) / 2;
    var perpX = -dy / dist;
    var perpY = dx / dist;

    var h = Math.sqrt(Math.max(radius * radius - (dist * dist) / 4, 0));
    var sign = bulge > 0 ? 1 : -1;
    var cx = midX + sign * h * perpX;
    var cy = midY + sign * h * perpY;

    var startAngle = Math.atan2(p1.y - cy, p1.x - cx);
    var endAngle = Math.atan2(p2.y - cy, p2.x - cx);

    ctx.arc(cx, cy, radius, startAngle, endAngle, bulge < 0);
  }

  function drawEllipse(ctx, e) {
    if (!e.center || !e.majorAxisEndPoint) return;
    var mx = e.majorAxisEndPoint.x;
    var my = e.majorAxisEndPoint.y;
    var rx = Math.sqrt(mx * mx + my * my);
    var ry = rx * (e.axisRatio || 1);
    var rotation = Math.atan2(my, mx);

    // ELLIPSE のパラメータはラジアン単位
    var start = e.startAngle || 0;
    var end = e.endAngle || Math.PI * 2;

    ctx.beginPath();
    ctx.ellipse(e.center.x, e.center.y, rx, ry, rotation, start, end);
    ctx.stroke();
  }

  function drawPoint(ctx, e, scale) {
    var p = e.position || e;
    if (p.x === undefined) return;
    var r = 2 / scale;
    ctx.beginPath();
    ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
    ctx.fill();
  }

  function drawSolid(ctx, e) {
    var pts = e.points || e.vertices;
    if (!pts || pts.length < 3) return;
    ctx.beginPath();
    ctx.moveTo(pts[0].x, pts[0].y);
    for (var i = 1; i < pts.length; i++) {
      ctx.lineTo(pts[i].x, pts[i].y);
    }
    ctx.closePath();
    ctx.fill();
  }

  function drawText(ctx, e, scale) {
    var p = e.startPoint || e.position;
    if (!p) return;
    var text = e.text || '';
    if (!text) return;

    var height = e.textHeight || e.height || 1;
    ctx.save();
    ctx.translate(p.x, p.y);
    ctx.scale(1, -1); // テキストの Y 軸を再反転
    if (e.rotation) ctx.rotate((-e.rotation * Math.PI) / 180);
    ctx.font = height + 'px sans-serif';
    ctx.fillText(text, 0, 0);
    ctx.restore();
  }

  function drawInsert(ctx, e, dxf, scale) {
    if (!e.name || !dxf.blocks || !dxf.blocks[e.name]) return;
    var block = dxf.blocks[e.name];
    if (!block.entities) return;

    ctx.save();
    if (e.position) ctx.translate(e.position.x, e.position.y);
    if (e.rotation) ctx.rotate((e.rotation * Math.PI) / 180);
    if (e.xScale || e.yScale) {
      ctx.scale(e.xScale || 1, e.yScale || 1);
    }
    drawEntities(ctx, block.entities, dxf, scale);
    ctx.restore();
  }

  /* ========== 色の解決 ========== */

  function getColor(entity, dxf) {
    var ci = entity.colorIndex;
    // BYLAYER (256) またはカラーインデックス未設定 → レイヤー色
    if (!ci || ci === 256) {
      if (entity.layer && dxf.tables && dxf.tables.layer) {
        var layer = dxf.tables.layer.layers[entity.layer];
        if (layer && layer.color != null) {
          ci = layer.color;
        }
      }
    }
    if (ci && ci > 0 && ci < ACI.length) return ACI[ci];
    return DEFAULT_COLOR;
  }

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

  function openModal(file) {
    var modal = document.getElementById('dxf-modal');
    if (!modal) modal = buildModal();

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

    var canvas = modal.querySelector('canvas');
    var zoomInfo = modal.querySelector('.dxf-zoom-info');
    var vs = {
      idx: 0,
      zoom: DEFAULT_ZOOM,
      dxf: null,
      files: [file],
      canvas: canvas,
      wrap: modal.querySelector('.dxf-modal-body'),
      loader: null,
      zoomInfo: zoomInfo
    };
    modal._vs = vs;

    zoomInfo.textContent = vs.zoom + '%';

    fetch(file.url)
      .then(function (res) {
        return res.text();
      })
      .then(function (text) {
        var parser = new DxfParser();
        vs.dxf = parser.parseSync(text);
        render(vs);
      })
      .catch(function () {
        zoomInfo.textContent = '読み込み失敗';
      });

    var onKey = function (e) {
      if (e.key === 'Escape') closeModal();
    };
    document.addEventListener('keydown', onKey);
    modal._onKey = onKey;
  }

  function buildModal() {
    var m = document.createElement('div');
    m.id = 'dxf-modal';
    m.innerHTML =
      '<div class="dxf-modal-overlay"></div>' +
      '<div class="dxf-modal-dialog">' +
      '<div class="dxf-modal-header">' +
      '<span class="dxf-modal-title"></span>' +
      '<button type="button" class="dxf-modal-close">' +
      '<span class="material-symbols-outlined">close</span>' +
      '</button>' +
      '</div>' +
      '<div class="dxf-modal-body"><canvas></canvas></div>' +
      '<div class="dxf-modal-controls">' +
      '<button type="button" class="dxf-modal-zoom-out">' +
      '<span class="material-symbols-outlined">remove</span>' +
      '</button>' +
      '<span class="dxf-zoom-info"></span>' +
      '<button type="button" class="dxf-modal-zoom-in">' +
      '<span class="material-symbols-outlined">add</span>' +
      '</button>' +
      '<button type="button" class="dxf-modal-fit">' +
      '<span class="material-symbols-outlined">fit_screen</span>' +
      '</button>' +
      '</div>' +
      '</div>';

    document.body.appendChild(m);

    m.querySelector('.dxf-modal-overlay').onclick = closeModal;
    m.querySelector('.dxf-modal-close').onclick = closeModal;
    m.querySelector('.dxf-modal-zoom-in').onclick = function () {
      var vs = m._vs;
      vs.zoom = Math.min(vs.zoom + ZOOM_STEP, 500);
      vs.zoomInfo.textContent = vs.zoom + '%';
      render(vs);
    };
    m.querySelector('.dxf-modal-zoom-out').onclick = function () {
      var vs = m._vs;
      vs.zoom = Math.max(vs.zoom - ZOOM_STEP, 25);
      vs.zoomInfo.textContent = vs.zoom + '%';
      render(vs);
    };
    m.querySelector('.dxf-modal-fit').onclick = function () {
      var vs = m._vs;
      vs.zoom = DEFAULT_ZOOM;
      vs.zoomInfo.textContent = vs.zoom + '%';
      render(vs);
    };
    return m;
  }

  function closeModal() {
    var m = document.getElementById('dxf-modal');
    if (!m) return;
    m.style.display = 'none';
    if (m._onKey) {
      document.removeEventListener('keydown', m._onKey);
    }
  }

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

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

STEP(3D モデル)をプレビューする ​

STEP(ISO 10303)は 3D CAD データの国際標準交換フォーマットで、中身はテキストですが形状は B-Rep(境界表現)で定義されており、JavaScript で直接パースするのは非常に困難です。そこで、OpenCascade Technology(OCCT)の CAD インポート機能を WebAssembly にした occt-import-js で三角形メッシュに変換し、Three.js で描画します。

図を読み込み中…

  • 編集画面: 埋め込みビューアー(タブ切替、折りたたみ)。一覧画面: プレビューボタンからモーダル表示。編集画面からも「別ウィンドウで表示」ボタンでモーダルを開けます。
  • ドラッグで回転、Shift+ドラッグ / 右ドラッグ / 中ボタンドラッグでパン、ホイールとボタンでズーム、リセットボタンで初期表示に戻す
  • パーツパネル(account_tree アイコン)で部品ごとの表示・非表示を切り替え(「全表示」ボタン付き)
  • STEP ファイルに色情報があれば反映。読み込み後にモデル全体が収まるようカメラを自動調整

操作の実装(球面座標による自前の軌道カメラ、PointerEvent、カメラの自動フィッティング、ライティング)は 添付ファイル の STL プレビュー と同じです。

設定値 ​

js
var OCCT_VER = '0.0.23';
var OCCT_DIST =
  'https://cdn.jsdelivr.net/npm/occt-import-js@' + OCCT_VER + '/dist/';
var THREE_VER = '0.172.0';
var THREE_URL =
  'https://cdn.jsdelivr.net/npm/three@' + THREE_VER + '/+esm';

var MODE = 'include';          // 'include' | 'all' | 'exclude'
var CSS_INCLUDE = 'step-viewer';
var CSS_EXCLUDE = 'no-step-viewer';
var DEFAULT_COLLAPSED = false; // true = 初期状態で折りたたみ

実装の要点 ​

2 つのライブラリの読み込み。occt-import-js は UMD 形式なので <script> タグで読み込んでグローバルの occtimportjs 関数を使い、Three.js は ES モジュールなので jsDelivr の +esm エンドポイントを import() します。両方を Promise.all で並行して読み込みます。

WASM の場所(locateFile)。occt-import-js は JavaScript(約 300KB)と WebAssembly(約 13MB)の 2 ファイルで構成され、JavaScript が実行時に WASM を読み込みます。CDN から JavaScript を読み込むと WASM のパスがページの URL 基準になり 404 になるため、locateFile で CDN に向けます。

js
occtimportjs({
  locateFile: function (file) {
    return OCCT_DIST + file;
  }
});

取得と変換。occt-import-js には Uint8Array で渡す必要があるため、response.arrayBuffer() で取得して occt.ReadStepFile(new Uint8Array(buf), null) を呼びます。戻り値の構造は次のとおりです。

プロパティ内容
successインポート成功の可否
root階層構造のルートノード(名前、子ノード、メッシュインデックス)
meshesメッシュオブジェクトの配列
meshes[].attributes.position.array頂点座標(3 つずつで 1 頂点)
meshes[].attributes.normal.array法線ベクトル(省略されることがある)
meshes[].index.array三角形インデックス(3 つずつで 1 三角形)
meshes[].colorメッシュの色 [r, g, b](0〜1)。省略されることがある

各メッシュから Three.js の BufferGeometry を作り、MeshPhongMaterial(side: THREE.DoubleSide、法線がないときだけ flatShading)で Group にまとめます。色がないメッシュはグレー(0.8, 0.8, 0.8)です。

パーツの表示切替。result.root を再帰的にたどり、メッシュを持つノードを平らなリストにします。ノードの meshes 配列は result.meshes のインデックスで、group.children[i] と一致するので、チェックボックスで mesh.visible を切り替えるだけでパーツ単位の表示・非表示ができます。

後始末。編集画面の再構築時とモーダルを閉じるときに WebGLRenderer を dispose() します。

制約 ​

  • WASM のサイズ: 約 13MB あり、初回はダウンロードに時間がかかることがあります。2 回目以降はブラウザのキャッシュが効きます。
  • WebGL: WebGL に対応していないブラウザでは動作しません(Chrome、Firefox、Edge、Safari の現行版は対応)。
  • 大規模モデル: アセンブリ数が多いと WASM でのメッシュ変換に時間がかかり、三角形数が多いと描画負荷で操作のレスポンスが落ちることがあります。

CSP を設定している場合

CDN からのスクリプト読み込みや WASM の実行がブロックされることがあります。次のディレクティブに cdn.jsdelivr.net を追加してください。

  • script-src: JavaScript と WASM の読み込みに必要
  • connect-src: WASM ファイルの fetch 読み込み(locateFile 経由)に必要
ExtendedStyles/StepViewer.css
css
/* ===== 埋め込みビューアー(編集画面) ===== */
.step-embed-field {
  padding-top: 0;
  min-height: 0;
}

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

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

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

.step-embed .step-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;
}

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

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

.step-embed .step-canvas-wrap {
  position: relative;
  display: flex;
  justify-content: center;
  padding: 0;
  background: #1e1e1e;
  aspect-ratio: 2 / 1;
  overflow: hidden;
}

.step-embed canvas {
  width: 100%;
  height: 100%;
  display: block;
}

/* ===== ローディング ===== */
.step-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;
}

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

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

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

.step-embed .step-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);
}

.step-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);
}

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

.step-info {
  font-size: 13px;
  min-width: 50px;
  text-align: center;
}

.step-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;
}

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

/* ===== パーツパネル ===== */
.step-parts-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;
}

.step-parts-panel.hidden {
  display: none;
}

.step-parts-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;
}

.step-parts-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;
}

.step-parts-header button:hover {
  background: rgba(255, 255, 255, 0.15);
}

.step-parts-list {
  overflow-y: auto;
  flex: 1;
}

.step-parts-item {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px 8px;
  cursor: pointer;
  transition: background 0.15s;
}

.step-parts-item:hover {
  background: rgba(255, 255, 255, 0.08);
}

.step-parts-item input[type='checkbox'] {
  accent-color: var(--primaryColor, #4285f4);
  margin: 0;
}

.step-parts-item span {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

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

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

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

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

#step-modal .step-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;
}

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

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

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

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

#step-modal .step-modal-body {
  position: relative;
  flex: 1;
  overflow: hidden;
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 0;
  background: #1e1e1e;
}

#step-modal .step-modal-body canvas {
  width: 100%;
  height: 100%;
  display: block;
}

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

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

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

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

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

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

  #step-modal .step-modal-body {
    padding: 0;
  }

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

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

  .step-parts-panel {
    width: 180px;
  }
}
ExtendedScripts/StepViewer.js
js
$(function () {
  // occt-import-js の設定(バージョンを固定して動作の安定性を確保)
  var OCCT_VER = '0.0.23';
  var OCCT_DIST =
    'https://cdn.jsdelivr.net/npm/occt-import-js@' + OCCT_VER + '/dist/';

  // Three.js の設定
  var THREE_VER = '0.172.0';
  var THREE_URL =
    'https://cdn.jsdelivr.net/npm/three@' + THREE_VER + '/+esm';

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

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

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

  // ライブラリを読み込む
  Promise.all([loadScript(OCCT_DIST + 'occt-import-js.js'), import(THREE_URL)])
    .then(function (results) {
      var THREE = results[1];
      return occtimportjs({
        locateFile: function (file) {
          return OCCT_DIST + file;
        }
      }).then(function (occt) {
        return { THREE: THREE, occt: occt };
      });
    })
    .then(function (libs) {
      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('STEP ビューアーの読み込みに失敗しました:', e);
    });

  // スクリプトを動的に読み込む
  function loadScript(src) {
    return new Promise(function (resolve, reject) {
      if (document.querySelector('script[src="' + src + '"]')) {
        resolve();
        return;
      }
      var s = document.createElement('script');
      s.src = src;
      s.onload = resolve;
      s.onerror = reject;
      document.head.appendChild(s);
    });
  }

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

  function initEdit(libs) {
    document.querySelectorAll('.step-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 = findStepFiles(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 から STEP だけを抽出する
  function findStepFiles(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 || !/\.(stp|step)$/i.test(att.Name)) return;
      files.push({
        name: att.Name,
        url: '/binaries/' + att.Guid + '/download'
      });
    });
    return files;
  }

  function buildEmbedViewer(field, stepFiles, libs) {
    var THREE = libs.THREE;

    // 添付ファイル項目の直後に field-wide の wrapper を作成
    var wrapper = document.createElement('div');
    wrapper.className = 'field-wide step-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 = 'step-embed';

    // タブバー
    var tabBar = document.createElement('div');
    tabBar.className = 'step-tabs';

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

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

    // コントロールバー
    var ctrl = document.createElement('div');
    ctrl.className = 'step-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 = 'step-info';
    info.textContent = 'ドラッグで回転';
    var btnParts = iconBtn('account_tree');
    btnParts.title = 'パーツ表示切替';
    var btnOpen = iconBtn('open_in_new');
    btnOpen.title = '別ウィンドウで表示';
    ctrl.append(btnZoomOut, btnZoomIn, btnReset, info, btnParts, 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: stepFiles,
      scene: scene,
      camera: camera,
      renderer: renderer,
      wrap: wrap,
      loader: loader,
      info: info,
      group: null,
      orbit: null
    };

    stepFiles.forEach(function (f, i) {
      var tab = document.createElement('button');
      tab.type = 'button';
      tab.className = 'step-tab' + (i === 0 ? ' active' : '');
      tab.textContent = f.name;
      tab.onclick = function () {
        tabBar.querySelectorAll('.step-tab').forEach(function (t, j) {
          t.classList.toggle('active', j === i);
        });
        loadStep(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);
    };
    btnParts.onclick = function () {
      if (vs.partsPanel) vs.partsPanel.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);
    });

    loadStep(vs, 0, libs);
  }

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

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

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

  function loadStep(vs, idx, libs) {
    var THREE = libs.THREE;
    var occt = libs.occt;

    vs.idx = idx;
    if (vs.group) {
      vs.scene.remove(vs.group);
      vs.group = null;
    }
    if (vs.loader) vs.loader.classList.remove('hidden');
    vs.info.textContent = '読み込み中...';

    fetch(vs.files[idx].url)
      .then(function (res) {
        return res.arrayBuffer();
      })
      .then(function (buf) {
        var result = occt.ReadStepFile(new Uint8Array(buf), null);
        if (!result.success) throw new Error('STEP import failed');

        var group = new THREE.Group();
        result.meshes.forEach(function (mesh) {
          var geo = new THREE.BufferGeometry();
          geo.setAttribute(
            'position',
            new THREE.Float32BufferAttribute(mesh.attributes.position.array, 3)
          );
          if (mesh.attributes.normal) {
            geo.setAttribute(
              'normal',
              new THREE.Float32BufferAttribute(
                mesh.attributes.normal.array,
                3
              )
            );
          }
          geo.setIndex(
            new THREE.BufferAttribute(new Uint32Array(mesh.index.array), 1)
          );

          var color = mesh.color
            ? new THREE.Color(mesh.color[0], mesh.color[1], mesh.color[2])
            : new THREE.Color(0.8, 0.8, 0.8);
          var mat = new THREE.MeshPhongMaterial({
            color: color,
            side: THREE.DoubleSide,
            flatShading: !mesh.attributes.normal
          });
          group.add(new THREE.Mesh(geo, mat));
        });

        vs.group = group;
        vs.scene.add(group);
        resetCamera(vs, THREE);
        vs.renderer.render(vs.scene, vs.camera);
        buildPartsPanel(vs, result);
        vs.info.textContent = 'ドラッグで回転';
      })
      .catch(function () {
        vs.info.textContent = '読み込み失敗';
      })
      .finally(function () {
        if (vs.loader) vs.loader.classList.add('hidden');
      });
  }

  /* ========== カメラ制御 ========== */

  function resetCamera(vs, THREE) {
    if (!vs.group) return;
    var box = new THREE.Box3().setFromObject(vs.group);
    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('step-modal');
    if (!modal) modal = buildModal(libs);

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

    var body = modal.querySelector('.step-modal-body');
    var oldPanel = body.querySelector('.step-parts-panel');
    if (oldPanel) oldPanel.remove();

    var canvas = body.querySelector('canvas');
    if (!canvas) {
      canvas = document.createElement('canvas');
      body.appendChild(canvas);
    }

    var loader = body.querySelector('.step-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('.step-info'),
      group: null,
      orbit: null
    };
    modal._vs = vs;
    modal._renderer = renderer;

    addOrbitControls(vs, THREE);
    vs.info.textContent = '読み込み中...';

    loadStep(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 = 'step-modal';
    m.innerHTML =
      '<div class="step-modal-overlay"></div>' +
      '<div class="step-modal-dialog">' +
      '<div class="step-modal-header">' +
      '<span class="step-modal-title"></span>' +
      '<button type="button" class="step-modal-close">' +
      '<span class="material-symbols-outlined">close</span>' +
      '</button>' +
      '</div>' +
      '<div class="step-modal-body">' +
      '<div class="step-loader"><div class="step-spinner"></div></div>' +
      '</div>' +
      '<div class="step-modal-controls">' +
      '<button type="button" class="step-modal-zoom-out" title="縮小">' +
      '<span class="material-symbols-outlined">zoom_out</span>' +
      '</button>' +
      '<button type="button" class="step-modal-zoom-in" title="拡大">' +
      '<span class="material-symbols-outlined">zoom_in</span>' +
      '</button>' +
      '<button type="button" class="step-modal-reset" title="デフォルト表示に戻す">' +
      '<span class="material-symbols-outlined">restart_alt</span>' +
      '</button>' +
      '<span class="step-info">ドラッグで回転</span>' +
      '<button type="button" class="step-modal-parts" title="パーツ表示切替">' +
      '<span class="material-symbols-outlined">account_tree</span>' +
      '</button>' +
      '</div>' +
      '</div>';

    document.body.appendChild(m);

    m.querySelector('.step-modal-overlay').onclick = closeModal;
    m.querySelector('.step-modal-close').onclick = closeModal;
    m.querySelector('.step-modal-zoom-out').onclick = function () {
      var vs = m._vs;
      if (!vs || !vs.orbit) return;
      vs.orbit.radius *= 1.3;
      vs.updateCamera();
    };
    m.querySelector('.step-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('.step-modal-reset').onclick = function () {
      var vs = m._vs;
      if (!vs) return;
      resetCamera(vs, libs.THREE);
      vs.renderer.render(vs.scene, vs.camera);
    };
    m.querySelector('.step-modal-parts').onclick = function () {
      var vs = m._vs;
      if (vs && vs.partsPanel) vs.partsPanel.classList.toggle('hidden');
    };
    return m;
  }

  function buildPartsPanel(vs, result) {
    var existing = vs.wrap.querySelector('.step-parts-panel');
    if (existing) existing.remove();
    vs.partsPanel = null;

    if (!result.root || !vs.group) return;

    var parts = [];
    (function walk(node) {
      if (node.meshes && node.meshes.length > 0) {
        parts.push({ name: node.name || '(名称なし)', meshes: node.meshes });
      }
      if (node.children) node.children.forEach(walk);
    })(result.root);

    if (parts.length === 0) return;

    var panel = document.createElement('div');
    panel.className = 'step-parts-panel hidden';

    var header = document.createElement('div');
    header.className = 'step-parts-header';
    header.innerHTML = '<span>パーツ</span>';
    var showAll = document.createElement('button');
    showAll.type = 'button';
    showAll.textContent = '全表示';
    showAll.onclick = function () {
      vs.group.children.forEach(function (m) {
        m.visible = true;
      });
      panel.querySelectorAll('input[type="checkbox"]').forEach(function (cb) {
        cb.checked = true;
      });
      vs.renderer.render(vs.scene, vs.camera);
    };
    header.appendChild(showAll);
    panel.appendChild(header);

    var list = document.createElement('div');
    list.className = 'step-parts-list';

    parts.forEach(function (part) {
      var item = document.createElement('label');
      item.className = 'step-parts-item';
      var cb = document.createElement('input');
      cb.type = 'checkbox';
      cb.checked = true;
      cb.onchange = function () {
        part.meshes.forEach(function (mi) {
          if (vs.group.children[mi]) {
            vs.group.children[mi].visible = cb.checked;
          }
        });
        vs.renderer.render(vs.scene, vs.camera);
      };
      var lbl = document.createElement('span');
      lbl.textContent = part.name;
      lbl.title = part.name;
      item.append(cb, lbl);
      list.appendChild(item);
    });

    panel.appendChild(list);
    vs.wrap.appendChild(panel);
    vs.partsPanel = panel;
  }

  function closeModal() {
    var m = document.getElementById('step-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 = 'step-ctrl-btn';
    var s = document.createElement('span');
    s.className = 'material-symbols-outlined';
    s.textContent = name;
    b.appendChild(s);
    return b;
  }
});

Excel をプレビューする ​

ExcelJS で .xlsx / .xlsm を読み込み、フォント・背景色・罫線・配置・セル結合などのスタイル情報をインライン CSS にした HTML の table を組み立てて表示します。ExcelJS には HTML テーブルを一発で生成する API がないため、レンダラーはスクリプト内で自作しています。

  • 編集画面: 添付ファイル項目の下に埋め込み表示。複数ファイルはファイルタブ、複数シートはシートタブで切り替え(非表示シートは除外)。Excel ファイルがなければ何も表示しない
  • 一覧画面: Excel ファイルの横にプレビューボタンを追加し、クリックでモーダル表示。追加読み込み後もボタンを付与し、一覧からモーダルで開いた編集画面にも埋め込み表示
  • 編集画面からもモーダルで拡大表示できる(モーダルにもシートタブあり)
  • ズームはなく、ブラウザ標準のスクロールで見る

.xls は対象外

ExcelJS が対応しているのは .xlsx / .xlsm(OpenXML 形式)だけです。旧形式の .xls(BIFF 形式)には対応していないため、対象の拡張子は /\.(xlsx|xlsm)$/i にしています。

ExcelJS を選んだ理由 ​

スタイル情報を読み取れること、OSS で商用利用できること、CDN から script タグ 1 本で読み込めること、ページ表示ごとに読み込んでも実用的なサイズであること、の 4 点で選んでいます。

ライブラリライセンス再現度サイズ(目安)特徴
SheetJS CEApache 2.0データのみ~200 KB軽量・高速。スタイル情報は保持しない
ExcelJS(採用)MIT中~400 KBスタイル情報を読み取れる。自前レンダラーで再現できる
SheetJS Pro商用中〜高非公開CE + スタイル保持・画像・グラフ・数式評価
Handsontable商用中〜高~500 KB+スプレッドシート UI
SpreadJS商用高~数 MBExcel に最も近い。数式・条件付き書式・グラフ対応。CDN は多数ファイル
UniverApache 2.0中〜高~数 MBフルスタック向けで拡張機能にはオーバースペック

ExcelJS は MIT ライセンスです。CDN から読み込む場合も、サービスのライセンス表記に ExcelJS の著作権表示を含めることが推奨されています。

再現できるスタイル ​

項目対応実装
フォント○書体名・サイズ(pt)・太字・斜体・下線・取り消し線・文字色
リッチテキスト○セル内の部分書式を span で再現
背景色○ソリッドは前景色。非ソリッドパターンは半透明で近似、グラデーションは linear-gradient
罫線○細線 1px、中線 2px、太線 3px、点線、破線(一点鎖線なども破線で表示)、二重線と色
配置○水平・垂直・折り返し
回転・インデント・縮小表示○transform: rotate()(255 は縦書き writing-mode: vertical-rl)、インデント 1 段 = 10px、縮小表示は省略記号で代替
セル結合○rowSpan / colSpan。非表示行・列にまたがる分は差し引く
列幅・行高○列幅 × 7px(未設定は 64px)、行高は pt で指定
数値書式○日付(yyyy・mm・dd・hh・ss など。hh の後の mm は分)、パーセント、桁区切り #,##0、固定小数
数式○計算結果(result)を表示。数式の再計算はしない
ハイパーリンク○新しいタブで開く a タグ
コメント・メモ○ツールチップ(title 属性)と右上の赤い三角
ウィンドウ枠固定○ws.views の固定行・列を position: sticky で固定
画像○埋め込み画像を Base64 の img として左上セルに挿入
非表示の行・列・シート○表示しない
シートタブの色○タブの下線の色に反映
条件付き書式△cellIs(数値の比較と、文字列との equal / notEqual)、containsText、notContainsText、beginsWith、endsWith、containsBlanks、notContainsBlanks だけ評価して適用(優先度の高いルールが最後に勝つ)。比較値にセル参照や数式を使うルール、colorScale・dataBar・iconSet・duplicateValues・uniqueValues などは非対応
グラフ×対象外

条件付き書式の対応範囲

条件付き書式は、下の全コード(applyConditionalFormats())が上記の型に対応しているため「一部対応」としています。このページの説明の値(インデント幅、コメント三角の色など)は、下の全コードに合わせています。

ExcelJS 4.4.0 のソースに合わせて、全コードの条件付き書式の処理は次のようにしています。

  • 条件付き書式の一覧は ws.conditionalFormattings 自体({ ref, rules } の配列)です(worksheet.js)。ws.conditionalFormattings.model は存在せず、これを読むと条件付き書式がまったく適用されないため、配列をそのまま読んでいます。
  • 読み込んだルールの type は、containsText・containsBlanks・notContainsBlanks がどれも 'containsText' にまとめられ、元の種類は operator に入ります。検索文字列は text として残らず、formulae[0] の数式(NOT(ISERROR(SEARCH("abc",A1))) など)の中にだけあります(cf-rule-xform.js)。そのため、operator で種類を分け、数式中の文字列リテラルを取り出して比較しています。
  • 条件付き書式の書式(dxf)の塗りつぶしは、patternType を省略して bgColor に色を持つことが多いため、applyCellStyle() の前に solid の fgColor に置き換えています。

設定値 ​

js
var EXCELJS_VER = '4.4.0';
var MODE = 'include';          // 'include' | 'all' | 'exclude'
var CSS_INCLUDE = 'xlsx-viewer';
var CSS_EXCLUDE = 'no-xlsx-viewer';
var DEFAULT_COLLAPSED = false; // true = 初期状態で折りたたみ
var HEADER_ROW = true;         // 先頭行を th にしてスクロール時に固定
var EXTENSIONS = /\.(xlsx|xlsm)$/i;

埋め込みビューアーの高さは CSS の .xlsx-table-wrap { max-height: 400px; } で制限しています。大きなシートは縦横にスクロールして見ます。高さを変えたいときはこの値を変えます。

実装の要点 ​

読み込み。window.ExcelJS が既にあれば(別の拡張スクリプトで読み込み済みなど)再読み込みせず、なければ script タグを動的に挿入して onload を Promise で待ちます。

パース。fetch → arrayBuffer() で取得し、new ExcelJS.Workbook() の xlsx.load(buf) で読み込みます。wb.worksheets には非表示シートも含まれるため、state が hidden / veryHidden のものを除外します。

テーブルの生成(buildStyledTable())。

  1. ws.views[0].state === 'frozen' なら ySplit / xSplit を固定行数・列数にする
  2. 非表示の行・列を集める
  3. ws.model.merges からセル結合を集め、非表示分を除いた rowSpan / colSpan を求める
  4. colgroup で列幅を設定する
  5. 行・セルを生成し、applyCellStyle() でスタイルを、formatValue() で値(HTML 文字列)を設定する。固定行・列には position: sticky と累積オフセットを付ける(交差セルは z-index を高くする)
  6. applyConditionalFormats() で条件付き書式を上書きする
  7. "行:列" → セル要素のマップ(cellMap)を返し、embedImages() がこれを使って画像を正しいセルに挿入する

色。ExcelJS は色を ARGB 文字列(例 "FF000000")で返すので、末尾 6 桁を取って #000000 にします。セルの色はインラインスタイルなのでテーマに関係なく Excel のままの色で表示され、テーマの CSS 変数が効くのはビューアーの外枠(タブバー・情報バーなど)だけです。

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

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

.xlsx-embed.collapsed .xlsx-sheet-tabs,
.xlsx-embed.collapsed .xlsx-table-wrap,
.xlsx-embed.collapsed .xlsx-info-bar {
  display: none;
}

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

.xlsx-embed .xlsx-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;
}

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

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

/* ===== シートタブ ===== */
.xlsx-sheet-tabs,
#xlsx-modal .xlsx-modal-sheet-tabs {
  display: flex;
  align-items: center;
  padding: 0 8px;
  background: var(--base-bg);
  border-bottom: 1px solid var(--base-border);
  overflow-x: auto;
  gap: 2px;
}

.xlsx-sheet-tab {
  padding: 4px 12px;
  border: none;
  background: none;
  cursor: pointer;
  font-size: 11px;
  white-space: nowrap;
  color: var(--scrollbar-thumb);
  border-radius: 4px 4px 0 0;
  border-bottom: 2px solid transparent;
  transition: background 0.2s, color 0.2s;
}

.xlsx-sheet-tab:hover {
  background: var(--btn-normal-hover);
  color: var(--base-text);
}

.xlsx-sheet-tab.active {
  color: var(--primaryColor);
  border-bottom-color: var(--primaryColor);
}

/* ===== テーブル表示エリア ===== */
.xlsx-table-wrap,
#xlsx-modal .xlsx-modal-body {
  position: relative;
  overflow: auto;
  background: var(--base-bg);
}

.xlsx-table-wrap {
  max-height: 400px;
}

.xlsx-table-content table,
#xlsx-modal .xlsx-modal-body table {
  border-collapse: collapse;
  font-size: 12px;
}

.xlsx-table-content td,
.xlsx-table-content th,
#xlsx-modal .xlsx-modal-body td,
#xlsx-modal .xlsx-modal-body th {
  border: 1px solid var(--base-border);
  padding: 4px 8px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.xlsx-table-content tr:first-child th,
#xlsx-modal .xlsx-modal-body tr:first-child th {
  background: var(--base-bg-light);
  position: sticky;
  top: 0;
  z-index: 1;
}

/* ===== コメントインジケーター ===== */
.xlsx-has-comment {
  position: relative;
}

.xlsx-has-comment::after {
  content: '';
  position: absolute;
  top: 0;
  right: 0;
  border-style: solid;
  border-width: 0 6px 6px 0;
  border-color: transparent #ff0000 transparent transparent;
}

/* ===== セル内ハイパーリンク ===== */
.xlsx-table-content a,
#xlsx-modal .xlsx-modal-body a {
  color: var(--primaryColor);
  text-decoration: underline;
}

.xlsx-table-content a:hover,
#xlsx-modal .xlsx-modal-body a:hover {
  opacity: 0.8;
}

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

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

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

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

/* ===== 情報バー ===== */
.xlsx-info-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 4px 8px;
  background: var(--base-bg-light);
  border-top: 1px solid var(--base-border);
  font-size: 12px;
  color: var(--base-text);
}

.xlsx-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);
}

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

.xlsx-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;
}

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

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

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

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

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

#xlsx-modal .xlsx-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;
}

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

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

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

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

#xlsx-modal .xlsx-modal-body {
  flex: 1;
}

#xlsx-modal .xlsx-modal-body table {
  font-size: 13px;
}

#xlsx-modal .xlsx-modal-body td {
  padding: 6px 10px;
}

#xlsx-modal .xlsx-modal-footer {
  display: flex;
  align-items: center;
  padding: 8px 16px;
  border-top: 1px solid var(--base-border);
  font-size: 12px;
  color: var(--base-text);
}

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

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

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

  #xlsx-modal .xlsx-modal-footer {
    padding: 6px 8px;
  }

  .xlsx-embed .xlsx-tab {
    padding: 4px 10px;
    font-size: 11px;
  }
}
ExtendedScripts/XlsxViewer.js
js
$(function () {
  // ExcelJS の設定(バージョンを固定して動作の安定性を確保)
  var EXCELJS_VER = '4.4.0';
  var EXCELJS_URL =
    'https://cdn.jsdelivr.net/npm/exceljs@' +
    EXCELJS_VER +
    '/dist/exceljs.min.js';

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

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

  // 先頭行をヘッダーとして固定表示する: true = 固定 / false = 通常行
  var HEADER_ROW = true;

  // 対象とする拡張子(ExcelJS は OpenXML 形式のみ対応)
  var EXTENSIONS = /\.(xlsx|xlsm)$/i;

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

  // ExcelJS を CDN から動的に読み込む
  loadLib()
    .then(function () {
      if (action === 'edit') {
        initEdit();
        // ファイル追加・削除・更新後にビューアーを再構築
        var timer = null;
        $(document).ajaxComplete(function () {
          clearTimeout(timer);
          timer = setTimeout(function () {
            initEdit();
          }, 300);
        });
      }
      if (action === 'index') {
        initList();
        // 追加読み込み(gridrows)後にアイコンを再付与
        // モーダル編集画面の埋込ビューワを初期化
        var listTimer = null;
        $(document).ajaxComplete(function () {
          clearTimeout(listTimer);
          listTimer = setTimeout(function () {
            initList();
            initEdit();
          }, 300);
        });
      }
    })
    .catch(function (e) {
      console.error('ExcelJS の読み込みに失敗しました:', e);
    });

  // ExcelJS を <script> タグで読み込み、Promise でラップする
  function loadLib() {
    if (window.ExcelJS) return Promise.resolve();
    return new Promise(function (resolve, reject) {
      var s = document.createElement('script');
      s.src = EXCELJS_URL;
      s.onload = resolve;
      s.onerror = reject;
      document.head.appendChild(s);
    });
  }

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

  function initEdit() {
    // 既存のビューアーを除去して再構築
    document.querySelectorAll('.xlsx-embed-field').forEach(function (el) {
      el.remove();
    });

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

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

  // モードに応じて対象項目かどうかを判定する
  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 から Excel ファイルだけを抽出する
  function findXlsxFiles(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 || !EXTENSIONS.test(att.Name)) return;
      files.push({
        name: att.Name,
        url: '/binaries/' + att.Guid + '/download'
      });
    });
    return files;
  }

  // 埋め込みビューアーを生成する
  function buildEmbedViewer(field, xlsxFiles) {
    // 添付ファイル項目の直後に field-wide の wrapper を作成
    var wrapper = document.createElement('div');
    wrapper.className = 'field-wide xlsx-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 = 'xlsx-embed';

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

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

    // シートタブバー
    var sheetBar = document.createElement('div');
    sheetBar.className = 'xlsx-sheet-tabs';

    // テーブル表示エリア
    var tableWrap = document.createElement('div');
    tableWrap.className = 'xlsx-table-wrap';
    var content = document.createElement('div');
    content.className = 'xlsx-table-content';
    // ローディング
    var loader = document.createElement('div');
    loader.className = 'xlsx-loader';
    loader.innerHTML = '<div class="xlsx-spinner"></div>';
    tableWrap.append(content, loader);

    // 情報バー
    var infoBar = document.createElement('div');
    infoBar.className = 'xlsx-info-bar';
    var info = document.createElement('span');
    var btnOpen = iconBtn('open_in_new');
    btnOpen.title = '別ウィンドウで表示';
    infoBar.append(info, btnOpen);

    el.append(tabBar, sheetBar, tableWrap, infoBar);
    if (DEFAULT_COLLAPSED) el.classList.add('collapsed');
    cn.appendChild(el);
    field.after(wrapper);

    // ビューアーの状態管理
    var vs = {
      idx: 0,
      sheetIdx: 0,
      wb: null,
      worksheets: [],
      sheets: [],
      files: xlsxFiles,
      content: content,
      sheetBar: sheetBar,
      loader: loader,
      el: el,
      info: info
    };
    el._vs = vs;

    // ファイルタブ生成
    xlsxFiles.forEach(function (f, i) {
      var tab = document.createElement('button');
      tab.type = 'button';
      tab.className = 'xlsx-tab' + (i === 0 ? ' active' : '');
      tab.textContent = f.name;
      tab.onclick = function () {
        tabBar.querySelectorAll('.xlsx-tab').forEach(function (t, j) {
          t.classList.toggle('active', j === i);
        });
        loadFile(vs, i);
      };
      tabBar.appendChild(tab);
    });
    tabBar.appendChild(btnToggle);

    // ボタンイベント
    btnOpen.onclick = function () {
      openModal(vs.files[vs.idx]);
    };
    btnToggle.onclick = function () {
      var collapsed = el.classList.toggle('collapsed');
      btnToggle.textContent = collapsed ? '◀' : '▼';
    };

    // 最初のファイルを読み込む
    loadFile(vs, 0);
  }

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

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

  /* ========== ファイル読み込み ========== */

  function loadFile(vs, idx) {
    vs.idx = idx;
    vs.sheetIdx = 0;
    vs.info.textContent = '読み込み中...';
    if (vs.loader) vs.loader.classList.remove('hidden');

    fetch(vs.files[idx].url)
      .then(function (res) {
        return res.arrayBuffer();
      })
      .then(function (buf) {
        var wb = new ExcelJS.Workbook();
        return wb.xlsx.load(buf).then(function () {
          vs.wb = wb;
          // 表示状態のシートのみ対象にする
          var visible = wb.worksheets.filter(function (ws) {
            return ws.state !== 'hidden' && ws.state !== 'veryHidden';
          });
          vs.worksheets = visible;
          vs.sheets = visible.map(function (ws) {
            return ws.name;
          });
          vs.sheetIdx = 0;
          buildSheetTabs(vs);
          renderSheet(vs);
        });
      })
      .catch(function () {
        vs.info.textContent = '読み込み失敗';
        if (vs.loader) vs.loader.classList.add('hidden');
      });
  }

  function buildSheetTabs(vs) {
    vs.sheetBar.innerHTML = '';
    vs.sheets.forEach(function (name, i) {
      var tab = document.createElement('button');
      tab.type = 'button';
      tab.className = 'xlsx-sheet-tab' + (i === 0 ? ' active' : '');
      tab.textContent = name;
      var tc = vs.worksheets[i].properties && vs.worksheets[i].properties.tabColor;
      if (tc && tc.argb) {
        var tcHex = argbToHex(tc.argb);
        if (tcHex) tab.style.borderBottomColor = tcHex;
      }
      tab.onclick = function () {
        vs.sheetBar
          .querySelectorAll('.xlsx-sheet-tab')
          .forEach(function (t, j) {
            t.classList.toggle('active', j === i);
          });
        vs.sheetIdx = i;
        renderSheet(vs);
      };
      vs.sheetBar.appendChild(tab);
    });
  }

  /* ========== 画像埋め込み ========== */

  function embedImages(table, ws, wb) {
    try {
      var images = ws.getImages();
      if (!images || images.length === 0) return;
      images.forEach(function (img) {
        try {
          var imgData = wb.getImage(img.imageId);
          if (!imgData) return;

          var base64;
          if (imgData.base64) {
            base64 = imgData.base64;
          } else if (imgData.buffer) {
            var buf = imgData.buffer;
            var bytes = new Uint8Array(
              buf instanceof ArrayBuffer ? buf : buf.buffer || buf
            );
            var bin = '';
            for (var i = 0; i < bytes.length; i++) {
              bin += String.fromCharCode(bytes[i]);
            }
            base64 = btoa(bin);
          } else {
            return;
          }

          var ext = (imgData.extension || 'png').toLowerCase();
          var mime =
            ext === 'jpg' || ext === 'jpeg'
              ? 'image/jpeg'
              : ext === 'gif'
                ? 'image/gif'
                : ext === 'svg'
                  ? 'image/svg+xml'
                  : 'image/png';

          var range = img.range;
          var nativeRow =
            typeof range.tl.nativeRow === 'number'
              ? range.tl.nativeRow
              : typeof range.tl.row === 'number'
                ? Math.floor(range.tl.row)
                : -1;
          var nativeCol =
            typeof range.tl.nativeCol === 'number'
              ? range.tl.nativeCol
              : typeof range.tl.col === 'number'
                ? Math.floor(range.tl.col)
                : -1;
          if (nativeRow < 0 || nativeCol < 0) return;

          var td = table._cellMap
            ? table._cellMap[(nativeRow + 1) + ':' + (nativeCol + 1)]
            : null;
          if (!td) return;

          var imgEl = document.createElement('img');
          imgEl.src = 'data:' + mime + ';base64,' + base64;
          imgEl.style.cssText =
            'max-width:100%;max-height:300px;display:block;';
          imgEl.alt = '';
          td.appendChild(imgEl);
        } catch (e) {
          /* 個別画像エラーは無視 */
        }
      });
    } catch (e) {
      /* 画像取得エラーは無視 */
    }
  }

  /* ========== レンダリング ========== */

  function renderSheet(vs) {
    var ws = vs.worksheets[vs.sheetIdx];
    var name = vs.sheets[vs.sheetIdx];

    if (!ws || ws.rowCount === 0) {
      vs.content.innerHTML =
        '<p style="padding:16px;color:var(--scrollbar-thumb)">データなし</p>';
      vs.info.textContent = name + ' (空のシート)';
      if (vs.loader) vs.loader.classList.add('hidden');
      return;
    }

    var table = buildStyledTable(ws);
    vs.content.innerHTML = '';
    vs.content.appendChild(table);

    // 画像の埋め込み
    embedImages(table, ws, vs.wb);

    var rows = ws.rowCount;
    var cols = ws.columnCount;
    vs.info.textContent =
      name +
      ' — ' +
      rows +
      ' 行 × ' +
      cols +
      ' 列' +
      (vs.sheets.length > 1
        ? ' (' + (vs.sheetIdx + 1) + '/' + vs.sheets.length + ')'
        : '');
    if (vs.loader) vs.loader.classList.add('hidden');
  }

  // ExcelJS のスタイル情報を反映した HTML テーブルを生成する
  function buildStyledTable(ws) {
    var table = document.createElement('table');

    // ウィンドウ枠固定(フリーズペイン)の検出
    var frozenRows = 0, frozenCols = 0;
    if (ws.views && ws.views.length) {
      var v = ws.views[0];
      if (v.state === 'frozen') {
        frozenRows = v.ySplit || 0;
        frozenCols = v.xSplit || 0;
      }
    }

    // 非表示行・列の収集
    var rowCount = ws.rowCount || 0;
    var colCount = ws.columnCount || 0;
    var hiddenRows = {};
    var hiddenCols = {};
    for (var hr = 1; hr <= rowCount; hr++) {
      var hrRow = ws.getRow(hr);
      if (hrRow && hrRow.hidden) hiddenRows[hr] = true;
    }
    for (var hc = 1; hc <= colCount; hc++) {
      var hcCol = ws.getColumn(hc);
      if (hcCol && hcCol.hidden) hiddenCols[hc] = true;
    }

    // セル結合情報の収集(非表示行・列を考慮)
    var mergedCells = {};
    var mergeSpans = {};
    if (ws.model && ws.model.merges) {
      ws.model.merges.forEach(function (range) {
        var p = parseRange(range);
        var visR = [], visC = [];
        for (var r = p.sr; r <= p.er; r++) { if (!hiddenRows[r]) visR.push(r); }
        for (var c = p.sc; c <= p.ec; c++) { if (!hiddenCols[c]) visC.push(c); }
        if (visR.length === 0 || visC.length === 0) return;
        var oR = visR[0], oC = visC[0];
        mergeSpans[oR + ':' + oC] = {
          rowSpan: visR.length,
          colSpan: visC.length
        };
        for (var ri = 0; ri < visR.length; ri++) {
          for (var ci = 0; ci < visC.length; ci++) {
            if (visR[ri] !== oR || visC[ci] !== oC) {
              mergedCells[visR[ri] + ':' + visC[ci]] = true;
            }
          }
        }
      });
    }

    // 列幅の設定(非表示列をスキップ)
    var colGroup = document.createElement('colgroup');
    var colWidths = {};
    for (var c = 1; c <= colCount; c++) {
      var column = ws.getColumn(c);
      var cw = column && column.width ? Math.round(column.width * 7) : 64;
      colWidths[c] = cw;
      if (hiddenCols[c]) continue;
      var col = document.createElement('col');
      col.style.width = cw + 'px';
      colGroup.appendChild(col);
    }
    table.appendChild(colGroup);

    // フリーズペイン — 固定行・列のオフセット計算(非表示をスキップ)
    var frozenRowTops = {};
    if (frozenRows > 0) {
      var cumTop = 0;
      for (var fr = 1; fr <= frozenRows; fr++) {
        if (hiddenRows[fr]) continue;
        frozenRowTops[fr] = cumTop;
        var frRow = ws.getRow(fr);
        cumTop += frRow.height ? frRow.height * 1.333 + 9 : 30;
      }
    }
    var frozenColLefts = {};
    if (frozenCols > 0) {
      var cumLeft = 0;
      for (var fc = 1; fc <= frozenCols; fc++) {
        if (hiddenCols[fc]) continue;
        frozenColLefts[fc] = cumLeft;
        cumLeft += (colWidths[fc] || 64) + 17;
      }
    }

    // 行とセルの生成
    var cellMap = {};
    for (var r = 1; r <= rowCount; r++) {
      var row = ws.getRow(r);
      if (row.hidden) continue;
      var tr = document.createElement('tr');
      if (row.height) {
        tr.style.height = row.height + 'pt';
      }

      for (var c = 1; c <= colCount; c++) {
        if (hiddenCols[c]) continue;
        if (mergedCells[r + ':' + c]) continue;

        var cell = row.getCell(c);
        var isHeader = HEADER_ROW && r === 1;
        var td = document.createElement(isHeader ? 'th' : 'td');

        // セル結合の適用
        var span = mergeSpans[r + ':' + c];
        if (span) {
          if (span.rowSpan > 1) td.rowSpan = span.rowSpan;
          if (span.colSpan > 1) td.colSpan = span.colSpan;
        }

        // スタイル適用
        applyCellStyle(td, cell);

        // フリーズペイン — sticky 配置
        if (frozenRows > 0 && r <= frozenRows) {
          td.style.position = 'sticky';
          td.style.top = frozenRowTops[r] + 'px';
          td.style.zIndex = '2';
          if (!td.style.backgroundColor) td.style.backgroundColor = 'var(--base-bg)';
        }
        if (frozenCols > 0 && c <= frozenCols) {
          td.style.position = 'sticky';
          td.style.left = frozenColLefts[c] + 'px';
          if (!td.style.backgroundColor) td.style.backgroundColor = 'var(--base-bg)';
          td.style.zIndex = frozenRows > 0 && r <= frozenRows ? '3' : '2';
        }

        // コメント(ノート)があればツールチップで表示
        if (cell.note) {
          var noteText =
            typeof cell.note === 'object' && cell.note.texts
              ? cell.note.texts
                  .map(function (t) {
                    return typeof t === 'object' && t.text
                      ? t.text
                      : String(t);
                  })
                  .join('')
              : typeof cell.note === 'string'
                ? cell.note
                : '';
          if (noteText) {
            td.title = noteText;
            td.classList.add('xlsx-has-comment');
          }
        }

        // 値の設定(HTML 文字列として挿入)
        td.innerHTML = formatValue(cell);

        cellMap[r + ':' + c] = td;
        tr.appendChild(td);
      }
      table.appendChild(tr);
    }

    // 条件付き書式の適用
    applyConditionalFormats(ws, cellMap, hiddenRows, hiddenCols);

    table._cellMap = cellMap;
    return table;
  }

  /* ========== スタイル適用 ========== */

  function applyCellStyle(el, cell) {
    if (!cell || !cell.style) return;
    var s = cell.style;
    var css = el.style;

    // フォント
    if (s.font) {
      if (s.font.name) css.fontFamily = s.font.name;
      if (s.font.size) css.fontSize = s.font.size + 'pt';
      if (s.font.bold) css.fontWeight = 'bold';
      if (s.font.italic) css.fontStyle = 'italic';
      var deco = [];
      if (s.font.underline) deco.push('underline');
      if (s.font.strike) deco.push('line-through');
      if (deco.length) css.textDecoration = deco.join(' ');
      if (s.font.color && s.font.color.argb) {
        css.color = argbToHex(s.font.color.argb);
      }
    }

    // 背景色・パターン
    if (s.fill && s.fill.type === 'pattern') {
      var fg = s.fill.fgColor;
      var bg = s.fill.bgColor;
      var fgHex = fg && fg.argb ? argbToHex(fg.argb) : null;
      var bgHex = bg && bg.argb ? argbToHex(bg.argb) : null;
      if (s.fill.pattern === 'solid') {
        if (fgHex) css.backgroundColor = fgHex;
      } else if (s.fill.pattern && fgHex) {
        // パターン塗りつぶし — 代替表現として半透明の背景色を適用
        css.backgroundColor = fgHex;
        css.opacity = '0.85';
        if (bgHex) css.backgroundColor = bgHex;
      }
    }
    if (s.fill && s.fill.type === 'gradient' && s.fill.stops) {
      var colors = s.fill.stops
        .map(function (stop) {
          return stop.color && stop.color.argb
            ? argbToHex(stop.color.argb)
            : null;
        })
        .filter(Boolean);
      if (colors.length >= 2) {
        css.background =
          'linear-gradient(' + (s.fill.degree || 0) + 'deg,' +
          colors.join(',') + ')';
      }
    }

    // 罫線
    if (s.border) {
      if (s.border.top && s.border.top.style)
        css.borderTop = borderCss(s.border.top);
      if (s.border.bottom && s.border.bottom.style)
        css.borderBottom = borderCss(s.border.bottom);
      if (s.border.left && s.border.left.style)
        css.borderLeft = borderCss(s.border.left);
      if (s.border.right && s.border.right.style)
        css.borderRight = borderCss(s.border.right);
    }

    // 配置
    if (s.alignment) {
      if (s.alignment.horizontal) css.textAlign = s.alignment.horizontal;
      if (s.alignment.vertical) {
        css.verticalAlign =
          s.alignment.vertical === 'middle'
            ? 'middle'
            : s.alignment.vertical === 'top'
              ? 'top'
              : 'bottom';
      }
      if (s.alignment.wrapText) {
        css.whiteSpace = 'pre-wrap';
        css.wordBreak = 'break-all';
      }
      // テキストの回転
      if (s.alignment.textRotation) {
        var rot = s.alignment.textRotation;
        // Excel: 255 = 縦書き, 正の値 = 反時計回り, 負の値 = 時計回り
        if (rot === 255) {
          css.writingMode = 'vertical-rl';
          css.textOrientation = 'upright';
        } else {
          // ExcelJS は 0〜180 の正の整数で返す(90超 = 時計回り: 実角度 = -(rot-90))
          var deg = rot <= 90 ? rot : -(rot - 90);
          css.transform = 'rotate(' + -deg + 'deg)';
        }
      }
      // インデント
      if (s.alignment.indent) {
        css.paddingLeft = s.alignment.indent * 10 + 'px';
      }
      // 縮小して全体を表示
      if (s.alignment.shrinkToFit) {
        css.overflow = 'hidden';
        css.textOverflow = 'ellipsis';
      }
    }
  }

  /* ========== 条件付き書式 ========== */

  function applyConditionalFormats(ws, cellMap, hiddenRows, hiddenCols) {
    // ExcelJS では ws.conditionalFormattings 自体が { ref, rules } の配列
    var cfModel = ws.conditionalFormattings;
    if (!cfModel || !cfModel.length) return;

    // ルールを優先度順に収集(priority が小さい = 優先度が高い)
    var entries = [];
    cfModel.forEach(function (cf) {
      var refs = cf.ref.split(/\s+/);
      (cf.rules || []).forEach(function (rule) {
        entries.push({ refs: refs, rule: rule });
      });
    });
    // 優先度の高いルールが後から適用されるようにソート(降順)
    entries.sort(function (a, b) {
      return (b.rule.priority || 0) - (a.rule.priority || 0);
    });

    entries.forEach(function (entry) {
      var rule = entry.rule;
      if (!rule.style) return;
      var style = cfStyle(rule.style);

      entry.refs.forEach(function (ref) {
        // 単一セル参照の場合はコロンがないので自身を範囲として扱う
        var rng = ref.indexOf(':') >= 0 ? parseRange(ref)
          : (function () { var c = cellRef(ref); return { sr: c.r, sc: c.c, er: c.r, ec: c.c }; })();

        for (var r = rng.sr; r <= rng.er; r++) {
          if (hiddenRows[r]) continue;
          for (var c = rng.sc; c <= rng.ec; c++) {
            if (hiddenCols[c]) continue;
            var td = cellMap[r + ':' + c];
            if (!td) continue;
            var cell = ws.getRow(r).getCell(c);
            if (matchesCfRule(rule, cell)) {
              applyCellStyle(td, { style: style });
            }
          }
        }
      });
    });
  }

  // 条件付き書式(dxf)の塗りつぶしは patternType を省略して bgColor に色を持つことが多いため、
  // applyCellStyle が扱える solid の fgColor に置き換える
  function cfStyle(style) {
    var f = style.fill;
    if (!f || f.type !== 'pattern') return style;
    if (f.pattern && f.pattern !== 'solid') return style;
    var color = (f.bgColor && f.bgColor.argb) ? f.bgColor : f.fgColor;
    if (!color) return style;
    var copy = {};
    Object.keys(style).forEach(function (k) { copy[k] = style[k]; });
    copy.fill = { type: 'pattern', pattern: 'solid', fgColor: color };
    return copy;
  }

  // 数式中の最初の文字列リテラル("...")を取り出す
  function cfText(formula) {
    var m = formula != null && String(formula).match(/"((?:[^"]|"")*)"/);
    return m ? m[1].replace(/""/g, '"') : null;
  }

  function matchesCfRule(rule, cell) {
    var v = cell.value;
    // RichText の場合はテキスト部分を結合
    if (v && typeof v === 'object' && v.richText) {
      v = v.richText.map(function (seg) { return seg.text; }).join('');
    }
    // ハイパーリンク付きの場合
    if (v && typeof v === 'object' && v.text !== undefined) v = v.text;
    // 数式結果の場合
    if (v && typeof v === 'object' && v.result !== undefined) v = v.result;

    var type = rule.type;
    var op = rule.operator;
    var f = rule.formulae || [];
    // テキスト系ルールの比較用(ExcelJS は検索文字列を formulae の数式の中にだけ持つ)
    var s = v == null ? '' : String(v).toLowerCase();
    var t = cfText(f[0]);
    var tl = t == null ? null : t.toLowerCase();

    switch (type) {
      case 'cellIs':
        return evalCellIs(op, v, f);
      case 'containsText':
        // ExcelJS は containsText / containsBlanks / notContainsBlanks を
        // type 'containsText' にまとめ、元の種類を operator に入れる
        if (op === 'containsBlanks') return s.trim() === '';
        if (op === 'notContainsBlanks') return s.trim() !== '';
        return tl != null && s.indexOf(tl) >= 0;
      case 'notContainsText':
        return tl != null && s.indexOf(tl) < 0;
      case 'beginsWith':
        return tl != null && s.indexOf(tl) === 0;
      case 'endsWith':
        return tl != null && s.length >= tl.length &&
          s.slice(s.length - tl.length) === tl;
      case 'duplicateValues':
      case 'uniqueValues':
      case 'colorScale':
      case 'dataBar':
      case 'iconSet':
        // ワークシート全体の評価が必要な型はスキップ
        return false;
      default:
        return false;
    }
  }

  function evalCellIs(op, cellVal, formulae) {
    // 比較値が文字列リテラル("abc")なら文字列として比較する
    var ta = cfText(formulae[0]);
    if (ta != null && /^\s*"/.test(String(formulae[0]))) {
      var sv = cellVal == null ? '' : String(cellVal).toLowerCase();
      if (op === 'equal') return sv === ta.toLowerCase();
      if (op === 'notEqual') return sv !== ta.toLowerCase();
      return false;
    }
    var v = typeof cellVal === 'string' ? parseFloat(cellVal) : cellVal;
    if (v == null) return false;
    var a = formulae[0] != null ? Number(formulae[0]) : NaN;
    var b = formulae[1] != null ? Number(formulae[1]) : NaN;

    switch (op) {
      case 'equal':
        return v == a;
      case 'notEqual':
        return v != a;
      case 'greaterThan':
        return v > a;
      case 'lessThan':
        return v < a;
      case 'greaterThanOrEqual':
        return v >= a;
      case 'lessThanOrEqual':
        return v <= a;
      case 'between':
        return v >= a && v <= b;
      case 'notBetween':
        return v < a || v > b;
      default:
        return false;
    }
  }

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

  function openModal(file) {
    var modal = document.getElementById('xlsx-modal');
    if (!modal) modal = buildModal();

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

    var body = modal.querySelector('.xlsx-modal-body');
    var sheetBar = modal.querySelector('.xlsx-modal-sheet-tabs');
    var info = modal.querySelector('.xlsx-modal-info');

    body.innerHTML =
      '<div class="xlsx-loader"><div class="xlsx-spinner"></div></div>';
    sheetBar.innerHTML = '';
    info.textContent = '読み込み中...';

    fetch(file.url)
      .then(function (res) {
        return res.arrayBuffer();
      })
      .then(function (buf) {
        var wb = new ExcelJS.Workbook();
        return wb.xlsx.load(buf).then(function () {
          var visible = wb.worksheets.filter(function (ws) {
            return ws.state !== 'hidden' && ws.state !== 'veryHidden';
          });
          var sheets = visible.map(function (ws) {
            return ws.name;
          });
          modal._vs = {
            wb: wb,
            worksheets: visible,
            sheets: sheets,
            sheetIdx: 0,
            content: body,
            info: info
          };

          // シートタブ生成
          sheets.forEach(function (name, i) {
            var tab = document.createElement('button');
            tab.type = 'button';
            tab.className = 'xlsx-sheet-tab' + (i === 0 ? ' active' : '');
            tab.textContent = name;
            var tc = visible[i].properties && visible[i].properties.tabColor;
            if (tc && tc.argb) {
              var tcHex = argbToHex(tc.argb);
              if (tcHex) tab.style.borderBottomColor = tcHex;
            }
            tab.onclick = function () {
              sheetBar
                .querySelectorAll('.xlsx-sheet-tab')
                .forEach(function (t, j) {
                  t.classList.toggle('active', j === i);
                });
              modal._vs.sheetIdx = i;
              renderModalSheet(modal);
            };
            sheetBar.appendChild(tab);
          });

          renderModalSheet(modal);
        });
      })
      .catch(function () {
        info.textContent = '読み込み失敗';
        body.innerHTML = '';
      });

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

  function renderModalSheet(modal) {
    var vs = modal._vs;
    var ws = vs.worksheets[vs.sheetIdx];
    var name = vs.sheets[vs.sheetIdx];

    if (!ws || ws.rowCount === 0) {
      vs.content.innerHTML =
        '<p style="padding:16px;color:var(--scrollbar-thumb)">データなし</p>';
      vs.info.textContent = name + ' (空のシート)';
      return;
    }

    var table = buildStyledTable(ws);
    vs.content.innerHTML = '';
    vs.content.appendChild(table);

    // 画像の埋め込み
    embedImages(table, ws, vs.wb);

    var rows = ws.rowCount;
    var cols = ws.columnCount;
    vs.info.textContent =
      name +
      ' — ' +
      rows +
      ' 行 × ' +
      cols +
      ' 列' +
      (vs.sheets.length > 1
        ? ' (' + (vs.sheetIdx + 1) + '/' + vs.sheets.length + ')'
        : '');
  }

  function buildModal() {
    var m = document.createElement('div');
    m.id = 'xlsx-modal';
    m.innerHTML =
      '<div class="xlsx-modal-overlay"></div>' +
      '<div class="xlsx-modal-dialog">' +
      '<div class="xlsx-modal-header">' +
      '<span class="xlsx-modal-title"></span>' +
      '<button type="button" class="xlsx-modal-close">' +
      '<span class="material-symbols-outlined">close</span>' +
      '</button>' +
      '</div>' +
      '<div class="xlsx-modal-sheet-tabs"></div>' +
      '<div class="xlsx-modal-body"></div>' +
      '<div class="xlsx-modal-footer">' +
      '<span class="xlsx-modal-info"></span>' +
      '</div>' +
      '</div>';

    document.body.appendChild(m);

    m.querySelector('.xlsx-modal-overlay').onclick = closeModal;
    m.querySelector('.xlsx-modal-close').onclick = closeModal;
    return m;
  }

  function closeModal() {
    var m = document.getElementById('xlsx-modal');
    if (!m) return;
    m.style.display = 'none';
    if (m._onKey) {
      document.removeEventListener('keydown', m._onKey);
    }
  }

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

  // HTML 特殊文字をエスケープする
  function esc(s) {
    return String(s)
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;');
  }

  // リッチテキストのフォント情報から <span> タグを生成する
  function richSpan(part) {
    var t = esc(part.text);
    if (!part.font) return t;
    var f = part.font;
    var styles = [];
    if (f.name) styles.push('font-family:' + f.name);
    if (f.size) styles.push('font-size:' + f.size + 'pt');
    if (f.bold) styles.push('font-weight:bold');
    if (f.italic) styles.push('font-style:italic');
    var deco = [];
    if (f.underline) deco.push('underline');
    if (f.strike) deco.push('line-through');
    if (deco.length) styles.push('text-decoration:' + deco.join(' '));
    if (f.color && f.color.argb) {
      styles.push('color:' + argbToHex(f.color.argb));
    }
    if (!styles.length) return t;
    return '<span style="' + styles.join(';') + '">' + t + '</span>';
  }

  // セルの値をフォーマットして HTML 文字列として返す
  function formatValue(cell) {
    if (!cell || cell.value === null || cell.value === undefined) return '';
    var v = cell.value;

    // richText: 各パーツにフォント装飾を適用して連結
    if (typeof v === 'object' && v.richText) {
      return v.richText.map(richSpan).join('');
    }

    // 数式セル(通常の数式・共有数式): 計算結果を取得
    if (
      typeof v === 'object' &&
      (v.formula !== undefined || v.sharedFormula !== undefined)
    ) {
      if (v.result === undefined || v.result === null) return '';
      v = v.result;
      // result が richText の場合
      if (typeof v === 'object' && v.richText) {
        return v.richText.map(richSpan).join('');
      }
      // result がエラー値の場合
      if (typeof v === 'object' && v.error !== undefined)
        return esc(v.error);
    }

    // ハイパーリンク
    if (typeof v === 'object' && v.text !== undefined && v.hyperlink) {
      return (
        '<a href="' +
        esc(v.hyperlink) +
        '" target="_blank" rel="noopener noreferrer">' +
        esc(v.text) +
        '</a>'
      );
    }
    if (typeof v === 'object' && v.text !== undefined) return esc(v.text);
    // エラー値
    if (typeof v === 'object' && v.error !== undefined) return esc(v.error);

    // Date オブジェクト → 書式文字列で整形
    if (v instanceof Date) return esc(fmtDate(v, cell.numFmt));

    // 数値 + 日付書式 → Excel シリアル値を Date に変換して整形
    if (typeof v === 'number' && cell.numFmt && isDateFmt(cell.numFmt)) {
      return esc(fmtDate(excelSerialToDate(v), cell.numFmt));
    }

    // 数値 + 数値書式 → 書式を適用
    if (typeof v === 'number' && cell.numFmt) {
      return esc(applyNumFmt(v, cell.numFmt));
    }

    return esc(v);
  }

  // 書式文字列が日付書式かどうかを判定する
  function isDateFmt(fmt) {
    var clean = fmt.replace(/"[^"]*"/g, '').replace(/\[[^\]]*\]/g, '');
    return /[ymd]/i.test(clean) && !/^[#0,.\s%]+$/.test(clean);
  }

  // Excel シリアル値を Date オブジェクトに変換する
  function excelSerialToDate(serial) {
    var base = new Date(Date.UTC(1899, 11, 30));
    return new Date(base.getTime() + Math.round(serial) * 86400000);
  }

  // Date を Excel の書式文字列に従って整形する
  function fmtDate(d, fmt) {
    if (!d || isNaN(d.getTime())) return '';
    if (!fmt) return d.toLocaleDateString('ja-JP');
    var Y = d.getFullYear();
    var M = d.getMonth() + 1;
    var D = d.getDate();
    var h = d.getHours();
    var mi = d.getMinutes();
    var s = d.getSeconds();
    var pad = function (n) {
      return ('0' + n).slice(-2);
    };
    // hh/h の後に現れる mm/m は「分」、それ以外は「月」として扱う
    var seenHour = false;
    var tokens = fmt
      .replace(/\[[^\]]*\]/g, '')
      .match(/"[^"]*"|yyyy|yy|mm|m|dd|d|hh|h|ss|s|AM\/PM|[^ymdhs"]+/gi);
    if (!tokens) return d.toLocaleDateString('ja-JP');
    return tokens
      .map(function (t) {
        var lo = t.toLowerCase();
        if (/^".*"$/.test(t)) return t.slice(1, -1);
        if (lo === 'yyyy') return Y;
        if (lo === 'yy') return String(Y).slice(-2);
        if (lo === 'dd') return pad(D);
        if (lo === 'd') return D;
        if (lo === 'hh') {
          seenHour = true;
          return pad(h);
        }
        if (lo === 'h') {
          seenHour = true;
          return h;
        }
        if (lo === 'ss') return pad(s);
        if (lo === 's') return s;
        if (lo === 'mm') return seenHour ? pad(mi) : pad(M);
        if (lo === 'm') return seenHour ? mi : M;
        if (/^AM\/PM$/i.test(t)) return h < 12 ? 'AM' : 'PM';
        return t;
      })
      .join('');
  }

  // 数値を書式文字列に従って整形する
  function applyNumFmt(v, fmt) {
    if (!fmt || fmt === 'General' || fmt === '@') return String(v);
    // パーセント
    if (fmt.indexOf('%') !== -1) {
      var pct = v * 100;
      var pMatch = fmt.match(/0\.(0+)%/);
      return pct.toFixed(pMatch ? pMatch[1].length : 0) + '%';
    }
    // 小数桁数を取得
    var decMatch = fmt.match(/0\.(0+)/);
    var decimals = decMatch ? decMatch[1].length : 0;
    // 桁区切り
    if (fmt.indexOf('#,##0') !== -1) {
      return v.toLocaleString('ja-JP', {
        minimumFractionDigits: decimals,
        maximumFractionDigits: decimals
      });
    }
    // 固定小数のみ
    if (decMatch) return v.toFixed(decimals);
    return String(v);
  }

  // ARGB 文字列(例: "FF000000")を CSS カラー("#000000")に変換する
  function argbToHex(argb) {
    if (!argb || argb.length < 6) return null;
    return '#' + argb.slice(-6);
  }

  // ExcelJS の罫線情報を CSS border 値に変換する
  function borderCss(b) {
    var w = '1px';
    var s = 'solid';
    if (b.style === 'medium' || b.style === 'mediumDashed') w = '2px';
    if (b.style === 'thick') w = '3px';
    if (b.style === 'dotted') s = 'dotted';
    if (
      b.style === 'dashed' ||
      b.style === 'mediumDashed' ||
      b.style === 'dashDot' ||
      b.style === 'dashDotDot'
    )
      s = 'dashed';
    if (b.style === 'double') {
      s = 'double';
      w = '3px';
    }
    var c = '#000';
    if (b.color && b.color.argb) c = argbToHex(b.color.argb);
    return w + ' ' + s + ' ' + c;
  }

  // セル参照範囲(例: "A1:C3")を行番号・列番号に変換する
  function parseRange(range) {
    var parts = range.split(':');
    var s = cellRef(parts[0]);
    var e = cellRef(parts[1]);
    return { sr: s.r, sc: s.c, er: e.r, ec: e.c };
  }

  // セル参照(例: "A1")を行番号・列番号に変換する
  function cellRef(ref) {
    var m = ref.match(/^([A-Z]+)(\d+)$/i);
    var col = 0;
    var letters = m[1].toUpperCase();
    for (var i = 0; i < letters.length; i++) {
      col = col * 26 + (letters.charCodeAt(i) - 64);
    }
    return { r: parseInt(m[2]), c: col };
  }

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

関連ページ ​

変更履歴

第7版Markdown の描画の仕組み・ショートカットキー・アイコン・公式マニュアルに無い $p 関数の解説と、Markdown 拡張・画像形式・ファビコン・テーマ・和暦などの改修・設計メモを追加
第6版「拡張機能」「画面カスタマイズ集」に対応バージョンを表示
第5版本文から元記事や以前の版への言及を除き、正しい動作だけを書く形に整理
第4版画面カスタマイズ集のコードを 1.5.8.1 のソースで検証し、動かなかったサンプルを修正
第3版画面カスタマイズ集にコード全体を収録し、サイト画像とサイト種別アイコンを両立するレシピを追加
第2版記事のファイル名に並び順の番号を付け、元記事リンクを frontmatter の sources に移行
第1版「画面カスタマイズ集」に CAD(DXF / STEP)と Excel のプレビューを追加