Artigo Técnico

Gradientes repeat/reflect COLRv1 como shadings PDF em Delphi

O HotPDF Component pinta gradientes repeat e reflect do COLRv1 como shadings PDF vetoriais em vez de rasterizar o glifo. A geometria é esticada para cobrir um número inteiro de ciclos da linha de cor, cada ciclo ganha seu próprio shading axial ou radial com Extend desligado, as cores são interpoladas em CalRGB em luz linear, e os modos de composite in/out do Porter-Duff são expressos com soft masks de luminosidade

Quando cobrimos como o HotPDF desenha fontes de emoji coloridos COLR v1 e SVG em Delphi, os modos de extensão repeat e reflect estavam na lista de coisas que o caminho nativo não emitia, e tais glifos iam para o fallback raster. Versões mais novas do HotPDF os desenham como vetores. Chegar lá levou várias partidas falsas, a maioria causada por uma suposição de aparência razoável sobre PDF que se revelou errada em ao menos um viewer bastante usado. Este post percorre essas suposições, porque qualquer um que mapeie gradientes de CSS, SVG ou OpenType para PDF vai encontrar as mesmas

Por que um leitor PDF não repete o gradiente por você?

Um shading PDF não tem modo repeat ou reflect, então o produtor tem de dispor cada ciclo por conta própria. A ISO 32000-1 §8.7.4.5.3 e §8.7.4.5.4 definem /Extend para shadings axiais e radiais, e tudo o que ela faz é continuar a cor em t0 ou t1 além do fim do eixo. Isso é o COLRv1 EXTEND_PAD, e nada mais. O OpenType COLR v1 também define EXTEND_REPEAT, em que a linha de cor recomeça cada vez que o parâmetro passa por um inteiro, e EXTEND_REFLECT, em que cada ciclo alternado roda de trás para frente

Mapeamento de COLRv1 para shading PDF no HotPDF em Delphi: EXTEND_PAD casa com a flag de continuação /Extend dos shadings axial e radial, enquanto EXTEND_REPEAT e EXTEND_REFLECT não têm equivalente em PDF e precisam ser construídos com geometria de shading por ciclo
Shadings PDF só estendem as cores em t0 e t1, então um gradiente repeat ou reflect do COLRv1 precisa ser disposto ciclo a ciclo pelo produtor, não ligado para o viewer

A primeira tentativa do HotPDF tentou fazer a função fazer o trabalho periódico. Ela registrava uma função amostrada Type 0 (§7.10.2) com um domínio largo, aproximadamente [-12.73, 12.73] para um vão de 25 ciclos, preenchia-a com amostras dobradas e ligava o Extend. Dentro do primeiro ciclo um arquivo de teste mínimo renderizava corretamente; fora dele, o MuPDF pintava uma única cor média e o Poppler não era melhor. Ambos os viewers avaliam a função ao longo do parâmetro do shading e estendem além do eixo, e nenhum volta para consultar uma função periódica na região estendida. O domínio largo também era uma leitura errada da especificação: o parâmetro do shading t é passado direto à função, e pela §7.10.1 uma entrada fora do domínio da função é recortada, então uma função cujo domínio não casa com o /Domain do shading (padrão [0 1]) só tem parte de suas amostras lida

O design que funciona tem três partes, todas verificadas contra renders do MuPDF e do Poppler. A geometria do shading cobre fisicamente um número inteiro N de ciclos, então nenhum pixel depende do Extend. A função amostrada mantém o domínio padrão [0 1], casando com o que o viewer realmente passa. E a sequência de amostras em si é dobrada por ciclo, com 256 intervalos por ciclo, então a função guarda dados de cor repeat ou reflect em vez de depender de qualquer recurso do viewer

Dobrando uma linha de cor em amostras Type 0

Dobrar significa amostrar a linha de cor uma vez por ciclo e espelhar a sequência nos ciclos ímpares para o modo reflect. O helper abaixo é Delphi puro, não uma API do HotPDF, e faz duas coisas que a versão ingênua erra. Ele interpola em luz linear em vez de nos bytes sRGB armazenados, e ele resolve hard stops (dois stops no mesmo offset) para a cor do lado mais distante, que é a semântica de OpenType e CSS

uses
  System.SysUtils, System.Math;

type
  TColorStop = record
    Offset: Single;  // 0..1 dentro de um ciclo, ordenado crescente
    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;

// Um ciclo de uma linha de cor como trinhas RGB de 8 bits. Reverse = True
// espelha o ciclo, que é o que o modo reflect faz em ciclos ímpares
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;
    // >= escolhe o lado final de um 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;

