HotPDF Component paints COLRv1 repeat and reflect gradients as vector PDF shadings instead of rasterizing the glyph. The geometry is stretched to cover a whole number of color-line cycles, each cycle gets its own axial or radial shading with Extend switched off, colors are interpolated in linear-light CalRGB, and the Porter-Duff in/out composite modes are expressed with luminosity soft masks
When we covered how HotPDF draws COLR v1 and SVG color emoji fonts in Delphi, repeat and reflect extend modes were on the list of things the native path did not emit, and such glyphs went to the raster fallback. Newer HotPDF versions draw them as vectors. Getting there took several false starts, most of them caused by a reasonable-looking assumption about PDF that turned out to be wrong in at least one widely used viewer. This post walks through those assumptions, because anyone mapping CSS, SVG or OpenType gradients onto PDF will meet the same ones
Why can't a PDF viewer repeat the gradient for you?
A PDF shading has no repeat or reflect mode, so the producer has to lay out every cycle itself. ISO 32000-1 §8.7.4.5.3 and §8.7.4.5.4 define /Extend for axial and radial shadings, and all it does is continue the color at t0 or t1 past the end of the axis. That is COLRv1 EXTEND_PAD, and nothing else. OpenType COLR v1 also defines EXTEND_REPEAT, where the color line starts over each time the parameter passes an integer, and EXTEND_REFLECT, where every other cycle runs backwards
The first HotPDF attempt tried to make the function do the periodic work. It registered one Type 0 sampled function (§7.10.2) with a wide domain, roughly [-12.73, 12.73] for a 25-cycle span, filled it with folded samples, and turned Extend on. Inside the first cycle a minimal test file rendered correctly; outside it, MuPDF painted a single averaged color and Poppler was no better. Both viewers evaluate the function along the shading parameter and pad beyond the axis, and neither one goes back to query a periodic function in the extended region. The wide domain was also a misreading of the spec: the shading parameter t is passed straight to the function, and per §7.10.1 an input outside the function domain is clipped, so a function whose domain does not match the shading's /Domain (default [0 1]) only ever has part of its samples read
The design that works has three parts, all verified against MuPDF and Poppler renders. The shading geometry physically covers an integer number N of cycles, so no pixel depends on Extend. The sampled function keeps the default [0 1] domain, matching what the viewer actually passes in. And the sample sequence itself is folded per cycle, with 256 intervals per cycle, so the function holds repeat or reflect color data rather than relying on any viewer feature
Folding a color line into Type 0 samples
Folding means sampling the color line once per cycle and mirroring the sequence on odd cycles for reflect mode. The helper below is plain Delphi, not a HotPDF API, and it does two things the naive version gets wrong. It interpolates in linear light instead of on the stored sRGB bytes, and it resolves hard stops (two stops at the same offset) to the color on the far side, which is the OpenType and CSS semantics
uses
System.SysUtils, System.Math;
type
TColorStop = record
Offset: Single; // 0..1 inside one cycle, sorted ascending
R, G, B: Byte; // CPAL-style sRGB bytes
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;
// One cycle of a color line as 8-bit RGB triples. Reverse = True
// mirrors the cycle, which is what reflect mode does on odd cycles
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;
// >= picks the trailing side of a 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;
The COLRv1 renderer inside HotPDF goes further than this sketch. It writes big-endian 16-bit linear samples into a CalRGB function and stores alpha as a separate 16-bit gray sample, while the public helpers shown next take 8-bit DeviceRGB triples, which is why the sketch converts back to sRGB bytes. For the theory of what a reader does with those samples, the article on Type 0 sampled functions as 3D color LUTs covers the interpolation model in detail
Why does each cycle need its own shading?
Each cycle needs its own shading because readers do not evaluate a sampled function exactly; they precompute a color table, and the table interpolates across the wrap from the last color of one cycle to the first color of the next. MuPDF stores the one-dimensional function of a shading as a 256-entry color table. Pack 11 cycles into a single 2816-sample function and every saved sample can be exact while MuPDF still shows 12 to 14 levels of channel error, because 256 table entries are spread over the whole span and the hard repeat seam gets smoothed into a ramp. Poppler and the analytic interpolation of the same file were both correct, so this is a reader precision limit, not a file error, and adding more samples does not help
The fix is to keep every reader table inside one cycle. HotPDF emits periodic linear gradients as one independent axial shading per original cycle, with both Extend flags false, so the end-to-start color jump lands on the boundary between two shadings instead of inside one table. For reflect, the odd test uses the original signed cycle number, including negative cycles before the color line's start, and the shading direction is swapped rather than the samples recomputed. All cycles are drawn into one linear-light isolated surface and the paint alpha is applied once at the end, so a translucent gradient does not double its opacity along the seams
The public THotPDF.RegisterSampledAxialGradient and THotPDF.RegisterSampledRadialGradient methods expose the sampled-shading building block for your own drawing. Both take (..., DomainT0, DomainT1: Single; const Samples: TBytes; SampleCount, Cycles: Integer) after the geometry, require PDF 1.3, raise an exception unless Samples holds exactly SampleCount * 3 bytes, and return a pattern name for THPDFPage.SetFillPattern. They set Extend on both ends and record Cycles as a custom /Cycles pattern key that viewers ignore. To follow the one-cycle-per-shading rule with them, register one pattern per cycle and let a band rectangle clip it
// Horizontal repeat or reflect gradient made of one shading per cycle.
// Pattern coordinates are in default PDF space; a horizontal axis keeps
// this example independent of the page's top-down Y convention
procedure PaintPeriodicBands(Pdf: THotPDF; const Stops: array of TColorStop;
X, Y, Width, Height, CycleLength: Single; Reflect: Boolean);
const
SamplesPerCycle = 257; // 256 intervals per cycle
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]: the shading parameter reaches the function unchanged
PatternName := Pdf.RegisterSampledAxialGradient(X0, 0, X1, 0, 0, 1,
Samples, SamplesPerCycle, 1);
if PatternName = '' then
Exit; // document version locked below PDF 1.3
Pdf.CurrentPage.SetFillPattern(PatternName);
// The band clips the shading to exactly one cycle, so Extend never shows
Pdf.CurrentPage.Rectangle(X0, Y, Math.Min(X1, X + Width) - X0, Height);
Pdf.CurrentPage.Fill;
end;
end;
Very long spans need a different trick, because one shading per cycle means one resource per cycle. For periodic linear COLRv1 gradients HotPDF switches to a doubling scheme: a leaf form holds a one-cycle repeat or two-cycle reflect primitive, and each Form XObject level draws the previous level twice, shifted by its width. The number of forms grows logarithmically with the span, while the viewer still paints every visible cycle, so coverage after phase alignment is capped at 65536 cycle units. An earlier version that put the primitive into a Type 1 tiling pattern was dropped, because readers adjust tile spacing to the device grid and Poppler drifted out of phase on a cycle that was not a whole number of pixels
Solving the parameter range for radial circle families
A radial gradient covers the glyph only when the cycle range is computed for the right family of circles, and COLRv1 radial paints fall into three families with different math. With D = c1 - c0 and dr = r1 - r0, a strictly nested family has |dr| > |D|, a non-nested cone has |D|² > dr², and a tangent family has |D| = |dr|. HotPDF measures the parameter from the zero-radius point of the original family and keeps the center and radius of every emitted circle tied to the same original parameter, so remapped negative radii are never clamped independently
- Nested families intersect the fill box monotonically, so the parameter range comes from the four corners plus a bisection for the first circle that touches the box. Up to 64 cycles are sampled into one shading at 256 samples per cycle, capped at 4096 samples; deeper spans switch to per-cycle slices
- Cones and equal-radius strips use separate short radial shadings at the original cycle boundaries, each with 257 samples and
Extendoff, composited in original parameter order inside an isolated, non-knockout group so the largest valid parameter wins where circles overlap - Tangent families reduce the circle equation to a linear one. Relative to the shared tangent point, the root is
q = |W|² / (2·(W·V)), and the covered region is the open half-plane in front of that point
The tangent case has a genuine singularity. When the tangent line crosses the fill box, the number of cycles needed to reach pixels near that line is unbounded, so no finite number of shadings covers it. HotPDF spends a 64-cycle budget from the tangent point and leaves the remaining thin strip transparent. When all four corners of the box sit behind the tangent point, the paint is transparent as a whole and the rest of the glyph still draws. The finite cone path accepts up to 4096 required cycles, with coverage capped at 1024 slices and anything past the cap left unpainted; a 1024-slice glyph comes to roughly 1.8 MB
Linear-light color and the two-decimal trap
COLRv1 interpolates and composites in linear light, so HotPDF decodes every CPAL byte with the sRGB inverse transfer function and paints solids, shadings and internal groups in one CalRGB space with D65 white, sRGB primaries and /Gamma [1 1 1] (§8.6.5.3). Only the finished root surface converts to the caller's blend space. Interpolating stored sRGB bytes instead produces visibly darker midpoints, which is the classic gamma mistake every gradient engine makes once
The move to linear light exposed an old formatter bug. A dark CPAL channel value of 12 becomes about 0.0036765 in linear light, and the shared number formatter behind scn kept only two decimals, so that component was written as 0 before any viewer touched the file. The symptom looked exactly like reader quantization, and the first instinct was to blame MuPDF's 8-bit group buffers. Fill and stroke components now go through an exponent-free decimal formatter that keeps small values and ignores the process locale. The lesson generalizes: when a dark color goes missing, dump the content stream and check the written operand before investigating the viewer
The viewer still matters after that fix. MuPDF 1.28.2 loses near-zero linear channels when it composites an isolated transparency group that carries an explicit blend color space, and Poppler 26.07.0 Splash ignores group blend spaces altogether. The opt-in THotPDF.COLRv1DeviceSpaceGroupBlend property (default False) emits COLRv1 groups without the blend color space, so they composite in the inherited device space while content colors, shadings and functions stay linear-light CalRGB. The trade-off is that overlapping translucent layers then blend in device space in viewers that do honor group color spaces
Which COLRv1 composite modes can PDF express exactly?
HotPDF renders COLRv1 composite modes 5 to 8 (SRC_IN, DEST_IN, SRC_OUT, DEST_OUT) natively, and modes 9 to 12 (SRC_ATOP, DEST_ATOP, XOR, PLUS) go to OnColorGlyphRasterize. The in and out modes are products of one surface with the other's alpha, which is exactly what a soft mask computes (§11.6.5). The surviving operand is captured as an ordinary form; the masked operand is captured on an alpha pass as flat content inside one isolated DeviceGray group and used as a /Luminosity mask with /BC [0]. The out modes write 1 − α for every alpha and put an opaque white cover under the mask content, so the survivor stays visible where the other operand never painted
The other four need premultiplied addition of two surfaces. In PDF's source-over model the backdrop term is always attenuated by (1 − α) of the source, and there is no primitive that adds two surfaces or scales the backdrop by the source's coverage alone, so atop, xor and plus cannot be written as vectors. One more restriction came from bare test PDFs: both viewers miscompute soft-mask groups whose content nests a form Do, so the masked operand must be flat. Solid colors, pad gradients and a PaintGlyph wrapper around them qualify; layers, transforms and periodic gradients, which all produce internal forms, send the glyph to the fallback
var
Pdf: THotPDF;
Fallback: TEmojiFallback; // handler class with the event signature below
begin
Pdf := THotPDF.Create(nil);
Fallback := TEmojiFallback.Create;
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'periodic-icons.pdf';
// Opt-in: device-space group compositing for MuPDF and Poppler
Pdf.COLRv1DeviceSpaceGroupBlend := True;
// Receives atop/xor/plus glyphs and anything else outside the 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;
Where the vector path still falls short
Vector output is not the same as identical pixels in every viewer, and the HotPDF acceptance records say so plainly. Samples within one or two steps of a cycle boundary are smoothed by reader interpolation, so pixel checks sample away from seams and function streams are verified by decoding them instead. Interpolating separately stored straight RGB and alpha samples is an approximation near low-alpha endpoints and hard stops; the measured error is second order at 256 samples per cycle, but it is not exact. Poppler Splash samples at the left edge of a pixel rather than its center, which shows up as small differences on the steepest ramps
The budgets are real limits too. Nested radial families decline beyond 4096 cycle spans, cones and tangent families beyond 4096 required cycles, and the tangent strip near the singular line stays empty. Varying stop alpha needs PDF 1.4 transparency, so it is not enabled under PDF/A-1, PDF/X-1a or PDF/X-3. If a glyph falls outside any of these limits, DrawRegisteredColorGlyph declines the whole graph before writing an operator, which is the same all-or-nothing behavior as before
FAQ
Does HotPDF rasterize COLRv1 repeat and reflect gradients?
No. Newer HotPDF versions emit them as vector axial and radial shadings that cover whole cycles. Only graphs outside the supported subset, such as atop, xor and plus composites, go to OnColorGlyphRasterize
Why not use one sampled function for all cycles?
Readers precompute a color table from the function, and MuPDF's table has 256 entries. Spread across many cycles, it interpolates across every repeat seam. One shading per cycle keeps each table inside a single cycle
When should I turn on COLRv1DeviceSpaceGroupBlend?
Turn it on when near-black channels in COLRv1 glyphs drop to zero in MuPDF. Leave it off when overlapping translucent layers must blend in linear light in viewers that honor group color spaces
Native COLRv1 gradients, color emoji, sampled shadings and transparency groups are all part of HotPDF Component for Delphi and C++Builder, which generates PDF documents directly from Delphi and C++Builder code