Technisch artikel

COLRv1 repeat/reflect-gradients als PDF shadings in Delphi

HotPDF Component tekent COLRv1 repeat- en reflect-gradients als vector-PDF-shadings in plaats van de glyph te rasteren. De geometrie wordt uitgerekt om een geheel aantal color-line-cycli te dekken, elke cyclus krijgt zijn eigen axiale of radiale shading met Extend uitgeschakeld, kleuren worden geïnterpoleerd in linear-light CalRGB, en de Porter-Duff in/out composite modes worden uitgedrukt met luminosity soft masks

Toen we behandelden hoe HotPDF COLR v1- en SVG-kleuremoji-fonts in Delphi tekent, stonden de repeat- en reflect-extend modes op de lijst van dingen die het native pad niet uitzond, en zulke glyphs gingen naar de raster-fallback. Nieuwere HotPDF-versies tekenen ze als vectoren. Daar komen kostte meerdere valse starts, de meeste veroorzaakt door een plausibel ogende aanname over PDF die in ten minste één veelgebruikte viewer onjuist bleek. Deze post loopt die aannames langs, want iedereen die CSS-, SVG- of OpenType-gradients op PDF mapt, ontmoet dezelfde

Waarom kan een PDF-viewer de gradient niet voor u herhalen?

Een PDF-shading heeft geen repeat- of reflect-modus, dus de producent moet elke cyclus zelf uitzetten. ISO 32000-1 §8.7.4.5.3 en §8.7.4.5.4 definiëren /Extend voor axiale en radiale shadings, en alles wat het doet is de kleur bij t0 of t1 doorlaten lopen voorbij het einde van de as. Dat is COLRv1 EXTEND_PAD, en niets anders. OpenType COLR v1 definieert ook EXTEND_REPEAT, waarbij de color line opnieuw begint telkens als de parameter een geheel getal passeert, en EXTEND_REFLECT, waarbij om de andere cyclus achterstevoren loopt

Mapping van HotPDF COLRv1 naar PDF shading in Delphi: EXTEND_PAD matcht de /Extend-doorgaanvlag van axiale en radiale shadings, terwijl EXTEND_REPEAT en EXTEND_REFLECT geen PDF-equivalent hebben en uit shading-geometrie per cyclus moeten worden opgebouwd
PDF shadings vullen de kleuren alleen aan bij t0 en t1, dus een COLRv1 repeat- of reflect-gradient moet door de producent cyclus voor cyclus worden uitgezet, niet voor de viewer worden aangezet

De eerste HotPDF-poging probeerde de functie het periodieke werk te laten doen. Die registreerde één Type 0 sampled function (§7.10.2) met een breed domein, ruwweg [-12.73, 12.73] voor een span van 25 cycli, vulde hem met gevouwen samples en zette Extend aan. Binnen de eerste cyclus rendert een minimaal testbestand correct; erbuiten schilderde MuPDF één gemiddelde kleur en Poppler deed het niet beter. Beide viewers evalueren de functie langs de shading-parameter en vullen voorbij de as aan, en geen van beide gaat terug om een periodieke functie in het uitgebreide gebied te bevragen. Het brede domein was bovendien een mislezing van de spec: de shading-parameter t wordt rechtstreeks aan de functie doorgegeven, en volgens §7.10.1 wordt een invoer buiten het functiedomein geclipt, dus een functie wiens domein niet matcht met de /Domain van de shading (default [0 1]) krijgt hooguit een deel van zijn samples gelezen

Het ontwerp dat werkt heeft drie delen, alle geverifieerd tegen MuPDF- en Poppler-renders. De shading-geometrie dekt fysiek een geheel aantal N cycli, dus geen enkele pixel hangt van Extend af. De sampled function houdt het default domein [0 1] aan, matchend met wat de viewer werkelijk doorgeeft. En de sample-reeks zelf is per cyclus gevouwen, met 256 intervallen per cyclus, zodat de functie de repeat- of reflect-kleurdata vasthoudt in plaats van op een willekeurige viewer-feature te leunen

