技術記事

HotPDFプリミティブを使用したPDFでのグラフの描画

HotPDFにはグラフオブジェクトがありません。TPDFChartも、AddBarSeriesもなく、数値の配列を受け取ってレンダリングされたグラフを返すようなものはありません。その代わりに提供されるのは、すべてのPDF描画モデルが使用するのと同じ低レベルの語彙(長方形、線、円、塗りつぶし、ストローク、および正確な座標に配置されたテキスト)を備えたページキャンバスです。したがって、HotPDFドキュメントのグラフは、リクエストするものではなく、自分で構築するものです。それは思っているよりも多くの作業のように聞こえるかもしれません。しかし、座標の計算を一度書いてしまえば、棒グラフは長方形のループであり、折れ線グラフはポリラインであり、円グラフは円弧の扇形になり、結果のすべてのピクセルを制御できます

なぜこれが重要なのかというと、人々が最初に手を伸ばしがちな代替案、つまり画面のグラフコントロールをビットマップにラスタライズしてその画像をページに貼り付ける方法は、画面の解像度に固定されたグラフを提供するため、印刷がぼやけ、ファイルが肥大化するからです。HotPDFのベクタープリミティブを使用してグラフを描画すると、バーと軸がピクセルではなく実際のPDFパス演算子であるため、どのズームや印刷DPIでも出力が鮮明に保たれます。その代償として、レイアウトを自分で管理する必要があります。その仕組みはいくつかの操作に集約されます:誰もがつまずく1つの座標の反転、機能する棒グラフ、折れ線グラフのためのポリラインのトリック、そして円グラフのスライスのための円弧の計算です

唯一難しい部分は座標系です

画面グラフィックスは原点を左上に置き、Yが下に向かって成長します。PDFはその逆を行います。原点はページの左下隅にあり、Yは上に向かって成長し、ポイント(1/72インチ)で測定されます。HotPDFのすべての描画呼び出し、TextOutRectangleMoveToLineToCircleは、その左下、Y-upの規則を使用します。画面グラフィックスの感覚を持ち込むと、最初のグラフは上下逆に描画され、ページの下端から外れてしまいます

したがって、すべてのグラフにおける実際の作業は1つのマッピングです。データ値を、Y-upを尊重するY座標に変換します。プロットの長方形、グラフが存在する領域のための4つの数値を決定し、データの最小値を下端に、最大値を上端にマッピングします。0からMaxValueまでのスケール上の値Vのバーの場合、バーの上端はPlotBottom + (V / MaxValue) * PlotHeightであり、バーはPlotBottomから上に向かって成長します。その1つの式を正しくすれば、あとは簿記のようなものです。以下のヘルパーはプロットのジオメトリを保持し、変換を行うため、描画コードが生の計算に2度触れることはありません:

type
  TPlotArea = record
    Left, Bottom, Width, Height: Single;  // PDF points, bottom-left origin
    MaxValue: Single;                     // top of the value scale
  end;

// Map a data value to its Y coordinate inside the plot, Y growing upward.
function ValueToY(const Plot: TPlotArea; V: Single): Single;
begin
  Result := Plot.Bottom + (V / Plot.MaxValue) * Plot.Height;
end;

MaxValueには1つの判断が隠されています。それを正確な最大データポイントに設定すると、最も高いバーがプロットの上端に触れ、切り取られているように見えます。それを最大値を超えるきれいな数字、たとえば次の10または100の倍数に切り上げることで、グラフにヘッドルームができ、グリッド線のラベルは、データがたまたまピークに達したものではなく、丸い数字として読み取られます

棒グラフは長方形のループです

マッピングが解決すれば、棒グラフはそれ自体を書き込みます。プロットの幅をカテゴリごとに1つのスロットに分割し、バーが触れないようにバーの間にギャップを残し、各バーを塗りつぶされた長方形として描画します。その高さはValueToYから得られます。HotPDFのRectangleは、左下隅と幅および高さを受け取ります。これは、ベースラインから上に向かって成長するバーと正確に一致します。最初に塗りつぶし色を設定し、パスを配置してから、Fillを呼び出してペイントします。カテゴリラベルはベースラインの下に、値はバーの上に配置されます:

