C# で表計算シートを PDF にする — Office もプリンタドライバも PDF ライブラリも使わない

· unvell team
C# で表計算シートを PDF にする — Office もプリンタドライバも PDF ライブラリも使わない

「帳票を PDF にしてメールで送るだけ」。チケットには 1 行で書かれていて、開発機ではどの方法でも動きます。ところがサーバーに載せた途端、それぞれ別の理由で止まります。Excel が入っていない、コンテナに印刷スプーラが無い、そして出てきた PDF は誰かが半日かけて整えた列幅を失っている。

厄介なのは、シート自身は紙の上でどう見えるべきかを既に知っているという点です。列幅も、結合も、罫線も、表示形式も、印刷範囲も、ページ設定も持っている。それなのに大半の PDF 生成パイプラインは、それを一度捨ててから組み直します。

ReoGrid V5 は、画面のグリッドを描いているレンダラーそのものから PDF を出力します。外部パッケージは一切使いません。この記事では呼び出し方、ページ設定、日本語の扱い、そしてヘッドレス運用で必ず引っかかる 1 点を扱います。


よくある 4 つの方法と、その代償

方法引っかかる点
Office Interop — Excel の ExportAsFixedFormat を自動操作サーバーに Excel が必要で、しかもサーバーサイドでの Office 自動操作は Microsoft が推奨もサポートもしていません。Windows 専用。EXCEL.EXE が残留する不具合はそれ自体でひとつのジャンル
Microsoft Print to PDF — 仮想プリンタに印刷Windows 専用で、しかも中身はプリンタドライバです。印刷スプーラ、ユーザーセッション、書き込み可能なスプールディレクトリを要求します。コンテナにはどれもありません
HTML を描いてヘッドレス Chromium で撮るイメージにブラウザを丸ごと同梱することになり、ページ分割を CSS で再実装する羽目になります。@media print の改ページは Excel の改ページとは別物です
PDF ライブラリ(iText / QuestPDF / PdfSharp)どれも優れたライブラリですが、描くのは自分で書いたレイアウトであってシートのレイアウトではありません。列幅も結合も罫線もコード側で書き直すことになり、ブックが編集された瞬間にズレ始めます

どれも「ある問題」に対しては正解です。ただし「このワークシートを、設定されているとおりに、サーバーで描いてほしい」に対する答えはひとつもありません。


V5 のやり方

V5 のコアは UI に依存しません。描画は IGridGraphics インターフェイス越しに行われ、各プラットフォームが実装を差し込みます — WinForms は GDI+、WPF と Avalonia は DrawingContextunvell.ReoGrid.IO.Pdf はもう 1 つの実装、PDF のコンテンツストリームを書き出す PdfGraphics を提供します。

ここから出てくる帰結は 2 つです。

  1. PDF は「グリッドが描いたもの」そのものです。 再実装ではありません。条件付き書式、リッチテキスト、結合セル、点線・二重罫線、表示形式 — 同じビューポートのコードが描くので、そのまま出ます。
  2. インストールするものがありません。 ReoGrid.IO.Pdfnet10.0 で、PackageReference が 1 つもなくSystem.Drawing にも触れません。Windows と同じように Linux / macOS で動きます。

ページ分割も PDF 側のコードではありません。改ページは共通の Paginator が計算し、行と列を単位のまま印刷可能領域に詰めます。Excel と同じく、1 行が 2 ページに割れることはありません。

サーバー/バッチ用途のパッケージは unvell.ReoGrid.One.Corenet10.0、依存なし、UI なし)です。

dotnet add package unvell.ReoGrid.One.Core

WinForms / WPF / Avalonia 版はコア・数式エンジン・XLSX / PDF I/O をすべて含んでいるので、デスクトップアプリで .Core併記する必要はありません


呼び出し

using unvell.ReoGrid.Core;
using unvell.ReoGrid.IO.Pdf;

PdfExporter.Export(wb, "book.pdf");              // ブックの全シート
PdfExporter.Export(ws, "sheet.pdf");             // 1 シートだけ

byte[] bytes = PdfExporter.ExportToBytes(wb);    // ファイルを経由しない

Web API が欲しいのは ExportToBytes のほうです。ファイルシステムに触れずに済みます。