Een color line vouwen tot Type 0 samples

Vouwen betekent de color line één keer per cyclus samplen en de reeks op oneven cycli spiegelen voor de reflect-modus. De helper hieronder is kale Delphi, geen HotPDF-API, en hij doet twee dingen die de naive versie verkeerd doet. Hij interpoleert in linear light in plaats van op de opgeslagen sRGB-bytes, en hij lost hard stops (twee stops op dezelfde offset) op naar de kleur aan de verre kant, wat de OpenType- en CSS-semantiek is

uses
  System.SysUtils, System.Math;

type
  TColorStop = record
    Offset: Single;  // 0..1 binnen één cyclus, oplopend gesorteerd
    R, G, B: Byte;   // sRGB-bytes in CPAL-stijl
  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;

// Eén cyclus van een color line als 8-bit RGB-tripels. Reverse = True
// spiegelt de cyclus, wat reflect mode op oneven cycli doet
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;
    // >= kiest de achterzijde van een 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;

De COLRv1-renderer in HotPDF gaat verder dan deze schets. Hij schrijft big-endian 16-bit lineaire samples in een CalRGB-functie en bewaart alpha als een aparte 16-bit grijze sample, terwijl de publieke helpers hieronder 8-bit DeviceRGB-tripels nemen, en daarom converteert de schets terug naar sRGB-bytes. Voor de theorie van wat een reader met die samples doet, behandelt het artikel over Type 0 sampled functions als 3D color LUTs het interpolatiemodel in detail

Waarom heeft elke cyclus zijn eigen shading nodig?

Elke cyclus heeft zijn eigen shading nodig, omdat readers een sampled function niet exact evalueren; ze rekenen vooraf een kleurentabel, en de tabel interpoleert over de overgang heen van de laatste kleur van één cyclus naar de eerste kleur van de volgende. MuPDF bewaart de eendimensionale functie van een shading als een kleurentabel van 256 entries. Propt u 11 cycli in één functie van 2816 samples, dan kan elke opgeslagen sample exact zijn terwijl MuPDF toch 12 tot 14 niveaus kanaalfout laat zien, want de 256 tabelentries liggen uitgesmeerd over het hele span en de harde repeat-naad wordt gladgestreken tot een ramp. Poppler en de analytische interpolatie van hetzelfde bestand waren allebei correct, dus dit is een precisielimiet van de reader, geen bestandsfout, en meer samples toevoegen helpt niet

Naaddiagram van de HotPDF COLRv1 repeat-gradient voor Delphi: één sampled function over 11 cycli laat de MuPDF-kleurentabel van 256 entries elke repeat-naad gladstrijken tot een ramp, terwijl één axiale shading per cyclus elke tabel binnen één enkele cyclus houdt en de harde kleursprong bewaart
Readers rekenen vooraf een vaste kleurentabel per shading, dus de fix is geometrisch: één shading per cyclus met Extend uit zet de repeat-naad op een grens waarover geen enkele tabel ooit interpoleert

De fix is elke reader-tabel binnen één cyclus houden. HotPDF zendt periodieke lineaire gradients uit als één onafhankelijke axiale shading per originele cyclus, met beide Extend-flags op false, zodat de sprong van einde naar begin op de grens tussen twee shadings valt in plaats van binnen één tabel. Voor reflect gebruikt de oneven-toets het originele signed cyclustal, inclusief negatieve cycli vóór de start van de color line, en wordt de shadingrichting omgedraaid in plaats van de samples te herberekenen. Alle cycli worden in één linear-light geïsoleerd vlak getekend en de paint-alpha wordt een keer op het eind toegepast, dus een translucide gradient verdubbelt zijn opaciteit niet langs de naden

