技術記事

HotPDFプリミティブでPDFにチャートを描く(Delphi)

スクリーンのチャートコントロールから 96 DPI のビットマップとして取り出したチャートは、PDF ビューアの 100% ズームでは上等に見えます。400% にズームするか、600 DPI のレーザープリンタへ送ると、バーの縁は柔らかくなり、軸ラベルは灰色のにじみへ溶け、本当に1ダースの矩形でしかないもののために、ファイルは数百キロバイトのピクセルを運びます。ベクタパスとして描けば、同じチャートはすべてのズーム段階と印刷解像度でシャープなままです。バー、軸、ラベルが PDF のパス演算子とテキスト演算子であり、ビューアが必要とするスケールで何度でも描き直すからです

ベクタ出力は、レイアウトの仕事をコードへ移します。バー、目盛、ラベルがどこに着地するかを計算する何かが必要で、棒、折れ線、円グラフに対するその計算は、1つの座標マッピングと、矩形、線分、円弧の上のいくつかのループです。マッピングを一度正しくすれば、ループは短く済みます

DrawChartか、HotPDFで手描きのプリミティブか

HotPDF には完成品のチャートレンダラーが同梱されており、標準的なレポートチャートなら、まず試すべきはこれです。THPDFChart を組み立て、AddSeries で系列を追加し(種類は hckLine、hckBar、hckArea、hckPie、hckScatter)、AddPoint で値を入れ、THPDFPage.DrawChart を目標の矩形とともに呼びます。レンダラーは軸の範囲、グリッド、目盛ラベル、凡例、テーマカラーを自分で処理し、構造化タグの親を受け取るオーバーロードは、データから作った代替テキスト付きのタグ付き図としてチャートを書き出します。文書がアクセシビリティ検査を通らねばならないなら、重要な話です

var
  Chart: THPDFChart;
  Sales: THPDFChartSeries;
begin
  Chart := THPDFChart.Create;
  try
    Chart.Title := 'Quarterly sales';
    Chart.LegendPosition := hclBottom;
    Sales := Chart.AddSeries(hckBar, 'Sales');
    Sales.AddPoint(1, 420, 'Q1');
    Sales.AddPoint(2, 515, 'Q2');
    Sales.AddPoint(3, 388, 'Q3');
    Sales.AddPoint(4, 610, 'Q4');
    // ページ単位の矩形: left、top、width、height
    HotPDF1.CurrentPage.DrawChart(Chart, 60, 120, 470, 280);
  finally
    Chart.Free;
  end;
end;

ただし、このレンダラーの語彙は固定です。複数の棒系列は各カテゴリの中で並んで座り、円グラフはチャート内の唯一の系列でなければならず、見た目は3つのテーマと系列ごとの色から来ます。積み上げ棒、バーの背後に影を落とした目標帯、1つの系列の中でバーごとに違うブランドカラー、12時の方向から時計回りに走る円グラフ。設計がこのどれかを求めた瞬間、レンダラーをねじ曲げるよりも、ページキャンバスで自分でチャートを描くほうが手間は少なくなります。それらのプリミティブは Rectangle、MoveTo、LineTo、Pie、Fill、Stroke、TextOut で、キャンバス描画のウォークスルーが扱うのと同じ呼び出しです

なぜ棒グラフが上下逆に描画されるのか

HotPDF のページ座標は、原点を左上コーナーに置き、Y を下向きに伸ばします。VCL キャンバスと同じ枠組みです。PDF のユーザー空間自体は逆に走ります(原点は左下、Y は上向き、ISO 32000-1 §8.3.2.3)。HotPDF はコンテンツストリームを書くときに、すべての座標をあちらの空間へ反転させます。一方、チャートの値は上向きに伸びます。スケール済みの値をそのまま Y に流し込むと、一番高いバーは、ツララのようにプロットの天井から吊り下がります

直し方は1つの式です。プロット矩形を選び、ゼロをその下辺に、スケールの上端をその上辺に写し、すべての値を、ページに触れさせる前にそのマッピングへ通します。0 から MaxValue のスケール上の値 V に対して、ページの Y は Plot.Bottom - (V / Plot.MaxValue) * Plot.Height です。Plot.Bottom がベースラインです。後ほど出てくる棒グラフのコードはこれを小さな ValueToY ヘルパーに包み、他のルーチンはすべてそれを再利用します