サーバー側の処理は結局 3 ステップになります。

using unvell.ReoGrid.Core;
using unvell.ReoGrid.IO.Excel;
using unvell.ReoGrid.IO.Pdf;

var workbook = XlsxReader.Read("input.xlsx");

// XLSX には Excel が保存したキャッシュ値が入っており、読み込み時はそれをそのまま使います
// (再計算はしません)。実際に値を変えたときだけ Recalculate を呼びます。
var sheet = workbook[0];
sheet.SetNumber(1, 1, 500);
sheet.Recalculate();

PdfExporter.Export(workbook, "output.pdf");

出力は PDF 1.7 です。空のブックでも 0 ページの PDF ではなく、有効な 1 ページの PDF になります(0 ページの PDF は開けないリーダーがあるため)。


ページ設定

どのオーバーロードも PrintSettings を省略できます。省略した場合、各シートはそのシート自身の ws.PrintSettings にフォールバックします。これは XLSX 読み込み時にファイルから復元されたものです。集計シートが A4 縦、データシートが A3 横というブックは、コードを一切書かなくても作られたとおりに出力されます。

using unvell.ReoGrid.Core.Printing;

var settings = new PrintSettings
{
    Paper = PaperKind.A4,
    Orientation = PageOrientation.Landscape,
    ShowGridLines = false,
    FitToPagesWide = 1,
};

PdfExporter.Export(ws, "sheet.pdf", settings);

主な設定項目です。

プロパティ既定値補足
PaperA4A5A3、JIS B5 / B4LetterLegalTabloidExecutiveCustom
OrientationPortrait横向きは用紙の縦横を入れ替えます
MarginLeft / Right / Top / Bottom54単位は ポイント(mm でもインチでもありません)。72 pt = 1 インチなので、54 は Excel の「標準」0.75 インチ
PrintAreanullRangePosition.Parse("A1:H60")。null なら使用範囲全体
Scale1.00.1〜4.0(Excel の 10%〜400%)。フィット指定があると無視されます
FitToPagesWide / Tallnull実務で多いのは Wide = 1, Tall = null — 横にはみ出さず、縦は必要なだけ
CenterHorizontally / Verticallyfalse印刷可能領域の中央に寄せます
OrderDownThenOver縦横ともに複数ページになるときのページ番号順(Excel の既定)
ShowGridLinestrue
ShowHeadersfalse行番号・列見出し。Excel と同じく既定はオフ
ShowImagestrueオフにすると文字だけの出力になります

余白がポイント単位なのは間違えやすいので明示しておきます。MarginLeft = 54 は 0.75 インチ、20 mm にしたいなら 20 * 72 / 25.4 です。

ページ数だけ知りたい場合 — 進捗表示のため、あるいは 400 ページの要求を描画前に弾くため — 描画せずにページ分割だけ行えます。

PrintLayout layout = Paginator.Paginate(ws, ws.PrintSettings);
Console.WriteLine($"{layout.Pages.Count} ページ");

見出し行の繰り返しとページ番号

200 行の表が 6 ページに渡るなら、見出し行は 6 ページ全部に必要です。Excel の「印刷タイトル」で、プロパティ 2 つです。

// 1〜2 行目を各ページの上端に、A 列を各ページの左端に繰り返す
ws.PrintSettings.RepeatRows = new LineSpan(0, 1);
ws.PrintSettings.RepeatColumns = new LineSpan(0, 0);

繰り返し部分は固定帯として描かれ、本文側からは除外されます。そのため同じページに同じ行が 2 回出ることはありません — 見出しを単純に描き足す実装で起きがちな不具合です。

ヘッダー/フッターは Excel の & コードをそのまま使います。Excel で作られたブックがそのまま動きます。

var hf = ws.PrintSettings.HeaderFooter;
hf.Header.Center = "&A";                 // シート名
hf.Footer.Right  = "&P / &N";            // 2 / 5
hf.Footer.Left   = "&D &T";              // 日付と時刻
hf.FontSizePt    = 9;

// 1 ページ目だけ別の見出しにする
hf.DifferentFirst = true;
hf.FirstHeader.Center = "四半期レポート";
コード展開結果
&Pページ番号。&P+2 / &P-1 でオフセット可
&N総ページ数
&Aシート名
&F元ファイル名
&Z元フォルダのパス
&D / &T日付 / 時刻(現在のカルチャ)
&&& 自身

