Artículo técnico

Gradientes PDF axiales y radiales en Delphi con PDFium

PDFium Component crea gradientes PDF axiales y radiales en Delphi mediante TPdf.SaveAsShadingCreated. Describís cada gradiente en un registro TPdfShadingCreate (tipo, espacio de color, coordenadas, colores o stops, página objetivo), y el método escribe una copia verificada en la que cada relleno es un diccionario de shading real de ISO 32000-1, registrado en el diccionario de página /Resources /Shading y pintado por el operador sh, así que PDFium lo renderiza de forma nativa

La necesidad aparece en generadores de informes y certificados. Un diseñador entrega una maqueta con una banda de cabecera de azul suave a blanco o un resplandor radial tras un sello, y la API de objetos de página que cubre texto, trazados e imágenes no tiene nada para eso. Podéis fingir un gradiente con cien rectángulos finos en colores ligeramente distintos, pero el resultado hace bandas visibles al hacer zoom, hincha el content stream y imprime mal. PDF tiene shadings suaves desde 1.3; el trabajo está en escribirlos para que un renderizador los dibuje de verdad

Por qué PDFium Component escribe los gradientes como un guardado aparte

TPdf.SaveAsShadingCreated es una operación de guardado y no una llamada de objeto de página porque la ABI pública de PDFium no tiene ninguna función que cree un diccionario de shading o emita un operador sh. La capa de objetos de página usada al crear PDFs desde cero con PDFium Component construye trazados, texto e imágenes, y hasta ahí llega. Los shadings viven un nivel más abajo, en el grafo de objetos y la sintaxis del content stream

Así que el método trabaja por etapas. PDFium primero guarda el documento abierto de forma nativa, lo que conserva cada edición que hicisteis por la API normal. PDFium Component reescribe entonces esa salida preparada: asigna un objeto indirecto por shading, añade la entrada de recurso a cada página objetivo, añade un nuevo content stream a /Contents de la página, y escribe una tabla de cross-reference fresca. Antes de que nada llegue a vuestro stream o fichero, el resultado se recarga con una comprobación estricta de cross-reference, y el método devuelve False con un mensaje en el informe si esa recarga falla. La sobrecarga de fichero escribe primero a un fichero temporal, así que un guardado fallido no machaca un objetivo existente

Construir un gradiente axial de dos colores

Un shading axial (ISO 32000-1 §8.7.4.5.3, Type 2) mezcla colores a lo largo de una recta de x0 y0 a x1 y1, así que Coords toma cuatro valores. Un shading radial (§8.7.4.5.4, Type 3) mezcla entre dos círculos y toma seis: x0 y0 r0 x1 y1 r1. Con StartColor y EndColor planos, PDFium Component emite una función exponencial Type 2 (§7.10.3) con /N 1, que es una interpolación lineal recta, y pone /Extend [true true] para que los colores finales continúen más allá de ambos extremos del eje

Geometría de shading axial y radial de PDFium Component para Delphi: un gradiente Type 2 mezcla StartColor a EndColor a lo largo de la recta de x0 y0 a x1 y1 con /Extend true true continuando más allá de ambos extremos, mientras un gradiente Type 3 mezcla entre un círculo inicial de radio r0 y un círculo final de radio r1
PDFium pinta el shading sobre lo que el clip permita: los Coords de Type 2 colocan el eje de mezcla de x0 y0 a x1 y1, los Coords de Type 3 colocan los dos círculos, y /Extend [true true] lleva ambos colores finales más allá de la geometría
uses
  System.SysUtils, PDFium, FPdfCompress;

