Articolo tecnico

Gradienti COLRv1 repeat/reflect come shading PDF in Delphi

HotPDF Component dipinge i gradienti COLRv1 repeat e reflect come shading PDF vettoriali invece di rasterizzare il glifo. La geometria viene stirata per coprire un numero intero di cicli della linea di colore, ogni ciclo riceve il proprio shading assiale o radiale con Extend spento, i colori si interpolano in CalRGB in luce lineare, e le modalità di compositing Porter-Duff in/out si esprimono con soft mask di luminosità

Quando abbiamo trattato come HotPDF disegna i font color emoji COLR v1 e SVG in Delphi, le modalità extend repeat e reflect erano nell'elenco delle cose che il percorso nativo non emetteva, e i glifi di quel tipo andavano al fallback raster. Le versioni più recenti di HotPDF li disegnano come vettori. Arrivarci è costato parecchi falsi start, la maggior parte causati da un'assunzione sul PDF dall'aspetto ragionevole che si è rivelata sbagliata in almeno un viewer molto usato. Questo post ripercorre quelle assunzioni, perché chiunque mappi gradienti CSS, SVG o OpenType sul PDF incontrerà le stesse

Perché un viewer PDF non può ripetere il gradient per voi?

Uno shading PDF non ha modalità repeat o reflect, quindi il produttore deve impaginare ogni ciclo da sé. ISO 32000-1 §8.7.4.5.3 e §8.7.4.5.4 definiscono /Extend per gli shading assiali e radiali, e tutto ciò che fa è continuare il colore a t0 o t1 oltre la fine dell'asse. È il COLRv1 EXTEND_PAD, e nient'altro. OpenType COLR v1 definisce anche EXTEND_REPEAT, dove la linea di colore riparte ogni volta che il parametro passa un intero, e EXTEND_REFLECT, dove ogni altro ciclo va all'indietro

Mappatura da COLRv1 a shading PDF di HotPDF in Delphi: EXTEND_PAD combacia con il flag di continuazione /Extend degli shading assiali e radiali, mentre EXTEND_REPEAT e EXTEND_REFLECT non hanno equivalenti PDF e vanno costruiti dalla geometria di shading per ciclo
Gli shading PDF fanno solo pad dei colori a t0 e t1, quindi un gradiente COLRv1 repeat o reflect va impaginato ciclo per ciclo dal produttore, non attivato per il viewer

Il primo tentativo di HotPDF provò a far fare alla funzione il lavoro periodico. Registrava una funzione campionata Type 0 (§7.10.2) con un dominio ampio, all'incirca [-12.73, 12.73] per un arco di 25 cicli, la riempiva di campioni ripiegati e accendeva Extend. Dentro il primo ciclo un file di test minimale si renderizzava correttamente; fuori, MuPDF dipingeva un unico colore mediato e Poppler non faceva di meglio. Entrambi i viewer valutano la funzione lungo il parametro di shading e fanno pad oltre l'asse, e nessuno dei due torna a interrogare una funzione periodica nella regione estesa. Il dominio ampio era anche una lettura sbagliata della specifica: il parametro di shading t passa dritto alla funzione, e per §7.10.1 un input fuori dal dominio della funzione viene tagliato, quindi una funzione il cui dominio non combacia con il /Domain dello shading (predefinito [0 1]) ha solo una parte dei propri campioni mai letta

Il design che funziona ha tre parti, tutte verificate contro render MuPDF e Poppler. La geometria dello shading copre fisicamente un numero intero N di cicli, così nessun pixel dipende da Extend. La funzione campionata mantiene il dominio predefinito [0 1], combaciando con ciò che il viewer passa davvero. E la sequenza di campioni stessa è ripiegata per ciclo, con 256 intervalli per ciclo, così la funzione contiene dati di colore repeat o reflect invece di contare su qualunque funzionalità del viewer

Ripiegare una linea di colore in campioni Type 0

