Artículo técnico

Repeat/reflect de COLRv1 como shadings PDF en Delphi

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 obtiene 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 HotPDF dibuja fuentes de emojis a color COLR v1 y SVG en Delphi, los modos extend repeat y reflect estaban en la lista de cosas que la ruta nativa no emitía, y tales glifos iban al fallback rasterizado. Las versiones más nuevas de HotPDF los dibujan como vectores. Llegar ahí tomó varios arranques en falso, la mayoría causados por un supuesto de aspecto razonable sobre PDF que resultó equivocado en al menos un visor de uso extendido. Esta entrada recorre esos supuestos, porque cualquiera que mapee gradientes de CSS, SVG u OpenType a PDF se topará con los mismos

¿Por qué un visor PDF no puede repetir el gradiente por usted?

Un shading PDF no tiene modo repeat ni reflect, así que el productor tiene que maquetar cada ciclo por su cuenta. 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 fin 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 recomienza cada vez que el parámetro pasa un entero, y EXTEND_REFLECT, donde cada otro ciclo corre al revés

Mapeo de COLRv1 a shading PDF de HotPDF en Delphi: EXTEND_PAD coincide con el flag de continuación /Extend de los shadings axial y radial, mientras que EXTEND_REPEAT y EXTEND_REFLECT no tienen equivalente PDF y deben construirse con geometría de shading por ciclo
Los shadings PDF solo rellenan los colores en t0 y t1, así que un gradiente COLRv1 repeat o reflect tiene que maquetarse ciclo por ciclo por el productor, no activarse para el visor

El primer intento de HotPDF trató de hacer que la función hiciera el trabajo periódico. Registró una función muestreada Type 0 (§7.10.2) con un dominio amplio, aproximadamente [-12.73, 12.73] para un tramo de 25 ciclos, la llenó con muestras plegadas, y activó Extend. Dentro del primer ciclo un archivo de prueba mínimo se renderizaba correctamente; fuera de él, MuPDF pintaba un solo 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 regresa a consultar una función periódica en la región extendida. El dominio amplio también era una mala lectura de la especificación: el parámetro t del shading 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 coincide con el /Domain del shading (por defecto [0 1]) solo tiene parte de sus muestras leídas

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 muestreada mantiene el dominio por defecto [0 1], coincidiendo con lo que el visor realmente pasa. Y la secuencia de muestras misma se pliega por ciclo, con 256 intervalos por ciclo, así que la función contiene datos de color repeat o reflect en lugar de depender de 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 puro, 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) al 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 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;
    // >= toma el lado posterior 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 renderer de COLRv1 dentro de HotPDF va más allá de 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 que los helpers públicos que se muestran a continuación toman triples DeviceRGB de 8 bits, razón por la que el boceto convierte de vuelta a bytes sRGB. Para la teoría de lo que un lector hace con esas muestras, el artículo sobre funciones muestreadas 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 muestreada 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 colores de 256 entradas. Empaquete 11 ciclos en una sola 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 las 256 entradas de la 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 archivo estaban ambos correctos, así que este es un límite de precisión del lector, no un error del archivo, y agregar más muestras no ayuda

Diagrama de costuras del gradiente repeat de COLRv1 de HotPDF para Delphi: una sola función muestreada que abarca 11 ciclos deja que la tabla de colores de 256 entradas de MuPDF suavice cada costura de repeat en una rampa, mientras que un shading axial por ciclo mantiene cada tabla dentro de un solo ciclo y preserva el salto duro de color
Los lectores precalculan una tabla de colores fija por shading, así que la corrección es geométrica: un shading por ciclo con Extend apagado mueve la costura del repeat a una frontera que ninguna tabla interpola jamás

La corrección es mantener cada tabla de 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 en falso, así que el salto de color de fin a inicio 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 a lo largo de las costuras

Los métodos públicos THotPDF.RegisterSampledAxialGradient y THotPDF.RegisterSampledRadialGradient exponen el bloque de construcción del shading muestreado para su propio dibujo. Ambos toman (..., DomainT0, DomainT1: Single; const Samples: TBytes; SampleCount, Cycles: Integer) después de la geometría, requieren PDF 1.3, lanzan una excepción a menos que Samples contenga exactamente SampleCount * 3 bytes, y devuelven un nombre de patrón para THPDFPage.SetFillPattern. Fijan Extend en ambos extremos y registran Cycles como una llave de patrón personalizada /Cycles que los visores ignoran. Para seguir la regla de un ciclo por shading con ellos, registre un patrón por ciclo y deje que un rectángulo de banda lo recorte

// Gradiente repeat o reflect horizontal hecho de un shading por ciclo.
// Las coordenadas del patrón están en el espacio PDF por defecto; un eje
// horizontal mantiene este ejemplo independiente de la convención Y 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));
    // Dominio [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 bajo PDF 1.3
    Pdf.CurrentPage.SetFillPattern(PatternName);
    // La banda recorta el shading a exactamente un ciclo, así Extend nunca se muestra
    Pdf.CurrentPage.Rectangle(X0, Y, Math.Min(X1, X + Width) - X0, Height);
    Pdf.CurrentPage.Fill;
  end;
end;

Los tramos muy largos necesitan un truco distinto, 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 repeat de un ciclo o reflect de dos ciclos, y cada nivel de Form XObject dibuja el nivel anterior dos veces, desplazado por su ancho. El número de forms crece logarítmicamente con el tramo, mientras que 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 mosaico a la grilla 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

