ClosedXMLはBlazor WebAssembly(.NET 9)で使えるのか?クライアントだけでExcelを生成する設計と代替案

「Blazor WebAssembly だけで Excel を吐きたいから ClosedXML を使いたいけど、本当に動くの? System.Drawing.Common がダメって話も聞くし……」という悩みを、.NET 9 時代の情報にアップデートしつつ整理してみます。結論から言うと「動かすことはできるが、公式に WebAssembly 向けをうたっているわけではなく、いくつか落とし穴と設計のコツがある」というポジションです。

目次

ClosedXML と Blazor WebAssembly の現在地

ClosedXML のターゲットと依存関係を整理する

まず、ClosedXML 自体がどのランタイムをターゲットにしているかを押さえておきます。

  • NuGet 版 ClosedXML(執筆時点の 0.105.0)は .NET Standard 2.0 / 2.1 をターゲットにしたライブラリです。
  • .NET Standard 2.0/2.1 に対応しているランタイム(.NET 5〜10 や net8.0-browser / net9.0-browser など)からは「互換あり」とみなされ、Blazor WebAssembly (.NET 9) からも参照可能です。

次に、よく話題になる System.Drawing.Common の依存関係ですが、これはすでに状況が変わっています。

  • .NET 6 以降、System.Drawing.Common は公式に「Windows 限定」となり、Linux や WebAssembly などでは PlatformNotSupportedException を投げるようになりました。
  • ClosedXML は 0.97.0 の段階で System.Drawing.Common の依存を削除 し、フォントや画像処理を IXLGraphicEngine インターフェース配下の「Graphic Engine」に抽象化しました。

この変更により、古い情報にある「ClosedXML は System.Drawing.Common 依存だから WebAssembly では動かない」という指摘は、最新バージョンに対してはそのまま当てはまりません。

ただし、ClosedXML が内部で利用している DocumentFormat.OpenXml(Open XML SDK) は、ZIP(.xlsx)の読み書きなどに System.IO.Packaging などの API を使っており、WebAssembly のランタイムや AOT 設定との相性でトラブルになるケースが残っています。Microsoft Q&A では、.NET 8 Blazor WASM で new XLWorkbook(ms) が Publish 後にだけ落ちる問題が報告されており、.NET 9 に上げたら解決した という実例もあります。

公式ドキュメントは「Blazor 用の使い方」を用意している

ClosedXML の公式ドキュメントには Graphic Engine のページがあり、そこに「How to use in Blazor」という項目が追加されています。

ここで説明されているポイントはざっくりいうと次のとおりです。

  • クライアント側 Blazor(WebAssembly)はファイルシステムにアクセスできない。
  • デフォルトの Graphic Engine は OS のフォント(Calibri など)をファイルシステムから読み込む設計のため、そのままでは役に立たない。
  • そこで、
    • DefaultGraphicEngine.CreateOnlyWithFonts などのファクトリメソッドに フォントストリーム(.ttf / .woff) を渡して、フォントを明示的に登録する。
    • その Graphic Engine を LoadOptions.GraphicEngine 経由で XLWorkbook に渡す。

つまり、ClosedXML 側も「クライアント側 Blazor で動かすこと」をある程度は想定しており、フォント周りについては公式に回避策を提示している、という状態です。

実際に Blazor WASM で動かしている例

ブログ記事やサンプルコードを見てみると、次のようなパターンで Blazor WebAssembly + ClosedXML が実際に動いている例があります。

  • WASM プロジェクトに ClosedXML を追加。
  • ExcelService のようなサービスクラスを作って、XLWorkbook でブックを生成・読み込み。
  • 生成したブックは MemoryStream に保存し、IJSRuntime で JavaScript 関数(Blob + a タグクリック)を呼び出してブラウザダウンロード。
  • 読み込み時は HttpClient で wwwroot/fonts 下のフォントファイルを取得し、DefaultGraphicEngine に渡したうえで XLWorkbook を開く。

また、前述の Microsoft Q&A では XLWorkbook を使った WASM アプリが .NET 8 では Publish 後にだけ落ち、.NET 9 へターゲット変更することで解決したケースが報告されています。

以上から、「.NET 9 の Blazor WebAssembly で ClosedXML をクライアント側だけで動かすことは、条件付きで現実的」と言えますが、まだまだ「サポート対象プラットフォーム」として明確に約束されているわけではなく、WebAssembly 特有の制約や AOT/トリミングとの相性を自分でケアする必要があります。

WebAssembly 環境の制約を押さえる

