Teknisk artikel

COLRv1 repeat/reflect-gradianter som PDF-shadings i Delphi

HotPDF Component ritar COLRv1 repeat- och reflect-gradianter som vektoriella PDF-shadings i stället för att rastrera glyfen. Geometrin sträcks ut så att den täcker ett helt antal färglinjecykler, varje cykel får sin egen axiala eller radiella shading med Extend avstängt, färger interpoleras i linjärljus CalRGB, och Porter-Duffs in/out-compositlägen uttrycks med luminositets-soft masks

När vi gick igenom hur HotPDF ritar COLR v1- och SVG-färg-emoji-typsnitt i Delphi fanns repeat- och reflect-extend-lägena på listan över saker den nativa vägen inte sände ut, och sådana glyfer gick till rasterreserven. Nyare HotPDF-versioner ritar dem som vektorer. Att komma dit tog flera falska starter, de flesta orsakade av ett rimligt seende antagande om PDF som visade sig vara fel i åtminstone en flitigt använd visare. Det här inlägget går igenom de antagandena, för vem som helst som mappar CSS-, SVG- eller OpenType-gradianter på PDF kommer att möta desamma

Varför kan inte en PDF-visare upprepa gradienten åt dig?

En PDF-shading har inget repeat- eller reflect-läge, så producenten måste själv lägga ut varje cykel. ISO 32000-1 §8.7.4.5.3 och §8.7.4.5.4 definierar /Extend för axiala och radiella shadings, och allt den gör är att fortsätta färgen vid t0 eller t1 förbi axelns ände. Det är COLRv1 EXTEND_PAD, och inget annat. OpenType COLR v1 definierar också EXTEND_REPEAT, där färglinjen börjar om varje gång parametern passerar ett heltal, och EXTEND_REFLECT, där varannan cykel går baklänges

Mappning från HotPDF COLRv1 till PDF-shading i Delphi: EXTEND_PAD matchar /Extend-fortsättningsflaggan för axiala och radiella shadings, medan EXTEND_REPEAT och EXTEND_REFLECT saknar PDF-motsvarighet och måste byggas av shadinggeometri per cykel
PDF-shadings fyller bara ut färgerna vid t0 och t1, så en COLRv1 repeat- eller reflect-gradient måste läggas ut cykel för cykel av producenten, inte slås på åt visaren

Det första HotPDF-försöket försökte få funktionen att göra det periodiska arbetet. Den registrerade en Type 0-samplad funktion (§7.10.2) med en vid domän, ungefär [-12.73, 12.73] för ett spann på 25 cykler, fyllde den med viktade sampel och slog på Extend. Inuti den första cykeln renderade en minimal testfil korrekt; utanför den målade MuPDF en enda medelvärdesbildad färg och Poppler var inte bättre. Båda visarna utvärderar funktionen längs shadingparametern och fyller ut bortom axeln, och ingen av dem går tillbaka och frågar en periodisk funktion i det utökade området. Den vida domänen var också en felfläsning av specen: shadingparametern t skickas rakt in i funktionen, och enligt §7.10.1 klipps en indata utanför funktionens domän, så en funktion vars domän inte matchar shadingens /Domain (standard [0 1]) får bara en del av sina sampel lästa

Designen som fungerar har tre delar, alla verifierade mot MuPDF- och Poppler-renderingar. Shadinggeometrin täcker fysiskt ett heltal N av cykler, så ingen pixel beror på Extend. Den samplade funktionen behåller standarddomänen [0 1], matchande vad visaren faktiskt skickar in. Och sampelsekvensen i sig är vikt per cykel, med 256 intervall per cykel, så funktionen håller repeat- eller reflect-färgdata i stället för att förlita sig på någon visarfunktion

Att vika en färglinje till Type 0-sampel

