技術記事

HotXLSでのグラフと画像:DelphiでのExcel描画オブジェクト

ワークシートのグリッド上に浮かぶもの(グラフ、ロゴ、スタンプ、コールアウトボックスなど)はすべて描画オブジェクト(Drawing Object)であり、これらは「何であるか」と「どこに固定(アンカー)されているか」の2点によって定義されます。アンカーは開発者が最も誤りやすい部分です。グラフはセル内に存在するのではなく、特定の行と列の範囲に固定された矩形領域に配置され、グラフがプロットするデータは、アンカーが何も感知していない独立したA1参照のセットです。フレーム(枠枠)を移動してもプロットデータは動きません。その下に行を挿入すると、フレームはそれらの行と一緒に下にスライドします。これら2つの座標系を混同しないようにすることが、描画処理を適切に機能させるための要点です

HotXLSは、Excelのオートメーションを使用せずにXLSおよびXLSXの読み書きを行うネイティブなObject Pascalライブラリですが、これら2つのファイル形式では描画データの保存方法が異なるため、2つの異なる描画モデルを保持しています。BIFF8 .xls 形式は、グラフを専用の独立したシート(グラフシート)に配置し、浮かぶ図形はワークシートに添付された OfficeArt ストリームに格納します。一方、OOXML .xlsx 形式は、グラフをグリッド内部に埋め込んでセルの矩形範囲にアンカーすることができ、浮かぶ画像や図形も同様に処理できます。オブジェクトモデルはこの違いをそのまま反映しており、不具合のほとんどは、一方のフォーマットのルールをもう一方に適用しようとした場合に発生します

どのコンテナが何を保持できるか

使用可能なオブジェクトのタイプはフォーマットによって異なるため、グラフ処理を記述する前にコンテナの選択を行う必要があります

  • XLS (BIFF8): グラフは、Sheets コレクションの AddChartSheet を介して作成される専用のグラフシート上に存在します。画像、テキストボックス、矩形、楕円、および直線は、ワークシートの Shapes コレクションを介して管理される OfficeArt 図形です。通常のワークシートのグリッド内にグラフを埋め込むためのAPIはありません
  • XLSX (OOXML): グラフは、セルの矩形範囲にアンカーされて通常のワークシート内に直接埋め込む(TXLSXWorksheet.AddChart)か、または専用のグラフシート(TXLSXWorkbook.AddChartSheet)に配置することができます。画像は AddImage または AddImageFromFile を介して、浮かぶラベルは AddTextBox を介して追加されます

そのため、「数値の隣にグラフがあるダッシュボードシート」という要件は、実質的に .xlsx を対象とすることになります。.xls においてこれを再現するには、グラフを独立した専用シートに逃がすしかありませんが、これはユーザーのファイル操作性やコードの動作仕様を変更することになります。XLS側の AddChartSheet から返されるシートはグリッドではなくグラフ専用のサブストリーム(Substream)であるため、これに対して Cells.Item を呼び出してセルデータを書き込むと、整合性のとれていない描画ストリームが生成され、Excelで開いた際に破棄されてしまいます。グラフは警告なしに消滅し、ビルドログにもその理由は記録されません。返されたシートはグラフ専用として扱うことで、「グラフが表示されない」という不具合報告のほとんどを防ぐことができます

XLSXワークシートへのグラフの埋め込み

XLSXはより柔軟に対応できるパスであり、冒頭で述べた2つの座標系が具体化する場所です。AddChart に渡されるアンカー矩形は、ワークシートの行と列で表現され、グラフフレームの配置場所を固定します。系列データ(Series Data)は、シート名を含む絶対A1参照として表現されます。これらは独立しています。フレームをシートの別の場所に移動しても、同じセルデータをプロットし続けます

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Chart: TXLSXChart;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Sales');
    Sheet.Cells[1, 1].Value := 'Region';
    Sheet.Cells[1, 2].Value := 'Revenue';
    Sheet.Cells[2, 1].Value := 'East';
    Sheet.Cells[2, 2].Value := 1184350;
    Sheet.Cells[3, 1].Value := 'Central';
    Sheet.Cells[3, 2].Value := 902210;
    Sheet.Cells[4, 1].Value := 'West';
    Sheet.Cells[4, 2].Value := 1010675;

    // Frame anchored to rows 6..22, columns 1..8
    Chart := Sheet.AddChart(xlsxChartColumn, 'Revenue by Region', 6, 1, 22, 8);
    Chart.AddSeries('Revenue', 'Sales!$A$2:$A$4', 'Sales!$B$2:$B$4');
    Chart.ValueAxisTitle := 'USD';

    Sheet.AddImageFromFile(1, 5, 'logo.png');
    Book.SaveAs('dashboard.xlsx');
  finally
    Book.Free;
  end;
end;