Ripiegare significa campionare la linea di colore una volta per ciclo e specchiare la sequenza sui cicli dispari per la modalità reflect. L'helper sotto è Delphi puro, non una API HotPDF, e fa due cose che la versione ingenua sbaglia. Interpola in luce lineare invece che sui byte sRGB memorizzati, e risolve gli hard stop (due stop allo stesso offset) verso il colore del lato lontano, che è la semantica OpenType e CSS

uses
  System.SysUtils, System.Math;

type
  TColorStop = record
    Offset: Single;  // 0..1 dentro un ciclo, ordinato crescente
    R, G, B: Byte;   // byte sRGB in stile 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 di una linea di colore come triple RGB a 8 bit. Reverse = True
// specchia il ciclo, che è ciò che la modalità reflect fa sui cicli dispari
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;
    // >= sceglie il lato di coda di 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;

Il renderer COLRv1 dentro HotPDF va oltre questo schizzo. Scrive campioni lineari big-endian a 16 bit in una funzione CalRGB e memorizza l'alpha come campione grigio a 16 bit separato, mentre gli helper pubblici mostrati dopo prendono triple DeviceRGB a 8 bit, ed è per questo che lo schizzo riconverte in byte sRGB. Per la teoria di ciò che un reader fa con quei campioni, l'articolo sulle funzioni campionate Type 0 come LUT di colore 3D copre il modello di interpolazione in dettaglio

Perché ogni ciclo ha bisogno del proprio shading?

Ogni ciclo ha bisogno del proprio shading perché i reader non valutano una funzione campionata in modo esatto; precalcolano una tabella di colori, e la tabella interpola attraverso il riavvolgimento dall'ultimo colore di un ciclo al primo colore del successivo. MuPDF memorizza la funzione monodimensionale di uno shading come tabella di colori da 256 voci. Imballate 11 cicli in un'unica funzione da 2816 campioni e ogni campione salvato può essere esatto mentre MuPDF mostra comunque da 12 a 14 livelli di errore di canale, perché 256 voci di tabella sono distribuite su tutto l'arco e la cucitura dura del repeat viene levigata in una rampa. Poppler e l'interpolazione analitica dello stesso file erano entrambi corretti, quindi è un limite di precisione del reader, non un errore del file, e aggiungere campioni non aiuta

Diagramma della cucitura del gradiente repeat COLRv1 di HotPDF per Delphi: una sola funzione campionata su 11 cicli lascia che la tabella di colori a 256 voci di MuPDF levigi ogni cucitura di repeat in una rampa, mentre uno shading assiale per ciclo tiene ogni tabella dentro un singolo ciclo e preserva il salto di colore netto
I reader precalcolano una tabella di colori fissa per shading, quindi la correzione è geometrica: uno shading per ciclo con Extend spento sposta la cucitura del repeat su un confine che nessuna tabella interpola mai attraverso

La correzione è tenere ogni tabella del reader dentro un ciclo. HotPDF emette i gradienti lineari periodici come uno shading assiale indipendente per ciclo originale, con entrambi i flag Extend falsi, così il salto di colore da fine a inizio cade sul confine tra due shading invece che dentro una tabella. Per reflect, il test di parità usa il numero di ciclo originale con segno, cicli negativi compresi prima dell'inizio della linea di colore, e la direzione dello shading viene scambiata invece di ricalcolare i campioni. Tutti i cicli vengono disegnati in una superficie isolata in luce lineare e l'alpha della vernice si applica una volta sola alla fine, così un gradiente traslucido non raddoppia la propria opacità lungo le cuciture

I metodi pubblici THotPDF.RegisterSampledAxialGradient e THotPDF.RegisterSampledRadialGradient espongono il mattone dello shading campionato per i vostri disegni. Entrambi prendono (..., DomainT0, DomainT1: Single; const Samples: TBytes; SampleCount, Cycles: Integer) dopo la geometria, richiedono PDF 1.3, sollevano un'eccezione a meno che Samples contenga esattamente SampleCount * 3 byte, e restituiscono un nome di pattern per THPDFPage.SetFillPattern. Impostano Extend su entrambe le estremità e registrano Cycles come chiave di pattern personalizzata /Cycles che i viewer ignorano. Per seguire la regola di un ciclo per shading con loro, registrate un pattern per ciclo e lasciate che un rettangolo di banda lo ritagli