Blazor WebAssembly では .NET ランタイム自体がブラウザのサンドボックス内に閉じ込められています。そのため、通常の .NET ライブラリを持ってくるときは次の制約を意識する必要があります。

ジャンルWASM の制約ClosedXML への影響イメージ
ファイル I/Oローカルディスクに直接アクセス不可。仮想ファイルシステムや HTTP 経由のみ。SaveAs("C:\\...") のようなパス指定は使えず、MemoryStream 経由で扱う必要がある。
ネイティブ APIWin32/GDI+ や OS 依存 API は使用不可。System.Drawing.Common ベースの描画は不可。ClosedXML がこれを捨てて Graphic Engine に移行したのはこのため。
スレッドマルチスレッドは制限付き(ブラウザの対応や設定に依存)。ClosedXML 自体は基本的にシングルスレッドで完結するので大きな問題にはなりにくいが、大量データ処理時のパフォーマンスは期待しにくい。
AOT / トリミングPublish 時に未使用コードが大胆に削られる。動的コード生成・反射の多用は危険。ClosedXML/DocumentFormat.OpenXml はリフレクションや XML を多用するため、トリミング警告を無視すると Publish 後だけ落ちるといった症状が出やすい。

特に Blazor WASM では、リリースビルド(AOT + トリミング) と デバッグビルド で挙動が変わりやすく、「デバッグでは動いていたのに、Publish したら new XLWorkbook で落ちる」というパターンが典型例です。

.NET 9 Blazor WebAssembly で ClosedXML をクライアント実行する手順(例)

ここからは、実際に クライアント側だけで ClosedXML を動かす場合の設計例 を、できるだけ具体的にまとめます。

前提とゴール

  • ターゲット: .NET 9 Blazor Web App(レンダーモード: Interactive WebAssembly)
  • ClosedXML バージョン: 0.104 以降(この記事執筆時点では 0.105.0)
  • やりたいこと:
    • 画面上の一覧データを Excel(.xlsx)にエクスポート
    • ユーザーがアップロードした Excel をクライアント側でパース
    • サーバー側 API を用意せず、純粋な WASM クライアントだけで完結

JavaScript 側のダウンロード関数

wwwroot/index.html(または wwwroot/index.html 相当)に、バイト配列からファイルをダウンロードする関数を用意します。

<script>
  window.downloadFileFromBytes = (fileName, bytesBase64) => {
    // Blazor から byte[] を渡す場合は Base64 を使うことが多い
    const bytes = Uint8Array.from(atob(bytesBase64), c => c.charCodeAt(0));
    const blob = new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
    const url = URL.createObjectURL(blob);

    const a = document.createElement('a');
    a.href = url;
    a.download = fileName;
    document.body.appendChild(a);
    a.click();

    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  };
</script>

ExcelService: エクスポート処理

Blazor から呼び出すサービスクラスの例です。ClosedXML でブックを生成し、JavaScript 経由でダウンロードします。

using System.Reflection;
using ClosedXML.Excel;
using Microsoft.JSInterop;

public sealed class ExcelService
{
    private readonly IJSRuntime _jsRuntime;

    public ExcelService(IJSRuntime jsRuntime)
    {
        _jsRuntime = jsRuntime;
    }

    public async Task ExportAsync<T>(IEnumerable<T> data, string fileName)
    {
        using var workbook = new XLWorkbook();
        var worksheet = workbook.AddWorksheet("Sheet1");

        var props = typeof(T).GetProperties(BindingFlags.Instance | BindingFlags.Public)
                             .Where(p => p.CanRead)
                             .ToArray();

        // ヘッダー行
        for (int i = 0; i < props.Length; i++)
        {
            worksheet.Cell(1, i + 1).Value = props[i].Name;
        }

        // データ行
        var rows = data.ToList();
        for (int rowIndex = 0; rowIndex < rows.Count; rowIndex++)
        {
            for (int colIndex = 0; colIndex < props.Length; colIndex++)
            {
                var value = props[colIndex].GetValue(rows[rowIndex]);
                worksheet.Cell(rowIndex + 2, colIndex + 1).Value = value;
            }
        }

        // ここで Columns().AdjustToContents() を呼ぶとフォント設定が必要になるので、
        // まずはシンプルにセル幅固定から始めた方が安全。
        // worksheet.Columns().AdjustToContents();

        using var ms = new MemoryStream();
        workbook.SaveAs(ms);

        var base64 = Convert.ToBase64String(ms.ToArray());
        await _jsRuntime.InvokeVoidAsync("downloadFileFromBytes", fileName, base64);
    }
}