注意が必要なのは、AddSeries に渡される範囲指定の文字列です。これは呼び出し時点の状態で解釈されるリテラル文字列であり、後からデータ行が追加されても自動的には追従しません。範囲文字列は、必ずデータが書き込まれた後に算出された行数に基づいて構築し、事前に固定しないでください。散布図やバブルチャートは、これと同じ2つの引数を異なる意味で使用します。カテゴリー範囲が X 値、値の範囲が Y 値を供給し、バブルの半径は TXLSXChartSeriesBubbleSizeRange プロパティを介して指定される3番目の参照から供給されます。縦棒や横棒グラフのファミリーを離れたら、これらの引数を「カテゴリー、値」ではなく「X、Y、サイズ」として解釈してください

TXLSXChartType は、縦棒、横棒、折れ線、円、面、ドーナツ、散布図、バブル、およびレーダーチャートをカバーしており、一般的なレポーティング要件に十分対応できます。周囲にグリッドを持たないフルページのグラフシートを作成するには、Book.AddChartSheet を使用します。これにより、IsChartSheet プロパティが true に設定されたシートが返されます。これはレガシーなグラフシートの .xlsx 版であり、同様にセルデータを書き込んではならないという前提が適用されます

画像はバイト配列として格納され、EMU単位でサイズ指定される

画像を挿入するためのオーバーロードは2つあり、これらを混同することは、コードレビューで最も多く見つかる画像関連のバグです。AddImage(ARow, ACol, AData, AFormat) は、すでにエンコードされた画像の生バイトデータ(PNG、JPEG、GIF、または BMP の中身)を AData に要求します。ここに誤ってファイルパス文字列を渡してしまうと、画像ではなくデコード不可能なファイルパスの文字列データが格納されてしまい、デプロイ後にバツ印(画像リンク切れ)が表示される不具合につながります。ファイルから画像を読み込む場合は、代わりに AddImageFromFile を呼び出して、ライブラリ側でバイトデータを読み込んでフォーマットを自動判別させてください

次にサイズ指定です。DrawingMLはピクセル単位ではなく、EMU(English Metric Units)単位で測定します。1インチは 914400 EMU、96 DPI の環境では1ピクセルは 9525 EMU に相当します。TXLSXImage オブジェクトは WidthEMU および HeightEMU プロパティを公開しているため、例えば 180×60 ピクセルでレンダリングしたいロゴ画像は、1714500×571500 EMU に変換して設定する必要があります。この変換は名前付き定数として保持し、それに基づいて計算してください。コード内に 1714500 のようなマジックナンバーを直接散りばめてしまうと、コードが読みにくくなるだけでなく、ターゲットDPIが変更された際に静かに狂ってしまいます。なお、アンカーの行と列は1から始まり、0から始まるEMUの計算とは異なり、セルのAPI仕様と一致しています

レガシーなXLSファイルにおけるグラフシートと図形

BIFF8側では、引数の多い AddChartSheet オーバーロードが使用されます。これは、グラフタイプ、軸のタイトル、および各レコードが名前、カテゴリー範囲、値の範囲を文字列として保持する TXLSChartSeriesInfo レコードのオープン配列を受け取ります。浮かぶ図形は別の処理になります。これらはグラフシート上ではなく、データシート自体の Shapes コレクションを介して追加します

var
  Book: IXLSWorkbook;
  Data, Trend: IXLSWorksheet;
  Series: array[0..0] of TXLSChartSeriesInfo;
begin
  Book := TXLSWorkbook.Create;   // interface-counted: do not Free
  Data := Book.Sheets.Add;
  Data.Name := 'Data';
  Data.Cells.Item[1, 1].Value := 'Month';
  Data.Cells.Item[1, 2].Value := 'Units';
  Data.Cells.Item[2, 1].Value := 'Apr';
  Data.Cells.Item[2, 2].Value := 1530;
  Data.Cells.Item[3, 1].Value := 'May';
  Data.Cells.Item[3, 2].Value := 1721;

  Series[0].Name := 'Units';
  Series[0].Categories := 'Data!$A$2:$A$3';
  Series[0].Values := 'Data!$B$2:$B$3';
  Trend := Book.Sheets.AddChartSheet('Trend', xlsChartTypeLine,
    'Units sold', 'Month', 'Units', Series);
  // Trend is a chart substream: never call cell methods on it

  Data.Shapes.AddTextBox('Source: ERP nightly export', 6, 1, 8, 4);
  Data.Shapes.AddPicture('approved-stamp.bmp');
  Book.SaveAs('trend.xls');
end;