// Gradiente repeat o reflect orizzontale fatto di uno shading per ciclo.
// Le coordinate del pattern sono nello spazio PDF predefinito; un asse orizzontale
// mantiene questo esempio indipendente dalla convenzione Y dall'alto della pagina
procedure PaintPeriodicBands(Pdf: THotPDF; const Stops: array of TColorStop;
  X, Y, Width, Height, CycleLength: Single; Reflect: Boolean);
const
  SamplesPerCycle = 257;  // 256 intervalli per 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]: il parametro di shading arriva alla funzione invariato
    PatternName := Pdf.RegisterSampledAxialGradient(X0, 0, X1, 0, 0, 1,
      Samples, SamplesPerCycle, 1);
    if PatternName = '' then
      Exit;  // versione documento bloccata sotto PDF 1.3
    Pdf.CurrentPage.SetFillPattern(PatternName);
    // La banda ritaglia lo shading esattamente a un ciclo, quindi Extend non compare mai
    Pdf.CurrentPage.Rectangle(X0, Y, Math.Min(X1, X + Width) - X0, Height);
    Pdf.CurrentPage.Fill;
  end;
end;

Gli archi molto lunghi richiedono un trucco diverso, perché uno shading per ciclo significa una risorsa per ciclo. Per i gradienti COLRv1 lineari periodici HotPDF passa a uno schema di raddoppio: una form foglia contiene una primitiva repeat a un ciclo o reflect a due cicli, e ogni livello di Form XObject disegna il livello precedente due volte, traslato della propria larghezza. Il numero di forme cresce logaritmicamente con l'arco, mentre il viewer continua a dipingere ogni ciclo visibile, così la copertura dopo l'allineamento di fase è limitata a 65536 unità di ciclo. Una versione precedente che metteva la primitiva in un tiling pattern Type 1 fu abbandonata, perché i reader adeguano la spaziatura delle tile alla griglia del dispositivo e Poppler uscì di fase su un ciclo che non era un numero intero di pixel

Risolvere l'intervallo di parametro per le famiglie di cerchi radiali

Un gradiente radiale copre il glifo solo quando l'intervallo di ciclo è calcolato per la giusta famiglia di cerchi, e le vernici radiali COLRv1 cadono in tre famiglie con matematica diversa. Con D = c1 - c0 e dr = r1 - r0, una famiglia strettamente annidata ha |dr| > |D|, un cono non annidato ha |D|² > dr², e una famiglia tangente ha |D| = |dr|. HotPDF misura il parametro dal punto a raggio zero della famiglia originale e tiene centro e raggio di ogni cerchio emesso legati allo stesso parametro originale, così i raggi negativi rimappati non vengono mai clampati indipendentemente

Albero decisionale del gradiente radiale COLRv1 di HotPDF per Delphi: confrontare dr con D separa le famiglie di cerchi annidate risolte per bisezione, i coni risolti con brevi shading radiali per ciclo, e le famiglie tangenti ridotte alla radice lineare q = |W|^2 / (2(W*V))
La disuguaglianza tra |dr| e |D| decide la matematica dei cicli: le famiglie annidate fanno bisezione per il primo cerchio che tocca, i coni ricevono un breve shading per ciclo, e le linee tangenti lasciano una striscia singolare trasparente
  • Le famiglie annidate intersecano il box di riempimento in modo monotono, quindi l'intervallo di parametro viene dai quattro angoli più una bisezione per il primo cerchio che tocca il box. Fino a 64 cicli vengono campionati in uno shading a 256 campioni per ciclo, con tetto a 4096 campioni; gli archi più profondi passano a fette per ciclo
  • Coni e strisce a raggio uguale usano brevi shading radiali separati ai confini di ciclo originali, ciascuno con 257 campioni e Extend spento, compositati in ordine di parametro originale dentro un gruppo isolato e non knockout così il parametro valido più grande vince dove i cerchi si sovrappongono
  • Le famiglie tangenti riducono l'equazione del cerchio a una lineare. Rispetto al punto di tangenza condiviso, la radice è q = |W|² / (2·(W·V)), e la regione coperta è il semipiano aperto davanti a quel punto