ポイントは次のとおりです。

  • WASM ではファイルパスに SaveAs("C:\\temp.xlsx") のような指定ができないため、必ず MemoryStream 経由でバイト配列に落とします。
  • 列幅自動調整(AdjustToContents)はフォントに依存するため、Blazor では Graphic Engine の設定ができるようになるまで封印しておくとトラブルが減ります。

ExcelService: インポート処理(フォント設定付き)

アップロードされた Excel を読む場合は、Graphic Engine にフォントストリームを渡す必要があります。これは公式ドキュメントの「How to use in Blazor」で推奨されているやり方です。

using ClosedXML.Excel;
using ClosedXML.Graphics;
using Microsoft.AspNetCore.Components.Forms;
using System.Net.Http;

public sealed partial class ExcelService
{
    private readonly HttpClient _httpClient;

    public ExcelService(IJSRuntime jsRuntime, HttpClient httpClient)
    {
        _jsRuntime = jsRuntime;
        _httpClient = httpClient;
    }

    public async Task<IReadOnlyList<T>> ImportAsync<T>(IBrowserFile file) where T : new()
    {
        using var ms = new MemoryStream();
        await file.OpenReadStream().CopyToAsync(ms);
        ms.Position = 0;

        // wwwroot/fonts/ に配置したフォントを読み込む(ttf / woff など)
        await using var fontStream =
            await _httpClient.GetStreamAsync("fonts/YourFallbackFont.ttf");

        var loadOptions = new LoadOptions
        {
            GraphicEngine = DefaultGraphicEngine.CreateOnlyWithFonts(fontStream)
        };

        var result = new List<T>();

        using (var workbook = new XLWorkbook(ms, loadOptions))
        {
            var ws = workbook.Worksheet(1);
            var firstRowUsed = ws.FirstRowUsed();
            var headerRow = firstRowUsed.RowUsed();
            var headerCells = headerRow.Cells().ToList();

            var map = headerCells
                .Select((cell, index) => new { cell, index })
                .ToDictionary(
                    x => x.cell.GetString(),
                    x => x.index + 1
                );

            foreach (var row in ws.RowsUsed().Skip(1))
            {
                var item = new T();
                foreach (var kvp in map)
                {
                    var prop = typeof(T).GetProperty(kvp.Key);
                    if (prop is null || !prop.CanWrite) continue;

                    var cellValue = row.Cell(kvp.Value).Value;
                    if (cellValue.IsBlank) continue;

                    var targetType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
                    var converted = Convert.ChangeType(cellValue.GetValue<object>(), targetType);
                    prop.SetValue(item, converted);
                }

                result.Add(item);
            }
        }

        return result;
    }
}

ここでのキモは次の部分です。

  • DefaultGraphicEngine.CreateOnlyWithFonts(fontStream) によって、OS フォントを使わず「埋め込んだフォントだけ」でテキスト幅の計測などを行うようにしている。
  • フォントファイル(.ttf)は wwwroot/fonts 以下に置き、HttpClient で取得している(Blazor の HttpClient は既定で Web ルートを指す)。

この設定を忘れると、AdjustToContents やテキスト計測を行うタイミングで

Unable to find font Calibri or fallback font Microsoft Sans Serif

といった例外が発生することが GitHub Issue でも報告されています。

AOT / トリミング時の注意点

.NET 9 の Blazor WebAssembly アプリを Publish するとき、多くの場合は PublishTrimmed=true などの設定で トリミング + AOT が有効になります。

ClosedXML や DocumentFormat.OpenXml は XML パーサやリフレクションを内部で使っているため、トリミングによって必要なコードが削られ、

  • デバッグビルドでは正常
  • Publish したアプリでは XLWorkbook のコンストラクタで落ちる

という症状が起きる可能性はゼロではありません。

このリスクに対処するには、少なくとも次を実践するのがおすすめです。

  • トリミング警告(IL2026, IL3050 など)をビルドログで必ず確認する。
  • 問題が出る場合は
    • ClosedXML / DocumentFormat.OpenXml をトリミング対象から除外する
    • 一時的に Publish 時のトリミングをオフにして挙動を切り分ける
  • 「.NET 8 ではダメだったが .NET 9 に上げたら動くようになった」という実例があるように、ランタイム側の改善も進んでいるので、できるだけ新しいランタイムを使う。

