HotPDF Component pinta los gradientes repeat y reflect de COLRv1 como shadings PDF vectoriales en lugar de rasterizar el glifo. La geometría se estira para cubrir un número entero de ciclos de la línea de color, cada ciclo recibe su propio shading axial o radial con Extend apagado, los colores se interpolan en CalRGB de luz lineal, y los modos de composición in/out de Porter-Duff se expresan con soft masks de luminosidad
Cuando cubrimos cómo dibuja HotPDF las fuentes de emoji a color COLR v1 y SVG en Delphi, los modos de extensión repeat y reflect estaban en la lista de cosas que la ruta nativa no emitía, y esos glifos iban al fallback raster. Las versiones más nuevas de HotPDF los dibujan como vectores. Llegar ahí llevó varios arranques en falso, la mayoría causados por una suposición de aspecto razonable sobre PDF que resultó equivocada en al menos un visor muy usado. Esta entrada recorre esas suposiciones, porque cualquiera que mapee gradientes de CSS, SVG u OpenType a PDF se encontrará con las mismas
Por qué un visor PDF no puede repetir el gradiente por ti
Un shading PDF no tiene modo repeat ni reflect, así que el productor tiene que colocar cada ciclo él mismo. ISO 32000-1 §8.7.4.5.3 y §8.7.4.5.4 definen /Extend para shadings axiales y radiales, y todo lo que hace es continuar el color en t0 o t1 más allá del final del eje. Eso es COLRv1 EXTEND_PAD, y nada más. OpenType COLR v1 también define EXTEND_REPEAT, donde la línea de color vuelve a empezar cada vez que el parámetro pasa un entero, y EXTEND_REFLECT, donde cada segundo ciclo corre al revés
El primer intento de HotPDF trató de hacer que la función hiciera el trabajo periódico. Registró una función sampled Type 0 (§7.10.2) con un dominio ancho, aproximadamente [-12.73, 12.73] para un tramo de 25 ciclos, la llenó con muestras plegadas y activó Extend. Dentro del primer ciclo un fichero de prueba mínimo se renderizaba correctamente; fuera de él, MuPDF pintaba un único color promediado y Poppler no estaba mejor. Ambos visores evalúan la función a lo largo del parámetro del shading y rellenan más allá del eje, y ninguno vuelve a consultar una función periódica en la región extendida. El dominio ancho era además una mala lectura de la especificación: el parámetro del shading t se pasa directo a la función, y según §7.10.1 una entrada fuera del dominio de la función se recorta, así que una función cuyo dominio no casa con el /Domain del shading (por defecto [0 1]) solo tiene leída parte de sus muestras
El diseño que funciona tiene tres partes, todas verificadas contra renders de MuPDF y Poppler. La geometría del shading cubre físicamente un número entero N de ciclos, así que ningún píxel depende de Extend. La función sampled mantiene el dominio por defecto [0 1], casando con lo que el visor pasa de verdad. Y la secuencia de muestras en sí se pliega por ciclo, con 256 intervalos por ciclo, así que la función contiene datos de color repeat o reflect en lugar de confiar en ninguna función del visor
Plegar una línea de color en muestras Type 0
Plegar significa muestrear la línea de color una vez por ciclo y espejar la secuencia en los ciclos impares para el modo reflect. El helper de abajo es Delphi plano, no una API de HotPDF, y hace dos cosas que la versión ingenua hace mal. Interpola en luz lineal en lugar de sobre los bytes sRGB guardados, y resuelve los hard stops (dos stops en el mismo offset) hacia el color del lado lejano, que es la semántica de OpenType y CSS
uses
System.SysUtils, System.Math;
type
TColorStop = record
Offset: Single; // 0..1 dentro de un ciclo, ordenado ascendente
R, G, B: Byte; // bytes sRGB estilo CPAL
end;
function SRGBToLinear(V: Byte): Double;
var
C: Double;
begin
C := V / 255;
if C <= 0.04045 then
Result := C / 12.92
else
Result := Power((C + 0.055) / 1.055, 2.4);
end;
function LinearToSRGB(L: Double): Byte;
var
C: Double;
begin
L := EnsureRange(L, 0, 1);
if L <= 0.0031308 then
C := L * 12.92
else
C := 1.055 * Power(L, 1 / 2.4) - 0.055;
Result := Round(C * 255);
end;
// Un ciclo de una línea de color como triples RGB de 8 bits. Reverse = True
// espeja el ciclo, que es lo que hace el modo reflect en los ciclos impares
function SampleCycle(const Stops: array of TColorStop; Count: Integer;
Reverse: Boolean): TBytes;
var
I, S: Integer;
T, F, Span: Double;
begin
SetLength(Result, Count * 3);
for I := 0 to Count - 1 do
begin
T := I / (Count - 1);
if Reverse then
T := 1 - T;
S := 0;
// >= elige el lado final de un hard stop
while (S < High(Stops) - 1) and (T >= Stops[S + 1].Offset) do
Inc(S);
Span := Stops[S + 1].Offset - Stops[S].Offset;
if Span > 0 then
F := EnsureRange((T - Stops[S].Offset) / Span, 0, 1)
else
F := 1;
Result[I * 3] := LinearToSRGB(SRGBToLinear(Stops[S].R) +
(SRGBToLinear(Stops[S + 1].R) - SRGBToLinear(Stops[S].R)) * F);
Result[I * 3 + 1] := LinearToSRGB(SRGBToLinear(Stops[S].G) +
(SRGBToLinear(Stops[S + 1].G) - SRGBToLinear(Stops[S].G)) * F);
Result[I * 3 + 2] := LinearToSRGB(SRGBToLinear(Stops[S].B) +
(SRGBToLinear(Stops[S + 1].B) - SRGBToLinear(Stops[S].B)) * F);
end;
end;
El renderizador COLRv1 dentro de HotPDF va más lejos que este boceto. Escribe muestras lineales de 16 bits big-endian en una función CalRGB y guarda el alfa como una muestra gris de 16 bits separada, mientras los helpers públicos que vienen a continuación toman triples DeviceRGB de 8 bits, que es por lo que el boceto convierte de vuelta a bytes sRGB. Para la teoría de lo que hace un lector con esas muestras, el artículo sobre funciones sampled Type 0 como LUTs de color 3D cubre el modelo de interpolación en detalle
Por qué cada ciclo necesita su propio shading
Cada ciclo necesita su propio shading porque los lectores no evalúan una función sampled de forma exacta; precalculan una tabla de colores, y la tabla interpola a través del salto desde el último color de un ciclo al primer color del siguiente. MuPDF guarda la función unidimensional de un shading como una tabla de 256 entradas. Empaquetad 11 ciclos en una única función de 2816 muestras y cada muestra guardada puede ser exacta mientras MuPDF sigue mostrando de 12 a 14 niveles de error de canal, porque 256 entradas de tabla se reparten por todo el tramo y la costura dura del repeat se suaviza en una rampa. Poppler y la interpolación analítica del mismo fichero fueron ambos correctos, así que esto es un límite de precisión del lector, no un error del fichero, y añadir más muestras no ayuda
El arreglo es mantener la tabla de cada lector dentro de un ciclo. HotPDF emite los gradientes lineales periódicos como un shading axial independiente por ciclo original, con ambos flags de Extend a falso, así que el salto de color de fin a principio cae en la frontera entre dos shadings en lugar de dentro de una tabla. Para reflect, la prueba de impar usa el número de ciclo original con signo, incluidos los ciclos negativos anteriores al inicio de la línea de color, y la dirección del shading se intercambia en lugar de recalcular las muestras. Todos los ciclos se dibujan en una superficie aislada de luz lineal y el alfa de la pintura se aplica una vez al final, así que un gradiente translúcido no duplica su opacidad en las costuras
Los métodos públicos THotPDF.RegisterSampledAxialGradient y THotPDF.RegisterSampledRadialGradient exponen el bloque de construcción de shadings con muestras para vuestro propio dibujo. Ambos toman (..., DomainT0, DomainT1: Single; const Samples: TBytes; SampleCount, Cycles: Integer) después de la geometría, exigen PDF 1.3, lanzan una excepción salvo que Samples contenga exactamente SampleCount * 3 bytes, y devuelven un nombre de pattern para THPDFPage.SetFillPattern. Activan Extend en ambos extremos y registran Cycles como una clave de pattern custom /Cycles que los visores ignoran. Para seguir la regla de un ciclo por shading con ellos, registrad un pattern por ciclo y dejad que un rectángulo de banda lo recorte
// Gradiente repeat o reflect horizontal hecho de un shading por ciclo.
// Las coordenadas del pattern están en el espacio PDF por defecto; un eje
// horizontal mantiene este ejemplo independiente de la convención Y top-down de la página
procedure PaintPeriodicBands(Pdf: THotPDF; const Stops: array of TColorStop;
X, Y, Width, Height, CycleLength: Single; Reflect: Boolean);
const
SamplesPerCycle = 257; // 256 intervalos por ciclo
var
Cycle, CycleCount: Integer;
X0, X1: Single;
Samples: TBytes;
PatternName: AnsiString;
begin
CycleCount := Ceil(Width / CycleLength);
for Cycle := 0 to CycleCount - 1 do
begin
X0 := X + Cycle * CycleLength;
X1 := X0 + CycleLength;
Samples := SampleCycle(Stops, SamplesPerCycle, Reflect and Odd(Cycle));
// Domain [0, 1]: el parámetro del shading llega a la función sin cambios
PatternName := Pdf.RegisterSampledAxialGradient(X0, 0, X1, 0, 0, 1,
Samples, SamplesPerCycle, 1);
if PatternName = '' then
Exit; // versión del documento bloqueada por debajo de PDF 1.3
Pdf.CurrentPage.SetFillPattern(PatternName);
// La banda recorta el shading a exactamente un ciclo, así que Extend nunca se ve
Pdf.CurrentPage.Rectangle(X0, Y, Math.Min(X1, X + Width) - X0, Height);
Pdf.CurrentPage.Fill;
end;
end;
Los tramos muy largos necesitan otro truco, porque un shading por ciclo significa un recurso por ciclo. Para gradientes lineales periódicos de COLRv1 HotPDF cambia a un esquema de duplicación: un form hoja contiene una primitiva de repeat de un ciclo o de reflect de dos ciclos, y cada nivel de Form XObject dibuja el nivel anterior dos veces, desplazado por su anchura. El número de forms crece logarítmicamente con el tramo, mientras el visor sigue pintando cada ciclo visible, así que la cobertura tras la alineación de fase se limita a 65536 unidades de ciclo. Una versión anterior que ponía la primitiva en un tiling pattern Type 1 se descartó, porque los lectores ajustan el espaciado de tiles a la rejilla del dispositivo y Poppler se desfasaba en un ciclo que no era un número entero de píxeles
Resolver el rango de parámetros para familias de círculos radiales
Un gradiente radial cubre el glifo solo cuando el rango de ciclos se calcula para la familia correcta de círculos, y las pinturas radiales de COLRv1 caen en tres familias con matemáticas distintas. Con D = c1 - c0 y dr = r1 - r0, una familia estrictamente anidada tiene |dr| > |D|, un cono no anidado tiene |D|² > dr², y una familia tangente tiene |D| = |dr|. HotPDF mide el parámetro desde el punto de radio cero de la familia original y mantiene el centro y el radio de cada círculo emitido atados al mismo parámetro original, así que los radios negativos remapeados nunca se recortan de forma independiente
- Las familias anidadas intersecan la caja de relleno de forma monótona, así que el rango de parámetros sale de las cuatro esquinas más una bisección para el primer círculo que toca la caja. Hasta 64 ciclos se muestrean en un shading a 256 muestras por ciclo, con un tope de 4096 muestras; los tramos más profundos cambian a rebanadas por ciclo
- Los conos y las franjas de radio igual usan shadings radiales cortos separados en las fronteras de ciclo originales, cada uno con 257 muestras y
Extendapagado, compuestos en orden de parámetro original dentro de un grupo aislado sin knockout, de modo que el parámetro válido más grande gana donde los círculos se solapan - Las familias tangentes reducen la ecuación del círculo a una lineal. Respecto al punto de tangencia compartido, la raíz es
q = |W|² / (2·(W·V)), y la región cubierta es el semiplano abierto delante de ese punto
El caso tangente tiene una singularidad genuina. Cuando la línea tangente cruza la caja de relleno, el número de ciclos necesarios para alcanzar píxeles cerca de esa línea no tiene cota, así que ningún número finito de shadings la cubre. HotPDF gasta un presupuesto de 64 ciclos desde el punto de tangencia y deja la franja delgada restante transparente. Cuando las cuatro esquinas de la caja quedan detrás del punto de tangencia, la pintura es transparente como conjunto y el resto del glifo se sigue dibujando. La ruta de cono finito acepta hasta 4096 ciclos requeridos, con la cobertura limitada a 1024 rebanadas y cualquier cosa pasada el tope sin pintar; un glifo de 1024 rebanadas ronda los 1,8 MB
Color de luz lineal y la trampa de los dos decimales
COLRv1 interpola y compone en luz lineal, así que HotPDF decodifica cada byte de CPAL con la función de transferencia inversa sRGB y pinta sólidos, shadings y grupos internos en un único espacio CalRGB con blanco D65, primarias sRGB y /Gamma [1 1 1] (§8.6.5.3). Solo la superficie raíz terminada se convierte al espacio de mezcla del llamador. Interpolar los bytes sRGB guardados en su lugar produce puntos medios visiblemente más oscuros, que es el clásico error de gamma que todo motor de gradientes comete una vez
El paso a luz lineal destapó un bug antiguo de formateo. Un valor de canal CPAL oscuro de 12 se convierte en unos 0,0036765 en luz lineal, y el formateador de números compartido detrás de scn conservaba solo dos decimales, así que ese componente se escribía como 0 antes de que ningún visor tocara el fichero. El síntoma se parecía exactamente a la cuantización del lector, y el primer instinto fue culpar a los buffers de grupo de 8 bits de MuPDF. Los componentes de fill y stroke pasan ahora por un formateador decimal sin exponente que conserva los valores pequeños e ignora la locale del proceso. La lección se generaliza: cuando un color oscuro desaparece, volcad el content stream y comprobad el operando escrito antes de investigar al visor
El visor sigue importando después de ese arreglo. MuPDF 1.28.2 pierde canales lineales cercanos a cero cuando compone un grupo de transparencia aislado que lleva un espacio de color de blend explícito, y Poppler 26.07.0 Splash ignora los espacios de blend de grupo por completo. La propiedad opt-in THotPDF.COLRv1DeviceSpaceGroupBlend (por defecto False) emite los grupos COLRv1 sin el espacio de color de blend, así que se componen en el espacio de dispositivo heredado mientras los colores del contenido, los shadings y las funciones se quedan en CalRGB de luz lineal. La contrapartida es que las capas translúcidas solapadas se mezclan entonces en espacio de dispositivo en los visores que sí honran los espacios de color de grupo
Qué modos de composición COLRv1 puede expresar PDF exactamente
HotPDF renderiza los modos de composición COLRv1 5 a 8 (SRC_IN, DEST_IN, SRC_OUT, DEST_OUT) de forma nativa, y los modos 9 a 12 (SRC_ATOP, DEST_ATOP, XOR, PLUS) van a OnColorGlyphRasterize. Los modos in y out son productos de una superficie por el alfa de la otra, que es exactamente lo que calcula un soft mask (§11.6.5). El operando superviviente se captura como un form ordinario; el operando enmascarado se captura en una pasada de alfa como contenido plano dentro de un grupo DeviceGray aislado y se usa como máscara /Luminosity con /BC [0]. Los modos out escriben 1 − α para cada alfa y colocan una cubierta blanca opaca bajo el contenido de la máscara, así que el superviviente sigue visible donde el otro operando nunca pintó
Los otros cuatro necesitan suma premultiplicada de dos superficies. En el modelo source-over de PDF el término de fondo siempre se atenúa por el (1 − α) del origen, y no hay primitiva que sume dos superficies o escale el fondo solo por la cobertura del origen, así que atop, xor y plus no se pueden escribir como vectores. Una restricción más vino de PDFs de prueba pelados: ambos visores calculan mal los grupos de soft mask cuyo contenido anida un form Do, así que el operando enmascarado debe ser plano. Colores sólidos, gradientes pad y un envoltorio PaintGlyph alrededor de ellos califican; capas, transforms y gradientes periódicos, que producen todos forms internos, mandan el glifo al fallback
var
Pdf: THotPDF;
Fallback: TEmojiFallback; // clase manejadora con la firma del evento de abajo
begin
Pdf := THotPDF.Create(nil);
Fallback := TEmojiFallback.Create;
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'periodic-icons.pdf';
// Opt-in: composición de grupos en espacio de dispositivo para MuPDF y Poppler
Pdf.COLRv1DeviceSpaceGroupBlend := True;
// Recibe los glifos atop/xor/plus y cualquier otra cosa fuera del subconjunto:
// (Sender; Format; const FontBytes, AssetData: TBytes; GlyphID: Word;
// PaletteIndex, PixelSize: Integer; out Width, Height: Integer;
// out RGBA: TBytes; out Handled: Boolean)
Pdf.OnColorGlyphRasterize := Fallback.Rasterize;
Pdf.BeginDoc;
Pdf.RegisterUnicodeTTF('Fonts\GradientIcons.ttf');
if not Pdf.DrawRegisteredColorGlyph(Pdf.CurrentPage, $E001,
72, 144, 'Gradient Icons', 48) then
Writeln('U+E001 was neither drawn natively nor rasterized');
Pdf.EndDoc;
finally
Fallback.Free;
Pdf.Free;
end;
end;
Dónde la ruta vectorial todavía se queda corta
La salida vectorial no es lo mismo que píxeles idénticos en todos los visores, y los registros de aceptación de HotPDF lo dicen sin rodeos. Las muestras a uno o dos pasos de una frontera de ciclo se suavizan por la interpolación del lector, así que las comprobaciones de píxeles muestrean lejos de las costuras y los streams de funciones se verifican decodificándolos. Interpolar por separado muestras guardadas de RGB directo y de alfa es una aproximación cerca de los extremos de alfa bajo y de los hard stops; el error medido es de segundo orden a 256 muestras por ciclo, pero no es exacto. Poppler Splash muestrea en el borde izquierdo de un píxel en lugar de en su centro, lo que se nota como pequeñas diferencias en las rampas más empinadas
Los presupuestos también son límites reales. Las familias radiales anidadas declinan más allá de tramos de 4096 ciclos, los conos y las familias tangentes más allá de 4096 ciclos requeridos, y la franja tangente cerca de la línea singular se queda vacía. El alfa de stops variables necesita transparencia PDF 1.4, así que no se activa bajo PDF/A-1, PDF/X-1a ni PDF/X-3. Si un glifo cae fuera de cualquiera de estos límites, DrawRegisteredColorGlyph declina el grafo entero antes de escribir un operador, que es el mismo comportamiento de todo o nada de antes
FAQ
¿Rasteriza HotPDF los gradientes repeat y reflect de COLRv1?
No. Las versiones más nuevas de HotPDF los emiten como shadings axiales y radiales vectoriales que cubren ciclos completos. Solo los grafos fuera del subconjunto soportado, como los compuestos atop, xor y plus, van a OnColorGlyphRasterize
¿Por qué no usar una función sampled para todos los ciclos?
Los lectores precalculan una tabla de colores desde la función, y la tabla de MuPDF tiene 256 entradas. Repartida por muchos ciclos, interpola a través de cada costura de repeat. Un shading por ciclo mantiene cada tabla dentro de un único ciclo
¿Cuándo debo activar COLRv1DeviceSpaceGroupBlend?
Activadlo cuando los canales cercanos al negro en glifos COLRv1 caen a cero en MuPDF. Dejadlo apagado cuando las capas translúcidas solapadas deben mezclarse en luz lineal en visores que honran los espacios de color de grupo
Los gradientes COLRv1 nativos, el emoji a color, los shadings con muestras y los grupos de transparencia son todos parte de HotPDF Component para Delphi y C++Builder, que genera documentos PDF directamente desde código Delphi y C++Builder