Árbol de decisión del gradiente radial COLRv1 de HotPDF para Delphi: comparar dr con D separa las familias de círculos anidadas resueltas por bisección, los conos resueltos con shadings radiales cortos por ciclo, y las familias tangentes reducidas a la raíz lineal q = |W|^2 / (2(W*V))
La desigualdad entre |dr| y |D| decide la matemática de ciclos: las familias anidadas hacen bisección para el primer círculo que toca, los conos reciben un shading corto por ciclo, y las líneas tangentes dejan una franja singular transparente
  • 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 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 los límites de ciclo originales, cada uno con 257 muestras y Extend apagado, compuestos en el orden de parámetro original dentro de un grupo aislado no knockout, así el parámetro válido más grande gana donde los círculos se superponen
  • Las familias tangentes reducen la ecuación del círculo a una lineal. Relativo al punto de tangencia compartido, la raíz es q = |W|² / (2·(W·V)), y la región cubierta es el semiplano abierto frente a ese punto

El caso tangente tiene una singularidad genuina. Cuando la línea tangente cruza la caja de relleno, el número de ciclos necesario para alcanzar los 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 delgada franja restante transparente. Cuando las cuatro esquinas de la caja quedan detrás del punto de tangencia, la pintura es transparente como un todo y el resto del glifo se dibuja igual. La ruta de cono finito acepta hasta 4096 ciclos requeridos, con cobertura limitada a 1024 rebanadas y cualquier cosa pasada la cota sin pintar; un glifo de 1024 rebanadas llega a unos 1.8 MB

Color en 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 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 salto a luz lineal expuso un viejo bug del formateador. Un valor de canal CPAL oscuro de 12 se vuelve aproximadamente 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 cualquier visor tocara el archivo. El síntoma se veía exactamente como cuantización del lector, y el primer instinto fue culpar a los buffers de grupo de 8 bits de MuPDF. Los componentes de relleno y trazo ahora pasan por un formateador decimal sin exponente que conserva los valores pequeños e ignora el locale del proceso. La lección se generaliza: cuando un color oscuro desaparece, dumpee el content stream y revise el operando escrito antes de investigar al visor

El visor sigue importando después de esa corrección. MuPDF 1.28.2 pierde los canales lineales cercanos a cero cuando compone un grupo de transparencia aislado que lleva un espacio de color de mezcla explícito, y Poppler 26.07.0 Splash ignora los espacios de mezcla de grupo por completo. La propiedad opt-in THotPDF.COLRv1DeviceSpaceGroupBlend (por defecto False) emite los grupos de COLRv1 sin el espacio de color de mezcla, así que se componen en el espacio de dispositivo heredado mientras los colores de contenido, shadings y funciones se quedan en CalRGB de luz lineal. El trade-off es que las capas translúcidas superpuestas entonces se mezclan en espacio de dispositivo en los visores que sí honran los espacios de color de grupo

Qué modos de composición de COLRv1 puede expresar PDF exactamente

HotPDF renderiza los modos de composición de COLRv1 del 5 al 8 (SRC_IN, DEST_IN, SRC_OUT, DEST_OUT) de forma nativa, y los modos del 9 al 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 que sobrevive 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 ponen una cubierta blanca opaca bajo el contenido de la máscara, así el sobreviviente 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 del fondo siempre se atenúa por el (1 − α) de la fuente, y no hay primitiva que sume dos superficies o escale el fondo solo por la cobertura de la fuente, 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. Los colores sólidos, los gradientes pad y un envoltorio PaintGlyph alrededor de ellos califican; las capas, las transformaciones y los gradientes periódicos, que todos producen forms internos, mandan el glifo al fallback

var
  Pdf: THotPDF;
  Fallback: TEmojiFallback;  // clase handler 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 grupo en espacio de dispositivo para MuPDF y Poppler
    Pdf.COLRv1DeviceSpaceGroupBlend := True;
    // Recibe glifos atop/xor/plus y todo lo demás 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 cada visor, 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 verificaciones de píxeles muestrean lejos de las costuras y los flujos de funciones se verifican decodificándolos. Interpolar muestras de RGB directo y alfa guardadas por separado 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 diferencias pequeñas 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 variable de los stops necesita transparencia de PDF 1.4, así que no se habilita bajo PDF/A-1, PDF/X-1a o PDF/X-3. Si un glifo cae fuera de cualquiera de estos límites, DrawRegisteredColorGlyph declina todo el grafo antes de escribir un operador, que es el mismo comportamiento de todo o nada de antes

FAQ

¿HotPDF rasteriza 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 enteros. Solo los grafos fuera del subconjunto soportado, como las composiciones atop, xor y plus, van a OnColorGlyphRasterize

¿Por qué no usar una sola función muestreada para todos los ciclos?

Los lectores precalculan una tabla de colores desde la función, y la tabla de MuPDF tiene 256 entradas. Repartida sobre muchos ciclos, interpola a través de cada costura de repeat. Un shading por ciclo mantiene cada tabla dentro de un solo ciclo

¿Cuándo debo activar COLRv1DeviceSpaceGroupBlend?

Actívela cuando los canales cercanos al negro en glifos de COLRv1 caigan a cero en MuPDF. Déjela apagada cuando las capas translúcidas superpuestas deban mezclarse en luz lineal en visores que honran los espacios de color de grupo

Los gradientes COLRv1 nativos, los emojis a color, los shadings muestreados y los grupos de transparencia son todos parte del HotPDF Component for Delphi y C++Builder, que genera documentos PDF directamente desde código Delphi y C++Builder