Um gráfico copiado de um controle de gráfico de tela como um bitmap de 96 DPI parece bom a zoom de 100% em um visualizador de PDF. Dê zoom a 400%, ou mande a página para uma impressora laser de 600 DPI, e as bordas das barras ficam suaves, os rótulos dos eixos viram borrões cinzas, e o arquivo carrega algumas centenas de kilobytes de pixels para o que é na verdade uma dúzia de retângulos. Desenhado como caminhos vetoriais, o mesmo gráfico continua nítido em qualquer nível de zoom e resolução de impressão, porque as barras, eixos e rótulos são operadores de caminho e texto de PDF que o visualizador renderiza de novo na escala que precisar
Saída vetorial move o trabalho de layout para o seu código. Alguém precisa calcular onde cada barra, marca e rótulo cai, e para gráficos de barras, linhas e pizza esse cálculo é um mapeamento de coordenadas mais alguns loops sobre retângulos, segmentos de linha e arcos. Acerte o mapeamento uma vez e os loops são curtos
DrawChart ou primitivas desenhadas à mão no HotPDF?
O HotPDF vem com um renderizador de gráficos pronto, e para um gráfico padrão de relatório é a primeira coisa a tentar. Construa um THPDFChart, adicione séries com AddSeries (tipos hckLine, hckBar, hckArea, hckPie e hckScatter), preencha-as com AddPoint, e chame o THPDFPage.DrawChart com um retângulo alvo. O renderizador calcula sozinho a faixa dos eixos, grade, rótulos de marca, legenda e cores de tema, e o overload que recebe um parent de estrutura grava o gráfico como uma figura marcada com texto alternativo construído dos dados, o que importa se o documento precisa passar por uma checagem de acessibilidade
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');
// Retângulo em unidades de página: esquerda, topo, largura, altura
HotPDF1.CurrentPage.DrawChart(Chart, 60, 120, 470, 280);
finally
Chart.Free;
end;
end;
O renderizador tem um vocabulário fixo, porém. Várias séries de barras ficam lado a lado em cada categoria, um gráfico de pizza precisa ser a única série do gráfico dele, e o visual vem de três temas mais cores por série. Barras empilhadas, uma faixa de meta sombreada atrás das barras, uma cor de marca diferente para cada barra em uma única série, uma pizza que começa ao meio-dia e corre em sentido horário: quando um design pede qualquer um desses, desenhar o gráfico você mesmo com o canvas da página dá menos trabalho do que dobrar o renderizador. Essas primitivas são Rectangle, MoveTo, LineTo, Pie, Fill, Stroke e TextOut, as mesmas chamadas cobertas no passo a passo de desenho no canvas
Por que meu gráfico de barras sai de cabeça para baixo?
As coordenadas de página do HotPDF põem a origem no canto superior esquerdo com Y crescendo para baixo, o mesmo enquadramento de um canvas VCL. O user space do PDF em si roda ao contrário (origem inferior esquerda, Y para cima, ISO 32000-1 §8.3.2.3), e o HotPDF inverte cada coordenada para esse espaço enquanto grava o content stream. Valores de gráfico, por outro lado, crescem para cima. Alimente um valor escalado direto como Y e a barra mais alta pende do topo da plotagem como uma estalactite
A correção é uma expressão. Escolha um retângulo de plotagem, mapeie zero para a borda inferior dele e o topo da escala para a borda superior, e passe cada valor por esse mapeamento antes de tocar a página. Para um valor V em uma escala de 0 a MaxValue, o Y de página é Plot.Bottom - (V / Plot.MaxValue) * Plot.Height, em que Plot.Bottom é a linha de base. O código do gráfico de barras mais adiante embrulha isso em um pequeno helper ValueToY e toda rotina seguinte o reutiliza
Duas escolhas se escondem nessa expressão. O MaxValue deve ser um número redondo acima do maior ponto de dados, o próximo múltiplo de 10 ou 100, para que a barra mais alta não toque a borda superior e os rótulos das linhas de grade leiam 0, 200, 400 em vez de 0, 203,5 e 407. E a escala começa em zero de propósito. O comprimento da barra é o valor, então um eixo de valores que começa em 350 faz uma barra de 388 parecer mal metade da altura de uma de 420 quando a diferença real é menor que 8%. O renderizador embutido puxa o zero para a faixa em séries de barra e área por exatamente essa razão. Mantenha a mesma regra no código desenhado à mão
Unidades são a outra metade do mapeamento. Com a Resolution padrão de 72, uma unidade de página é um ponto PDF (1/72 de polegada). Mude a resolução e suas coordenadas, larguras de linha e medidas de texto mudam para a unidade nova, o que o artigo sobre Resolution e unidades de desenho cobre em detalhes. O DrawChart recebe o retângulo alvo em unidades de página mas compõe as próprias margens, tamanhos de fonte e larguras de linha em pontos em qualquer resolução
Um gráfico de barras é um loop sobre retângulos
Com o mapeamento em mãos, um gráfico de barras é um loop. Divida a largura da plotagem em um slot por categoria, deixe um vão para barras vizinhas não se tocarem, e desenhe cada barra como um retângulo preenchido do topo em ValueToY até a linha de base. O Rectangle recebe o canto superior esquerdo, uma largura e uma altura, o que se encaixa direto nesse mapeamento:
type
TPlotArea = record
Left, Bottom, Width, Height: Single; // unidades de página; Bottom é o Y da linha de base
MaxValue: Single; // topo da escala de valores
end;
// Helper seu: um valor maior fica mais alto, o que em uma página com Y para baixo
// significa um Y menor
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; // um quarto do slot, dividido à esquerda e à direita
BarW := SlotW - Gap;
// Linhas de grade tracejadas primeiro, para as barras pintarem por cima
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 posiciona o topo do texto em Y
Page.SetRGBFillColor(clBlack);
Page.TextOut(BarX, Plot.Bottom + 4, 0, Labels[I]);
Page.TextOut(BarX, BarTop - 12, 0, FormatFloat('0', Values[I]));
end;
// Linha de base por último, para ficar por cima das bases das barras
Page.SetLineWidth(1.0);
Page.SetRGBStrokeColor(clBlack);
Page.MoveTo(Plot.Left, Plot.Bottom);
Page.LineTo(Plot.Left + Plot.Width, Plot.Bottom);
Page.Stroke;
end;
A ordem é todo o modelo de camadas aqui. O PDF usa um modelo de pintor, em que operadores posteriores no content stream pintam sobre os anteriores, então as linhas de grade vão primeiro, as barras sobre elas, a linha de base por último. Troque o bloco de linhas de grade para depois do loop e os traços correm direto por cima de cada barra
O vão é uma fração do slot em vez de um número fixo de pontos. Com quatro categorias em uma plotagem de 470 pontos, cada slot tem 117,5 pontos de largura e as barras ganham cerca de 29 pontos de ar entre elas; com quarenta categorias o mesmo código deixa menos de 3 pontos, o que ainda se lê como barras separadas. Um vão fixo de 12 pontos engoliria 480 dos 470 pontos disponíveis em quarenta categorias e a largura da barra ficaria negativa
Rótulos de eixo que não colidem
Os rótulos são onde gráficos desenhados à mão quebram primeiro, e quase sempre do mesmo jeito. Texto que cabia na tela transborda o espaço dele na página, porque o desenvolvedor testou com "Sep" e o relatório alemão imprime "September" ou "Dezember" em fonte de 9 pontos. O eixo de valores tem uma variante mais silenciosa do mesmo problema: rótulos de marca alinhados à esquerda em um offset X fixo ficam irregulares assim que os números mudam de comprimento, e um rótulo de 1.200 invade a linha do eixo que o 800 evitava
Meça em vez de chutar. O TextWidth retorna a largura de uma string na fonte atual, em unidades de página, e o TextHeight retorna o ascent da fonte no tamanho atual. Alinhe cada rótulo de marca à direita contra o eixo e centralize-o na marca dele:
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); // marca curta fora do eixo
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;
Para rótulos de categoria a mesma medida diz quando desistir do texto horizontal. Compare o rótulo mais largo contra a largura do slot antes de desenhar qualquer coisa. Se não couber, baixe a fonte um ou dois pontos; se ainda não couber, passe 90 como argumento de ângulo do TextOut e erga os rótulos, então deixe margem suficiente sob a linha de base para o mais longo. Teste com o rótulo mais largo que o relatório pode realmente produzir, que para nomes de meses em um build localizado raramente é o inglês
Gráficos de linha reutilizam o mesmo mapeamento
Um gráfico de linha muda apenas como os pontos se conectam. Percorra os dados uma vez, converta cada valor com ValueToY, e costure os pontos em um caminho: MoveTo para o primeiro ponto, LineTo para cada seguinte, um único Stroke no fim. Se você já tem os pontos em um array, o Polyline recebe um array of THPDFCurrPoint, abre um caminho novo e deixa o stroke para você; ele levanta uma exceção para menos de dois pontos, então uma série com uma única leitura precisa de um marcador em vez de uma linha
O espaçamento é onde um gráfico de linha diverge de um gráfico de barras desenhado sobre a mesma plotagem. Barras dividem a largura pelo número de categorias porque cada barra possui um slot. Uma linha divide pelo número de intervalos, Count - 1, porque o primeiro e o último pontos ficam nas bordas da plotagem. Sobreponha os dois e os pontos da linha caem nas fronteiras dos slots em vez dos centros das barras. Quando uma linha anda sobre barras, posicione os pontos dela no centro de cada slot, Plot.Left + I * SlotW + SlotW / 2, e abandone o espaçamento Count - 1 por completo. Para um marcador em cada ponto, um pequeno Circle seguido de Fill depois que a linha é traçada resolve
Fatias de pizza com Pie, no sentido horário a partir do meio-dia
Uma fatia de pizza são dois raios e um arco. O jeito artesanal de construir uma começa no centro, dá passos curtos de LineTo a poucos graus de distância pela borda, e fecha de volta ao centro, que é a construção na figura abaixo. Funciona, mas uma fatia de 270 graus vira bem mais de cem segmentos de caminho e a borda ainda é um polígono sob zoom forte
Use o Pie em vez disso. O THPDFPage.Pie segue a convenção do GDI: um retângulo delimitador, um ângulo inicial e um ângulo final em graus, 0 apontando para leste, 90 para norte, ângulos positivos no sentido anti-horário. Ele constrói o arco com segmentos Bézier de no máximo 90 graus cada, desenha a linha ao centro e fecha o caminho, então a borda continua uma curva verdadeira e uma fatia completa custa um punhado de operadores. Pizzas de negócio costumam começar ao meio-dia e correr em sentido horário, o que em termos do GDI significa começar em 90 e andar o ângulo para baixo:
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; // meio-dia
Page.SetRGBStrokeColor(clWhite);
Page.SetLineWidth(0.8);
for I := 0 to High(Values) do
begin
if Values[I] <= 0 then
Continue; // veja abaixo: nunca passe uma varredura zero ou negativa
Sweep := Values[I] / Total * 360;
Page.SetRGBFillColor(Colors[I mod Length(Colors)]);
// Anti-horário de Angle - Sweep até Angle é a fatia que
// fica em sentido horário da posição atual
Page.Pie(CX - Radius, CY - Radius, CX + Radius, CY + Radius,
Angle - Sweep, Angle);
Page.FillAndStroke; // o traço branco separa as fatias
Angle := Angle - Sweep;
end;
end;
A guarda do Continue é a linha que mais importa. O Pie trata um ângulo final menor que o ângulo inicial como uma varredura que contorna além de 360, a leitura do GDI, que é o que deixa uma fatia cruzar a linha de 0 graus. Uma linha de reembolso ou um lançamento de correção nos dados de origem dá um valor negativo, o ângulo final calculado cai abaixo do inicial, e a fatia contorna em um disco quase completo pintado sobre tudo que foi desenhado antes dela. Um valor zero é mais leve mas ainda visível: o caminho fechado colapsa a um raio, e o traço separador branco o desenha como uma linha perdida da borda ao centro. Filtre os dois antes do loop, e decida separadamente como o relatório mostra valores negativos, porque uma pizza não consegue mostrá-los de forma alguma
Quando o design não insiste em um círculo, uma única barra empilhada horizontal mostra as mesmas proporções sem nada além de chamadas de Rectangle postas de ponta a ponta, e leitores comparam comprimentos com mais precisão do que ângulos. Para uma figura de parte-todo com mais de cinco ou seis categorias, esse é o gráfico melhor, e o código do gráfico de barras acima já faz a maior parte do trabalho
Todo gráfico neste artigo é construído com as chamadas de canvas de página do HotPDF Delphi Component, com o DrawChart disponível para os layouts padrão de linha, barras, área, pizza e dispersão, e tudo roda a partir do Delphi ou C++Builder sem uma biblioteca de gráficos instalada na máquina de build