HotPDF: スクリーン座標と HotPDF ページ座標は、Y が下向きに伸びる左上原点を共有する。チャートの値は上向きに伸び、ValueToY がデータ値をその Y 下向きのページ空間へ反転させる
HotPDF は見慣れたスクリーンの枠組み、つまり左上原点と下向きの Y を保ちます。1つの ValueToY 式が、上向きに伸びるすべてのデータ値をページ空間へ反転させるのです

あの式には2つの選択が隠れています。MaxValue は、最大データ点より上の丸い数字、次の 10 か 100 の倍数にすべきです。そうすれば一番高いバーが上辺に触れず、グリッド線のラベルは 0、203.5、407 ではなく 0、200、400 と読めます。そしてスケールは意図的にゼロから始まります。バーの長さが値そのものなので、350 から始まる値軸は、実際の差が 8% 未満なのに、388 のバーを 420 のバーの半分ちょっとの高さに見せます。組み込みレンダラーが棒と面の系列でゼロを範囲に引き込むのは、まさにこの理由です。手描きのコードでも同じ規則を守ってください

マッピングのもう半分は単位です。既定の Resolution が 72 なら、1ページ単位は 1 PDF ポイント(1/72 インチ)です。解像度を変えれば、座標、線幅、テキスト計測はすべて新しい単位へ切り替わります。詳細はResolutionと描画単位の記事が扱っています。DrawChart は目標矩形をページ単位で受け取りますが、独自のマージン、フォントサイズ、線幅はどの解像度でもポイントで処理します

棒グラフは矩形のループである

マッピングが整えば、棒グラフは1つのループです。プロット幅をカテゴリごとのスロットに分け、隣のバーと触れ合わないよう隙間を残し、各バーを、ValueToY の上端からベースラインまでの塗りつぶし矩形として描きます。Rectangle は左上コーナーと幅と高さを受け取るため、このマッピングにそのまま嵌まります

HotPDF: 棒グラフの解剖図。プロット幅からスロットが分けられ、バーの間に隙間があり、各バーは Plot.Bottom から伸びる塗りつぶしの Rectangle、ベースラインの下にカテゴリラベル、バーの高さに乗る値ラベル、そして ValueToY を再利用する軸の目盛
各バーはスロットの中で Plot.Bottom から伸びる1つの塗りつぶし Rectangle で、1/4 スロットの隙間は幅に比例し、軸の目盛は ValueToY を再利用するため、バーとグリッド線が一致します
type
  TPlotArea = record
    Left, Bottom, Width, Height: Single;  // ページ単位。Bottom がベースラインの Y
    MaxValue: Single;                     // 値スケールの上端
  end;

// 自前のヘルパー: 大きい値ほど高く置かれる。Y が下向きのページでは
// Y が小さくなるということ
function ValueToY(const Plot: TPlotArea; V: Single): Single;
begin
  Result := Plot.Bottom - (V / Plot.MaxValue) * Plot.Height;
end;

procedure DrawBarChart(Page: THPDFPage; const Plot: TPlotArea;
  const Values: array of Single; const Labels: array of string;
  const BarColors: array of TColor);
var
  I, Count: Integer;
  SlotW, BarW, BarX, BarTop, Gap, GridV, GridY: Single;
begin
  Count := Length(Values);
  SlotW := Plot.Width / Count;
  Gap := SlotW * 0.25;              // スロットの 1/4 を左右に振り分ける
  BarW := SlotW - Gap;

  // 破線のグリッド線を先に。バーがその上に塗られる
  Page.SetLineWidth(0.5);
  Page.SetRGBStrokeColor(RGB(200, 200, 200));
  Page.SetDash([2, 2], 0);
  GridV := Plot.MaxValue / 4;
  while GridV <= Plot.MaxValue do
  begin
    GridY := ValueToY(Plot, GridV);
    Page.MoveTo(Plot.Left, GridY);
    Page.LineTo(Plot.Left + Plot.Width, GridY);
    Page.Stroke;
    GridV := GridV + Plot.MaxValue / 4;
  end;
  Page.NoDash;

  Page.SetFont('Arial', [], 9);
  for I := 0 to Count - 1 do
  begin
    BarX := Plot.Left + I * SlotW + Gap / 2;
    BarTop := ValueToY(Plot, Values[I]);

    Page.SetRGBFillColor(BarColors[I mod Length(BarColors)]);
    Page.Rectangle(BarX, BarTop, BarW, Plot.Bottom - BarTop);
    Page.Fill;

    // TextOut はテキストの上端を Y に置く
    Page.SetRGBFillColor(clBlack);
    Page.TextOut(BarX, Plot.Bottom + 4, 0, Labels[I]);
    Page.TextOut(BarX, BarTop - 12, 0, FormatFloat('0', Values[I]));
  end;

  // ベースラインは最後に。バーの下端の上に乗る
  Page.SetLineWidth(1.0);
  Page.SetRGBStrokeColor(clBlack);
  Page.MoveTo(Plot.Left, Plot.Bottom);
  Page.LineTo(Plot.Left + Plot.Width, Plot.Bottom);
  Page.Stroke;