O renderizador COLRv1 dentro do HotPDF vai além deste esboço. Ele grava amostras lineares de 16 bits big-endian em uma função CalRGB e guarda o alfa como uma amostra de cinza de 16 bits separada, enquanto os helpers públicos mostrados a seguir recebem trinhas DeviceRGB de 8 bits, razão pela qual o esboço converte de volta a bytes sRGB. Para a teoria do que um leitor faz com essas amostras, o artigo sobre funções amostradas Type 0 como LUTs de cor 3D cobre o modelo de interpolação em detalhe

Por que cada ciclo precisa do próprio shading?

Cada ciclo precisa do próprio shading porque leitores não avaliam uma função amostrada de forma exata; eles precomputam uma tabela de cores, e a tabela interpola através da emenda da última cor de um ciclo à primeira cor do seguinte. O MuPDF guarda a função unidimensional de um shading como uma tabela de cores de 256 entradas. Empacote 11 ciclos em uma única função de 2816 amostras e toda amostra salva pode ser exata enquanto o MuPDF ainda mostra de 12 a 14 níveis de erro de canal, porque 256 entradas de tabela se espalham pelo vão inteiro e a emenda dura do repeat é suavizada em uma rampa. O Poppler e a interpolação analítica do mesmo arquivo estavam ambos corretos, então este é um limite de precisão do leitor, não um erro do arquivo, e adicionar mais amostras não ajuda

Diagrama de emendas de gradiente repeat COLRv1 do HotPDF para Delphi: uma função amostrada cobrindo 11 ciclos deixa a tabela de cores de 256 entradas do MuPDF suavizar cada emenda de repeat em uma rampa, enquanto um shading axial por ciclo mantém cada tabela dentro de um único ciclo e preserva o salto de cor duro
Leitores precomputam uma tabela de cores fixa por shading, então a correção é geométrica: um shading por ciclo com Extend desligado move a emenda de repeat para uma fronteira que nenhuma tabela interpola através

A correção é manter cada tabela de leitor dentro de um ciclo. O HotPDF emite gradientes lineares periódicos como um shading axial independente por ciclo original, com ambas as flags de Extend falsas, então o salto de cor do fim para o começo cai na fronteira entre dois shadings em vez de dentro de uma tabela. Para reflect, o teste de paridade usa o número de ciclo original com sinal, incluindo ciclos negativos antes do início da linha de cor, e a direção do shading é trocada em vez de recompute as amostras. Todos os ciclos são desenhados em uma superfície isolada em luz linear e o alfa da pintura é aplicado uma vez no fim, então um gradiente translúcido não dobra a opacidade pelas emendas

Os métodos públicos THotPDF.RegisterSampledAxialGradient e THotPDF.RegisterSampledRadialGradient expõem o bloco de construção de shading amostrado para seu próprio desenho. Ambos recebem (..., DomainT0, DomainT1: Single; const Samples: TBytes; SampleCount, Cycles: Integer) depois da geometria, exigem PDF 1.3, levantam exceção a menos que Samples contenha exatamente SampleCount * 3 bytes, e devolvem um nome de pattern para THPDFPage.SetFillPattern. Eles ligam Extend nas duas pontas e registram Cycles como uma chave customizada /Cycles do pattern que os viewers ignoram. Para seguir a regra de um ciclo por shading com eles, registre um pattern por ciclo e deixe um retângulo de faixa recortá-lo

// Gradiente repeat ou reflect horizontal feito de um shading por ciclo.
// Coordenadas do pattern estão no espaço PDF padrão; um eixo horizontal mantém
// este exemplo independente da convenção de Y de cima para baixo da 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));
    // Domínio [0, 1]: o parâmetro do shading chega à função inalterado
    PatternName := Pdf.RegisterSampledAxialGradient(X0, 0, X1, 0, 0, 1,
      Samples, SamplesPerCycle, 1);
    if PatternName = '' then
      Exit;  // versão do documento travada abaixo de PDF 1.3
    Pdf.CurrentPage.SetFillPattern(PatternName);
    // A faixa recorta o shading para exatamente um ciclo, então Extend nunca aparece
    Pdf.CurrentPage.Rectangle(X0, Y, Math.Min(X1, X + Width) - X0, Height);
    Pdf.CurrentPage.Fill;
  end;
end;

Vãos muito longos precisam de um truque diferente, porque um shading por ciclo significa um recurso por ciclo. Para gradientes lineares periódicos COLRv1 o HotPDF troca para um esquema de duplicação: um form folha guarda uma primitiva repeat de um ciclo ou reflect de dois ciclos, e cada nível de Form XObject desenha o nível anterior duas vezes, deslocado por sua largura. O número de forms cresce logaritmicamente com o vão, enquanto o viewer ainda pinta cada ciclo visível, então a cobertura depois do alinhamento de fase é limitada a 65536 unidades de ciclo. Uma versão anterior que punha a primitiva em um tiling pattern Type 1 foi descartada, porque leitores ajustam o espaçamento dos tiles à grade do dispositivo e o Poppler saía de fase em um ciclo que não era um número inteiro de pixels