ここではオブジェクトの生存期間(ライフタイム)に関する2つの詳細が重要であり、これらは逆の方向に働きます。TXLSWorkbookIXLSWorkbook インターフェースを介して参照カウントで管理されるため、自分で Free を呼び出すと二重解放(Double Release)を引き起こします。一方、前述のセクションの TXLSXWorkbook は通常のオブジェクトであるため、try..finally ブロック内で確実に Free を呼び出す必要があります。同じコードレビューにおいて、XLSX側での Free の欠落を指摘する一方で、XLS側での Free の存在も指摘しなければならず、これは同じユニット内で両方のフォーマットを扱う際の罠になります。図形作成ヘルパー自体は共通であり、AddRectangleAddOval、および AddLine、さらに指定した範囲の図形を一括削除する DeleteInRange などが提供され、すべて行と列のペアでアンカーされるため、テンプレート上に行が挿入された場合はグリッドの移動に追従します

レガシーファイルで役立つもう1つのプロパティは、TXLSPicture.TransparentColor です。これは、ビットマップから指定された背景色をマスク(透過)処理するものであり、BIFFのレンダリングがPNGの透過アルファチャンネルに対応していない古いフォーマットにおいて、矩形ではないスタンプ(「承認」印や透かしなど)をグリッド上にきれいに配置するために使用されます。スタンプ画像が作成された背景色を設定すれば、周囲の背景矩形は消え去ります

テーマカラーはBIFF8の往復処理で維持されない

OOXMLの塗りつぶしはテーマカラーのスロットを指定できるため、テーマを差し替えるだけで .xlsx ファイル全体の色調を変更できます。しかし、BIFF8の描画レコードにはそのようなテーマスロットはありません。HotXLSがテーマカラーを XLS の描画オブジェクトに適用する際、カラーは実際のリテラルRGB値に解決されて保存されます。その色がどのテーマインデックスに由来していたかという情報はファイルが書き出された瞬間に消失するため、ファイルを開き直しても復元できません。これは特に、同じ生成ドキュメントを顧客ごとにブランド変更(ホワイトラベル化)して出力するツールにおいて注意が必要です。テーマとRGB値のマッピングはアプリケーション自身のコード(または設定ファイル)で維持し、エクスポートのたびに適用するようにしてください。保存された .xls からそれを読み戻すことはできません

これに関連する判断はパフォーマンス面でも現れます。大規模なレガシーファイルからセルデータのみを読み込みたい場合は、_DisableGraphics を true に設定することで、描画レイヤーの解析を完全にスキップして読み込み時間を大幅に短縮できます。しかし、その制限は永続的です。この方法で開かれたワークブックはメモリ上に OfficeArt ストリームを保持しないため、ファイルを再保存すると、元のファイルに含まれていた図形や画像は完全に消失します。このフラグは読み取り専用の集計処理などに限定して使用してください。パフォーマンスの全体像については、HotXLSにおける大規模ワークブックのパフォーマンスに関する記事を参照してください

グリッド変更時にアンカーの安定性を維持する方法

レポートのデータ数が常に固定であるとは限りません。ここで冒頭のアンカーモデルが活きてきます。XLSXファサードの構造的編集操作(InsertRowsDeleteRows、および対応する列操作)は、セルデータと一緒にそれに付随するレイヤーも移動させます。結合範囲、ハイパーリンク、コメント、ウィンドウ枠の固定、オートフィルター範囲、条件付き書式、データの入力規則、テーブル、定義された名前、画像やグラフのアンカーもすべて一緒に移動します。行1に固定されたロゴ画像は、その下に10行挿入されても一番上に留まります。データブロックの下に配置されたグラフフレームは、ブロックが大きくなるにつれて自動的に押し下げられます。ただし、挿入が実行される前にリテラル文字列として組み立てられた数式内の範囲指定は、自動的には書き換えられません。これは単なる文字列データであり、ライブラリがそこまで解析して修正する理由がないためです。したがって、テンプレートにデータを流し込む際の安全な順序は、「まずデータを書き込んで行挿入などの変形を完了させ、その後にグラフの作成や画像の配置を行い、範囲指定の文字列はすべて挿入完了後の行数から算出する」というものです

配置用の小さなツールがさらに2つあります。XLS側における TXLSTextBox.SetArea は、既存のテキストボックスやオートシェイプを新しいセルの矩形範囲に再アンカー(再配置)します。これはフッターブロックがシフトした際にオブジェクトを削除して再作成するよりもスマートです。また、AddPicture のビットマップ版オーバーロードは、透過フラグ付きの生の TBitmap を直接受け取ることができるため、VCLのコードが描画できるもの(インジケーター、スパークライン、または標準の一覧にないグラフタイプなど)を、一時ファイルを経由することなく直接シートに描画して配置できます

グラフや画像は、ほぼ常に構築済みのレポートに対する仕上げのレイヤーです。そのため、データの準備段階でそれらがきれいに配置できるかが決定されます。グラフが参照するデータの流し込みについてはテンプレート駆動のレポート生成で説明されており、アンカーの下のグリッドを安定して維持する方法については結合セルとレイアウト制御に関する記事で説明されています。完全なクラスおよびメソッドのドキュメントは、HotXLS Componentの製品ページで公開されています