Artigo Técnico

Gradientes Repeat/Reflect COLRv1 como Shadings PDF no Delphi

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

Quando cobrimos como o HotPDF desenha fontes de emoji a cores COLR v1 e SVG no 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 recentes do HotPDF desenham-nos como vetores. Chegar lá levou vários falsos começos, a maioria causada por um pressuposto de aparência razoável sobre o PDF que se revelou errado em pelo menos um visualizador muito usado. Este post percorre esses pressupostos, porque quem mapear gradientes CSS, SVG ou OpenType para o PDF vai encontrar os mesmos

Por que não pode um visualizador repetir o gradiente por si?

Um shading PDF não tem modo repeat nem reflect, pelo que o produtor tem de dispor cada ciclo ele próprio. A ISO 32000-1 §8.7.4.5.3 e §8.7.4.5.4 definem o /Extend para shadings axiais e radiais, e tudo o que ele faz é continuar a cor em t0 ou t1 para lá do fim do eixo. Isso é o EXTEND_PAD do COLRv1, e nada mais. O OpenType COLR v1 também define EXTEND_REPEAT, em que a linha de cores recomeça cada vez que o parâmetro passa um inteiro, e EXTEND_REFLECT, em que cada segundo ciclo corre ao contrário

Mapeamento de COLRv1 para shading PDF do HotPDF no Delphi: EXTEND_PAD corresponde à flag de continuação /Extend dos shadings axial e radial, enquanto EXTEND_REPEAT e EXTEND_REFLECT não têm equivalente PDF e têm de ser construídos a partir de geometria de shading por ciclo
Os shadings PDF só prolongam as cores em t0 e t1, pelo que um gradiente COLRv1 repeat ou reflect tem de ser disposto ciclo a ciclo pelo produtor, e não ativado para o visualizador

A primeira tentativa do HotPDF tentou fazer a função fazer o trabalho periódico. Registou 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, encheu-a com amostras dobradas e ligou o Extend. Dentro do primeiro ciclo um ficheiro de teste mínimo renderizava corretamente; fora dele, o MuPDF pintava uma única cor média e o Poppler não estava melhor. Ambos os visualizadores avaliam a função ao longo do parâmetro do shading e prolongam para lá do eixo, e nenhum volta a consultar uma função periódica na região estendida. O domínio largo era também uma má leitura da especificação: o parâmetro do shading t é passado diretamente à função, e por §7.10.1 uma entrada fora do domínio da função é aparada, pelo que uma função cujo domínio não coincide com o /Domain do shading (predefinição [0 1]) só tem parte das suas amostras lida

O design que funciona tem três partes, todas verificadas contra renderizações do MuPDF e do Poppler. A geometria do shading cobre fisicamente um número inteiro N de ciclos, pelo que nenhum píxel depende do Extend. A função amostrada mantém o domínio predefinido [0 1], correspondendo ao que o visualizador realmente passa. E a sequência de amostras em si é dobrada por ciclo, com 256 intervalos por ciclo, pelo que a função contém dados de cor repeat ou reflect em vez de depender de qualquer funcionalidade do visualizador

Dobrar uma linha de cores em amostras Type 0

Dobrar significa amostrar a linha de cores uma vez por ciclo e espelhar a sequência nos ciclos ímpares para o modo reflect. O auxiliar abaixo é Delphi simples, não uma API do HotPDF, e faz duas coisas que a versão ingénua erra. Interpola em luz linear em vez de nos bytes sRGB guardados, e resolve paragens duras (duas paragens no mesmo deslocamento) para a cor do lado de lá, que é a semântica OpenType e CSS

uses
  System.SysUtils, System.Math;

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

// Um ciclo de uma linha de cores como triplas RGB de 8 bits. Reverse = True
// espelha o ciclo, que é o que o modo reflect faz nos 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 uma paragem dura
    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 mais longe do que este esboço. Escreve amostras lineares big-endian de 16 bits numa função CalRGB e guarda o alfa como uma amostra de cinzento de 16 bits separada, enquanto os auxiliares públicos mostrados a seguir recebem triplas DeviceRGB de 8 bits, razão pela qual o esboço converte de volta para 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 precisa cada ciclo do seu próprio shading?

Cada ciclo precisa do seu próprio shading porque os leitores não avaliam uma função amostrada exatamente; pré-calculam uma tabela de cores, e a tabela interpola através da passagem de volta da última cor de um ciclo para a 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 numa única função de 2816 amostras e todas as amostras guardadas podem ser exatas enquanto o MuPDF ainda mostra 12 a 14 níveis de erro por canal, porque 256 entradas de tabela se espalham pelo vão todo e a emenda dura de repetição é alisada numa rampa. O Poppler e a interpolação analítica do mesmo ficheiro estavam ambos corretos, pelo que isto é um limite de precisão do leitor, não um erro de ficheiro, e acrescentar mais amostras não ajuda