Att vika innebär att sampla färglinjen en gång per cykel och spegla sekvensen på udda cykler för reflect-läget. Hjälpfunktionen nedan är ren Delphi, inte ett HotPDF-API, och den gör två saker som den naiva versionen får fel. Den interpolerar i linjärt ljus i stället för på de lagrade sRGB-bytena, och den löser hårda stopp (två stopp på samma offset) till färgen på fjärrsidan, vilket är OpenType- och CSS-semantiken

uses
  System.SysUtils, System.Math;

type
  TColorStop = record
    Offset: Single;  // 0..1 inuti en cykel, sorterat stigande
    R, G, B: Byte;   // CPAL-stil sRGB-byte
  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;

// En cykel av en färglinje som 8-bitars RGB-tripplar. Reverse = True
// speglar cykeln, vilket är vad reflect-läget gör på udda cykler
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;
    // >= väljer den avslutande sidan av ett hårt stopp
    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;

COLRv1-renderaren inuti HotPDF går längre än den här skissen. Den skriver big-endian 16-bitars linjära sampel i en CalRGB-funktion och lagrar alfa som ett separat 16-bitars gråsampel, medan de publika hjälpfunktionerna som visas härnäst tar 8-bitars DeviceRGB-tripplar, vilket är varför skissen konverterar tillbaka till sRGB-byte. För teorin om vad en läsare gör med de sampelen täcker artikeln om Type 0-samplade funktioner som 3D-färg-LUT:er interpolationsmodellen i detalj

Varför behöver varje cykel sin egen shading?

Varje cykel behöver sin egen shading eftersom läsare inte utvärderar en samplad funktion exakt; de förberäknar en färgtabell, och tabellen interpolerar över övergången från sista färgen i en cykel till första färgen i nästa. MuPDF lagrar en shadings endimensionella funktion som en färgtabell med 256 poster. Packa 11 cykler i en enda funktion med 2816 sampel och varje sparat sampel kan vara exakt medan MuPDF ändå visar 12 till 14 nivåer av kanalfel, eftersom 256 tabellposter sprids över hela spannet och det hårda repeterarseamet jämnas ut till en ramp. Poppler och den analytiska interpolationen av samma fil var båda korrekta, så det här är en läsarprecision, inte ett filfel, och att lägga till fler sampel hjälper inte

Seam-diagram för HotPDF COLRv1 repeat-gradient i Delphi: en samplad funktion över 11 cykler låter MuPDF:s färgtabell med 256 poster jämma ut varje repeterarseam till en ramp, medan en axial shading per cykel håller varje tabell inne i en enda cykel och bevarar det hårda färgsprånget
Läsare förberäknar en fast färgtabell per shading, så fixen är geometrisk: en shading per cykel med Extend av flyttar repeterarseamet till en gräns som ingen tabell någonsin interpolerar över

Fixen är att hålla varje läsartabell inne i en cykel. HotPDF sänder ut periodiska linjära gradienter som en oberoende axial shading per ursprunglig cykel, med båda Extend-flaggorna falska, så färgsprånget slut-till-start landar på gränsen mellan två shadings i stället för inne i en tabell. För reflect använder det udda testet det ursprungliga signerade cykelnumret, inklusive negativa cykler före färglinjens start, och shadingriktningen byts i stället för att räkna om sampelen. Alla cykler ritas in i en isolerad yta i linjärt ljus och målningsalfan appliceras en gång i slutet, så en genomskinlig gradient fördubblar inte sin opacitet längs seamerna

De publika metoderna THotPDF.RegisterSampledAxialGradient och THotPDF.RegisterSampledRadialGradient exponerar byggstenen samplad shading för din egen ritning. Båda tar (..., DomainT0, DomainT1: Single; const Samples: TBytes; SampleCount, Cycles: Integer) efter geometrin, kräver PDF 1.3, kastar ett undantag om inte Samples håller exakt SampleCount * 3 byte, och returnerar ett mönsternamn för THPDFPage.SetFillPattern. De sätter Extend i båda ändar och registrerar Cycles som en anpassad /Cycles-mönsternyckel som visare ignorerar. För att följa en-cykel-per-shading-regeln med dem, registrera ett mönster per cykel och låt en bandrektangel klippa det