end;

ここでの順序が、レイヤリングのモデルすべてです。PDF はペインターのモデルを使い、コンテンツストリームの後の演算子が先の演算子の上に塗るため、グリッド線が先、バーがその上、ベースラインが最後です。グリッド線のブロックをループの下へ移すと、破線がすべてのバーの真ん中を横切ります

隙間は、固定ポイント数ではなくスロットの端数です。470 ポイントのプロットに4カテゴリなら、各スロットは 117.5 ポイント幅で、バーの間には約 29 ポイントの空気が入ります。40カテゴリなら、同じコードは 3 ポイント未満しか残しませんが、それでもバーは別々に読めます。固定の 12 ポイント隙間では、40カテゴリの時点で 470 の利用可能ポイントのうち 480 を呑み込み、バー幅は負になります

ぶつからない軸ラベル

ラベルは、手描きチャートが最初に壊れる場所であり、ほとんどいつも同じ壊れ方をします。画面では収まっていたテキストが、ページでは自分のスペースをはみ出します。開発者が「Sep」で試したのに、ドイツのレポートは 9 ポイントのフォントで「September」や「Dezember」を印刷するからです。値軸には同じ問題の静かな変種があります。固定 X オフセットの左寄せ目盛ラベルは、数字の長さが変わった途端にガタつき、「1,200」のラベルは「800」が余らせていた軸線に食い込みます

推測ではなく、計測してください。TextWidth は現在のフォントでの文字列の幅をページ単位で返し、TextHeight は現在のサイズでのフォントのアセントを返します。各目盛ラベルを軸に対して右寄せし、目盛の中央に置きます

procedure DrawValueAxis(Page: THPDFPage; const Plot: TPlotArea;
  TickCount: Integer);
var
  I: Integer;
  TickV, TickY: Single;
  S: string;
begin
  Page.SetLineWidth(1.0);
  Page.MoveTo(Plot.Left, Plot.Bottom);
  Page.LineTo(Plot.Left, Plot.Bottom - Plot.Height);
  Page.Stroke;

  Page.SetFont('Arial', [], 8);
  for I := 0 to TickCount do
  begin
    TickV := (Plot.MaxValue / TickCount) * I;
    TickY := ValueToY(Plot, TickV);
    Page.MoveTo(Plot.Left - 4, TickY);        // 軸の外側の短い目盛
    Page.LineTo(Plot.Left, TickY);
    Page.Stroke;

    S := FormatFloat('#,##0', TickV);
    Page.TextOut(Plot.Left - 7 - Page.TextWidth(AnsiString(S)),
      TickY - Page.TextHeight(AnsiString(S)) / 2, 0, S);
  end;
end;

カテゴリラベルには、同じ計測が、横書きテキストを諦めるタイミングを教えてくれます。何かを描く前に、最も幅の広いラベルをスロット幅と比べてください。収まらなければフォントを1、2ポイント落とします。それでも収まらなければ、TextOut の角度引数に 90 を渡してラベルを立て、最長のものが入るだけのマージンをベースラインの下に残します。レポートが実際に生成し得る最も幅の広いラベルでテストしてください。ローカライズされたビルドの月名なら、英語版であることはめったにありません

折れ線グラフは同じマッピングを再利用する

折れ線グラフは、点のつなぎ方だけが違います。データを一度歩き、各値を ValueToY で変換し、点を1つのパスへ縫い合わせます。最初の点は MoveTo、その後はすべて LineTo、最後に Stroke の1回です。点をすでに配列で持っているなら、Polyline が array of THPDFCurrPoint を受け取り、新しいパスを開き、ストロークはこちらに任せます。2点未満では例外を上げるため、読み値が1つだけの系列は、線ではなくマーカーが必要です