NativeAOT / トリミングにフル対応させるには、ライブラリ側での注釈付けが必要であり、「単体テストを通しただけでは AOT 問題をすべて検出できない」という点も公式に指摘されています。

サーバーサイドを足さずに済む代替策

ここまで読むと、「ClosedXML でも頑張れば WASM だけでいけそうだけど、もう少し割り切った選択肢はないの?」という気持ちも出てくると思います。サーバーコンポーネントを増やさずに済ませたい場合の、代表的な代替案を整理します。

JavaScript ベースの Excel ライブラリを使う(SheetJS など)

一番単純なのは、「Excel の重たい処理は .NET ではなく JavaScript ライブラリに任せる」アプローチです。

  • 代表例: SheetJS(xlsx.js)
  • SheetJS は公式に「Blazor との連携デモ」を公開しており、Blazor + WASM から JavaScript の SheetJS を呼び出して Excel を読み書きするサンプルが用意されています(Blazor サポートはまだ experimental 扱い)。
  • Blazor 側では IJSRuntime を通じて
    • テーブルデータを JS に渡す
    • JS 側で XLSX.write などを呼ぶ
    • 生成された Blob をブラウザでダウンロード

メリットは、

  • 完全クライアントサイドで完結し、.NET 側は比較的薄いラッパーで済む。
  • Excel 機能(数式・スタイルなど)の実装が豊富で、ブラウザ環境向けに枯れている。

一方で、

  • JavaScript ライブラリの API を学ぶ必要がある。
  • 型安全性やテストのしやすさは C# コードだけの構成に比べて一段落ちる。

というトレードオフがあります。

WASM / NativeAOT フレンドリーな .NET ライブラリを選ぶ

Excel 処理の要件が「既存の .xlsx を開いて細かい書式まで再現する」というより、「データを表形式で書き出せればよい」というレベルであれば、ClosedXML ほどリッチでなくてもよいケースが多いです。

その場合、次のようなライブラリが候補になります。

ライブラリ特徴WASM 観点のコメント
SpreadCheetah高速・低メモリで Excel 生成専用。Source Generator による型安全な行生成。NativeAOT 対応・トリミング対応 を明言しており、.NET 6〜10 をサポート。WASM で「書き出し専用」の用途には非常に相性が良い。
Sylvan.Data.Excel高速な Excel 読み取り専用ライブラリ。netstandard2.0/2.1 ターゲット、依存ライブラリなし。依存が少なく純マネージコードなので、WASM から使っても System.Drawing のような問題は起こりにくい。ただし AOT/トリミング対応までは公式には明言されていないため検証は必要。
MiniExcelシンプルで低メモリな Excel 処理ライブラリ。サーバーサイドでの利用実績が多い。基本的には .NET Standard ベースで動作するため WASM でも動く可能性は高いが、NativeAOT などへの公式言及はない。軽量なので候補にはなる。

たとえば「クライアント側で 10 万行以上の CSV/Excel を吐きたい」という用途なら、

  • 読み取りは Sylvan.Data.Excel などの専用リーダー
  • 書き出しは SpreadCheetah のような AOT 対応ライブラリ

といった組み合わせの方が、ClosedXML を無理やり WASM で動かすよりもパフォーマンス面・安定性の両方で有利になる可能性があります。

「Excel ではなく CSV で十分」という割り切り

意外と忘れがちですが、「ユーザーが Excel で開ければよい」というだけなら CSV でダウンロードさせる のが最もシンプルで安全です。

  • WASM から string を Blob にしてダウンロードするだけ。
  • トリミング・フォント・Open XML SDK などの問題から完全に解放される。
  • Excel 側での列幅調整・書式設定はユーザーに任せる、という割り切りができるかがポイント。

サーバーサイド実行とクライアント実行の比較

ここで一度、代表的なパターンを表で比較しておきます。

方式実行場所メリット注意点向いているケース
ClosedXML を WASM で直接実行ブラウザ(クライアント)・サーバー不要
・C# だけで完結
・既存 ClosedXML ノウハウを流用可能
・フォント設定が必須
・AOT/トリミングとの相性問題
・大きなファイルではメモリ負荷が高い
・比較的小さなレポート
・オフライン PWA でも Excel 出力したい
ClosedXML を API / Blazor Server で実行サーバー・.NET ライブラリを素直に利用可能
・ファイル I/O やフォントも柔軟
・パフォーマンスチューニングの自由度が高い
・サーバーリソースが必要
・スケール設計が必要
・業務システムの帳票出力
・大容量 Excel / 複雑な書式
JS ライブラリ(SheetJS など)ブラウザ(クライアント)・Excel 機能が豊富
・ブラウザ向け実績が多い
・サーバーレス構成にしやすい
・JavaScript 側コードが増える
・型安全性は C# より弱い
・Excel 上での操作性を重視
・純粋な SPA + PWA として完結させたい
SpreadCheetah / MiniExcel など AOT 対応寄りのライブラリクライアント or サーバー・軽量で高速
・NativeAOT / トリミングへの追従が早い傾向
・ClosedXML ほどの機能(ピボット、スタイルなど)はない
・サンプルや日本語情報がまだ少ない
・大量データのエクスポート
・サーバーレス寄りアーキテクチャ