procedure DrawBarChart(Page: THPDFPage; const Plot: TPlotArea;
  const Values: array of Single; const Labels: array of string);
var
  I, Count: Integer;
  SlotW, BarW, BarX, BarH, Gap: Single;
begin
  Count := Length(Values);
  SlotW := Plot.Width / Count;
  Gap := SlotW * 0.25;          // quarter-slot gap on each side
  BarW := SlotW - Gap;

  // Baseline (the X axis) along the bottom of the plot.
  Page.SetLineWidth(1.0);
  Page.MoveTo(Plot.Left, Plot.Bottom);
  Page.LineTo(Plot.Left + Plot.Width, Plot.Bottom);
  Page.Stroke;

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

    Page.SetRGBFillColor(RGB(56, 110, 219));
    Page.Rectangle(BarX, Plot.Bottom, BarW, BarH);  // X, Y, Width, Height
    Page.Fill;

    // Category label below the baseline, value above the bar.
    Page.SetRGBFillColor(clBlack);
    Page.TextOut(BarX, Plot.Bottom - 14, 0, Labels[I]);
    Page.TextOut(BarX, Plot.Bottom + BarH + 4, 0,
      FormatFloat('0', Values[I]));
  end;
end;

2つの詳細がその価値を証明します。Gapは固定のポイント数ではなくスロットの割合であるため、4つのカテゴリをプロットしても40のカテゴリをプロットしても、バーは比例した間隔を保ちます。また、値のラベルはバーが使用するのと同じValueToYから派生した高さで配置されるため、推測されたオフセットに浮かぶのではなく、常に自身のバーのすぐ上に位置します。バーの後ろに水平なグリッド線が必要な場合は、ループの前に描画します。3つまたは4つの丸い値を選択し、それぞれにValueToYを実行し、そのYでプロットを横切るかすかな線をストロークします。最初にそれらを描画することで、PDFが使用するペインターモデルのスタックでバーの後ろに配置されます

軸、目盛り、およびラベルは、さらに多くの線とテキストに過ぎません

読者がバーが何を意味するかを理解できるようになるまでグラフは完成しません。それは完全に軸の作業です。垂直軸は、プロットの左端に沿ってストロークされた1本の線であり、いくつかの目盛りとその値があります。ValueToYを再利用して、目盛りがバーが使用するのと同じスケールに着地するようにします。そうしないと、バーとそのグリッド線が一致せず、グラフが静かに嘘をつくことになります:

procedure DrawValueAxis(Page: THPDFPage; const Plot: TPlotArea;
  TickCount: Integer);
var
  I: Integer;
  TickV, TickY: Single;
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);   // short tick outside the axis
    Page.LineTo(Plot.Left, TickY);
    Page.Stroke;
    Page.TextOut(Plot.Left - 30, TickY - 3, 0, FormatFloat('0', TickV));
  end;
end;

ラベルは本番環境でグラフが最も頻繁に壊れる場所であり、その失敗は常に同じです:画面に収まっていたテキストがPDF内のスペースをオーバーランします。長いカテゴリ名が隣のものと衝突し、「septembre」や「Dezember」などのローカライズされた月名は、テストした英語の「Sep」よりも広くなります。ここであなたを救う自動サイズ調整はないので、ベースラインの下に十分なマージンを残し、密集したカテゴリセットにはフォントを1〜2ポイント縮小し、名前が本当に長い場合は回転させます。TextOutは3番目の引数として角度を受け取るため、90を渡すとラベルが端に立ち、重ならずにスペースを確保できます。エクスポートを出荷する前に、最も短いラベルではなく、予想される最も広いラベルでレイアウトをテストしてください

折れ線グラフ:マッピングされたポイントを通る1つのポリライン

折れ線グラフは、全体の値のマッピングを再利用し、ポイントの接続方法のみを変更します。カテゴリごとの長方形の代わりに、データを1回歩き、各値をValueToYでその(X、Y)に変換し、単一のMoveToに続くLineTo呼び出しでポイントを縫い合わせ、最後にストロークします。最初のポイントがパスを開き、その後のすべてのポイントがそれを拡張します:

procedure DrawLineChart(Page: THPDFPage; const Plot: TPlotArea;
  const Values: array of Single);
