Artículo técnico

Dibujo de gráficos en un PDF con primitivas de HotPDF

HotPDF no tiene un objeto de gráfico. No existe TPDFChart, ni AddBarSeries, ni nada que tome un arreglo de números y devuelva un gráfico renderizado. En su lugar, le proporciona un lienzo de página con el mismo vocabulario de bajo nivel que usa todo modelo de dibujo PDF: rectángulos, líneas, círculos, rellenos, trazos y texto ubicado en coordenadas exactas. Por lo tanto, un gráfico en un documento de HotPDF es algo que usted construye, no algo que solicita. Suena como más trabajo del que es. Una vez que haya escrito la matemática de coordenadas una vez, un gráfico de barras es un bucle sobre rectángulos, un gráfico de líneas es una polilínea y un gráfico circular es un abanico de arcos, y usted controla cada píxel del resultado

Esto es importante porque la alternativa a la que las personas recurren primero, rasterizar un control de gráfico de pantalla en un mapa de bits y pegar la imagen en la página, le da un gráfico bloqueado a la resolución de pantalla que se imprime borroso e infla el archivo. Dibujar el gráfico con las primitivas vectoriales de HotPDF mantiene la salida nítida en cualquier nivel de zoom y cualquier DPI de impresión, porque las barras y los ejes son operadores reales de trazado de PDF, no píxeles. El costo es que usted es dueño del diseño. La mecánica se reduce a unos pocos movimientos: el giro de coordenadas que atrapa a todos, un gráfico de barras elaborado, el truco de la polilínea para los gráficos de líneas y la matemática de los arcos para las porciones circulares

La única parte difícil es el sistema de coordenadas

Los gráficos de pantalla colocan el origen en la parte superior izquierda con la Y creciendo hacia abajo. PDF hace lo contrario. El origen se encuentra en la esquina inferior izquierda de la página y la Y crece hacia arriba, medida en puntos (1/72 de pulgada). Cada llamada de dibujo en HotPDF, TextOut, Rectangle, MoveTo, LineTo, Circle, usa esa convención de abajo a la izquierda, Y hacia arriba. Si traslada sus instintos de gráficos de pantalla, su primer gráfico se dibujará al revés y se saldrá por la parte inferior de la página

Por lo tanto, el verdadero trabajo en cualquier gráfico es un mapeo: convertir un valor de datos en una coordenada Y que respete la Y hacia arriba. Decida un rectángulo de trazado, cuatro números para el área en la que vivirá el gráfico, luego asigne el valor más pequeño de sus datos al borde inferior y el más grande al superior. Para una barra de valor V en una escala que va de 0 a MaxValue, el borde superior de la barra es PlotBottom + (V / MaxValue) * PlotHeight, y la barra crece hacia arriba desde PlotBottom. Haga bien esa expresión y todo lo demás será contabilidad. El ayudante a continuación contiene la geometría del trazado y realiza la conversión, por lo que el código de dibujo nunca toca la aritmética sin procesar dos veces:

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;

Hay una decisión de juicio escondida en MaxValue. Si lo ajusta exactamente al punto de datos más grande, la barra más alta tocará el borde superior del trazado y se verá cortada. Redondéelo hacia arriba a un número limpio por encima del máximo, digamos el siguiente múltiplo de 10 o 100, de modo que el gráfico tenga espacio libre y las etiquetas de las líneas de cuadrícula se lean como cifras redondas en lugar del valor máximo que alcanzaron los datos por casualidad

Un gráfico de barras es un bucle sobre rectángulos

Con el mapeo resuelto, un gráfico de barras se escribe solo. Divida el ancho del trazado en un espacio por categoría, deje un espacio entre las barras para que no se toquen y dibuje cada barra como un rectángulo relleno cuya altura proviene de ValueToY. La función Rectangle de HotPDF toma la esquina inferior izquierda más un ancho y una altura, lo cual se alinea exactamente con una barra que crece hacia arriba desde la línea base. Establezca primero el color de relleno, trace la ruta, y luego llame a Fill para pintarlo. La etiqueta de la categoría va debajo de la línea base, el valor encima de la 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;

Dos detalles justifican su existencia. El Gap (espacio) es una fracción de la ranura en lugar de un recuento de puntos fijo, de modo que las barras se mantengan proporcionalmente espaciadas ya sea que trace cuatro categorías o cuarenta. Y la etiqueta del valor se posiciona con la misma altura derivada de ValueToY que usa la barra, de modo que siempre se asienta justo por encima de su propia barra en lugar de flotar a un desplazamiento adivinado. Si desea líneas de cuadrícula horizontales detrás de las barras, dibújelas antes del bucle: elija tres o cuatro valores redondos, ejecute ValueToY en cada uno y trace una línea tenue a lo largo del trazado en esa Y. Dibujarlas primero las pone detrás de las barras en el modelo de apilamiento del pintor que usa PDF