procedure AddHeaderBand(const InFile, OutFile: string);
var
  Pdf: TPdf;
  Fills: TPdfShadingCreateShadings;
  Report: TPdfShadingCreateReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := InFile;
    Pdf.Active := True;

    SetLength(Fills, 1);
    Fills[0] := Default(TPdfShadingCreate);
    Fills[0].ShadingType := pstAxial;
    Fills[0].ColorSpace := pscDeviceRGB;
    Fills[0].Coords := [0, 792, 0, 640];        // x0 y0 x1 y1, de arriba abajo
    Fills[0].StartColor := [0.12, 0.35, 0.75];  // azul oscuro en el borde superior
    Fills[0].EndColor := [1, 1, 1];             // blanco más abajo
    Fills[0].PageNumber := 1;                   // 1-based
    Fills[0].ResourceName := 'HeaderBand';      // opcional, el valor por defecto es Sh1, Sh2...

    if not Pdf.SaveAsShadingCreated(OutFile, Fills, Report) then
      raise Exception.Create('Shading creation failed: ' + Report.ErrorMessage);
  finally
    Pdf.Free;
  end;
end;

El recuento de componentes de cada color debe casar con el espacio de color: uno para pscDeviceGray y pscCalGray, tres para pscDeviceRGB, pscCalRGB y pscLab, cuatro para pscDeviceCMYK. Para pscICCBased el recuento viene de los bytes del perfil que pasáis en IccProfile, que debe exponer 1, 3 o 4 componentes; el perfil se incrusta como su propio stream ICC con el /N que corresponda. Los espacios calibrados aceptan CalWhitePoint opcional (tres valores, Y positivo), CalGamma (un valor para CalGray, tres para CalRGB) y, para Lab, un LabRange de seis valores. Cada coordenada y componente se comprueba contra NaN e infinito antes de escribir un byte

Fijaos en que las coordenadas están en el espacio de usuario por defecto, con el origen abajo a la izquierda de la página. Y recordad que el eje solo define dónde ocurre la mezcla: el área pintada es la que el clip permita, lo que importa más de lo que suena, como muestran las siguientes secciones

Los gradientes multi-stop compilan a una función stitching Type 3

PDFium Component convierte un array Stops en la función que espera un lector PDF: dos stops se vuelven una única función Type 2, y de 3 a 256 stops se vuelven una función stitching Type 3 (ISO 32000-1 §7.10.4) hecha de un segmento Type 2 lineal por par adyacente. Cada TPdfShadingCreateStop lleva un Position y un Color. Cuando Stops no está vacío sustituye por completo a StartColor y EndColor

La función stitching necesita tres arrays que a mano son fáciles de torcer sutilmente. /Functions contiene los segmentos, /Bounds contiene solo las posiciones de stop interiores (k segmentos necesitan k-1 bounds), y /Encode mapea cada subdominio de vuelta a [0 1] para que cada segmento vea su rango de entrada completo. El componente deriva los tres de vuestros stops, por eso es estricto con la entrada: las posiciones deben ser finitas, estrictamente crecientes, empezar en 0 y acabar en 1 (dentro de 1e-6). Una lista de stops que empiece en 0,1 dejaría parte del dominio sin función definida, y el método la rechaza en lugar de adivinar

function Stop(Position: Double; const Rgb: array of Double): TPdfShadingCreateStop;
var
  I: Integer;
begin
  Result.Position := Position;
  SetLength(Result.Color, Length(Rgb));
  for I := 0 to High(Rgb) do
    Result.Color[I] := Rgb[I];
end;

procedure BuildSealGlow(var Fill: TPdfShadingCreate);
begin
  Fill := Default(TPdfShadingCreate);
  Fill.ShadingType := pstRadial;
  Fill.ColorSpace := pscDeviceRGB;
  // x0 y0 r0 x1 y1 r1: un punto en el centro del sello que crece a un círculo de 90 pt
  Fill.Coords := [450, 160, 0, 450, 160, 90];
  Fill.Stops := [Stop(0.0, [1.00, 0.95, 0.70]),
                 Stop(0.4, [0.95, 0.75, 0.30]),
                 Stop(1.0, [1.00, 1.00, 1.00])];
  Fill.PageNumber := 1;
end;

Para un gradiente de tres stops eso produce /Functions con dos segmentos Type 2, /Bounds [0.4] y /Encode [0 1 0 1]. Las fronteras de segmento son uniones de color duras, así que una lista de stops no suaviza a través de ellas; si una unión parece demasiado brusca, añadid stops en lugar de esperar que el lector interpole curvatura