&F&Z は出力先パスではなく、第 3 引数から取ります。

PdfExporter.Export(wb, "out.pdf", settings, documentName: "/srv/reports/Q3.xlsx");

省略した場合は勝手な名前を作らず、どちらもになります。存在しないファイル名を静かに名乗るフッターより、空欄のほうがましだという判断です。

装飾コード(&B 太字、&I 斜体、&K 色、&"フォント名")は解釈された上で捨てられます。ヘッダー帯は 1 つのスタイルで描くためです。ただし保存文字列の中には残るので、XLSX を往復してもコードは失われません。


日本語

サーバーサイドの PDF 生成が破綻しやすいのがここです。コンテナにフォントが入っていないので日本語が豆腐になる、あるいは出力自体が落ちる。

IPAexGothic が DLL に同梱されています。 zlib 圧縮した埋め込みリソースとして持ち、Type0 / CIDFontType2、Identity-H エンコーディングで書き出します。フォントが 1 つも入っていないマシンでも日本語が出ます。

見た目以上に効いてくる点が 3 つあります。

  • テキストがテキストのまま残ります。 グリフと一緒に ToUnicode CMap を書き出すので、PDF は検索でき、コピーもできます。「商品名」は PDF から「商品名」として取り出せます — グリフ番号ではありません。生成 PDF に対して繰り返し出る「画像としてしか検索できない」という不満を、これが防いでいます。
  • 使わなければコストを払いません。 フォントは最初に CJK を描くときに初めて展開され、CJK を含まないシートにはフォントが一切埋め込まれません(欧文は標準 Helvetica 4 書体を使うのでファイルは小さいままです)。日本語を含む場合は約 4 MB のフォントがファイルに乗ると見ておいてください。
  • 改行は禁則処理に従います。 折り返しは原則どの文字間でも起きますが、句読点が行頭に来る位置では起きません。行が で始まることはなく、代わりに 1 文字戻して改行します。

見込んでおくべき制限が 2 つあります。

  • フォントのサブセット化は未対応です。 日本語を含む PDF には書体全体が埋め込まれます。帳票 1 通なら問題ありませんが、小さな PDF を 1 万通生成するなら 40 GB 分が同じフォントの複製になるので、結合や後処理を検討する場面です
  • CJK の太字・斜体フェイスは未対応です。 日本語に太字を指定しても通常のウェイトで描かれます(欧文は Helvetica の 4 書体が揃っています)

画像

セルに配置した画像はそのまま埋め込まれます。PNG(FlateDecode、アルファは /SMask になります)と JPEG(元のバイト列を /DCTDecode にそのまま通すので、再エンコードも劣化もありません)に対応します。

12 ページに登場する画像 — 繰り返しヘッダーに置いたロゴなど — は1 回だけ埋め込まれ、12 回参照されます。デコードできない画像はスキップして出力を続けるので、ロゴが 1 つ壊れていても夜間バッチは止まりません。


ASP.NET Core のエンドポイントに載せる

ExportToBytes の存在意義は、ハンドラがこれだけで済むという点にあります。

using unvell.ReoGrid.Core;
using unvell.ReoGrid.Core.License;
using unvell.ReoGrid.IO.Excel;
using unvell.ReoGrid.IO.Pdf;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// 起動時に 1 回だけ — 出力処理が走る前に。
ReoGridLicense.SetLicense(builder.Configuration["ReoGrid:License"]!);

app.MapPost("/reports/pdf", async (IFormFile xlsx) =>
{
    await using var stream = xlsx.OpenReadStream();
    var workbook = XlsxReader.Read(stream);

    byte[] pdf = PdfExporter.ExportToBytes(workbook, documentName: xlsx.FileName);

    return Results.File(pdf, "application/pdf",
        Path.ChangeExtension(xlsx.FileName, ".pdf"));
}).DisableAntiforgery();

app.Run();

この記事に Dockerfile の節が無いのは、書き足すことが何も無いからです。mcr.microsoft.com/dotnet/aspnet:10.0 とアプリだけ — フォントパッケージも libgdiplus も Chromium もスプーラも要りません。