Resolvendo a faixa de parâmetro para famílias de círculos radiais

Um gradiente radial cobre o glifo apenas quando a faixa de ciclos é computada para a família certa de círculos, e as pinturas radiais COLRv1 caem em três famílias com matemáticas diferentes. Com D = c1 - c0 e dr = r1 - r0, uma família estritamente aninhada tem |dr| > |D|, um cone não aninhado tem |D|² > dr², e uma família tangente tem |D| = |dr|. O HotPDF mede o parâmetro a partir do ponto de raio zero da família original e mantém o centro e o raio de todo círculo emitido atados ao mesmo parâmetro original, então raios negativos remapeados nunca são limitados independentemente

Árvore de decisão de gradiente radial COLRv1 do HotPDF para Delphi: comparar dr com D separa famílias de círculos aninhadas resolvidas por bisseção, cones resolvidos com shadings radiais curtos por ciclo, e famílias tangentes reduzidas à raiz linear q = |W|^2 / (2(W*V))
A desigualdade entre |dr| e |D| decide a matemática dos ciclos: famílias aninhadas fazem bisseção pelo primeiro círculo que toca, cones ganham um shading curto por ciclo, e retas tangentes deixam uma faixa singular transparente
  • Famílias aninhadas intersectam a caixa de preenchimento monotonicamente, então a faixa de parâmetro vem dos quatro cantos mais uma bisseção pelo primeiro círculo que toca a caixa. Até 64 ciclos são amostrados em um shading a 256 amostras por ciclo, com teto de 4096 amostras; vãos mais fundos trocam para fatias por ciclo
  • Cones e faixas de raio igual usam shadings radiais curtos separados nas fronteiras de ciclo originais, cada um com 257 amostras e Extend desligado, compostos em ordem de parâmetro original dentro de um grupo isolado não knockout, para o maior parâmetro válido vencer onde círculos se sobrepõem
  • Famílias tangentes reduzem a equação do círculo a uma linear. Relativo ao ponto de tangência compartilhado, a raiz é q = |W|² / (2·(W·V)), e a região coberta é o semiplano aberto à frente desse ponto

O caso tangente tem uma singularidade genuína. Quando a reta tangente cruza a caixa de preenchimento, o número de ciclos necessários para alcançar pixels perto dessa reta é ilimitado, então nenhum número finito de shadings a cobre. O HotPDF gasta um orçamento de 64 ciclos a partir do ponto de tangência e deixa a fina faixa restante transparente. Quando os quatro cantos da caixa ficam atrás do ponto de tangência, a pintura é transparente como um todo e o resto do glifo ainda desenha. O caminho de cone finito aceita até 4096 ciclos exigidos, com cobertura limitada a 1024 fatias e qualquer coisa além do teto deixada sem pintar; um glifo de 1024 fatias chega a aproximadamente 1,8 MB

Cor em luz linear e a armadilha das duas casas decimais

O COLRv1 interpola e compõe em luz linear, então o HotPDF decodifica cada byte CPAL com a função de transferência inversa do sRGB e pinta sólidos, shadings e grupos internos em um espaço CalRGB com branco D65, primárias sRGB e /Gamma [1 1 1] (§8.6.5.3). Apenas a superfície raiz pronta converte para o espaço de blend do chamador. Interpolar bytes sRGB armazenados em vez disso produz pontos médios visivelmente mais escuros, que é o erro clássico de gamma que todo engine de gradiente comete uma vez

A mudança para luz linear expôs um bug antigo de formatador. Um valor de canal CPAL escuro de 12 vira cerca de 0,0036765 em luz linear, e o formatador de números compartilhado por trás do scn mantinha só duas casas decimais, então esse componente era gravado como 0 antes de qualquer viewer tocar o arquivo. O sintoma parecia exatamente quantização de leitor, e o primeiro instinto foi culpar os buffers de grupo de 8 bits do MuPDF. Componentes de fill e stroke agora passam por um formatador decimal sem expoente que mantém valores pequenos e ignora o locale do processo. A lição se generaliza: quando uma cor escura desaparece, despeje o content stream e confira o operando gravado antes de investigar o viewer

O viewer ainda importa depois dessa correção. O MuPDF 1.28.2 perde canais lineares quase zero quando compõe um grupo de transparência isolado que carrega um espaço de cor de blend explícito, e o Poppler 26.07.0 Splash ignora espaços de blend de grupo por completo. A propriedade opt-in THotPDF.COLRv1DeviceSpaceGroupBlend (padrão False) emite grupos COLRv1 sem o espaço de cor de blend, então eles compõem no espaço de dispositivo herdado enquanto cores de conteúdo, shadings e funções permanecem CalRGB em luz linear. A contrapartida é que camadas translúcidas sobrepostas então fazem blend no espaço de dispositivo em viewers que honram espaços de cor de grupo

