Technical Article

Drawing Charts in a PDF with HotPDF Primitives

A chart copied out of a screen chart control as a 96 DPI bitmap looks fine at 100% zoom in a PDF viewer. Zoom to 400%, or send the page to a 600 DPI laser printer, and the bar edges turn soft, the axis labels blur into gray smudges, and the file carries a few hundred kilobytes of pixels for what is really a dozen rectangles. Drawn as vector paths instead, the same chart stays sharp at every zoom level and print resolution, because the bars, axes and labels are PDF path and text operators that the viewer re-renders at whatever scale it needs

Vector output moves the layout work into your code. Something has to compute where each bar, tick and label lands, and for bar, line and pie charts that computation is one coordinate mapping plus a few loops over rectangles, line segments and arcs. Get the mapping right once and the loops are short

DrawChart or hand-drawn primitives in HotPDF?

HotPDF ships a ready-made chart renderer, and for a standard report chart it is the first thing to try. Build a THPDFChart, add series with AddSeries (kinds hckLine, hckBar, hckArea, hckPie and hckScatter), fill them with AddPoint, and call THPDFPage.DrawChart with a target rectangle. The renderer works out the axis range, grid, tick labels, legend and theme colors on its own, and the overload that takes a structure parent writes the chart as a tagged figure with alternate text built from the data, which matters if the document has to pass an accessibility check

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');
    // Rectangle in page units: left, top, width, height
    HotPDF1.CurrentPage.DrawChart(Chart, 60, 120, 470, 280);
  finally
    Chart.Free;
  end;
end;

The renderer has a fixed vocabulary, though. Several bar series sit side by side in each category, a pie chart must be the only series in its chart, and the look comes from three themes plus per-series colors. Stacked bars, a target band shaded behind the bars, a different brand color for each bar in a single series, a pie that starts at twelve o'clock and runs clockwise: once a design asks for any of these, drawing the chart yourself with the page canvas is less work than bending the renderer. Those primitives are Rectangle, MoveTo, LineTo, Pie, Fill, Stroke and TextOut, the same calls covered in the canvas drawing walkthrough

Why does my bar chart come out upside down?

HotPDF page coordinates put the origin at the top-left corner with Y growing downward, the same frame as a VCL canvas. PDF user space itself runs the other way (origin bottom-left, Y up, ISO 32000-1 §8.3.2.3), and HotPDF flips every coordinate into that space as it writes the content stream. Chart values, on the other hand, grow upward. Feed a scaled value straight in as Y and the tallest bar hangs from the top of the plot like an icicle

The fix is one expression. Pick a plot rectangle, map zero to its bottom edge and the top of the scale to its top edge, and run every value through that mapping before it touches the page. For a value V on a scale from 0 to MaxValue, the page Y is Plot.Bottom - (V / Plot.MaxValue) * Plot.Height, where Plot.Bottom is the baseline. The bar chart code further down wraps that in a small ValueToY helper and every other routine reuses it

HotPDF: Screen coordinates and HotPDF page coordinates share a top-left origin with Y growing downward while chart values grow upward, and ValueToY flips a data value into that Y-down page space
HotPDF keeps the familiar screen frame, origin top-left and Y growing downward, so one ValueToY expression flips every upward-growing data value into page space

Two choices hide in that expression. MaxValue should be a round number above the largest data point, the next multiple of 10 or 100, so the tallest bar does not touch the top edge and the gridline labels read 0, 200, 400 rather than 0, 203.5, 407. And the scale starts at zero on purpose. Bar length is the value, so a value axis that starts at 350 makes a 388 bar look barely more than half as tall as a 420 bar when the real gap is under 8%. The built-in renderer pulls zero into the range for bar and area series for exactly this reason. Keep the same rule in hand-drawn code

Units are the other half of the mapping. With the default Resolution of 72, one page unit is one PDF point (1/72 inch). Change the resolution and your coordinates, line widths and text measurements all switch to the new unit, which the Resolution and drawing units article covers in detail. DrawChart takes its target rectangle in page units but lays out its own margins, font sizes and line widths in points at any resolution

A bar chart is a loop over rectangles

With the mapping in place, a bar chart is a loop. Divide the plot width into one slot per category, leave a gap so neighboring bars do not touch, and draw each bar as a filled rectangle from its ValueToY top down to the baseline. Rectangle takes the top-left corner, a width and a height, which fits this mapping directly:

HotPDF: Bar chart anatomy showing slots divided from the plot width with gaps between bars, each bar a filled Rectangle growing up from Plot.Bottom, category labels below the baseline, value labels riding the bar height, and axis ticks reusing ValueToY
Each bar is one filled Rectangle growing up from Plot.Bottom inside its slot, the quarter-slot gap scales with width, and axis ticks reuse ValueToY so bars and gridlines agree
type
  TPlotArea = record
    Left, Bottom, Width, Height: Single;  // page units; Bottom is the baseline Y
    MaxValue: Single;                     // top of the value scale
  end;

// Your own helper: a larger value sits higher, which on a Y-down page
// means a smaller 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;              // quarter of the slot, split left and right
  BarW := SlotW - Gap;

  // Dashed gridlines first, so the bars paint over them
  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 places the top of the text at Y
    Page.SetRGBFillColor(clBlack);
    Page.TextOut(BarX, Plot.Bottom + 4, 0, Labels[I]);
    Page.TextOut(BarX, BarTop - 12, 0, FormatFloat('0', Values[I]));
  end;

  // Baseline last, so it sits on top of the bar bottoms
  Page.SetLineWidth(1.0);
  Page.SetRGBStrokeColor(clBlack);
  Page.MoveTo(Plot.Left, Plot.Bottom);
  Page.LineTo(Plot.Left + Plot.Width, Plot.Bottom);
  Page.Stroke;
