Artículo técnico

Dibujar gráficos en PDF con primitivas HotPDF (Delphi)

Un gráfico copiado de un control de gráfico de pantalla como bitmap de 96 DPI se ve bien al 100% de zoom en un visor PDF. Sube al 400%, o manda la página a una impresora láser de 600 DPI, y los bordes de las barras se vuelven blandos, las etiquetas de los ejes se difuminan en manchas grises, y el archivo arrastra unos cientos de kilobytes de píxeles para lo que en realidad son una docena de rectángulos. Dibujado como rutas vectoriales, el mismo gráfico se mantiene nítido a cualquier nivel de zoom y resolución de impresión, porque las barras, los ejes y las etiquetas son operadores de ruta y texto PDF que el visor vuelve a renderizar a la escala que necesite

La salida vectorial mueve el trabajo de maquetación a tu código. Alguien tiene que calcular dónde aterriza cada barra, tick y etiqueta, y para gráficos de barras, líneas y circular ese cálculo es un mapeo de coordenadas más unos bucles sobre rectángulos, segmentos de línea y arcos. Clavar el mapeo una vez y los bucles son cortos

¿DrawChart o primitivas dibujadas a mano en HotPDF?

HotPDF trae un renderizador de gráficos hecho, y para un gráfico de informe estándar es lo primero que hay que probar. Construye un THPDFChart, añade series con AddSeries (tipos hckLine, hckBar, hckArea, hckPie y hckScatter), llénalas con AddPoint y llama a THPDFPage.DrawChart con un rectángulo objetivo. El renderizador calcula por su cuenta el rango del eje, la rejilla, las etiquetas de tick, la leyenda y los colores del tema, y la sobrecarga que toma un padre de estructura escribe el gráfico como figura etiquetada con texto alternativo construido a partir de los datos, lo que importa si el documento tiene que pasar una revisión de accesibilidad

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');
    // Rectángulo en unidades de página: izquierda, arriba, ancho, alto
    HotPDF1.CurrentPage.DrawChart(Chart, 60, 120, 470, 280);
  finally
    Chart.Free;
  end;
end;

El renderizador tiene un vocabulario fijo, eso sí. Varias series de barras se sientan lado a lado en cada categoría, un gráfico circular debe ser la única serie de su gráfico, y el aspecto sale de tres temas más colores por serie. Barras apiladas, una banda objetivo sombreada detrás de las barras, un color de marca distinto para cada barra en una sola serie, un circular que empieza a las doce y corre en sentido horario: en cuanto un diseño pide cualquiera de estas cosas, dibujar el gráfico tú mismo con el canvas de la página es menos trabajo que forzar el renderizador. Esas primitivas son Rectangle, MoveTo, LineTo, Pie, Fill, Stroke y TextOut, las mismas llamadas cubiertas en el recorrido de dibujo en canvas

¿Por qué mi gráfico de barras sale boca abajo?

Las coordenadas de página de HotPDF ponen el origen en la esquina superior izquierda con Y creciendo hacia abajo, el mismo marco que un canvas VCL. El espacio de usuario PDF corre al revés (origen abajo a la izquierda, Y hacia arriba, ISO 32000-1 §8.3.2.3), y HotPDF voltea cada coordenada a ese espacio mientras escribe el flujo de contenido. Los valores del gráfico, por su parte, crecen hacia arriba. Alimentar la Y con un valor escalado directamente y la barra más alta cuelga desde lo alto del trazado como un carámbano

La solución es una expresión. Elige un rectángulo de trazado, mapea el cero a su borde inferior y lo alto de la escala a su borde superior, y pasa cada valor por ese mapeo antes de que toque la página. Para un valor V en una escala de 0 a MaxValue, la Y de página es Plot.Bottom - (V / Plot.MaxValue) * Plot.Height, donde Plot.Bottom es la línea base. El código del gráfico de barras más abajo envuelve eso en un pequeño helper ValueToY y las demás rutinas lo reutilizan

HotPDF: Las coordenadas de pantalla y las coordenadas de página de HotPDF comparten un origen arriba a la izquierda con Y creciendo hacia abajo mientras los valores del gráfico crecen hacia arriba, y ValueToY voltea un valor de datos a ese espacio de página con Y hacia abajo
HotPDF mantiene el marco de pantalla familiar, origen arriba a la izquierda y Y creciendo hacia abajo, así que una expresión ValueToY voltea cada valor de datos creciente hacia arriba al espacio de página

Dos decisiones se esconden en esa expresión. MaxValue debería ser un número redondo por encima del mayor dato, el siguiente múltiplo de 10 o 100, para que la barra más alta no toque el borde superior y las etiquetas de las líneas de rejilla lean 0, 200, 400 en lugar de 0, 203.5, 407. Y la escala empieza en cero a propósito. La longitud de la barra es el valor, así que un eje de valores que empieza en 350 hace que una barra de 388 parezca apenas más de la mitad de alta que una de 420 cuando la diferencia real es menor del 8%. El renderizador integrado mete el cero en el rango para las series de barras y área por exactamente esta razón. Guarda la misma regla en el código dibujado a mano