Diagrama de emendas de gradiente repeat COLRv1 do HotPDF para Delphi: uma única função amostrada a abranger 11 ciclos deixa a tabela de cores de 256 entradas do MuPDF alisar cada emenda de repetição numa rampa, enquanto um shading axial por ciclo mantém cada tabela dentro de um único ciclo e preserva o salto de cor duro
Os leitores pré-calculam uma tabela de cores fixa por shading, pelo que a correção é geométrica: um shading por ciclo com o Extend desligado põe a emenda de repetição numa fronteira que nenhuma tabela interpola

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 Extend falsas, pelo que o salto de cor de fim para início 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 cores, e a direção do shading é trocada em vez de as amostras serem recalculadas. Todos os ciclos são desenhados numa superfície isolada em luz linear e o alfa da tinta é aplicado uma vez no fim, pelo que um gradiente translúcido não duplica a sua opacidade ao longo das emendas

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

// Gradiente horizontal repeat ou reflect feito de um shading por ciclo.
// As coordenadas do padrão estão no espaço PDF predefinido; um eixo horizontal mantém
// este exemplo independente da convenção 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 bloqueada abaixo do PDF 1.3
    Pdf.CurrentPage.SetFillPattern(PatternName);
    // A faixa recorta o shading a exatamente um ciclo, pelo que o Extend nunca aparece
    Pdf.CurrentPage.Rectangle(X0, Y, Math.Min(X1, X + Width) - X0, Height);
    Pdf.CurrentPage.Fill;
  end;
end;

Vãos muito compridos precisam de um truque diferente, porque um shading por ciclo significa um recurso por ciclo. Para gradientes COLRv1 lineares periódicos o HotPDF muda para um esquema de duplicação: uma folha de formulário 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 pela sua largura. O número de formulários cresce logaritmicamente com o vão, enquanto o visualizador continua a pintar cada ciclo visível, pelo que a cobertura após o alinhamento de fase fica limitada a 65536 unidades de ciclo. Uma versão anterior que punha a primitiva num padrão de mosaico Type 1 foi abandonada, porque os leitores ajustam o espaçamento dos mosaicos à grelha do dispositivo e o Poppler dessincronizava de fase num ciclo que não era um número inteiro de píxeis

Resolver o intervalo de parâmetros para famílias de círculos radiais

Um gradiente radial só cobre o glifo quando o intervalo de ciclos é calculado para a família certa de círculos, e as tintas 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 cada círculo emitido ligados ao mesmo parâmetro original, pelo que raios negativos remapeados nunca são aparados de forma independente

Á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 tangente, cones recebem um shading curto por ciclo, e linhas tangentes deixam uma faixa singular transparente
  • Famílias aninhadas intersectam a caixa de preenchimento monotonicamente, pelo que o intervalo de parâmetros sai dos quatro cantos mais uma bisseção pelo primeiro círculo que toca a caixa. Até 64 ciclos são amostrados num único shading a 256 amostras por ciclo, com limite de 4096 amostras; vãos mais profundos mudam 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 pela ordem do parâmetro original dentro de um grupo isolado não knockout, de modo que o maior parâmetro válido ganhe onde os círculos se sobrepõem
  • Famílias tangentes reduzem a equação do círculo a uma linear. Relativamente ao ponto de tangência partilhado, 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 linha tangente atravessa a caixa de preenchimento, o número de ciclos necessários para chegar a píxeis perto dessa linha é ilimitado, pelo que 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 estão atrás do ponto de tangência, a tinta é transparente como um todo e o resto do glifo continua a desenhar. O caminho de cone finito aceita até 4096 ciclos necessários, com a cobertura limitada a 1024 fatias e tudo o que passe o limite por pintar; um glifo de 1024 fatias dá cerca de 1,8 MB

Cor em luz linear e a armadilha das duas casas decimais

O COLRv1 interpola e compõe em luz linear, pelo que o HotPDF descodifica cada byte CPAL com a função de transferência inversa do sRGB e pinta sólidos, shadings e grupos internos num único espaço CalRGB com branco D65, primárias sRGB e /Gamma [1 1 1] (§8.6.5.3). Só a superfície raiz acabada converte para o espaço de mistura do chamador. Interpolar os bytes sRGB guardados em vez disso produz pontos médios visivelmente mais escuros, que é o erro clássico de gama que todo o motor de gradientes comete uma vez