De publieke methoden THotPDF.RegisterSampledAxialGradient en THotPDF.RegisterSampledRadialGradient leggen de sampled-shading-bouwsteen bloot voor uw eigen tekenwerk. Beide nemen (..., DomainT0, DomainT1: Single; const Samples: TBytes; SampleCount, Cycles: Integer) na de geometrie, eisen PDF 1.3, gooien een exception tenzij Samples exact SampleCount * 3 bytes bevat, en geven een pattern-naam terug voor THPDFPage.SetFillPattern. Ze zetten Extend aan beide einden en leggen Cycles vast als een custom /Cycles-patternsleutel die viewers negeren. Om de regel één-cyclus-per-shading ermee te volgen, registreert u één pattern per cyclus en laat u een bandrechthoek hem clippen

// Horizontale repeat- of reflect-gradient opgebouwd uit één shading per cyclus.
// Pattern-coördinaten staan in default PDF space; een horizontale as houdt
// dit voorbeeld onafhankelijk van de van-boven-naar-beneden-Y-conventie van de pagina
procedure PaintPeriodicBands(Pdf: THotPDF; const Stops: array of TColorStop;
  X, Y, Width, Height, CycleLength: Single; Reflect: Boolean);
const
  SamplesPerCycle = 257;  // 256 intervallen per cyclus
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));
    // Domain [0, 1]: de shading-parameter komt onveranderd bij de functie aan
    PatternName := Pdf.RegisterSampledAxialGradient(X0, 0, X1, 0, 0, 1,
      Samples, SamplesPerCycle, 1);
    if PatternName = '' then
      Exit;  // documentversie vastgezet onder PDF 1.3
    Pdf.CurrentPage.SetFillPattern(PatternName);
    // De band clipt de shading op exact één cyclus, dus Extend komt nooit tevoorschijn
    Pdf.CurrentPage.Rectangle(X0, Y, Math.Min(X1, X + Width) - X0, Height);
    Pdf.CurrentPage.Fill;
  end;
end;

Zeer lange spans hebben een andere truc nodig, want één shading per cyclus betekent één resource per cyclus. Voor periodieke lineaire COLRv1-gradients schakelt HotPDF over op een verdubbelschema: een leaf form bevat een repeat-primitive van één cyclus of een reflect-primitive van twee cycli, en elk Form XObject-niveau tekent het vorige niveau twee keer, verschoven om zijn breedte. Het aantal forms groeit logaritmisch met het span, terwijl de viewer toch elke zichtbare cyclus schildert, dus de dekking na fase-uitlijning is gekapt op 65536 cyclus-eenheden. Een eerdere versie die de primitive in een Type 1 tiling pattern stopte is geschrapt, omdat readers de tile-afstand op het device-raster bijstellen en Poppler uit de fase dreef op een cyclus die geen geheel aantal pixels was

Het parameterbereik voor radiale cirkelfamilies oplossen

Een radiale gradient dekt de glyph pas als het cyclusbereik voor de juiste cirkelfamilie is berekend, en COLRv1 radiale paints vallen in drie families met verschillende wiskunde. Met D = c1 - c0 en dr = r1 - r0 heeft een streng geneste familie |dr| > |D|, een niet-geneste kegel |D|² > dr², en een tangentfamilie |D| = |dr|. HotPDF meet de parameter vanaf het nulstraalpunt van de originele familie en houdt centrum en straal van elke uitgezonden cirkel verbonden aan dezelfde originele parameter, dus gehermapte negatieve stralen worden nooit onafhankelijk geclampt

Beslisboom voor de HotPDF COLRv1 radiale gradient in Delphi: dr met D vergelijken scheidt geneste cirkelfamilies opgelost met bisectie, kegels opgelost met korte radiale shadings per cyclus, en tangentfamilies gereduceerd tot de lineaire root q = |W|^2 / (2(W*V))
De ongelijkheid tussen |dr| en |D| bepaalt de cycluswiskunde: geneste families bisecteren naar de eerste aanrakende cirkel, kegels krijgen één korte shading per cyclus, en tangentlijnen laten een singuliere strook transparant
  • Geneste families snijden de fill box monotoon, dus het parameterbereik komt uit de vier hoeken plus een bisectie voor de eerste cirkel die de box raakt. Tot 64 cycli worden in één shading gesampled met 256 samples per cyclus, gekapt op 4096 samples; diepere spans schakelen over op slices per cyclus
  • Kegels en gelijke-straal-stroken gebruiken aparte korte radiale shadings op de originele cyclusgrenzen, elk met 257 samples en Extend uit, gecomposited in originele parametervolgorde binnen een geïsoleerde non-knockout-groep zodat de grootste geldige parameter wint waar cirkels overlappen
  • Tangentfamilies reduceren de cirkelvergelijking tot een lineaire. Relatief aan het gedeelde raakpunt is de root q = |W|² / (2·(W·V)), en het gedekte gebied is het open halfvlak vóór dat punt

