Техническая статья

Рисование диаграмм в PDF с помощью примитивов HotPDF

Диаграмма, скопированная из экранного элемента как битмап 96 DPI, выглядит хорошо при 100% зуме в просмотрщике PDF. Поднимите зум до 400% или отправьте страницу на лазерный принтер 600 DPI — и края полос станут мягкими, подписи осей размоются в серые кляксы, а файл потащит несколько сотен килобайт пикселей ради того, что в действительности является дюжиной прямоугольников. Нарисованная же векторными путями, та же диаграмма остаётся резкой на любом уровне зума и разрешении печати, потому что полосы, оси и подписи — это операторы пути и текста PDF, которые просмотрщик перерендеривает в любом нужном масштабе

Векторный вывод переносит работу по макету в ваш код. Кто-то должен вычислить, куда ложится каждая полоса, метка и подпись, и для столбчатых, линейных и круговых диаграмм это вычисление — одно сопоставление координат плюс несколько циклов по прямоугольникам, отрезкам и дугам. Забейте сопоставление один раз — и циклы короткие

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;

У рендерера, впрочем, фиксированный словарь. Несколько серий полос стоят бок о бок в каждой категории, круговая диаграмма обязана быть единственной серией в своей диаграмме, а внешний вид берётся из трёх тем плюс цвета по сериям. Стековые полосы, целевая полоса-фон за полосами, отдельный брендовый цвет каждой полосе в одной серии, круг, начинающийся с двенадцати часов и идущий по часовой: как только дизайн запросит хоть что-то из этого, нарисовать диаграмму самому канвой страницы — меньше работы, чем гнуть рендерер. Эти примитивы — Rectangle, MoveTo, LineTo, Pie, Fill, Stroke и TextOut, те же вызовы, что разобраны в пошаговом руководстве по рисованию на канве

Столбчатая диаграмма — это цикл по прямоугольникам

Страничные координаты HotPDF кладут начало координат в верхний левый угол с ростом Y вниз — та же рамка, что у канвы VCL. Пользовательское пространство PDF само бежит в другую сторону (начало в левом нижнем углу, Y вверх, ISO 32000-1 §8.3.2.3), и HotPDF переворачивает каждую координату в это пространство, записывая поток содержимого. Значения диаграммы, напротив, растут вверх. Скормите масштабированное значение прямо как Y — и самая высокая полоса повиснет с верха области построения сосулькой

Лекарство — одно выражение. Возьмите прямоугольник построения, отобразите ноль в его нижний край, а верх шкалы — в его верхний край, и прогоняйте каждое значение через это отображение, прежде чем оно коснётся страницы. Для значения V на шкале от 0 до MaxValue страничный Y — это Plot.Bottom - (V / Plot.MaxValue) * Plot.Height, где Plot.Bottom — базовая линия. Код столбчатой диаграммы ниже заворачивает это в маленький помощник ValueToY, и все остальные процедуры переиспользуют его

HotPDF: экранные координаты и страничные координаты HotPDF делят начало в левом верхнем углу с ростом Y вниз, тогда как значения диаграммы растут вверх, и ValueToY переворачивает значение данных в это пространство страницы с Y вниз
HotPDF сохраняет привычную экранную рамку — начало в левом верхнем углу и Y, растущий вниз, — так что одно выражение ValueToY переворачивает каждое растущее вверх значение данных в пространство страницы

В этом выражении спрятаны два выбора. MaxValue должен быть круглым числом выше наибольшей точки данных — очередное кратное 10 или 100, чтобы самая высокая полоса не касалась верхнего края, а подписи линий сетки читались 0, 200, 400, а не 0, 203.5, 407. И шкала начинается с нуля намеренно. Длина полосы — это значение, поэтому ось значений, начинающаяся с 350, делает полосу 388 едва ли не вдвое ниже полосы 420, когда реальная разница меньше 8%. Встроенный рендерер затягивает ноль в диапазон для серий bar и area именно по этой причине. Держите то же правило и в коде, нарисованном вручную

Единицы — вторая половина отображения. При Resolution по умолчанию 72 одна единица страницы — один пункт PDF (1/72 дюйма). Поменяйте разрешение — и координаты, ширины линий и текстовые измерения все переключатся на новую единицу; подробно это разбирает статья о Resolution и единицах рисования. DrawChart принимает целевой прямоугольник в единицах страницы, но раскладывает собственные поля, размеры шрифтов и ширины линий в пунктах при любом разрешении

Столбчатая диаграмма — это цикл по прямоугольникам

Когда отображение на месте, столбчатая диаграмма — это цикл. Разделите ширину области построения на слоты по одному на категорию, оставьте зазор, чтобы соседние полосы не слипались, и рисуйте каждую полосу залитым прямоугольником от её верха по ValueToY вниз до базовой линии. Rectangle принимает левый верхний угол, ширину и высоту — это ложится на отображение напрямую:

HotPDF: анатомия столбчатой диаграммы — слоты, поделённые из ширины области построения с зазорами между полосами, каждая полоса — залитый Rectangle, растущий вверх от Plot.Bottom, подписи категорий под базовой линией, подписи значений верхом на высоте полосы, а метки осей переиспользуют ValueToY
Каждая полоса — один залитый Rectangle, растущий вверх от Plot.Bottom внутри своего слота, четверть-слотовый зазор масштабируется с шириной, а метки осей переиспользуют 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;              // четверть слота, поделённая влево и вправо
  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 пунктов каждый слот шириной 117.5 пункта, и полосы получают около 29 пунктов воздуха между собой; при сорока категориях тот же код оставляет меньше 3 пунктов, что всё ещё читается как отдельные полосы. Фиксированный зазор в 12 пунктов при сорока категориях проглотил бы 480 из 470 доступных пунктов, и ширина полосы ушла бы в минус