end;

Order is the whole layering model here. PDF uses a painter's model, where later operators in the content stream paint over earlier ones, so the gridlines go first, the bars over them, the baseline last. Swap the gridline block below the loop and the dashes run straight across every bar

The gap is a fraction of the slot instead of a fixed number of points. With four categories on a 470-point plot each slot is 117.5 points wide and the bars get about 29 points of air between them; with forty categories the same code leaves under 3 points, which still reads as separate bars. A fixed 12-point gap would swallow 480 of the 470 available points at forty categories and the bar width would go negative

Axis labels that do not collide

Labels are where hand-drawn charts break first, and almost always the same way. Text that fit on screen overruns its space on the page, because the developer tested with "Sep" and the German report prints "September" or "Dezember" in a 9-point font. The value axis has a quieter variant of the same problem: left-aligned tick labels at a fixed X offset look ragged as soon as the numbers change length, and a "1,200" label runs into the axis line that "800" cleared

Measure instead of guessing. TextWidth returns the width of a string in the current font, in page units, and TextHeight returns the font's ascent at the current size. Right-align each tick label against the axis and center it on its tick:

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);        // short tick outside the axis
    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;

For category labels the same measurement tells you when to give up on horizontal text. Compare the widest label against the slot width before drawing anything. If it does not fit, drop the font a point or two; if it still does not fit, pass 90 as the angle argument of TextOut and stand the labels on end, then leave enough margin under the baseline for the longest one. Test with the widest label the report can actually produce, which for month names in a localized build is rarely the English one

Line charts reuse the same mapping

A line chart changes only how the points connect. Walk the data once, convert each value with ValueToY, and stitch the points into one path: MoveTo for the first point, LineTo for every later one, a single Stroke at the end. If you already have the points in an array, Polyline takes an array of THPDFCurrPoint, opens a fresh path, and leaves the stroke to you; it raises an exception for fewer than two points, so a series with a single reading needs a marker instead of a line

The spacing is where a line chart drifts from a bar chart drawn over the same plot. Bars divide the width by the number of categories because each bar owns a slot. A line divides it by the number of intervals, Count - 1, because the first and last points sit on the plot edges. Overlay the two and the line points land on the slot boundaries instead of the bar centers. When a line rides on top of bars, place its points at each slot center, Plot.Left + I * SlotW + SlotW / 2, and drop the Count - 1 spacing entirely. For a marker at each point, a small Circle followed by Fill after the line is stroked does the job

Pie slices with Pie, clockwise from twelve o'clock

A pie wedge is two radii and an arc. The hand-rolled way to build one starts at the center, steps short LineTo segments a couple of degrees apart along the rim, and closes back to the center, which is the construction in the figure below. It works, but a 270-degree slice turns into well over a hundred path segments and the rim is still a polygon under heavy zoom

HotPDF: Pie slice construction starting with MoveTo at the circle center, stepping small LineTo segments about two degrees apart along the arc over each slice's sweep angle, then closing to the center so Fill paints a solid wedge
A wedge starts with MoveTo at the center, walks small LineTo steps about two degrees apart along its sweep of the circumference, and closes back so Fill paints it solidly

Use Pie instead. THPDFPage.Pie follows the GDI convention: a bounding rectangle, a start angle and an end angle in degrees, 0 pointing east, 90 north, positive angles counter-clockwise. It builds the arc from Bézier segments of at most 90 degrees each, draws the line to the center, and closes the path, so the rim stays a true curve and a full slice costs a handful of operators. Business pies usually start at twelve o'clock and run clockwise, which in GDI terms means starting at 90 and walking the angle down:

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;                            // twelve o'clock
  Page.SetRGBStrokeColor(clWhite);
  Page.SetLineWidth(0.8);
  for I := 0 to High(Values) do
  begin
    if Values[I] <= 0 then
      Continue;                           // see below: never pass a zero or negative sweep
    Sweep := Values[I] / Total * 360;
    Page.SetRGBFillColor(Colors[I mod Length(Colors)]);
    // Counter-clockwise from Angle - Sweep to Angle is the slice that
    // sits clockwise of the current position
    Page.Pie(CX - Radius, CY - Radius, CX + Radius, CY + Radius,
      Angle - Sweep, Angle);
    Page.FillAndStroke;                   // white stroke separates the slices
    Angle := Angle - Sweep;
  end;
end;

The Continue guard is the line that matters most. Pie treats an end angle smaller than the start angle as a sweep that wraps past 360, the GDI reading, which is what lets a slice cross the 0-degree line. A refund row or a correction entry in the source data gives a negative value, the computed end angle lands below the start, and the slice wraps into a nearly full disc painted over everything drawn before it. A zero value is milder but still visible: the closed path collapses to a radius, and the white separator stroke draws it as a stray line from the rim to the center. Filter both before the loop, and decide separately how the report shows negative amounts, because a pie cannot show them at all

When the design does not insist on a circle, a single horizontal stacked bar shows the same shares with nothing but Rectangle calls laid end to end, and readers compare lengths more accurately than angles. For a part-of-whole figure with more than five or six categories that is the better chart, and the bar chart code above already does most of the work

Every chart in this article is built from the page canvas calls of the HotPDF Delphi Component, with DrawChart available for the standard line, bar, area, pie and scatter layouts, and it all runs from Delphi or C++Builder without a chart library installed on the build machine