Quais modos de composite do COLRv1 o PDF expressa exatamente?

O HotPDF renderiza os modos de composite 5 a 8 do COLRv1 (SRC_IN, DEST_IN, SRC_OUT, DEST_OUT) nativamente, e os modos 9 a 12 (SRC_ATOP, DEST_ATOP, XOR, PLUS) vão para o OnColorGlyphRasterize. Os modos in e out são produtos de uma superfície pelo alfa da outra, que é exatamente o que um soft mask computa (§11.6.5). O operando sobrevivente é capturado como um form comum; o operando mascarado é capturado em uma passada de alfa como conteúdo achatado dentro de um grupo DeviceGray isolado e usado como máscara /Luminosity com /BC [0]. Os modos out gravam 1 − α para todo alfa e põem uma capa branca opaca sob o conteúdo da máscara, para o sobrevivente continuar visível onde o outro operando nunca pintou

Os outros quatro precisam de adição pré-multiplicada de duas superfícies. No modelo source-over do PDF, o termo de fundo é sempre atenuado por (1 − α) da origem, e não há primitiva que some duas superfícies ou escale o fundo só pela cobertura da origem, então atop, xor e plus não podem ser escritos como vetores. Mais uma restrição veio de PDFs de teste bare: ambos os viewers computam errado grupos de soft mask cujo conteúdo aninha um form Do, então o operando mascarado precisa ser achatado. Cores sólidas, gradientes pad e um wrapper PaintGlyph em volta deles qualificam; camadas, transformações e gradientes periódicos, que todos produzem forms internos, mandam o glifo para o fallback

var
  Pdf: THotPDF;
  Fallback: TEmojiFallback;  // classe handler com a assinatura de evento abaixo
begin
  Pdf := THotPDF.Create(nil);
  Fallback := TEmojiFallback.Create;
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'periodic-icons.pdf';
    // Opt-in: compositing de grupo em espaço de dispositivo para MuPDF e Poppler
    Pdf.COLRv1DeviceSpaceGroupBlend := True;
    // Recebe glifos atop/xor/plus e qualquer coisa fora do 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;

Onde o caminho vetorial ainda fica aquém

Saída vetorial não é o mesmo que pixels idênticos em todo viewer, e os registros de aceitação do HotPDF dizem isso com franqueza. Amostras a um ou dois passos de uma fronteira de ciclo são suavizadas pela interpolação do leitor, então checagens de pixel amostram longe das emendas e os streams de função são verificados decodificando-os em vez disso. Interpolar amostras de RGB direto e de alfa armazenadas separadamente é uma aproximação perto de endpoints de alfa baixo e hard stops; o erro medido é de segunda ordem a 256 amostras por ciclo, mas não é exato. O Poppler Splash amostra na borda esquerda de um pixel em vez de no centro, o que aparece como pequenas diferenças nas rampas mais íngremes

Os orçamentos também são limites reais. Famílias radiais aninhadas declinam além de vãos de 4096 ciclos, cones e famílias tangentes além de 4096 ciclos exigidos, e a faixa tangente perto da reta singular fica vazia. Alfa de stop variável precisa da transparência do PDF 1.4, então não é habilitado sob PDF/A-1, PDF/X-1a ou PDF/X-3. Se um glifo cai fora de qualquer um desses limites, o DrawRegisteredColorGlyph declina o grafo inteiro antes de escrever um operador, que é o mesmo comportamento de tudo ou nada de antes

FAQ

O HotPDF rasteriza gradientes repeat e reflect COLRv1?

Não. Versões mais novas do HotPDF emitem-nos como shadings axiais e radiais vetoriais que cobrem ciclos inteiros. Apenas grafos fora do subset suportado, como composites atop, xor e plus, vão para o OnColorGlyphRasterize

Por não usar uma função amostrada para todos os ciclos?

Leitores precomputam uma tabela de cores a partir da função, e a tabela do MuPDF tem 256 entradas. Espalhada por muitos ciclos, ela interpola através de cada emenda de repeat. Um shading por ciclo mantém cada tabela dentro de um único ciclo

Quando devo ligar o COLRv1DeviceSpaceGroupBlend?

Ligue-o quando canais quase pretos em glifos COLRv1 caírem para zero no MuPDF. Deixe desligado quando camadas translúcidas sobrepostas precisarem fazer blend em luz linear em viewers que honram espaços de cor de grupo

Gradientes COLRv1 nativos, emoji coloridos, shadings amostrados e grupos de transparência fazem todos parte do HotPDF Component for Delphi and C++Builder, que gera documentos PDF diretamente de código Delphi e C++Builder