// Horisontell repeat- eller reflect-gradient byggd av en shading per cykel.
// Mönsterkoordinater ligger i standard-PDF-utrymme; en horisontell axel håller
// det här exemplet oberoende av sidans uppifrån-och-ned Y-konvention
procedure PaintPeriodicBands(Pdf: THotPDF; const Stops: array of TColorStop;
  X, Y, Width, Height, CycleLength: Single; Reflect: Boolean);
const
  SamplesPerCycle = 257;  // 256 intervall per cykel
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än [0, 1]: shadingparametern når funktionen oförändrad
    PatternName := Pdf.RegisterSampledAxialGradient(X0, 0, X1, 0, 0, 1,
      Samples, SamplesPerCycle, 1);
    if PatternName = '' then
      Exit;  // dokumentversion låst under PDF 1.3
    Pdf.CurrentPage.SetFillPattern(PatternName);
    // Bandet klipper shadingen till exakt en cykel, så Extend visas aldrig
    Pdf.CurrentPage.Rectangle(X0, Y, Math.Min(X1, X + Width) - X0, Height);
    Pdf.CurrentPage.Fill;
  end;
end;

Mycket långa spann behöver ett annat trick, eftersom en shading per cykel betyder en resurs per cykel. För periodiska linjära COLRv1-gradienter växlar HotPDF till ett dubbleringsschema: ett lövform-objekt håller en en-cykel-repeat- eller två-cykel-reflect-primitiv, och varje Form XObject-nivå ritar föregående nivå två gånger, förskjuten med sin bredd. Antalet former växer logaritmiskt med spannet, medan visaren ändå målar varje synlig cykel, så täckningen efter fasjustering är kapad vid 65536 cykelenheter. En tidigare version som lade primitiven i ett Type 1 tiling-mönster kastades, eftersom läsare justerar brickavståndet till enhetsrutnätet och Poppler kom ur fas på en cykel som inte var ett helt antal pixlar

Att lösa parametervidden för radiella cirkelfamiljer

En radiell gradient täcker glyfen bara när cykelvidden beräknas för rätt familj av cirklar, och COLRv1 radiella målningar faller i tre familjer med olika matematik. Med D = c1 - c0 och dr = r1 - r0 har en strikt nästlad familj |dr| > |D|, en icke-nästlad kon |D|² > dr², och en tangentfamilj |D| = |dr|. HotPDF mäter parametern från nollradiepunkten i den ursprungliga familjen och håller centrum och radie för varje utsänd cirkel knuten till samma ursprungliga parameter, så om-mappade negativa radier klammas aldrig oberoende

Beslutsträd för HotPDF COLRv1 radiell gradient i Delphi: att jämföra dr med D skiljer nästlade cirkelfamiljer lösta med bisektion, koner lösta med korta radiella shadings per cykel, och tangentfamiljer reducerade till den linjära roten q = |W|^2 / (2(W*V))
Olikheten mellan |dr| och |D| avgör cykelmatematiken: nästlade familjer bisekterar för den första berörande cirkeln, koner får en kort shading per cykel, och tangentlinjer lämnar ett singulärt band transparent
  • Nästlade familjer skär ifyllningsrutan monont, så parametervidden kommer från de fyra hörnen plus en bisektering för den första cirkeln som rör rutan. Upp till 64 cykler samplas i en shading vid 256 sampel per cykel, kapad vid 4096 sampel; djupare spann växlar till skivor per cykel
  • Koner och lika-radie-band använder separata korta radiella shadings vid de ursprungliga cykelgränserna, var och en med 257 sampel och Extend av, compositerade i ursprunglig parameterordning inne i en isolerad, icke-knockout-grupp så att den största giltiga parametern vinner där cirklar överlappar
  • Tangentfamiljer reducerar cirkelns ekvation till en linjär. Relativt den gemensamma tangentpunkten är roten q = |W|² / (2·(W·V)), och det täckta området är den öppna halvplanet framför den punkten

