Technical Article

Crear documentos PDF desde cero con PDFium VCL en Delphi

PDFium tiene fama de motor de visualización, el renderizador que hay detrás de la pestaña PDF de Chrome, de modo que lo primero que conviene aclarar es que PDFium VCL también puede construir un documento que no existía antes. El lado de autoría envuelve la API de objetos de página de PDFium: se crea un documento vacío, se añaden páginas con dimensiones explícitas y se coloca texto, trazados vectoriales e imágenes en cada página en las coordenadas que se elijan. No hay ningún lenguaje de descripción de página que aprender ni ningún controlador de impresión en el proceso. Se llama a métodos, la biblioteca ensambla objetos PDF y SaveAs serializa el resultado.

Lo que no se obtiene es un motor de maquetación. Esto es suficientemente importante como para decirlo desde el principio, porque condiciona cada ejemplo a continuación. PDFium VCL coloca el contenido donde se le indica, en coordenadas absolutas, y en ningún otro lugar. No ajustará un párrafo, no hará fluir texto a través de un salto de página ni calculará una tabla a partir de filas y columnas. Eso es responsabilidad del desarrollador. Si se llega esperando algo que reajuste la prosa como un procesador de texto, conviene recalibrarse: esta es una API de colocación precisa y de bajo nivel, más próxima a dibujar sobre un lienzo que a componer un documento. Para facturas generadas, certificados, etiquetas y páginas de informe donde ya se sabe dónde va cada elemento, esa precisión es exactamente lo que se necesita.

Lo mínimo que produce un fichero

Tres llamadas separan un TPdf vacío de un PDF guardado: crear el documento, añadir una página y escribirlo. Todo lo demás es contenido que se intercala en medio.

uses
  Vcl.Graphics,   // for clBlack and TColor
  PDFium;         // TPdf lives here

procedure CreateBlankPdf(const FileName: string);
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;                 // empty in-memory document
    Pdf.AddPage(0, 595, 842);           // A4 portrait, in points
    Pdf.AddText('First page', 'Arial', 18, 50, 780);
    Pdf.SaveAs(FileName);               // serialize to disk
  finally
    Pdf.Active := False;
    Pdf.Free;
  end;
end;

Un detalle confunde a quien ha visto fragmentos de código más antiguos: no se asigna Pdf.Active := True después de CreateDocument. La propiedad Active informa de si existe un handle de documento, y CreateDocument ya ha creado uno, por lo que la propiedad es True en el momento en que esa llamada retorna. Volver a asignarla es, en el mejor caso, una no-operación y, en el peor, confuso para el siguiente lector. Active resulta útil a la salida: asignar False libera el documento subyacente antes de Free, que es el orden de limpieza correcto. Hay que tratar CreateDocument y una apertura con carga de fichero como mutuamente excluyentes. La biblioteca se niega a crear un nuevo documento en un TPdf que ya tiene uno abierto, de modo que reutilizarlo implica cerrar el documento actual primero.

Las coordenadas empiezan en la esquina inferior izquierda

El segundo par de argumentos de AddText, y de cualquier llamada de colocación, es un punto en el espacio de usuario PDF. El origen se sitúa en la esquina inferior izquierda de la página, X va hacia la derecha e Y va hacia arriba. Una unidad es un punto, 1/72 de pulgada, de modo que una página A4 mide 595 por 842 unidades y US Letter mide 612 por 792. Ese Y ascendente es la fuente más habitual de confusión del tipo "mi texto está fuera de la página", porque las coordenadas de pantalla y de mapa de bits colocan el origen arriba con Y creciendo hacia abajo. En una página de 842 puntos de alto, un encabezado cerca de la parte superior se sitúa alrededor de Y 780, no de Y 60. Cuando un elemento acaba en un lugar inesperado, la altura de página menos el Y utilizado casi siempre es el número que se quería poner.

AddPage toma una posición de inserción como primer argumento, expresada en base 1, con 0 como atajo conveniente para "inicio del documento". Pasar 0 o 1 para la primera página inserta la página al principio; pasar el valor igual al recuento al que se está añadiendo inserta al final. La página recién añadida también pasa a ser la página activa, la que apuntan las llamadas de dibujo siguientes, de modo que no hay ningún paso "seleccionar esta página" separado después de añadirla. Si se añaden varias páginas y más tarde hay que volver a dibujar sobre una anterior, hay que asignar PageNumber para mover el cursor; mientras se rellenen páginas en orden a medida que se crean, puede dejarse solo.

Escribir texto y la regla de fuentes que falla en silencio

La signatura de AddText contiene todo lo que necesita una cadena: el texto, un nombre de fuente, un tamaño en puntos, el ancla X e Y, y opcionalmente color, un byte alfa para la transparencia y un ángulo de rotación en grados.