入力が非常に大きい場合は、XlsxReader.OpenVirtual("large.xlsx", SheetLoadMode.OnDemand) でブック全体を読み込まずにシート単位で遅延読み込みできます。


押さえておくべき挙動 — ライセンス未適用時は例外になる

ここが引っかかりどころで、しかも意図的な設計です。

有効なライセンスキーが無い場合、PDF 出力は ReoGridLicenseException を投げます。 透かし入りの PDF は出ません。

using unvell.ReoGrid.Core.License;

// ソース中の文字列リテラルではなく、設定ファイルか環境変数から読みます。
ReoGridLicense.SetLicense(Environment.GetEnvironmentVariable("REOGRID_LICENSE")!);

理由は書いておく価値があります。画面上のグリッドは逆の挙動をするからです。未ライセンスのコントロールは読み取り専用のまま使え、透かしが表示されます。画面を見ている人間は、何がおかしいかを見て対処できるためです。ヘッドレスのサービスにはその経路がありません。ファイル I/O が静かに劣化したら、透かしを最初に読むのは請求書を受け取った顧客になります。だから ファイル I/O のほうは明示的に失敗します — XLSX / CSV / PDF / reogrid-json のすべてが、出力せずに拒否します。

評価版キーの期限切れも同じです。期限を過ぎた日から、稼働中のサービスは出力時に例外を投げ始めます。月末に 500 で気付くのではなく、起動時に ReoGridLicense.IsLicensed を見るヘルスチェックを入れておくのが安全です。

if (!ReoGridLicense.IsLicensed)
    throw new InvalidOperationException("ReoGrid のライセンスが未適用または期限切れです — PDF 出力は失敗します。");

キーは 1 つで全バージョン・全プラットフォームをカバーし、買い切りです。アップデート期間が切れても購入済みのキーは動き続け、V4 用に発行されたキーはそのまま V5 を有効化します。


V4 の場合

ReoGrid V4 に PDF エクスポーターはありません。PDF に至る唯一の経路は、印刷パイプラインを仮想プリンタに向けることです。

sheet.PrintSettings.PrinterName = "Microsoft Print to PDF";
sheet.CreatePrintSession().Print();

デスクトップではこれで十分に動きます(C# で表計算シートを印刷するで扱っています)。ただしプリンタドライバの制約をそのまま引き継ぎますし、V4 はモデルがコントロールと強く結合していたため、サーバーサイドでの利用は現実的ではありませんでした。V5 の UI / コア分離は、まさにこのシナリオを成立させるために行われたものです。


まとめ

  • サーバーでワークシートを PDF にするのに、Office もプリンタドライバもヘッドレスブラウザも PDF パッケージも要りません。unvell.ReoGrid.One.Corenet10.0依存ゼロSystem.Drawing も使いません
  • PDF は画面を描いているレンダラーそのものから出力されるので、結合・罫線・条件付き書式・表示形式といったシート自身のレイアウトが、組み直されることなくそのまま乗ります
  • PdfExporter.Export(...) はファイルに書き出し、API エンドポイントが欲しいのは ExportToBytes(...) のほうです
  • PrintSettings を省略すれば各シートは XLSX から復元された自前のページ設定を使います。余白の単位はポイントです
  • RepeatRows は見出し行を各ページに繰り返しつつ二重描画を防ぎ、&P / &N / &A は Excel と同じに動きます
  • 日本語は最初から解決済みです。IPAexGothic を同梱し、ToUnicode CMap で検索可能なまま、禁則処理も効きます。CJK を含まない出力にはフォントが乗りません
  • ライセンス未適用時は透かしではなく例外です。 起動時にキーを設定し、IsLicensed を確認してください — 透かし入りの請求書を出すくらいなら失敗する、という設計です

V5 の新機能を見る / 30 日間の評価版を試す


関連記事・ドキュメント

ご自身のプロジェクトで ReoGrid を試す

.NET WinForms / WPF 向けの Excel 互換スプレッドシートコンポーネント。30 日間の無償トライアルをご利用いただけます。

ニュースレター

最新リリースをメールでお届け

ReoGrid の新バージョン・新機能・技術記事のお知らせをお送りします。配信停止はいつでも可能です。

関連する記事