Las unidades son la otra mitad del mapeo. Con la Resolution por defecto de 72, una unidad de página es un punto PDF (1/72 de pulgada). Cambia la resolución y tus coordenadas, anchos de línea y medidas de texto cambian todos a la nueva unidad, cosa que el artículo de Resolution y unidades de dibujo cubre en detalle. DrawChart toma su rectángulo objetivo en unidades de página pero maqueta sus márgenes, tamaños de fuente y anchos de línea en puntos a cualquier resolución

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

Con el mapeo en su sitio, un gráfico de barras es un bucle. Divide el ancho del trazado en un hueco por categoría, deja un margen para que las barras vecinas no se toquen, y dibuja cada barra como un rectángulo relleno desde su cima de ValueToY hasta la línea base. Rectangle toma la esquina superior izquierda, un ancho y un alto, lo que encaja directamente con este mapeo:

HotPDF: Anatomía del gráfico de barras mostrando huecos divididos desde el ancho del trazado con márgenes entre barras, cada barra un Rectangle relleno creciendo desde Plot.Bottom, etiquetas de categoría bajo la línea base, etiquetas de valor sobre la altura de la barra, y ticks del eje reutilizando ValueToY
Cada barra es un Rectangle relleno que crece desde Plot.Bottom dentro de su hueco, el margen de un cuarto del hueco escala con el ancho, y los ticks del eje reutilizan ValueToY para que barras y líneas de rejilla concuerden
type
  TPlotArea = record
    Left, Bottom, Width, Height: Single;  // unidades de página; Bottom es la Y de la línea base
    MaxValue: Single;                     // cima de la escala de valores
  end;

// Tu propio helper: un valor mayor queda más arriba, lo que en una página
// con Y hacia abajo significa una 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;              // un cuarto del hueco, repartido a izquierda y derecha
  BarW := SlotW - Gap;

  // Líneas de rejilla discontinuas primero, para que las barras pinten sobre ellas
  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 coloca la parte superior del texto en Y
    Page.SetRGBFillColor(clBlack);
    Page.TextOut(BarX, Plot.Bottom + 4, 0, Labels[I]);
    Page.TextOut(BarX, BarTop - 12, 0, FormatFloat('0', Values[I]));
  end;

  // La línea base al final, para que quede sobre las bases de las 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;

El orden es todo el modelo de capas aquí. PDF usa un modelo de pintor, donde los operadores posteriores del flujo de contenido pintan sobre los anteriores, así que las líneas de rejilla van primero, las barras sobre ellas y la línea base al final. Cambia el bloque de rejilla de sitio por debajo del bucle y las líneas discontinuas cruzan por encima de todas las barras

El margen es una fracción del hueco en lugar de un número fijo de puntos. Con cuatro categorías en un trazado de 470 puntos cada hueco mide 117.5 puntos de ancho y las barras obtienen unos 29 puntos de aire entre ellas; con cuarenta categorías el mismo código deja menos de 3 puntos, que todavía se lee como barras separadas. Un margen fijo de 12 puntos se tragaria 480 de los 470 puntos disponibles con cuarenta categorías y el ancho de barra se volvería negativo

Etiquetas de eje que no chocan

Las etiquetas son donde los gráficos dibujados a mano se rompen primero, y casi siempre de la misma manera. Texto que cabía en pantalla desborda su espacio en la página, porque el desarrollador probó con «Sep» y el informe alemán imprime «September» o «Dezember» en una fuente de 9 puntos. El eje de valores tiene una variante más silenciosa del mismo problema: etiquetas de tick alineadas a la izquierda con offset X fijo se ven desalineadas en cuanto los números cambian de longitud, y una etiqueta «1,200» choca contra la línea del eje que «800» despejaba

Mide en lugar de adivinar. TextWidth devuelve el ancho de una cadena en la fuente actual, en unidades de página, y TextHeight devuelve el ascendente de la fuente al tamaño actual. Alinea a la derecha cada etiqueta de tick contra el eje y céntrala sobre su 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);        // tick corto fuera del eje
    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 las etiquetas de categoría la misma medida te dice cuándo rendirte con el texto horizontal. Compara la etiqueta más ancha contra el ancho del hueco antes de dibujar nada. Si no cabe, baja la fuente un punto o dos; si aún no cabe, pasa 90 como argumento de ángulo de TextOut y pon las etiquetas en vertical, y deja margen suficiente bajo la línea base para la más larga. Prueba con la etiqueta más ancha que el informe pueda producir realmente, que para nombres de mes en una compilación localizada rara vez es la inglesa

Los gráficos de líneas reutilizan el mismo mapeo