procedure WriteHeader(Pdf: TPdf; const Title, Author: string);
begin
  // Title in black, default opacity, no rotation
  Pdf.AddText(Title, 'Arial', 20, 50, 780);
  // A lighter byline 24 points below it
  Pdf.AddText('By ' + Author, 'Arial', 11, 50, 756, clGray);
  // A faint diagonal draft stamp across the page
  Pdf.AddText('DRAFT', 'Arial', 64, 180, 380, clGray, $30, 45.0);
end;

El byte alfa va de $00 (invisible) a $FF (opaco), que es lo que convierte el sello de borrador en una marca de agua en lugar de en un bloque sólido: $30 es aproximadamente el diecinueve por ciento de opacidad, suficiente para leer a través. El ángulo gira la cadena en sentido antihorario alrededor de su ancla, de modo que 45 grados dan el clásico sello de esquina a esquina. Nada de esto necesita una función de marca de agua separada. Una marca de agua es simplemente una llamada a AddText grande, semitransparente y rotada, y dibujarla antes o después del cuerpo decide si queda detrás o encima del contenido.

Las fuentes merecen una mención cuidadosa, porque el modo de fallo es silencioso. Cuando se pasa un nombre de fuente, PDFium VCL solicita al sistema operativo los datos TrueType de esa fuente y los incrusta en el documento, que es por lo que un fichero construido en una máquina se renderiza de forma idéntica en una que nunca ha tenido la fuente instalada. El problema es lo que ocurre cuando el nombre no se resuelve: una errata, o una tipografía que simplemente no está presente en la máquina de compilación. No se lanza ninguna excepción. La biblioteca crea un objeto de texto que lleva el nombre solo como etiqueta, sin nada incrustado, y deja al visor que sustituya lo que considere más próximo. El texto aparece en las pruebas, tiene buena pinta, y las métricas o los glifos cambian en el momento en que el fichero se abre en algún lugar con fuentes diferentes instaladas. Hay que usar nombres que se sabe que están presentes en la máquina generadora, tratar la lista de fuentes como una dependencia de despliegue, y abrir una muestra en un visor en un sistema limpio antes de confiar en la salida.

Formas vectoriales: construir un trazado y luego confirmarlo

Las líneas, rectángulos y regiones rellenas se gestionan a través de un trazado. Se abre uno con CreatePath, que establece el punto de inicio y todo el estilo a la vez: modo de relleno, colores de relleno y contorno con sus propios bytes alfa, anchura del trazo, extremos y uniones de línea. Luego se extiende con LineTo, BezierTo y ClosePath, y finalmente AddPath confirma el trazado terminado en la página. El paso de confirmación es fácil de olvidar y no produce nada si se omite.

procedure DrawDivider(Pdf: TPdf; X, Y, Width: Single);
begin
  // A thin horizontal rule. The rectangle overload sets a box directly:
  // X, Y, Width, Height, then fill mode and colors.
  Pdf.CreatePath(X, Y, Width, 0.5, fmNone, clBlack, $FF,
    True, clBlack, $FF, 1.0);
  Pdf.AddPath;
end;

procedure DrawTriangle(Pdf: TPdf);
begin
  // Point overload: start at the first vertex, line to the rest, close.
  Pdf.CreatePath(200, 300, fmWinding, clBlue, $80, True, clNavy, $FF, 2.0);
  Pdf.LineTo(300, 300);
  Pdf.LineTo(250, 400);
  Pdf.ClosePath;
  Pdf.AddPath;          // nothing is drawn until this runs
end;

Dos sobrecargas cubren los casos habituales. La forma de cuatro coordenadas toma X, Y, anchura y altura y da un rectángulo alineado con los ejes en una sola llamada, que es lo que se usa para dibujar una regla, un borde de celda o un panel de fondo relleno. La forma de dos coordenadas establece solo un punto de inicio, y el resto del contorno se traza manualmente con LineTo y BezierTo. El modo de relleno controla cómo se pintan las regiones superpuestas: fmWinding (devanado no nulo) es adecuado para la mayoría de formas sólidas, fmAlternate (par-impar) maneja recortes y contornos auto-intersectados, y fmNone deja un trazado solo con contorno sin relleno, que es lo que usa el divisor anterior.

Las tablas son trazados y texto ensamblados a mano

Como no existe ningún primitivo de tabla, una tabla es un bucle. Se deciden los desplazamientos X de las columnas y la altura de fila, se escribe cada celda con AddText y se dibujan las líneas con trazados rectangulares. La aritmética es responsabilidad del desarrollador, pero es directa, y una vez escrita se generaliza a cualquier cuadrícula que se necesite.