Los ejes, marcas y etiquetas son solo más líneas y texto

El gráfico no está terminado hasta que un lector pueda saber qué significan las barras, y eso es trabajo exclusivo de los ejes. El eje vertical es una línea trazada en el borde izquierdo del área del gráfico con un puñado de marcas y sus valores. Reutilice ValueToY para que las marcas caigan en la misma escala que usan las barras, de lo contrario, una barra y su línea de cuadrícula no concordarán y el gráfico mentirá silenciosamente:

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;

Las etiquetas son donde los gráficos se rompen con mayor frecuencia en producción, y la falla siempre es la misma: el texto que cabía en la pantalla sobrepasa su espacio en el PDF. Los nombres largos de categorías chocan con sus vecinos, y los nombres de meses localizados como "septiembre" o "Dezember" son más anchos que el "Sep" en inglés con el que probó. No hay tamaño automático que lo rescate aquí, así que deje un margen real bajo la línea base, reduzca la fuente uno o dos puntos para conjuntos densos de categorías, y si los nombres son genuinamente largos, rótelos. TextOut toma un ángulo como su tercer argumento, por lo que pasar 90 pone la etiqueta de pie y le da espacio sin superposición. Pruebe el diseño con su etiqueta esperada más ancha, no con la más corta, antes de enviar la exportación

Gráficos de líneas: una polilínea a través de puntos mapeados

Un gráfico de líneas reutiliza todo el mapeo de valores y cambia únicamente cómo se conectan los puntos. En lugar de un rectángulo por categoría, usted recorre los datos una vez, convierte cada valor a sus coordenadas (X, Y) con ValueToY y une los puntos con un único MoveTo seguido por llamadas a LineTo, trazados al final. El primer punto abre la ruta; cada punto posterior la extiende:

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;

Tenga en cuenta la diferencia de espaciado. Un gráfico de barras divide el ancho entre la cantidad de barras, porque cada barra posee una ranura. Un gráfico de líneas divide por el número de intervalos, Count - 1, porque el primer y el último punto se ubican en los bordes del área del gráfico y la línea abarca los espacios entre ellos. Confundir estos dos es la razón habitual por la que un gráfico de líneas se desvía media ranura del gráfico de barras que debe superponer. Si desea un marcador en cada punto de datos, coloque un pequeño Circle y Fill en cada (X, Y) después de que se trace la polilínea

Gráficos circulares: arcos o cuñas si lo mantiene simple

Las porciones circulares son la única forma que requiere trigonometría, porque una cuña está delimitada por dos radios y un arco. La versión honesta barre el arco haciendo pasos con pequeños segmentos de línea a lo largo de la circunferencia, lo cual aproxima la curva lo suficientemente cerca como para que ningún lector pueda notarlo. El ángulo de barrido de cada porción es su parte del total, (Value / Total) * 2π, y usted acumula el ángulo actual a medida que avanza:

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;

El trazado desde el centro hacia afuera, primero el vértice, luego el arco, y luego de vuelta al vértice, le da una cuña cerrada que Fill pinta de forma sólida. La cantidad de segmentos intercambia suavidad por tamaño de trazado: aproximadamente dos grados por paso se ve redondo en cualquier radio sensato sin producir un flujo de contenido enorme. Si no necesita un círculo verdadero, puede saltarse la trigonometría por completo y renderizar los mismos datos como una única barra apilada horizontal, donde el ancho de cada segmento es proporcional a su participación. A menudo eso es más legible que un gráfico circular de todos modos, y es solo el código de barra con los rectángulos colocados de extremo a extremo. Recurra a la versión de arcos solo cuando el diseño requiera un círculo real

Nada de esto depende de que haya instalada alguna biblioteca de gráficos, lo cual es la ventaja silenciosa de dibujar las primitivas directamente. Las mismas llamadas de dibujo en el lienzo que colocan un logotipo o una casilla de firma construyen estos gráficos, y el mismo TextOut que etiqueta un campo de formulario etiqueta un eje. Coloque la geometría del área del gráfico en un registro (record), asigne valores a Y una vez, y un gráfico de barras, de líneas o circular es una rutina corta sobre Rectangle, LineTo y Circle que puede incluir en cualquier reporte. Las llamadas a Rectangle, MoveTo, LineTo, Circle, Fill, Stroke y TextOut utilizadas aquí son parte del Componente HotPDF para Delphi y C++Builder