Technical Article

Desenhar Gráficos num PDF com Primitivas HotPDF

O HotPDF não possui nenhum objeto de gráfico. Não existe um TPDFChart, nem AddBarSeries, nem nada que receba um array de números e devolva um gráfico renderizado. Em vez disso, disponibiliza um canvas de página com o mesmo vocabulário de baixo nível que todos os modelos de desenho de PDF utilizam: retângulos, linhas, círculos, preenchimentos, traços e texto posicionados em coordenadas exatas. Por conseguinte, um gráfico num documento HotPDF é algo que o programador constrói, e não algo que solicita. Isto parece dar mais trabalho do que realmente dá. Uma vez escrita a matemática das coordenadas, um gráfico de barras torna-se num ciclo sobre retângulos, um gráfico de linhas numa polilinha e um gráfico de setores (tarte) num leque de arcos, controlando cada píxel do resultado.

Isto é importante porque a alternativa à qual as pessoas recorrem primeiro (rasterizar um controlo de gráfico de ecrã para um bitmap e colar a imagem na página) resulta num gráfico bloqueado à resolução do ecrã, que é impresso de forma desfocada e aumenta o tamanho do ficheiro. Desenhar o gráfico com as primitivas vetoriais do HotPDF mantém o resultado nítido em qualquer zoom e em qualquer DPI de impressão, porque as barras e os eixos são operadores reais de caminho PDF, não píxeis. O custo é que o programador é responsável pelo esquema (layout). A mecânica resume-se a alguns passos: a inversão de coordenadas que costuma confundir a maioria das pessoas, um gráfico de barras estruturado, o truque da polilinha para gráficos de linhas e a matemática de arcos para fatias de setores.

A única parte difícil é o sistema de coordenadas

Os gráficos de ecrã colocam a origem no canto superior esquerdo, com o Y a crescer para baixo. O PDF faz o oposto. O origem situa-se no canto inferior esquerdo da página e o Y cresce para cima, medido em pontos (1/72 polegada). Todas as chamadas de desenho no HotPDF, como TextOut, Rectangle, MoveTo, LineTo, Circle, utilizam esta convenção de canto inferior esquerdo com Y para cima. Se mantiver os instintos dos gráficos de ecrã, o seu primeiro gráfico será desenhado de pernas para o ar e sairá pela parte inferior da página.

Assim, o verdadeiro trabalho em qualquer gráfico é um mapeamento: converter um valor de dados numa coordenada Y que respeite a orientação Y para cima. Defina um retângulo de desenho (plot), ou seja, quatro números para a área onde o gráfico irá residir, e depois mapeie o menor valor dos seus dados para a extremidade inferior e o maior para a superior. Para uma barra de valor V numa escala que vai de 0 a MaxValue, a extremidade superior da barra será PlotBottom + (V / MaxValue) * PlotHeight, e a barra cresce a partir de PlotBottom. Acerte nesta expressão e o resto é apenas organização. O seguinte utilitário armazena a geometria do desenho e faz a conversão, para que o código de desenho nunca tenha de lidar com aritmética bruta duas vezes:

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;

Existe uma decisão de design oculta em MaxValue. Se definir este valor exatamente para o maior ponto de dados, a barra mais alta tocará o limite superior da área de desenho e parecerá cortada. Arredonde-o para um número inteiro acima do máximo, por exemplo, o próximo múltiplo de 10 ou 100, para que o gráfico tenha alguma folga no topo e as etiquetas das linhas de grelha apareçam como valores redondos, em vez de coincidirem com o pico exato dos dados.

Um gráfico de barras é um ciclo sobre retângulos

Com o mapeamento resolvido, o gráfico de barras constrói-se facilmente. Divida a largura de desenho num espaço para cada categoria, deixe um intervalo entre as barras para que não se toquem e desenhe cada barra como um retângulo preenchido cuja altura provém de ValueToY. O método Rectangle do HotPDF recebe o canto inferior esquerdo, além da largura e da altura, o que se alinha perfeitamente com uma barra que cresce para cima a partir da linha de base. Defina a cor de preenchimento primeiro, desenhe o caminho e depois chame Fill para o pintar. A etiqueta da categoria é colocada abaixo da linha de base, e o valor acima da barra:

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;

Dois detalhes justificam a sua implementação. O Gap é uma fração do espaço total disponível, em vez de uma quantidade fixa de pontos, para que as barras mantenham um espaçamento proporcional, quer desenhe quatro categorias ou quarenta. Além disso, a etiqueta de valor é posicionada com a mesma altura derivada de ValueToY que a barra utiliza, pelo que fica sempre colocada imediatamente acima da respetiva barra, em vez de flutuar numa posição estimada. Se pretender linhas de grelha horizontais por trás das barras, desenhe-as antes do ciclo: escolha três ou quatro valores redondos, aplique ValueToY e desenhe uma linha suave ao longo da área de desenho nesse Y. Desenhar estas linhas primeiro coloca-as por trás das barras, respeitando a sobreposição de camadas do PDF.