間隔の取り方は、同じプロットの上に描いた棒グラフと折れ線グラフがずれる場所です。バーは幅をカテゴリ数で割ります。各バーがスロットを所有するからです。折れ線は幅を区間数、つまり Count - 1 で割ります。最初と最後の点がプロットの端に座るからです。この2つを重ねると、折れ線の点はバーの中心ではなくスロットの境界に着地します。折れ線をバーの上に乗せるなら、その点は各スロットの中心、Plot.Left + I * SlotW + SlotW / 2 に置き、Count - 1 の間隔は完全に捨ててください。各点にマーカーを付けるなら、線をストロークした後で小さな Circle に Fill を続ければ用が足ります

円スライスはPieで、12時の方向から時計回りに

円の扇形は、2本の半径と1つの円弧です。手作りするなら、中心から始め、短い LineTo セグメントを縁に沿って数度おきに歩かせ、中心へ閉じる、というのが下の図の作り方です。動きますが、270 度のスライスは100を優に超えるパスセグメントになり、縁は強いズームの下では依然ポリゴンのままです

HotPDF: 円スライスの構築。円の中心で MoveTo から始め、各スライスのスイープ角にわたる円弧に沿って約2度間隔の小さな LineTo セグメントを歩かせ、中心へ閉じて Fill がベタの扇形を塗る
扇形は中心の MoveTo から始まり、円周のうちの自分のスイープに沿って約2度間隔の小さな LineTo の歩みを進め、閉じることで Fill がベタに塗ります

代わりに Pie を使ってください。THPDFPage.Pie は GDI の流儀に従います。バウンディング矩形、開始角、終了角は度で、0 は東、90 は北、正の角度は反時計回りです。円弧を最大90度のベジエセグメントから組み立て、中心への線を描き、パスを閉じるため、縁は真の曲線のままになり、完全なスライスが一握りの演算子で済みます。業務の円グラフはたいてい 12 時の方向から時計回りに始まります。GDI 流に言えば、90 から始めて角度を下へ歩かせるということです

procedure DrawPieChart(Page: THPDFPage; CX, CY, Radius: Single;
  const Values: array of Double; const Colors: array of TColor);
var
  I: Integer;
  Total, Sweep, Angle: Double;
begin
  Total := 0;
  for I := 0 to High(Values) do
    if Values[I] > 0 then
      Total := Total + Values[I];
  if Total <= 0 then
    Exit;

  Angle := 90;                            // 12時の方向
  Page.SetRGBStrokeColor(clWhite);
  Page.SetLineWidth(0.8);
  for I := 0 to High(Values) do
  begin
    if Values[I] <= 0 then
      Continue;                           // 下記を参照: ゼロや負のスイープは絶対に渡さない
    Sweep := Values[I] / Total * 360;
    Page.SetRGBFillColor(Colors[I mod Length(Colors)]);
    // Angle - Sweep から Angle への反時計回りが、現在位置の
    // 時計回り側に座るスライス
    Page.Pie(CX - Radius, CY - Radius, CX + Radius, CY + Radius,
      Angle - Sweep, Angle);
    Page.FillAndStroke;                   // 白いストロークがスライスを分ける
    Angle := Angle - Sweep;
  end;
end;

Continue のガードは、最も重要な行です。Pie は、開始角より小さい終了角を、360 を超えて回り込むスイープとして扱います。GDI 流の読み方で、これこそスライスが0度の線をまたげる理由です。ソースデータの返金行や訂正エントリは負の値を与え、計算された終了角は開始角を下回り、スライスは、それ以前に描かれたすべての上に塗られる、ほぼ満円の円盤へ回り込みます。ゼロ値は穏やかですが、それでも目に付きます。閉じたパスは1本の半径へ潰れ、白い仕切りストロークが、縁から中心への迷子の線としてそれを描きます。両方ともループの前でふるい落とし、負の金額をレポートがどう見せるかは別途決めてください。円グラフはそれをまったく見せられないのですから

設計が円を強要しないなら、1本の水平の積み上げ棒が、同じ構成比を、端から端まで並べた Rectangle 呼び出しだけで示します。読み手は角度より長さのほうを正確に比べられます。5、6カテゴリを超える部分と全体の図なら、こちらのほうが良いチャートで、上の棒グラフのコードがその大部分をすでにこなしています

この記事のすべてのチャートは、HotPDF Delphi Component のページキャンバス呼び出しから組み立てています。標準の折れ線、棒、面、円、散布図のレイアウトには DrawChart も使えます。そしてそのすべてが、ビルドマシンにチャートライブラリをインストールせずに、Delphi か C++Builder から走ります