procedure DrawTable(Pdf: TPdf; Left, Top: Double);
const
  ColX: array[0..2] of Double = (0, 110, 210);  // column offsets
  RowH = 20;
var
  Y: Double;
  Row: Integer;
begin
  // Header row
  Pdf.AddText('Item', 'Arial', 10, Left + ColX[0], Top);
  Pdf.AddText('Qty', 'Arial', 10, Left + ColX[1], Top);
  Pdf.AddText('Price', 'Arial', 10, Left + ColX[2], Top);

  // Rule under the header
  Pdf.CreatePath(Left, Top - 5, 260, 0.5, fmNone, clBlack, $FF);
  Pdf.AddPath;

  // Data rows, stepping Y downward each iteration
  Y := Top;
  for Row := 1 to 3 do
  begin
    Y := Y - RowH;
    Pdf.AddText('Item ' + IntToStr(Row), 'Arial', 9, Left + ColX[0], Y);
    Pdf.AddText(IntToStr(Row * 2), 'Arial', 9, Left + ColX[1], Y);
    Pdf.AddText('$' + IntToStr(Row * 10) + '.00', 'Arial', 9, Left + ColX[2], Y);
  end;
end;

Nótese cómo Y disminuye en cada pasada por la altura de fila, de nuevo porque arriba es positivo. Aquí también se evidencia la ausencia de medición de texto: nada impide que un nombre de elemento largo se desborde a la columna siguiente, porque la biblioteca no sabe cuánto ancho ha ocupado la cadena al renderizarse. Para salida de formato fijo donde se controlan los datos, se dimensionan las columnas generosamente y se avanza. Para contenido genuinamente variable, o bien se restringen las entradas o bien se miden los anchos de glifo antes de colocarlos, que es el punto en que una biblioteca de composición dedicada empieza a justificar su uso.

Imágenes y múltiples páginas

El contenido raster entra a través de los helpers de imagen. AddPicture toma un TPicture cargado y lo coloca en un punto, con anchura y altura opcionales para escalarlo; AddImage acepta una ruta de fichero o un TBitmap directamente, y AddJpegImage envía bytes JPEG sin un ciclo de ida y vuelta a través de un mapa de bits. Como con todo lo demás, las coordenadas de colocación son la esquina inferior izquierda de la imagen en el espacio de usuario, y la anchura y altura son el tamaño en página en puntos, no las dimensiones en píxeles de la fuente.

procedure CreateMultiPageReport(const FileName: string; PageCount: Integer);
var
  Pdf: TPdf;
  P: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;
    for P := 1 to PageCount do
    begin
      Pdf.AddPage(P, 595, 842);     // append; the new page becomes current
      Pdf.AddText('Page ' + IntToStr(P) + ' of ' + IntToStr(PageCount),
        'Arial', 10, 50, 30);       // footer near the bottom edge
      // ... draw this page's body here ...
    end;
    Pdf.SaveAs(FileName);
  finally
    Pdf.Active := False;
    Pdf.Free;
  end;
end;

Un documento de varias páginas es el patrón de una sola página en un bucle. Cada AddPage añade una página y la convierte en la activa, de modo que el cuerpo y el pie de página que se dibujan a continuación caen sobre la página recién añadida. No hay que reasignar PageNumber dentro de este bucle, porque añadir una página ya ha movido el cursor allí; solo se necesita PageNumber cuando se vuelve a una página fuera del orden de creación. Se llama a SaveAs una sola vez al final, después de que se haya rellenado la última página. Si se necesita un perfil de archivo en lugar de un fichero simple, el mismo objeto de documento expone SaveAsPdfA y las demás variantes de conformidad, de modo que la elección del estándar de salida es una llamada de guardado diferente, no una ruta de construcción diferente.

Dónde encaja esto

La descripción honesta es que la API de autoría de PDFium VCL es una capa fiel y fina sobre el modelo de objetos de página de PDFium: creación real de documentos, fuentes realmente incrustadas, contenido vectorial y raster real, serializado a un fichero conforme con los estándares. No es, ni pretende ser, un motor de maquetación con reajuste. La línea divisoria está en la maquetación del texto. Si la salida es plantillada, facturas, certificados, etiquetas, paneles renderizados en una cuadrícula fija, el modelo de coordenadas absolutas es directo, rápido y el código se mantiene legible. Si la salida es prosa larga que debe ajustarse y paginar por sí sola, se estará reconstruyendo un motor de maquetación encima de estas llamadas, y esa es la herramienta equivocada para el trabajo. Saber de qué lado de esa línea se está es la mayor parte de la decisión.

Los métodos de creación descritos aquí forman parte del PDFium VCL Component para Delphi, que combina esta vía de autoría con las funciones de renderización y extracción de texto por las que PDFium es más conocido.