A mudança para luz linear expôs um bug antigo de formatador. Um valor de canal CPAL escuro de 12 torna-se cerca de 0,0036765 em luz linear, e o formatador de números partilhado por trás do scn mantinha só duas casas decimais, pelo que esse componente era escrito como 0 antes de qualquer visualizador tocar no ficheiro. O sintoma parecia exatamente quantização do leitor, e o primeiro instinto foi culpar os buffers de grupo de 8 bits do MuPDF. Os componentes de preenchimento e traço agora passam por um formatador decimal sem expoente que mantém valores pequenos e ignora o locale do processo. A lição generaliza: quando uma cor escura desaparece, despeje o fluxo de conteúdo e verifique o operando escrito antes de investigar o visualizador

O visualizador ainda importa depois dessa correção. O MuPDF 1.28.2 perde canais lineares quase nulos quando compõe um grupo de transparência isolado que transporta um espaço de cor de mistura explícito, e o Poppler 26.07.0 Splash ignora completamente espaços de mistura de grupo. A propriedade opt-in THotPDF.COLRv1DeviceSpaceGroupBlend (predefinição False) emite grupos COLRv1 sem o espaço de cor de mistura, pelo que se compõem no espaço de dispositivo herdado enquanto as cores de conteúdo, shadings e funções ficam em CalRGB em luz linear. O compromisso é que camadas translúcidas sobrepostas passam a misturar-se no espaço de dispositivo em visualizadores que honram espaços de cor de grupo

Que modos de composição COLRv1 o PDF consegue exprimir exatamente?

O HotPDF renderiza os modos de composição COLRv1 5 a 8 (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 calcula (§11.6.5). O operando sobrevivente é capturado como um formulário ordinário; o operando mascarado é capturado numa passagem de alfa como conteúdo plano dentro de um grupo DeviceGray isolado e usado como máscara /Luminosity com /BC [0]. Os modos out escrevem 1 − α para todos os alfas e põem uma cobertura branca opaca sob o conteúdo da máscara, pelo que o sobrevivente permanece 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 pelo (1 − α) da origem, e não há primitiva que some duas superfícies ou escale o fundo apenas pela cobertura da origem, pelo que atop, xor e plus não podem ser escritos como vetores. Mais uma restrição veio de PDFs de teste despidos: ambos os visualizadores calculam mal grupos de soft mask cujo conteúdo aninha um Do de formulário, pelo que o operando mascarado tem de ser plano. Cores sólidas, gradientes pad e um invólucro PaintGlyph em torno deles qualificam; camadas, transformações e gradientes periódicos, que todos produzem formulários internos, mandam o glifo para o fallback

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

A saída vetorial não é o mesmo que píxeis idênticos em todos os visualizadores, e os registos de aceitação do HotPDF dizem-no sem rodeios. Amostras a um ou dois passos de uma fronteira de ciclo são alisadas pela interpolação do leitor, pelo que as verificações de píxel amostram longe das emendas e os fluxos de função são verificados descodificando-os em vez disso. Interpolar amostras retas de RGB e alfa guardadas separadamente é uma aproximação perto de pontos finais de alfa baixo e paragens duras; o erro medido é de segunda ordem a 256 amostras por ciclo, mas não é exato. O Poppler Splash amostra na aresta esquerda de um píxel em vez do 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 para além de vãos de 4096 ciclos, cones e famílias tangentes para além de 4096 ciclos necessários, e a faixa tangente junto à linha singular permanece vazia. Alfa variável nas paragens precisa de transparência PDF 1.4, pelo que não é ativado sob PDF/A-1, PDF/X-1a ou PDF/X-3. Se um glifo cair fora de qualquer destes limites, o DrawRegisteredColorGlyph declina o grafo inteiro antes de escrever um operador, que é o mesmo comportamento tudo-ou-nada de antes

FAQ

O HotPDF rasteriza gradientes COLRv1 repeat e reflect?

Não. Versões mais recentes do HotPDF emitem-nos como shadings axiais e radiais vetoriais que cobrem ciclos inteiros. Só grafos fora do subconjunto suportado, como composições atop, xor e plus, vão para o OnColorGlyphRasterize

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

Os leitores pré-calculam uma tabela de cores a partir da função, e a tabela do MuPDF tem 256 entradas. Espalhada por muitos ciclos, interpola através de cada emenda de repetição. 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 caem a zero no MuPDF. Deixe-o desligado quando camadas translúcidas sobrepostas têm de se misturar em luz linear em visualizadores que honram espaços de cor de grupo

Gradientes COLRv1 nativos, emoji a cores, 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