HotPDF Component zeichnet COLRv1-Repeat- und -Reflect-Gradients als Vektor-PDF-Shadings, statt die Glyphe zu rastern. Die Geometrie wird gestreckt, um eine ganze Anzahl von Farbzyklen abzudecken, jeder Zyklus bekommt sein eigenes axiales oder radiales Shading mit ausgeschaltetem Extend, Farben werden im Linear-Light-CalRGB interpoliert, und die Porter-Duff-in/out-Composite-Modi werden mit Luminosity-Soft-Masks ausgedrückt
Als wir wie HotPDF COLR-v1- und SVG-Color-Emoji-Fonts in Delphi zeichnet behandelt haben, standen die Repeat- und Reflect-Extend-Modi auf der Liste der Dinge, die der native Pfad nicht emittierte, und solche Glyphen gingen an den Raster-Fallback. Neuere HotPDF-Versionen zeichnen sie als Vektoren. Der Weg dorthin nahm mehrere Fehlanfänge, die meisten davon verursacht durch eine plausibel aussehende Annahme über PDF, die sich in mindestens einem weit verbreiteten Viewer als falsch herausstellte. Dieser Beitrag geht die Annahmen durch, denn jeder, der CSS-, SVG- oder OpenType-Gradients auf PDF mappt, trifft auf dieselben
Warum kann ein PDF-Viewer den Gradient nicht für Sie wiederholen?
Ein PDF-Shading hat keinen Repeat- oder Reflect-Modus, also muss der Produzent jeden Zyklus selbst auslegen. ISO 32000-1 §8.7.4.5.3 und §8.7.4.5.4 definieren /Extend für axiale und radiale Shadings, und alles, was es tut, ist die Farbe an t0 oder t1 über das Achsenende hinaus fortzusetzen. Das ist COLRv1 EXTEND_PAD, und sonst nichts. OpenType COLR v1 definiert zudem EXTEND_REPEAT, bei dem die Farblinie neu beginnt, sobald der Parameter eine ganze Zahl passiert, und EXTEND_REFLECT, bei dem jeder zweite Zyklus rückwärts läuft
Der erste HotPDF-Versuch versuchte, die Funktion die periodische Arbeit machen zu lassen. Er registrierte eine Type-0-Sampled-Function (§7.10.2) mit weiter Domain, grob [-12.73, 12.73] für einen 25-Zyklen-Span, füllte sie mit gefalteten Samples und schaltete Extend ein. Innerhalb des ersten Zyklus renderte eine minimale Testdatei korrekt; außerhalb malte MuPDF eine einzige gemittelte Farbe, und Poppler war nicht besser. Beide Viewer evaluieren die Funktion entlang des Shading-Parameters und padden jenseits der Achse, und keiner geht zurück, um im erweiterten Bereich eine periodische Funktion abzufragen. Die weite Domain war zudem eine Fehllesung der Spezifikation: Der Shading-Parameter t wird unverändert an die Funktion durchgereicht, und gemäß §7.10.1 wird eine Eingabe außerhalb der Funktions-Domain geclippt, also wird von einer Funktion, deren Domain nicht zum /Domain des Shadings passt (Default [0 1]), nur je ein Teil ihrer Samples gelesen
Das Design, das funktioniert, hat drei Teile, alle verifiziert gegen MuPDF- und Poppler-Renderings. Die Shading-Geometrie überdeckt physisch eine ganze Anzahl N von Zyklen, sodass kein Pixel von Extend abhängt. Die Sampled-Function behält die Default-Domain [0 1], passend zu dem, was der Viewer tatsächlich durchreicht. Und die Sample-Sequenz selbst wird pro Zyklus gefaltet, mit 256 Intervallen pro Zyklus, sodass die Funktion Repeat- oder Reflect-Farbdaten hält, statt auf ein Viewer-Feature zu bauen
Eine Farblinie in Type-0-Samples falten
Falten bedeutet, die Farblinie einmal pro Zyklus zu samplen und die Sequenz bei ungeraden Zyklen für den Reflect-Modus zu spiegeln. Der Helfer unten ist pures Delphi, keine HotPDF-API, und er tut zwei Dinge, die die naive Version falsch macht. Er interpoliert im Linear-Light statt auf den gespeicherten sRGB-Bytes, und er löst Hard Stops (zwei Stops am selben Offset) zur Farbe auf der fernen Seite auf, was der OpenType- und CSS-Semantik entspricht
uses
System.SysUtils, System.Math;
type
TColorStop = record
Offset: Single; // 0..1 innerhalb eines Zyklus, aufsteigend sortiert
R, G, B: Byte; // sRGB-Bytes im CPAL-Stil
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;
// Ein Zyklus einer Farblinie als 8-Bit-RGB-Tripel. Reverse = True
// spiegelt den Zyklus, was der Reflect-Modus bei ungeraden Zyklen macht
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;
// >= wählt die nachlaufende Seite eines Hard Stops
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;
Der COLRv1-Renderer in HotPDF geht über diese Skizze hinaus. Er schreibt Big-Endian-16-Bit-Linear-Samples in eine CalRGB-Funktion und legt Alpha als separates 16-Bit-Grausample ab, während die öffentlichen Helfer, die als Nächstes folgen, 8-Bit-DeviceRGB-Tripel nehmen — deshalb konvertiert die Skizze zurück zu sRGB-Bytes. Für die Theorie, was ein Reader mit diesen Samples macht, behandelt der Artikel zu Type-0-Sampled-Functions als 3D-Color-LUTs das Interpolationsmodell im Detail
Warum braucht jeder Zyklus sein eigenes Shading?
Jeder Zyklus braucht sein eigenes Shading, weil Reader eine Sampled-Function nicht exakt evaluieren; sie berechnen eine Color-Tabelle vor, und die Tabelle interpoliert über den Wrap von der letzten Farbe eines Zyklus zur ersten Farbe des nächsten. MuPDF legt die eindimensionale Funktion eines Shadings als Color-Tabelle mit 256 Einträgen ab. Packt man 11 Zyklen in eine einzige 2816-Sample-Funktion, kann jedes gespeicherte Sample exakt sein, während MuPDF trotzdem 12 bis 14 Stufen Kanalfehler zeigt, weil sich 256 Tabelleneinträge über den ganzen Span verteilen und die harte Repeat-Naht in einen Rampenverlauf geglättet wird. Poppler und die analytische Interpolation derselben Datei waren beide korrekt, also ist das eine Reader-Präzisionsgrenze, kein Dateifehler, und mehr Samples helfen nicht
Der Fix besteht darin, jede Reader-Tabelle innerhalb eines Zyklus zu halten. HotPDF emittiert periodische lineare Gradients als ein unabhängiges axiales Shading pro Originalzyklus, mit beiden Extend-Flags false, sodass der Farb-Level von Ende zu Start auf der Grenze zwischen zwei Shadings landet statt innerhalb einer Tabelle. Für Reflect nutzt der Ungeraden-Test die ursprüngliche vorzeichenbehaftete Zyklennummer, einschließlich negativer Zyklen vor dem Start der Farblinie, und die Shading-Richtung wird getauscht, statt die Samples neu zu berechnen. Alle Zyklen werden in eine Linear-Light-isolierte Oberfläche gezeichnet und die Paint-Alpha wird einmal am Ende angewendet, sodass ein transluzenter Gradient seine Deckkraft entlang der Nähte nicht verdoppelt
Die öffentlichen Methoden THotPDF.RegisterSampledAxialGradient und THotPDF.RegisterSampledRadialGradient legen den Sampled-Shading-Baustein für Ihr eigenes Zeichnen offen. Beide nehmen nach der Geometrie (..., DomainT0, DomainT1: Single; const Samples: TBytes; SampleCount, Cycles: Integer) entgegen, verlangen PDF 1.3, werfen eine Exception, sofern Samples nicht exakt SampleCount * 3 Bytes enthält, und geben einen Pattern-Namen für THPDFPage.SetFillPattern zurück. Sie setzen Extend an beiden Enden und verzeichnen Cycles als eigenen /Cycles-Pattern-Schlüssel, den Viewer ignorieren. Um mit ihnen der Ein-Zyklus-pro-Shading-Regel zu folgen, registrieren Sie ein Pattern pro Zyklus und clippen es mit einem Bandrechteck
// Horizontales Repeat- oder Reflect-Gradient aus einem Shading pro Zyklus.
// Pattern-Koordinaten liegen im Default-PDF-Space; eine horizontale Achse hält
// dieses Beispiel unabhängig von der Top-down-Y-Konvention der Seite
procedure PaintPeriodicBands(Pdf: THotPDF; const Stops: array of TColorStop;
X, Y, Width, Height, CycleLength: Single; Reflect: Boolean);
const
SamplesPerCycle = 257; // 256 Intervalle pro Zyklus
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]: der Shading-Parameter erreicht die Funktion unverändert
PatternName := Pdf.RegisterSampledAxialGradient(X0, 0, X1, 0, 0, 1,
Samples, SamplesPerCycle, 1);
if PatternName = '' then
Exit; // Dokumentversion unter PDF 1.3 festgelegt
Pdf.CurrentPage.SetFillPattern(PatternName);
// Das Band clippt das Shading auf exakt einen Zyklus, also zeigt sich Extend nie
Pdf.CurrentPage.Rectangle(X0, Y, Math.Min(X1, X + Width) - X0, Height);
Pdf.CurrentPage.Fill;
end;
end;
Sehr lange Spans brauchen einen anderen Trick, denn ein Shading pro Zyklus bedeutet eine Ressource pro Zyklus. Für periodische lineare COLRv1-Gradients wechselt HotPDF auf ein Verdopplungsschema: Ein Blatt-Form hält eine Ein-Zyklus-Repeat- oder Zwei-Zyklen-Reflect-Primitive, und jede Form-XObject-Ebene zeichnet die vorherige Ebene zweimal, um ihre Breite verschoben. Die Zahl der Formen wächst logarithmisch mit dem Span, während der Viewer weiterhin jeden sichtbaren Zyklus malt, sodass die Abdeckung nach Phasenausrichtung auf 65536 Zykluseinheiten gekappt ist. Eine frühere Version, die die Primitive in ein Type-1-Tiling-Pattern legte, wurde fallengelassen, weil Reader den Kachelabstand am Device-Grid justieren und Poppler auf einem Zyklus, der keine ganze Pixelzahl war, aus der Phase driftete
Den Parameterbereich für radiale Kreisfamilien lösen
Ein radialer Gradient überdeckt die Glyphe nur, wenn der Zyklusbereich für die richtige Familie von Kreisen berechnet wird, und COLRv1-Radial-Malungen fallen in drei Familien mit unterschiedlicher Mathematik. Mit D = c1 - c0 und dr = r1 - r0 hat eine streng verschachtelte Familie |dr| > |D|, ein nicht verschachtelter Kegel hat |D|² > dr², und eine Tangentenfamilie hat |D| = |dr|. HotPDF misst den Parameter vom Nullradius-Punkt der Originalfamilie und hält Zentrum und Radius jedes emittierten Kreises am selben Originalparameter, sodass remappte negative Radien nie unabhängig geklemmt werden
- Verschachtelte Familien schneiden die Fill-Box monoton, also kommt der Parameterbereich aus den vier Ecken plus einer Bisektion für den ersten Kreis, der die Box berührt. Bis zu 64 Zyklen werden mit 256 Samples pro Zyklus in ein Shading gesampelt, gekappt bei 4096 Samples; tiefere Spans wechseln zu Per-Zyklus-Scheiben
- Kegel und Streifen mit gleichem Radius verwenden separate kurze radiale Shadings an den Originalzyklus-Grenzen, jedes mit 257 Samples und
Extendaus, komponiert in Originalparameter-Reihenfolge innerhalb einer isolierten, Nicht-Knockout-Gruppe, sodass der größte gültige Parameter gewinnt, wo sich Kreise überlappen - Tangentenfamilien reduzieren die Kreisgleichung auf eine lineare. Relativ zum gemeinsamen Tangentenpunkt ist die Wurzel
q = |W|² / (2·(W·V)), und die überdeckte Region ist die offene Halbebene vor diesem Punkt
Der Tangentenfall hat eine echte Singularität. Wenn die Tangentenlinie die Fill-Box schneidet, ist die Zahl der Zyklen, die nötig sind, um Pixel nahe dieser Linie zu erreichen, unbegrenzt, also deckt keine endliche Zahl von Shadings sie ab. HotPDF investiert ein 64-Zyklen-Budget ab dem Tangentenpunkt und lässt den verbleibenden dünnen Streifen transparent. Wenn alle vier Ecken der Box hinter dem Tangentenpunkt liegen, ist die Malung als Ganzes transparent und der Rest der Glyphe zeichnet trotzdem. Der finite Kegel-Pfad akzeptiert bis zu 4096 benötigte Zyklen, mit Abdeckung gekappt bei 1024 Scheiben und allem darüber Hinausgehenden ungemalt; eine 1024-Scheiben-Glyphe kommt auf rund 1,8 MB
Linear-Light-Farbe und die Zwei-Dezimalstellen-Falle
COLRv1 interpoliert und komponiert im Linear-Light, also dekodiert HotPDF jedes CPAL-Byte mit der sRGB-Invers-Transferfunktion und malt Solid Colors, Shadings und interne Gruppen in einem CalRGB-Space mit D65-Weiß, sRGB-Primaries und /Gamma [1 1 1] (§8.6.5.3). Nur die fertige Root-Oberfläche konvertiert in den Blend-Space des Aufrufers. Gespeicherte sRGB-Bytes stattdessen zu interpolieren liefert sichtbar dunklere Mittelpunkte — der klassische Gamma-Fehler, den jede Gradient-Engine einmal macht
Der Wechsel zu Linear-Light legte einen alten Formatter-Bug offen. Ein dunkler CPAL-Kanalwert von 12 wird im Linear-Light etwa 0,0036765, und der gemeinsame Zahlenformatter hinter scn behielt nur zwei Dezimalstellen, also wurde diese Komponente als 0 geschrieben, bevor irgendein Viewer die Datei anfasste. Das Symptom sah exakt wie Reader-Quantisierung aus, und der erste Instinkt war, MuPDFs 8-Bit-Gruppenpuffer die Schuld zu geben. Fill- und Stroke-Komponenten laufen jetzt durch einen exponentenfreien Dezimalformatter, der kleine Werte hält und das Prozess-Locale ignoriert. Die Lehre verallgemeinert: Wenn eine dunkle Farbe verschwindet, dumpen Sie den Content-Stream und prüfen den geschriebenen Operanden, bevor Sie den Viewer untersuchen
Der Viewer zählt nach diesem Fix weiterhin. MuPDF 1.28.2 verliert nahezu null lineare Kanäle, wenn es eine isolierte Transparenzgruppe komponiert, die einen expliziten Blend-Color-Space trägt, und Poppler 26.07.0 Splash ignoriert Gruppen-Blend-Spaces komplett. Die Opt-in-Eigenschaft THotPDF.COLRv1DeviceSpaceGroupBlend (Default False) emittiert COLRv1-Gruppen ohne den Blend-Color-Space, sodass sie im geerbten Device-Space komponieren, während Inhaltsfarben, Shadings und Funktionen im Linear-Light-CalRGB bleiben. Der Trade-off: Überlappende transluzente Schichten blenden dann in Viewern, die Gruppen-Color-Spaces beachten, im Device-Space
Welche COLRv1-Composite-Modi kann PDF exakt ausdrücken?
HotPDF rendert die COLRv1-Composite-Modi 5 bis 8 (SRC_IN, DEST_IN, SRC_OUT, DEST_OUT) nativ, und die Modi 9 bis 12 (SRC_ATOP, DEST_ATOP, XOR, PLUS) gehen an OnColorGlyphRasterize. Die in- und out-Modi sind Produkte einer Oberfläche mit der Alpha der anderen, was genau das ist, was eine Soft-Mask berechnet (§11.6.5). Der überlebende Operand wird als gewöhnliche Form erfasst; der maskierte Operand wird in einem Alpha-Durchlauf als flacher Inhalt innerhalb einer isolierten DeviceGray-Gruppe erfasst und als /Luminosity-Maske mit /BC [0] verwendet. Die out-Modi schreiben 1 − α für jede Alpha und legen eine opake weiße Abdeckung unter den Maskeninhalt, sodass der Überlebende dort sichtbar bleibt, wo der andere Operand nie malte
Die anderen vier benötigen prämultiplizierte Addition zweier Oberflächen. In PDFs Source-over-Modell wird der Backdrop-Term immer mit (1 − α) der Quelle abgeschwächt, und es gibt kein Primitiv, das zwei Oberflächen addiert oder den Backdrop allein mit der Abdeckung der Quelle skaliert, also lassen sich atop, xor und plus nicht als Vektoren schreiben. Eine weitere Einschränkung kam aus nackten Test-PDFs: Beide Viewer berechnen Soft-Mask-Gruppen falsch, deren Inhalt eine Form-Do verschachtelt, also muss der maskierte Operand flach sein. Solid Colors, Pad-Gradients und ein PaintGlyph-Wrapper darum herum qualifizieren; Layer, Transformationen und periodische Gradients, die alle interne Formen erzeugen, schicken die Glyphe an den Fallback
var
Pdf: THotPDF;
Fallback: TEmojiFallback; // Handler-Klasse mit der unten stehenden Event-Signatur
begin
Pdf := THotPDF.Create(nil);
Fallback := TEmojiFallback.Create;
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'periodic-icons.pdf';
// Opt-in: Group-Compositing im Device-Space für MuPDF und Poppler
Pdf.COLRv1DeviceSpaceGroupBlend := True;
// Empfängt Atop-/Xor-/Plus-Glyphen und alles andere außerhalb der Teilmenge:
// (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;
Wo der Vektorpfad weiterhin hinterherhinkt
Vektorausgabe ist nicht dasselbe wie identische Pixel in jedem Viewer, und die HotPDF-Abnahmeaufzeichnungen sagen das unverblümt. Samples innerhalb von ein, zwei Schritten einer Zyklusgrenze werden von der Reader-Interpolation geglättet, also samplen Pixelchecks abseits der Nähte, und Funktions-Streams werden verifiziert, indem man sie dekodiert. Getrennt gespeicherte straight-RGB- und Alpha-Samples zu interpolieren ist eine Näherung nahe Endpunkten mit niedriger Alpha und bei Hard Stops; der gemessene Fehler ist zweiter Ordnung bei 256 Samples pro Zyklus, aber es ist nicht exakt. Poppler Splash sampelt an der linken Kante eines Pixels statt in seiner Mitte, was sich als kleine Differenzen auf den steilsten Rampen zeigt
Die Budgets sind echte Grenzen. Verschachtelte radiale Familien lehnen über 4096-Zyklen-Spans ab, Kegel- und Tangentenfamilien über 4096 benötigte Zyklen, und der Tangentenstreifen nahe der singulären Linie bleibt leer. Variierende Stop-Alpha braucht PDF-1.4-Transparenz, ist also unter PDF/A-1, PDF/X-1a oder PDF/X-3 nicht aktiviert. Fällt eine Glyphe außerhalb einer dieser Grenzen, lehnt DrawRegisteredColorGlyph den ganzen Graphen ab, bevor ein Operator geschrieben wird — dasselbe Alles-oder-Nichts-Verhalten wie zuvor
FAQ
Rastert HotPDF COLRv1-Repeat- und -Reflect-Gradients?
Nein. Neuere HotPDF-Versionen emittieren sie als vektorielle axiale und radiale Shadings, die ganze Zyklen abdecken. Nur Graphen außerhalb der unterstützten Teilmenge, etwa Atop-, Xor- und Plus-Composites, gehen an OnColorGlyphRasterize
Warum nicht eine Sampled-Function für alle Zyklen?
Reader berechnen aus der Funktion eine Color-Tabelle vor, und MuPDFs Tabelle hat 256 Einträge. Über viele Zyklen verteilt interpoliert sie über jede Repeat-Naht. Ein Shading pro Zyklus hält jede Tabelle innerhalb eines einzelnen Zyklus
Wann sollte ich COLRv1DeviceSpaceGroupBlend einschalten?
Schalten Sie es ein, wenn nahezu schwarze Kanäle in COLRv1-Glyphen in MuPDF auf null fallen. Lassen Sie es aus, wenn sich überlappende transluzente Schichten in Viewern, die Gruppen-Color-Spaces beachten, im Linear-Light blenden müssen
Native COLRv1-Gradients, Color-Emoji, Sampled-Shadings und Transparenzgruppen sind alle Teil des HotPDF Component for Delphi and C++Builder, das PDF-Dokumente direkt aus Delphi- und C++Builder-Code erzeugt