Het tangentgeval heeft een echte singulariteit. Kruist de tangentlijn de fill box, dan is het aantal cycli dat nodig is om pixels bij die lijn te bereiken onbegrensd, dus geen enkel eindig aantal shadings dekt hem. HotPDF besteedt een budget van 64 cycli vanaf het raakpunt en laat de resterende dunne strook transparant. Zitten alle vier de hoeken van de box achter het raakpunt, dan is de paint als geheel transparant en tekent de rest van de glyph gewoon. Het eindige kegelpad accepteert tot 4096 vereiste cycli, met dekking gekapt op 1024 slices en alles voorbij de kap ongeverfd; een glyph van 1024 slices komt op zo'n 1.8 MB

Linear-light kleur en de valkuil van twee decimals

COLRv1 interpoleert en composits in linear light, dus HotPDF decodeert elke CPAL-byte met de omgekeerde sRGB-overdrachtsfunctie en verft solids, shadings en interne groepen in één CalRGB-space met D65-wit, sRGB-primaries en /Gamma [1 1 1] (§8.6.5.3). Alleen het afgeronde rootvlak converteert naar de blend space van de aanroeper. In plaats daarvan opgeslagen sRGB-bytes interpoleren geeft zichtbaar donkerdere middenpunten, de klassieke gammafout die elke gradient-engine één keer maakt

De stap naar linear light legde een oude formatter-bug bloot. Een donkere CPAL-kanaalwaarde van 12 wordt in linear light ongeveer 0.0036765, en de gedeelde getallenformatter achter scn hield maar twee decimals aan, dus die component werd als 0 weggeschreven nog voordat een viewer het bestand aanraakte. Het symptoom leek precies op reader-quantisatie, en de eerste ingeving was MuPDF's 8-bit group buffers de schuld te geven. Fill- en stroke-componenten gaan nu door een decimal-formatter zonder exponent die kleine waarden vasthoudt en de process-locale negeert. De les generaliseert: wanneer een donkere kleur verdwijnt, dump de content stream en controleer de weggeschreven operand voordat u de viewer gaat onderzoeken

De viewer telt daarna nog steeds mee. MuPDF 1.28.2 verliest nabij-nul lineaire kanalen bij het compositen van een geïsoleerde transparency group die een expliciete blend color space draagt, en Poppler 26.07.0 Splash negeert group blend spaces volledig. De opt-in eigenschap THotPDF.COLRv1DeviceSpaceGroupBlend (default False) zendt COLRv1-groepen uit zonder de blend color space, dus ze compositen in de overgeërfde device space terwijl contentkleuren, shadings en functies linear-light CalRGB blijven. De afweging is dat overlappende translucide lagen dan in device space blenden in viewers die group color spaces wel eren

Welke COLRv1 composite modes kan PDF exact uitdrukken?

HotPDF rendert COLRv1 composite modes 5 tot 8 (SRC_IN, DEST_IN, SRC_OUT, DEST_OUT) native, en modes 9 tot 12 (SRC_ATOP, DEST_ATOP, XOR, PLUS) gaan naar OnColorGlyphRasterize. De in- en out-modes zijn producten van het ene vlak met de alpha van het andere, precies wat een soft mask berekent (§11.6.5). Het overlevende vlak wordt als een gewone form vastgelegd; het gemaskeerde vlak wordt in een alpha-pas vastgelegd als platte content binnen één geïsoleerde DeviceGray-groep en gebruikt als /Luminosity-mask met /BC [0]. De out-modes schrijven 1 − α voor elke alpha en zetten een dekkende witte afdekking onder de maskcontent, zodat de overlevende zichtbaar blijft waar het andere vlak nooit verfde