Compilación de gradientes multi-stop en PDFium Component para Delphi: tres Stops en las posiciones 0.0, 0.4 y 1.0 se vuelven una función stitching Type 3 con un segmento Type 2 por par de stops adyacentes, /Bounds [0.4] para la posición interior y /Encode [0 1 0 1] mapeando cada subdominio de vuelta al rango completo
Cada par de stops adyacentes se convierte en un segmento Type 2 dentro de una función Type 3; los arrays /Bounds y /Encode derivados son lo que las funciones stitching escritas a mano suelen torcer

Por qué PDFium dibuja silenciosamente nada

PDFium renderiza un shading recién creado solo cuando dos detalles están bien: /ShadingType debe ser un entero, y la llamada sh debe estar dentro de una ruta de recorte. Torced cualquiera de los dos y el fichero se abre, valida estructuralmente, y muestra una página intacta. Sin error, sin aviso, sin línea de log

Trampa uno: /ShadingType es un número, no un nombre

ISO 32000-1 Tabla 78 define /ShadingType como un entero: 2 para axial, 3 para radial. La primera versión de este writer emitía /ShadingType /Axial porque se leía mejor en un hex dump. PDFium lee la clave con un getter entero, recibe 0 para un nombre, clasifica el shading como inválido, falla al cargarlo, y se salta el operador sh sin queja. La página se renderizaba en blanco y nada en PDFium decía por qué. La causa solo se hizo visible tras construir un PDF mínimo escrito a mano y abrirlo en un segundo renderizador independiente, que informaba de un tipo de shading desconocido 0. La lección se generaliza a todo writer PDF: cuando la especificación tipa una enumeración como entero, escribid el entero, por tentador que parezca un nombre autodescriptivo

Trampa dos: un content stream que solo contiene sh no tiene bounding box

El operador sh (ISO 32000-1 §8.7.4.2) pinta el shading sobre la región de recorte actual, así que la extensión de la propia página lo acota en el papel. PDFium, sin embargo, construye un objeto de página de shading cuyo rectángulo viene o de la ruta de clip activa o del bounding box acumulado por operadores de dibujo anteriores en el mismo stream, y el renderizador elimina los objetos con rectángulo vacío. Un stream recién añadido que no contiene nada más que /Sh1 sh no tiene ninguno de los dos, así que el gradiente se descarta antes de la rasterización. TPdf.SaveAsShadingCreated lee por tanto el crop box de cada página (con fallback al media box, y luego al tamaño de página) y envuelve cada invocación en un clip explícito re W n. En una página US Letter con el origen en cero, el stream añadido se ve así:

q
0 0 612 792 re W n
/HeaderBand sh
Q

El par q/Q aísla el clip y cualquier transformada del contenido existente de la página, así que nada dibujado después en la página los hereda. Si escribís invocaciones de shading vosotros mismos en otra herramienta, copiad esta forma: un clip explícito en el mismo stream es barato y elimina la dependencia de lo que viniera antes

Por qué un shading PDF de aspecto válido no dibuja nada en PDFium: un /ShadingType escrito como el nombre Axial lo lee un getter entero como 0 y el operador sh se salta en silencio, y un content stream que solo contiene sh no tiene bounding box hasta que un clip explícito re W n le da un rectángulo
Ambos fallos de shading invisible son silenciosos en PDFium, así que SaveAsShadingCreated escribe el ShadingType entero él mismo y envuelve cada llamada sh en un clip re W n del crop box

Cómo interactúa una matriz de gradiente custom con el clip

Una matriz de gradiente en PDFium Component se aplica como un operador cm colocado después del clip y antes de sh, así que el clip se queda a página completa mientras solo la geometría del gradiente se transforma. Poned HasMatrix a True y rellenad MatrixA a MatrixF, los mismos seis números que cualquier matriz de transformación PDF. Los valores deben ser finitos y el determinante distinto de cero, porque una matriz singular colapsaría el shading sobre una recta. El componente registra además los seis valores como /Matrix en el diccionario de shading, pero para una llamada directa sh es el cm del content stream el que fija el sistema de coordenadas en el que pinta PDFium