Un gráfico de líneas cambia solo cómo se conectan los puntos. Recorre los datos una vez, convierte cada valor con ValueToY y une los puntos en una sola ruta: MoveTo para el primer punto, LineTo para cada uno posterior, un único Stroke al final. Si ya tienes los puntos en una matriz, Polyline toma un array of THPDFCurrPoint, abre una ruta nueva y deja el trazo para ti; lanza una excepción con menos de dos puntos, así que una serie con una sola lectura necesita un marcador en lugar de una línea

El espaciado es donde un gráfico de líneas se aparta de uno de barras dibujado sobre el mismo trazado. Las barras dividen el ancho entre el número de categorías porque cada barra posee un hueco. Una línea lo divide entre el número de intervalos, Count - 1, porque el primer y último punto se sientan en los bordes del trazado. Superpon ambos y los puntos de la línea caen en los límites de los huecos en lugar de en los centros de las barras. Cuando una línea va sobre barras, coloca sus puntos en el centro de cada hueco, Plot.Left + I * SlotW + SlotW / 2, y suelta del todo el espaciado de Count - 1. Para un marcador en cada punto, un pequeño Circle seguido de Fill después de trazar la línea hace el trabajo

Sectores circulares con Pie, en sentido horario desde las doce

Una porción circular son dos radios y un arco. La manera artesanal de construir una empieza en el centro, avanza en cortos segmentos LineTo a un par de grados de distancia por el borde y cierra de vuelta al centro, que es la construcción de la figura de abajo. Funciona, pero una porción de 270 grados se convierte en más de un centenar de segmentos de ruta y el borde sigue siendo un polígono con mucho zoom

HotPDF: Construcción de una porción circular empezando con MoveTo en el centro del círculo, avanzando en pequeños segmentos LineTo a unos dos grados de distancia por el arco sobre el ángulo de barrido de cada porción, y cerrando hacia el centro para que Fill pinte una cuña sólida
Una cuña empieza con MoveTo en el centro, avanza en pequeños pasos LineTo a unos dos grados de distancia por su barrido de la circunferencia y cierra de vuelta para que Fill la pinte sólida

Usa Pie mejor. THPDFPage.Pie sigue la convención GDI: un rectángulo envolvente, un ángulo inicial y uno final en grados, 0 apuntando al este, 90 al norte, ángulos positivos en sentido antihorario. Construye el arco con segmentos Bézier de 90 grados como máximo cada uno, dibuja la línea al centro y cierra la ruta, así que el borde sigue siendo una curva verdadera y una porción completa cuesta un puñado de operadores. Los circulares de negocio suelen empezar a las doce y correr en sentido horario, que en términos GDI significa empezar en 90 y bajar el ángulo:

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;                            // las doce
  Page.SetRGBStrokeColor(clWhite);
  Page.SetLineWidth(0.8);
  for I := 0 to High(Values) do
  begin
    if Values[I] <= 0 then
      Continue;                           // ver abajo: nunca pases un barrido cero o negativo
    Sweep := Values[I] / Total * 360;
    Page.SetRGBFillColor(Colors[I mod Length(Colors)]);
    // En sentido antihorario de Angle - Sweep a Angle está la porción que
    // queda en sentido horario de la posición actual
    Page.Pie(CX - Radius, CY - Radius, CX + Radius, CY + Radius,
      Angle - Sweep, Angle);
    Page.FillAndStroke;                   // el trazo blanco separa las porciones
    Angle := Angle - Sweep;
  end;
end;

La guarda del Continue es la línea que más importa. Pie trata un ángulo final menor que el inicial como un barrido que envuelve pasando 360, la lectura GDI, que es lo que permite a una porción cruzar la línea de 0 grados. Una fila de reembolsos o un asiento de corrección en los datos de origen da un valor negativo, el ángulo final calculado cae por debajo del inicial, y la porción envuelve en un disco casi completo pintado sobre todo lo dibujado antes. Un valor cero es más leve pero también visible: la ruta cerrada colapsa a un radio, y el trazo separador blanco lo dibuja como una línea perdida del borde al centro. Filtra ambos antes del bucle, y decide por separado cómo muestra el informe los importes negativos, porque un circular no puede mostrarlos en absoluto

Cuando el diseño no exige un círculo, una única barra apilada horizontal muestra las mismas proporciones con nada más que llamadas Rectangle puestas de extremo a extremo, y los lectores comparan longitudes con más precisión que ángulos. Para una figura de parte de un todo con más de cinco o seis categorías ese es el mejor gráfico, y el código del gráfico de barras de arriba ya hace la mayor parte del trabajo

Todos los gráficos de este artículo se construyen con las llamadas de canvas de página del HotPDF Delphi Component, con DrawChart disponible para los diseños estándar de líneas, barras, área, circular y dispersión, y todo corre desde Delphi o C++Builder sin una biblioteca de gráficos instalada en la máquina de compilación