Tangentfallet har en genuin singularitet. När tangentlinjen skär ifyllningsrutan är antalet cykler som behövs för att nå pixlar nära den linjen obegränsat, så inget ändligt antal shadings täcker den. HotPDF spenderar en budget på 64 cykler från tangentpunkten och lämnar den kvarvarande tunna remsan transparent. När alla fyra hörn av rutan ligger bakom tangentpunkten är målningen transparent som helhet och resten av glyfen ritas ändå. Den ändliga kon-vägen accepterar upp till 4096 nödvändiga cykler, med täckning kapad vid 1024 skivor och allt förbi kappen lämnat omålat; en glyf på 1024 skivor landar på ungefär 1,8 MB

Linjärljus-färg och tvådecimalfällan

COLRv1 interpolerar och compositerar i linjärt ljus, så HotPDF avkodar varje CPAL-byte med sRGB:s inversa överföringsfunktion och målar solida färgytor, shadings och interna grupper i ett CalRGB-utrymme med D65-vitt, sRGB-primärer och /Gamma [1 1 1] (§8.6.5.3). Bara den färdiga rotytan konverterar till anroparens blandningsutrymme. Att interpolera lagrade sRGB-byte i stället ger synligt mörkare mittpunkter, vilket är det klassiska gamma-misstaget varje gradientmotor gör en gång

Övergången till linjärt ljus blottlade en gammal formaterarbugg. Ett mörkt CPAL-kanalvärde på 12 blir omkring 0,0036765 i linjärt ljus, och den gemensamma talsformateraren bakom scn behöll bara två decimaler, så den komponenten skrevs som 0 innan någon visare rörde filen. Symptomet såg exakt ut som läsarkvantisering, och första instinkten var att skylla på MuPDF:s 8-bitars gruppbuffertar. Fyllnads- och streckkomponenter går nu genom en exponentfri decimalformaterare som behåller små värden och ignorerar processens locale. Lärdomen generaliserar: när en mörk färg försvinner, dumpa innehållsströmmen och kontrollera den skrivna operanden innan du undersöker visaren

Visaren spelar ändå roll efter den fixen. MuPDF 1.28.2 tappar nära-noll linjära kanaler när den compositerar en isolerad transparensgrupp som bär ett explicit blandningsfärgutrymme, och Poppler 26.07.0 Splash ignorerar gruppblandningsutrymmen helt. Den frivilliga egenskapen THotPDF.COLRv1DeviceSpaceGroupBlend (standard False) sänder ut COLRv1-grupper utan blandningsfärgutrymmet, så de compositerar i det ärvda enhetsutrymmet medan innehållsfärger, shadings och funktioner förblir linjärljus CalRGB. Avvägningen är att överlappande genomskinliga lager då blandas i enhetsutrymme i visare som faktiskt hedrar gruppfärgutrymmen

Vilka COLRv1 compositlägen kan PDF uttrycka exakt?

HotPDF renderar COLRv1 compositlägena 5 till 8 (SRC_IN, DEST_IN, SRC_OUT, DEST_OUT) nativt, och lägena 9 till 12 (SRC_ATOP, DEST_ATOP, XOR, PLUS) går till OnColorGlyphRasterize. In- och ut-lägena är produkter av en yta med den andras alfa, vilket är exakt vad en soft mask beräknar (§11.6.5). Den överlevande operanden fångas som en vanlig form; den maskerade operanden fångas i ett alfa-varv som platt innehåll inne i en isolerad DeviceGray-grupp och används som en /Luminosity-mask med /BC [0]. Ut-lägena skriver 1 − α för varje alfa och lägger ett opakt vitt täcke under maskinnehållet, så överlevaren förblir synlig där den andra operanden aldrig målade