El orden importa aquí, y es la tercera trampa. Los operandos de re se interpretan en la matriz de transformación actual. Poned el cm primero y el rectángulo de clip del tamaño de página se escala, rota o sesga junto con el gradiente, así que una matriz que aplasta una elipse también aplasta el área visible y partes de la página pierden su relleno. Recortar primero en espacio de usuario sin transformar, y luego concatenar la matriz, da el resultado esperado: un gradiente transformado que cubre toda la página. La guía de sellos Form XObject y su matriz de seis números recorre qué hace cada componente de la matriz si necesitáis construir una

procedure MakeEllipticalGlow(var Fill: TPdfShadingCreate);
begin
  BuildSealGlow(Fill);       // relleno radial centrado en 450,160
  Fill.Coords := [0, 0, 0, 0, 0, 90];  // círculos en el origen
  Fill.HasMatrix := True;
  Fill.MatrixA := 1.6;       // estirar horizontalmente
  Fill.MatrixB := 0;
  Fill.MatrixC := 0;
  Fill.MatrixD := 0.8;       // comprimir verticalmente
  Fill.MatrixE := 450;       // luego mover al centro del sello
  Fill.MatrixF := 160;
end;

Leer el informe y conocer los límites

TPdfShadingCreateReport os dice qué se escribió: SourceByteCount y OutputByteCount, CreatedShadingObjectNumbers (un número de objeto por entrada de entrada, en orden), AttachedShadingCount, Succeeded y ErrorMessage. Los errores de validación nombran la entrada y el campo, por ejemplo una lista de stops que no es estrictamente creciente, así que merece la pena registrarlos textualmente

if Pdf.SaveAsShadingCreated(OutFile, Fills, Report) then
  Writeln(Format('%d shading(s) attached, %d -> %d bytes',
    [Report.AttachedShadingCount, Report.SourceByteCount,
     Report.OutputByteCount]))
else
  Writeln('Rejected: ' + Report.ErrorMessage);

El perfil es deliberadamente acotado. Crea solo shadings axiales y radiales; los shadings basados en función y de malla (Types 1 y 4 a 7) y los espacios de color de patrón se rechazan. Una llamada acepta de 1 a 4096 rellenos, los números de página deben existir, y los nombres de recurso se limitan a 127 caracteres de letras, dígitos, guion bajo, guion y punto. La entrada cifrada se rechaza en la etapa de trailer. Un documento firmado necesita AllowSignedDocument := True, y como la salida es una reescritura completa y no una actualización incremental, las firmas existentes dejarán de validar, así que firmad después de añadir gradientes. Cada relleno se pinta al final del contenido de la página, encima de lo que ya hay; para un efecto de fondo, cread primero el gradiente y añadid después texto e imágenes, o usad contenido opcional como se describe en el artículo sobre controlar capas de contenido opcional PDF en Delphi cuando el relleno deba poder conmutarse

FAQ

¿Soporta SaveAsShadingCreated gradientes multi-stop?

Sí. Pasad de 2 a 256 entradas en Stops con posiciones estrictamente crecientes de 0 a 1. Dos stops producen una función Type 2 y tres o más producen una función stitching Type 3 con arrays /Bounds y /Encode derivados

¿Por qué mi gradiente es invisible en PDFium cuando el fichero parece válido?

Comprobad que /ShadingType es el entero 2 o 3 en lugar de un nombre, y que el operador sh corre dentro de un clip re W n en el mismo content stream. PDFium se salta silenciosamente los tipos de shading inválidos y elimina los objetos de shading con rectángulo vacío

¿Qué espacios de color puede usar un shading creado?

TPdfShadingCreate acepta DeviceGray, DeviceRGB, DeviceCMYK, CalGray, CalRGB, Lab e ICCBased con un perfil de 1, 3 o 4 componentes. Los espacios de color Pattern e Indexed no forman parte del perfil de creación

La creación de gradientes, el contenido opcional y el resto de writers por etapas vienen con el PDFium Component para Delphi y C++Builder, que envuelve PDFium para proyectos Delphi, C++Builder y Lazarus