実務での判断フロー例

最後に、要件から逆算してどの方式を選ぶかの「ざっくりフロー」を言語化しておきます。

  1. 出力したいものは?
    • ただの一覧(表形式)でよい → CSV / SpreadCheetah / MiniExcel などを優先検討。
    • 凝った書式・ピボット・数式もガッツリ使いたい → ClosedXML か商用コンポーネント(Syncfusion, SpreadJS など)。
  2. サーバーを増やすことは許容できる?
    • Yes → 一番安全なのは「サーバー側で ClosedXML」。WASM は単なる UI と割り切る。
    • No → クライアントサイドのみで完結する設計(ClosedXML on WASM / JS ライブラリ)を検討。
  3. オフライン PWA でも Excel 出力が必要?
    • Yes → クライアント実行一択。ClosedXML を使う場合は、フォント設定と AOT/トリミング検証に時間を割く前提で設計する。
    • No → サーバー側での生成 + ダウンロードという王道構成のほうがトータルの保守コストは低くなりがち。

まとめ:ClosedXML を WASM だけで使うかどうかの結論

ここまでの内容を、元の質問に沿って整理し直します。

1. 「ClosedXML は Blazor WebAssembly (.NET 9) でクライアントだけで使えるか?」

  • 技術的には可能であり、実際に動作しているサンプルやブログ記事があります。
  • ただし、ClosedXML 自体は .NET Standard ライブラリとして提供されているだけで、「WebAssembly を公式サポート」と明言されているわけではありません。
  • 特に Publish 時の AOT/トリミングとの兼ね合いで、.NET のバージョンや設定次第ではランタイム例外を踏み抜く可能性があります(.NET 9 で改善した実例あり)。

2. 「System.Drawing.Common 依存の問題はどうなったか?」 最新の ClosedXML では System.Drawing.Common への依存は削除済み です。 代わりに Graphic Engine(IXLGraphicEngine) を経由し、フォントや画像処理を抽象化しています。 Blazor WebAssembly のようにファイルシステムや OS フォントを利用できない環境では、フォントストリームを渡して DefaultGraphicEngine を構成することが必須です(公式 docs の「How to use in Blazor」参照)。 3. 「WASM 特有の制約を回避するためのドキュメントやベストプラクティスはあるか?」 ClosedXML 公式ドキュメントに Graphic Engine の章があり、そこに Blazor 向けの使い方が記載されています。 また、外部ブログやサンプルコードでも、 フォントを wwwroot に置いて DefaultGraphicEngine に渡す JS Interop 経由でファイルダウンロードを行う といったパターンが定番になりつつあります。 AOT/トリミングについては ClosedXML 固有のドキュメントはありませんが、.NET 公式が「ライブラリをトリミング対応にするためのガイド」を公開しており、それに沿って警告を潰していくのが基本方針になります。 4. 「使えない・不安な場合に、サーバーサイドを増やさずに済む代替策は?」 JavaScript ライブラリ(SheetJS など)を Blazor から呼び出して Excel を扱う。 Excel の生成専用なら SpreadCheetah のような NativeAOT/トリミング対応を明言しているライブラリ を使う。 フォーマット要件が緩ければ、CSV ダウンロードで割り切る。 総合すると、 ClosedXML をクライアントのみの Blazor WebAssembly で直接使うことは、最新バージョンと .NET 9 の組み合わせなら現実的だが、「公式に完全サポートされた安定運用」とまでは言えない。 確実性と保守性を最優先するなら「サーバー側で規模の大きい Excel 処理を行い、WASM クライアントはダウンロードのみ担当する」設計が依然として最も安全です。 どうしてもサーバーを足したくない場合は、「ClosedXML + Blazor 向け Graphic Engine 設定」か「SpreadCheetah / SheetJS などの代替手段」のどちらかに寄せる、というのが現実的な落としどころになります。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次