De andra fyra behöver premultiplierad addition av två ytor. I PDF:s source-over-modell dämpas bakgrundstermen alltid av (1 − α) hos källan, och det finns ingen primitiv som adderar två ytor eller skalar bakgrunden med enbart källans täckning, så atop, xor och plus kan inte skrivas som vektorer. En begränsning till kom från rena test-PDF:er: båda visarna feltolkar soft mask-grupper vars innehåll nästlar en form-Do, så den maskerade operanden måste vara platt. Solida färger, pad-gradienter och en PaintGlyph-inpackning runt dem kvalificerar; lager, transformeringar och periodiska gradienter, som alla producerar interna former, skickar glyfen till reserven

var
  Pdf: THotPDF;
  Fallback: TEmojiFallback;  // handlarklass med händelsesignaturen nedan
begin
  Pdf := THotPDF.Create(nil);
  Fallback := TEmojiFallback.Create;
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'periodic-icons.pdf';
    // Opt-in: compositing av grupper i device space för MuPDF och Poppler
    Pdf.COLRv1DeviceSpaceGroupBlend := True;
    // Tar emot atop/xor/plus-glyfer och allt annat utanför delmängden:
    // (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;

Där vektorsvägen fortfarande kommer till kort

Vektorutdata är inte detsamma som identiska pixlar i varje visare, och HotPDF:s acceptansloggar säger det rakt ut. Sampel inom ett eller två steg från en cykelgräns jämnas ut av läsareinterpolation, så pixelkontroller sampar bort från seamerna och funktionsströmmar verifieras genom att avkoda dem i stället. Att interpolera separat lagrade raka RGB- och alfasampel är en approximation nära låga-alfa-ändpunkter och hårda stopp; det uppmätta felet är av andra ordningen vid 256 sampel per cykel, men det är inte exakt. Poppler Splash sampar vid pixlens vänsterkant i stället för dess centrum, vilket syns som små skillnader på de brattaste ramperna

Budgetarna är verkliga gränser också. Nästlade radiella familjer tackar nej förbi 4096 cykelspänn, koner och tangentfamiljer förbi 4096 nödvändiga cykler, och tangentbandet nära den singulära linjen förblir tomt. Varierande stopp-alfa behöver PDF 1.4-transparens, så den är inte aktiverad under PDF/A-1, PDF/X-1a eller PDF/X-3. Om en glyf faller utanför någon av de gränserna tackar DrawRegisteredColorGlyph nej till hela grafen innan en operator skrivs, vilket är samma allt-eller-inget-beteende som tidigare

FAQ

Rastrerar HotPDF COLRv1 repeat- och reflect-gradianter?

Nej. Nyare HotPDF-versioner sänder ut dem som vektoriella axiala och radiella shadings som täcker hela cykler. Bara grafer utanför den stödda delmängden, som atop-, xor- och plus-compositer, går till OnColorGlyphRasterize

Varför inte använda en samplad funktion för alla cykler?

Läsare förberäknar en färgtabell från funktionen, och MuPDF:s tabell har 256 poster. Spridd över många cykler interpolerar den över varje repeterarseam. En shading per cykel håller varje tabell inne i en enda cykel

När ska jag slå på COLRv1DeviceSpaceGroupBlend?

Slå på den när nära-svarta kanaler i COLRv1-glyfer sjunker till noll i MuPDF. Låt den vara av när överlappande genomskinliga lager måste blandas i linjärt ljus i visare som hedrar gruppfärgutrymmen

Nativa COLRv1-gradienter, färg-emoji, samplade shadings och transparensgrupper är alla del av HotPDF Component for Delphi and C++Builder, som genererar PDF-dokument direkt från Delphi- och C++Builder-kod