Artículo técnico

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

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

La salida vectorial mueve el trabajo de diseño a su código. Alguien tiene que calcular dónde cae cada barra, marca y etiqueta, y para gráficos de barras, líneas y circulares ese cálculo es un mapeo de coordenadas más unos cuantos bucles sobre rectángulos, segmentos de línea y arcos. Haga bien el mapeo una vez y los bucles son cortos

¿DrawChart o primitivas dibujadas a mano en HotPDF?

HotPDF trae un renderizador de gráficos listo para usar, y para un gráfico de informe estándar es lo primero que hay que probar. Construya un THPDFChart, agregue series con AddSeries (tipos hckLine, hckBar, hckArea, hckPie y hckScatter), llénelas con AddPoint, y llame a THPDFPage.DrawChart con un rectángulo destino. El renderizador resuelve por su cuenta el rango de ejes, la cuadrícula, las etiquetas de marcas, la leyenda y los colores del tema, y la sobrecarga que toma un padre de estructura escribe el gráfico como una figura etiquetada con texto alternativo construido a partir de los datos, algo que importa si el documento tiene que pasar una verificació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 de meta sombreada detrás de las barras, un color de marca distinto para cada barra de una misma serie, un circular que empieza a las doce y corre en sentido horario: en cuanto un diseño pide cualquiera de esas cosas, dibujar el gráfico usted mismo con el lienzo de página es menos trabajo que doblegar el renderizador. Esas primitivas son Rectangle, MoveTo, LineTo, Pie, Fill, Stroke y TextOut, las mismas llamadas cubiertas en el recorrido de dibujo en el lienzo

¿Por qué mi gráfico de barras sale al revés?

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 lienzo VCL. El espacio de usuario de 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 de un gráfico, en cambio, crecen hacia arriba. Meta un valor escalado directo como Y y la barra más alta cuelga de la parte superior del trazado como un carámbano

La solución es una expresión. Elija un rectángulo de trazado, mapee el cero a su borde inferior y el tope de la escala a su borde superior, y pase 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 ayudante ValueToY y todas 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 conserva el marco de pantalla familiar, con origen arriba a la izquierda y Y creciendo hacia abajo, así que una sola expresión ValueToY voltea cada valor de datos que crece 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 punto de datos más grande, el siguiente múltiplo de 10 o 100, de modo que la barra más alta no toque el borde superior y las etiquetas de cuadrícula 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 se vea apenas más de la mitad de alta que una de 420 cuando la brecha real es menor al 8%. El renderizador incorporado mete el cero en el rango para series de barras y áreas exactamente por esta razón. Conserve 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). Cambie la resolución y sus coordenadas, anchos de línea y mediciones de texto pasan todos a la unidad nueva, algo que el artículo sobre Resolution y unidades de dibujo cubre en detalle. DrawChart toma su rectángulo destino en unidades de página pero organiza sus márgenes, tamaños de fuente y anchos de línea propios en puntos a cualquier resolución

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

Con el mapeo resuelto, un gráfico de barras es un bucle. Divida el ancho del trazado en una ranura por categoría, deje un hueco para que las barras vecinas no se toquen, y dibuje cada barra como un rectángulo relleno desde su tope de ValueToY hasta la línea base. Rectangle toma la esquina superior izquierda, un ancho y una altura, lo cual encaja directo con este mapeo:

HotPDF: anatomía del gráfico de barras que muestra ranuras divididas a partir del ancho del área de trazado con espacios entre barras, cada barra un Rectangle relleno que crece hacia arriba desde Plot.Bottom, etiquetas de categoría bajo la línea base, etiquetas de valor sobre la altura de la barra, y marcas del eje reutilizando ValueToY
Cada barra es un Rectangle relleno que crece hacia arriba desde Plot.Bottom dentro de su ranura, el hueco de un cuarto de ranura escala con el ancho, y las marcas del eje reutilizan ValueToY para que barras y líneas de cuadrícula coincidan
type
  TPlotArea = record
    Left, Bottom, Width, Height: Single;  // unidades de página; Bottom es la Y de la línea base
    MaxValue: Single;                     // tope de la escala de valores
  end;

// Su propio ayudante: 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 de la ranura, repartido a izquierda y derecha
  BarW := SlotW - Gap;

  // Líneas de cuadrícula discontinuas primero, para que las barras pinten encima
  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 el tope 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;

  // 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 cuadrícula van primero, las barras encima, y la línea base al final. Mueva el bloque de cuadrícula debajo del bucle y los guiones cruzarán cada barra de lado a lado