Eixos, marcas e etiquetas são apenas mais linhas e texto

O gráfico não estará terminado enquanto o leitor não conseguir compreender o significado das barras, o que é inteiramente o papel dos eixos. O eixo vertical é uma linha desenhada no limite esquerdo da área com algumas marcas (ticks) e os respetivos valores. Reutilize a função ValueToY para que as marcas fiquem posicionadas na mesma escala utilizada pelas barras; caso contrário, uma barra e a sua linha de grelha discordarão, transmitindo informação incorreta:

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;

As etiquetas são onde os gráficos falham mais frequentemente em ambiente de produção, e o problema é sempre o mesmo: o texto que cabia no ecrã excede o espaço disponível no PDF. Nomes longos de categorias colidem uns com os outros, e nomes de meses localizados como "setembro" ou "Dezembro" são mais largos do que o "Sep" inglês que testou originalmente. Não existe um redimensionamento automático para o salvar aqui, por isso deixe uma margem generosa sob a linha de base, reduza a fonte um ponto ou dois para conjuntos densos de categorias e, se os nomes forem realmente longos, rode-os. O TextOut aceita um ângulo como terceiro argumento, pelo que passar 90 coloca a etiqueta verticalmente e poupa espaço sem sobreposição. Teste o layout com a etiqueta mais comprida esperada, e não com a mais curta, antes de disponibilizar o PDF.

Gráficos de linhas: uma polilinha através de pontos mapeados

Um gráfico de linhas reutiliza todo o mapeamento de valores e altera apenas a forma como os pontos se ligam. Em vez de um retângulo por categoria, percorre os dados uma vez, converte cada valor nas suas coordenadas (X, Y) com ValueToY e une os pontos com um único MoveTo seguido de chamadas LineTo, aplicando o traço (stroke) no fim. O primeiro ponto inicia o caminho; cada ponto subsequente estende-o:

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;

Note a diferença no espaçamento. Um gráfico de barras divide a largura pelo número de barras, porque cada barra possui o seu próprio espaço. Um gráfico de linhas divide pelo número de intervalos, Count - 1, porque o primeiro e o último ponto situam-se nos limites da área de desenho e a linha cobre os intervalos entre eles. Confundir estes dois aspetos é a razão habitual pela qual um gráfico de linhas se desvia meio espaço em relação ao gráfico de barras que deveria sobrepor. Se desejar um marcador em cada ponto de dados, desenhe um pequeno Circle e chame Fill em cada coordenada (X, Y) após traçar a polilinha.

Gráficos de setores (tarte): arcos, ou fatias se quiser manter a simplicidade

As fatias de um gráfico de setores são a única forma que necessita de trigonometria, porque uma fatia é delimitada por dois raios e um arco. A abordagem padrão desenha o arco traçando pequenos segmentos de linha ao longo da circunferência, aproximando a curva de forma tão precisa que nenhum leitor notará a diferença. O ângulo de varrimento de cada fatia é a sua quota do total, (Value / Total) * 2π, acumulando-se o ângulo à medida que avança na circunferência:

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;

O caminho que parte do centro (primeiro o vértice, depois o arco e finalmente o retorno ao vértice) gera uma fatia fechada que o método Fill pinta uniformemente. O número de segmentos equilibra a suavidade com o tamanho do caminho: cerca de dois graus por passo confere uma aparência arredondada a qualquer raio razoável, sem gerar uma lista excessiva de comandos. Se não necessitar de um círculo perfeito, pode ignorar a trigonometria e apresentar os mesmos dados como uma barra horizontal empilhada, com a largura de cada segmento proporcional à sua quota-parte. Esta opção é frequentemente mais legível do que um gráfico circular e resume-se ao código de barras com os retângulos dispostos consecutivamente. Utilize a versão com arco apenas quando o design exigir mesmo uma forma circular.

Nada disto depende de qualquer biblioteca de gráficos instalada, o que constitui a grande vantagem de desenhar as primitivas diretamente. As mesmas chamadas de desenho de canvas que posicionam um logótipo ou uma caixa de assinatura constroem estes gráficos, e o mesmo TextOut que etiqueta um campo de formulário identifica um eixo. Guarde a geometria da área de desenho num registo, mapeie os valores para Y uma vez e qualquer gráfico de barras, linhas ou setores será apenas uma rotina curta que recorre a Rectangle, LineTo e Circle para integrar em qualquer relatório. As chamadas Rectangle, MoveTo, LineTo, Circle, Fill, Stroke e TextOut aqui utilizadas fazem parte do HotPDF Component para Delphi e C++Builder.