Il caso tangente ha una singolarità genuina. Quando la linea tangente attraversa il box di riempimento, il numero di cicli necessari per raggiungere i pixel vicini a quella linea è illimitato, quindi nessun numero finito di shading la copre. HotPDF spende un budget di 64 cicli dal punto di tangenza e lascia trasparente la sottile striscia rimanente. Quando tutti e quattro gli angoli del box stanno dietro il punto di tangenza, la vernice è trasparente nel complesso e il resto del glifo si disegna comunque. Il percorso del cono finito accetta fino a 4096 cicli richiesti, con copertura limitata a 1024 fette e tutto ciò che va oltre lasciato non dipinto; un glifo da 1024 fette arriva a circa 1,8 MB

Colore in luce lineare e la trappola dei due decimali

COLRv1 interpola e composita in luce lineare, quindi HotPDF decodifica ogni byte CPAL con la funzione di trasferimento inversa sRGB e dipinge solidi, shading e gruppi interni in uno spazio CalRGB con bianco D65, primarie sRGB e /Gamma [1 1 1] (§8.6.5.3). Solo la superficie radice finita si converte nello spazio di blend del chiamante. Interpolare i byte sRGB memorizzati produce invece punti medi visibilmente più scuri, che è il classico errore di gamma che ogni motore di gradienti fa una volta

Il passaggio alla luce lineare ha esposto un vecchio bug del formatter. Un valore di canale CPAL scuro di 12 diventa circa 0,0036765 in luce lineare, e il formatter numerico condiviso dietro scn teneva solo due decimali, così quella componente veniva scritta come 0 prima ancora che un viewer toccasse il file. Il sintomo sembrava esattamente quantizzazione del reader, e il primo istinto fu dare la colpa ai buffer di gruppo a 8 bit di MuPDF. Le componenti di fill e stroke ora passano per un formatter decimale senza esponente che conserva i valori piccoli e ignora il locale del processo. La lezione si generalizza: quando un colore scuro sparisce, fate il dump del content stream e controllate l'operando scritto prima di indagare sul viewer

Il viewer conta ancora dopo quella correzione. MuPDF 1.28.2 perde i canali lineari quasi nulli quando composita un gruppo di trasparenza isolato che porta uno spazio di colore di blend esplicito, e Poppler 26.07.0 Splash ignora del tutto gli spazi di blend dei gruppi. La proprietà opt-in THotPDF.COLRv1DeviceSpaceGroupBlend (predefinito False) emette i gruppi COLRv1 senza lo spazio di colore di blend, così compositano nello spazio dispositivo ereditato mentre i colori del contenuto, gli shading e le funzioni restano in CalRGB in luce lineare. Il compromesso è che i livelli traslucidi sovrapposti allora fanno blend nello spazio dispositivo nei viewer che onorano gli spazi di colore dei gruppi

Quali modalità composite COLRv1 il PDF può esprimere esattamente?

HotPDF renderizza nativamente le modalità composite COLRv1 da 5 a 8 (SRC_IN, DEST_IN, SRC_OUT, DEST_OUT), e le modalità da 9 a 12 (SRC_ATOP, DEST_ATOP, XOR, PLUS) vanno a OnColorGlyphRasterize. Le modalità in e out sono prodotti di una superficie per l'alpha dell'altra, che è esattamente ciò che calcola una soft mask (§11.6.5). L'operando superstite viene catturato come form ordinaria; l'operando mascherato viene catturato in un passaggio alpha come contenuto piatto dentro un gruppo DeviceGray isolato e usato come maschera /Luminosity con /BC [0]. Le modalità out scrivono 1 − α per ogni alpha e mettono una copertura bianca opaca sotto il contenuto della maschera, così il superstite resta visibile dove l'altro operando non ha mai dipinto

