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
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
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
- 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
Extenddesligado, 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