Подписи осей, которые не сталкиваются

Подписи — то, где самописные диаграммы ломаются первыми, и почти всегда одинаково. Текст, умещавшийся на экране, переполняет своё место на странице, потому что разработчик тестировал с «Sep», а немецкий отчёт печатает «September» или «Dezember» шрифтом 9 пунктов. У оси значений более тихий вариант той же проблемы: подписи меток, выровненные влево на фиксированном смещении X, выглядят лохматыми, как только числа меняют длину, и «1,200» влетает в линию оси, которую «800» обходила

Измеряйте вместо того чтобы гадать. TextWidth возвращает ширину строки текущим шрифтом в единицах страницы, TextHeight — вынос шрифта вверх (ascent) при текущем размере. Выравнивайте каждую подпись метки по правому краю относительно оси и центрируйте на её метке:

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;

Для подписей категорий то же измерение подсказывает, когда сдаваться с горизонтальным текстом. Сравните самую широкую подпись с шириной слота, прежде чем что-либо рисовать. Не помещается — сбросьте шрифт на пункт-другой; всё ещё не помещается — передайте 90 как аргумент угла в TextOut и поставьте подписи на торец, а затем оставьте под базовой линией достаточно поля для самой длинной. Тестируйтесь с самой широкой подписью, какую отчёт вообще может выдать, — для названий месяцев в локализованной сборке это редко английская

Линейные диаграммы переиспользуют то же отображение

Линейная диаграмма меняет только способ соединения точек. Пройдите данные один раз, переведите каждое значение через ValueToY и сшейте точки в один путь: MoveTo для первой, LineTo для каждой следующей, один Stroke в конце. Если точки уже лежат в массиве, Polyline принимает array of THPDFCurrPoint, открывает свежий путь и оставляет обводку вам; меньше двух точек — исключение, так что серии с единственным замером нужен маркер вместо линии

Именно расстановка расходится у линейной диаграммы со столбчатой, нарисованной над той же областью построения. Полосы делят ширину на число категорий, потому что у каждой полосы свой слот. Линия делит её на число интервалов, Count - 1, потому что первая и последняя точки сидят на краях области. Наложите их — и точки линии лягут на границы слотов вместо центров полос. Когда линия едет поверх полос, ставьте её точки в центр каждого слота, Plot.Left + I * SlotW + SlotW / 2, и выкидывайте расстановку Count - 1 целиком. Для маркера в каждой точке небольшой Circle с последующим Fill после обводки линии делает своё дело

Круговые секторы через Pie, по часовой от двенадцати часов

Сектор круговой диаграммы — это два радиуса и дуга. Самопальный способ построить его начинается в центре, шагает короткими сегментами LineTo в паре градусов друг от друга по ободу и замыкается обратно в центр — конструкция на рисунке ниже. Работает, но сектор в 270 градусов превращается в без малого полторы сотни сегментов пути, и обод под сильным зумом всё равно многоугольник

HotPDF: построение сектора круга — MoveTo в центре круга, шаги мелкими сегментами LineTo примерно в два градуса по дуге над углом разворота каждого сектора, затем замыкание в центр, чтобы Fill закрасил сплошной клин
Клин начинается с MoveTo в центре, шагает мелкими шагами LineTo примерно в два градуса вдоль своего разворота по окружности и замыкается обратно, так что Fill красит его сплошь

Используйте вместо этого Pie. THPDFPage.Pie следует конвенции GDI: ограничивающий прямоугольник, начальный и конечный угол в градусах, 0 смотрит на восток, 90 — на север, положительные углы против часовой. Он строит дугу из сегментов Безье не более 90 градусов каждый, ведёт линию в центр и замыкает путь, так что обод остаётся настоящей кривой, а целый сектор стоит горстки операторов. Деловые круговые обычно начинаются с двенадцати часов и идут по часовой, что на языке 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;                            // двенадцать часов
  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, благодаря которому сектор может пересекать нулевую линию. Строка возврата или корректирующая проводка в исходных данных даёт отрицательное значение, вычисленный конечный угол уходит ниже начального, и сектор закручивается в почти полный диск, нарисованный поверх всего, что было до него. Нулевое значение мягче, но всё равно заметно: замкнутый путь схлопывается в радиус, и белая разделительная обводка рисует его как паразитную линию от обода к центру. Отфильтруйте оба случая до цикла и решите отдельно, как отчёт показывает отрицательные суммы, — круговая показать их не может в принципе

Когда дизайн не настаивает на круге, одна горизонтальная стековая полоса показывает те же доли одними лишь вызовами Rectangle встык, а читатели сравнивают длины точнее, чем углы. Для части-от-целого с пятью-шестью и более категориями это лучшая диаграмма, и код столбчатой диаграммы выше уже делает большую часть работы

Каждая диаграмма в этой статье собрана из вызовов канвы страницы HotPDF Delphi Component, с доступным DrawChart для стандартных макетов line, bar, area, pie и scatter, и всё это работает из Delphi или C++Builder без установленной библиотеки диаграмм на машине сборки