Le altre quattro richiedono l'addizione premoltiplicata di due superfici. Nel modello source-over del PDF il termine del backdrop è sempre attenuato per (1 − α) della sorgente, e non esiste una primitiva che sommi due superfici o scala il backdrop solo per la copertura della sorgente, quindi atop, xor e plus non si possono scrivere come vettori. Un'altra restrizione è venuta dai PDF di test nudi: entrambi i viewer calcolano male i gruppi di soft mask il cui contenuto annida una form Do, quindi l'operando mascherato deve essere piatto. Colori solidi, gradienti pad e un wrapper PaintGlyph attorno a loro qualificano; livelli, transform e gradienti periodici, che producono tutti forme interne, mandano il glifo al fallback

var
  Pdf: THotPDF;
  Fallback: TEmojiFallback;  // classe handler con la firma dell'evento sotto
begin
  Pdf := THotPDF.Create(nil);
  Fallback := TEmojiFallback.Create;
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'periodic-icons.pdf';
    // Opt-in: compositing del gruppo in spazio dispositivo per MuPDF e Poppler
    Pdf.COLRv1DeviceSpaceGroupBlend := True;
    // Riceve i glifi atop/xor/plus e tutto il resto fuori dal sottoinsieme:
    // (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;

Dove il percorso vettoriale resta ancora indietro

L'output vettoriale non è la stessa cosa di pixel identici in ogni viewer, e i registri di accettazione di HotPDF lo dicono chiaramente. I campioni entro uno o due passi da un confine di ciclo vengono levigati dall'interpolazione del reader, quindi i controlli sui pixel campionano lontano dalle cuciture e gli stream delle funzioni si verificano decodificandoli invece. Interpolare campioni RGB e alpha retti memorizzati separatamente è un'approssimazione vicino ai punti finali a alpha bassa e agli hard stop; l'errore misurato è di secondo ordine a 256 campioni per ciclo, ma non è esatto. Poppler Splash campiona al bordo sinistro di un pixel invece che al centro, il che si manifesta come piccole differenze sulle rampe più ripide

Anche i budget sono limiti veri. Le famiglie radiali annidate declinano oltre archi di 4096 cicli, i coni e le famiglie tangenti oltre 4096 cicli richiesti, e la striscia tangente vicino alla linea singolare resta vuota. L'alpha variabile degli stop richiede la trasparenza di PDF 1.4, quindi non è abilitata sotto PDF/A-1, PDF/X-1a o PDF/X-3. Se un glifo cade fuori da uno qualsiasi di questi limiti, DrawRegisteredColorGlyph declina l'intero grafo prima di scrivere un operatore, che è lo stesso comportamento tutto-o-nulla di prima

FAQ

HotPDF rasterizza i gradienti COLRv1 repeat e reflect?

No. Le versioni più recenti di HotPDF li emettono come shading assiali e radiali vettoriali che coprono cicli interi. Solo i grafi fuori dal sottoinsieme supportato, come i composite atop, xor e plus, vanno a OnColorGlyphRasterize

Perché non usare una sola funzione campionata per tutti i cicli?

I reader precalcolano una tabella di colori dalla funzione, e la tabella di MuPDF ha 256 voci. Distribuita su molti cicli, interpola attraverso ogni cucitura di repeat. Uno shading per ciclo tiene ogni tabella dentro un singolo ciclo

Quando attivare COLRv1DeviceSpaceGroupBlend?

Attivatelo quando i canali quasi neri nei glifi COLRv1 crollano a zero in MuPDF. Lasciatelo spento quando livelli traslucidi sovrapposti devono fare blend in luce lineare nei viewer che onorano gli spazi di colore dei gruppi

Gradienti COLRv1 nativi, color emoji, shading campionati e gruppi di trasparenza fanno tutti parte di HotPDF Component per Delphi e C++Builder, che genera documenti PDF direttamente da codice Delphi e C++Builder