var
  I, Count: Integer;
  StepX, X, Y: Single;
begin
  Count := Length(Values);
  if Count < 2 then Exit;
  StepX := Plot.Width / (Count - 1);

  Page.SetLineWidth(1.5);
  Page.SetRGBStrokeColor(RGB(214, 92, 36));
  for I := 0 to Count - 1 do
  begin
    X := Plot.Left + I * StepX;
    Y := ValueToY(Plot, Values[I]);
    if I = 0 then
      Page.MoveTo(X, Y)        // open the path at the first point
    else
      Page.LineTo(X, Y);       // extend it through every later point
  end;
  Page.Stroke;                 // one stroke paints the whole polyline
end;

間隔の違いに注意してください。棒グラフは幅をバーの数で割ります。なぜなら、各バーがスロットを所有するからです。折れ線グラフは間隔の数、つまりCount - 1で割ります。なぜなら、最初と最後のポイントはプロットの端に位置し、線はそれらの間のギャップにまたがるからです。これら2つを混同することが、折れ線グラフがオーバーレイするつもりの棒グラフから半スロットずれてしまう通常の理由です。各データポイントにマーカーが必要な場合は、ポリラインがストロークされた後、すべての(X、Y)に小さなCircleFillをドロップします

円グラフ:円弧、またはシンプルに保つなら扇形

円グラフのスライスは、扇形が2つの半径と円弧で囲まれているため、三角関数が必要な唯一の形状です。正直なバージョンでは、円周に沿って小さな線分をステップさせることで円弧をスイープします。これにより、読者が気付かないほど曲線が近似されます。各スライスのスイープ角度はその総計に対するシェア、(Value / Total) * 2πであり、周回しながら進行角度を蓄積します:

procedure DrawPieChart(Page: THPDFPage; CX, CY, Radius: Single;
  const Values: array of Single; const Colors: array of TColor);
var
  I, Step, Steps: Integer;
  Total, Start, Sweep, A: Single;
begin
  Total := 0;
  for I := 0 to High(Values) do Total := Total + Values[I];
  Start := 0;

  for I := 0 to High(Values) do
  begin
    Sweep := (Values[I] / Total) * 2 * Pi;
    Steps := Round(Sweep / (Pi / 90)) + 1;  // ~2 degrees per segment

    Page.SetRGBFillColor(Colors[I]);
    Page.MoveTo(CX, CY);                     // wedge apex at the center
    for Step := 0 to Steps do
    begin
      A := Start + Sweep * (Step / Steps);
      Page.LineTo(CX + Radius * Cos(A), CY + Radius * Sin(A));
    end;
    Page.LineTo(CX, CY);                      // close back to the center
    Page.Fill;

    Start := Start + Sweep;                   // advance to the next slice
  end;
end;

中心から外へのパス、まず頂点、次に円弧、そして頂点に戻るというパスにより、Fillがしっかりとペイントする閉じた扇形が得られます。セグメントの数は滑らかさとパスサイズのトレードオフです。1ステップあたり約2度は、巨大なコンテンツストリームを生成することなく、妥当な半径であれば丸く見えます。真の円が必要ない場合は、三角関数を完全にスキップして、同じデータを単一の水平な積み上げ棒グラフとしてレンダリングし、各セグメントの幅をそのシェアに比例させることができます。とにかく円グラフよりも読みやすいことが多く、それは長方形を端から端まで並べた棒グラフにすぎません。デザインが実際の円を要求する場合にのみ、円弧のバージョンに手を伸ばしてください

このいずれもグラフライブラリがインストールされていることに依存していません。これがプリミティブを直接描画する静かな利点です。ロゴや署名ボックスを配置するのと同じキャンバス描画の呼び出しがこれらのグラフを構築し、フォームフィールドにラベルを付けるのと同じTextOutが軸にラベルを付けます。プロットのジオメトリをレコードに配置し、値を一度Yにマッピングすれば、棒グラフ、折れ線グラフ、または円グラフは、RectangleLineTo、およびCircleの短いルーチンであり、任意のレポートにドロップできます。ここで使用されているRectangleMoveToLineToCircleFillStroke、およびTextOutの呼び出しは、DelphiおよびC++Builder用のHotPDF Componentの一部です