De andere vier vereisen premultiplied optelling van twee vlakken. In het source-over-model van PDF wordt de backdrop-term altijd geattenuëerd door (1 − α) van de bron, en er is geen primitive die twee vlakken optelt of de backdrop alleen naar de dekking van de bron schaalt, dus atop, xor en plus zijn niet als vectoren te schrijven. Nog een beperking kwam uit kale test-PDF's: beide viewers rekenen soft-mask-groepen waarvan de content een form Do nest verkeerd, dus het gemaskeerde vlak moet plat zijn. Solide kleuren, pad-gradients en een PaintGlyph-wikkel eromheen kwalificeren; lagen, transformaties en periodieke gradients, die allemaal interne forms opleveren, sturen de glyph naar de fallback

var
  Pdf: THotPDF;
  Fallback: TEmojiFallback;  // handler-klasse met de eventsignatuur hieronder
begin
  Pdf := THotPDF.Create(nil);
  Fallback := TEmojiFallback.Create;
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'periodic-icons.pdf';
    // Opt-in: device-space group compositing voor MuPDF en Poppler
    Pdf.COLRv1DeviceSpaceGroupBlend := True;
    // Ontvangt atop/xor/plus-glyphs en al het andere buiten de subset:
    // (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;

Waar het vectorpad nog tekortschiet

Vectoroutput is niet hetzelfde als identieke pixels in elke viewer, en de HotPDF-acceptatieregistraties zeggen dat onverbloemd. Samples binnen een of twee stappen van een cyclusgrens worden gladgestreken door reader-interpolatie, dus pixelcontroles samplen weg van de naden en functiestreams worden geverifieerd door ze te decoderen. Apart opgeslagen rechte RGB- en alpha-samples interpoleren is een benadering bij lage-alpha-eindpunten en hard stops; de gemeten fout is tweede orde bij 256 samples per cyclus, maar het is niet exact. Poppler Splash samplet aan de linkerrand van een pixel in plaats van in het centrum, wat op de steilste ramps als kleine verschillen naar voren komt

De budgets zijn ook echte limieten. Geneste radiale families weigeren voorbij spans van 4096 cycli, kegels en tangentfamilies voorbij 4096 vereiste cycli, en de tangentstrook bij de singuliere lijn blijft leeg. Wisselende stop-alpha vereist PDF 1.4-transparantie, dus hij staat niet aan onder PDF/A-1, PDF/X-1a of PDF/X-3. Valt een glyph buiten een van deze limieten, dan weigert DrawRegisteredColorGlyph de hele graaf voordat er een operator wordt geschreven, hetzelfde alles-of-niets-gedrag als voorheen

FAQ

Rastert HotPDF COLRv1 repeat- en reflect-gradients?

Nee. Nieuwere HotPDF-versies zenden ze uit als vector-axiale en -radiale shadings die hele cycli dekken. Alleen grafen buiten de ondersteunde subset, zoals atop-, xor- en plus-composites, gaan naar OnColorGlyphRasterize

Waarom niet één sampled function voor alle cycli?

Readers rekenen vooraf een kleurentabel uit de functie, en de tabel van MuPDF heeft 256 entries. Uitgesmeerd over veel cycli interpoleert hij over elke repeat-naad heen. Eén shading per cyclus houdt elke tabel binnen één enkele cyclus

Wanneer moet ik COLRv1DeviceSpaceGroupBlend aanzetten?

Zet hem aan wanneer nabij-zwarte kanalen in COLRv1-glyphs in MuPDF naar nul zakken. Laat hem uit wanneer overlappende translucide lagen in linear light moeten blenden in viewers die group color spaces eren

Native COLRv1-gradients, kleuremoji, sampled shadings en transparency groups maken allemaal deel uit van de HotPDF Component for Delphi and C++Builder, die PDF-documenten rechtstreeks uit Delphi- en C++Builder-code genereert