El hueco es una fracción de la ranura en lugar de un número fijo de puntos. Con cuatro categorías en un trazado de 470 puntos cada ranura mide 117.5 puntos de ancho y las barras reciben 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 hueco fijo de 12 puntos se tragaría 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. Un 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 fuente de 9 puntos. El eje de valores tiene una variante más silenciosa del mismo problema: las etiquetas de marcas alineadas a la izquierda con un offset X fijo se ven desalineadas en cuanto los números cambian de longitud, y una etiqueta "1,200" choca con la línea del eje que "800" despejaba

Mida en lugar de adivinar. TextWidth devuelve el ancho de una cadena en la fuente actual, en unidades de página, y TextHeight devuelve el ascenso de la fuente al tamaño actual. Alinee a la derecha cada etiqueta de marca contra el eje y céntrela sobre su marca:

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 corta 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 medición le dice cuándo rendirse con el texto horizontal. Compare la etiqueta más ancha contra el ancho de la ranura antes de dibujar nada. Si no cabe, baje la fuente un punto o dos; si todavía no cabe, pase 90 como argumento de ángulo de TextOut y ponga las etiquetas de pie, y luego deje margen suficiente bajo la línea base para la más larga. Pruebe con la etiqueta más ancha que el informe pueda producir de verdad, que para nombres de meses 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 solo cambia cómo se conectan los puntos. Recorra los datos una vez, convierta cada valor con ValueToY, y cosa los puntos en una sola ruta: MoveTo para el primer punto, LineTo para cada uno posterior, un único Stroke al final. Si ya tiene los puntos en un arreglo, Polyline toma un array of THPDFCurrPoint, abre una ruta nueva, y le deja el trazo a usted; 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 desvía de un gráfico de barras dibujado sobre el mismo trazado. Las barras dividen el ancho entre el número de categorías porque cada barra es dueña de una ranura. Una línea lo divide entre el número de intervalos, Count - 1, porque el primer y el último punto se sientan en los bordes del trazado. Superponga ambos y los puntos de la línea caen en los límites de ranura en lugar de en los centros de las barras. Cuando una línea va montada sobre barras, coloque sus puntos en el centro de cada ranura, Plot.Left + I * SlotW + SlotW / 2, y elimine por completo 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

Porciones circulares con Pie, en sentido horario desde las doce

Una porción circular son dos radios y un arco. La forma artesanal de construir una empieza en el centro, avanza con segmentos cortos de LineTo separados un par de grados a lo largo del 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 cien segmentos de ruta, y el borde sigue siendo un polígono bajo mucho zoom

HotPDF: construcción de gajos de pastel comenzando con MoveTo en el centro del círculo, avanzando pequeños segmentos LineTo a unos dos grados de distancia a lo largo del arco sobre el ángulo de barrido de cada gajo, y cerrando hacia el centro para que Fill pinte una cuña sólida
Un sector comienza con MoveTo en el centro, avanza con pequeños pasos LineTo separados unos dos grados a lo largo de su barrido de la circunferencia, y se cierra de vuelta para que Fill lo pinte sólidamente

Use Pie en su lugar. THPDFPage.Pie sigue la convención de GDI: un rectángulo delimitador, un ángulo inicial y un ángulo final en grados, 0 apuntando al este, 90 al norte, ángulos positivos en sentido antihorario. Construye el arco a partir de segmentos Bézier de a lo sumo 90 grados cada uno, dibuja la línea al centro y cierra la ruta, así que el borde se queda como una curva verdadera y una porción completa cuesta un puñado de operadores. Los circulares de negocios suelen empezar a las doce y correr en sentido horario, lo que en términos de 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 en punto
  Page.SetRGBStrokeColor(clWhite);
  Page.SetLineWidth(0.8);
  for I := 0 to High(Values) do
  begin
    if Values[I] <= 0 then
      Continue;                           // ver abajo: nunca pase 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 ángulo inicial como un barrido que envuelve pasando 360, la lectura de GDI, que es lo que permite a una porción cruzar la línea de 0 grados. Una fila de reembolso 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 hasta casi un disco completo pintado sobre todo lo dibujado antes. Un valor cero es más leve pero igual visible: la ruta cerrada colapsa a un radio, y el trazo blanco separador lo dibuja como una línea perdida del borde al centro. Filtre ambos antes del bucle, y decida por separado cómo muestra el informe los montos negativos, porque un circular no puede mostrarlos en absoluto

Cuando el diseño no insiste en un círculo, una única barra apilada horizontal muestra las mismas proporciones con nada más que llamadas a 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

Cada gráfico de este artículo está construido con las llamadas de lienzo de página del HotPDF Delphi Component, con DrawChart disponible para los diseños estándar de líneas, barras, áreas, circulares y dispersión, y todo corre desde Delphi o C++Builder sin ninguna biblioteca de gráficos instalada en la máquina de compilación