Convert a PDF to CMYK and the gradients often suffer most: midtones go muddy, smooth skies pick up bands, and two viewers disagree about the result. PDFium Component's ConvertPdfDocumentColours interpolates every shading in its source color space and applies the ICC transform afterwards, since a nonlinear ICC transform does not commute with the blend a reader performs between color stops
Why does a gradient go muddy after CMYK conversion?
A two-stop axial gradient from DeviceRGB red [1 0 0] to blue [0 0 1] shows the problem without any ICC profile. Use the ISO 32000-1 §10.3 device conversion with full black generation: C = 1 − R, M = 1 − G, Y = 1 − B, K is the smallest of the three, and K is removed from each. Expected: converting the stops gives C0 M100 Y100 K0 and C100 M100 Y0 K0, and a reader blending between them paints C50 M100 Y50 K0 at t = 0.5, a 200% ink build darkened by a three-color gray: yellow inherited from red, cyan inherited from blue. Interpolating first gives RGB [0.5 0 0.5], which converts to C0 M50 Y0 K50, with half the ink and the gray carried by black. Same endpoints, different middle. Without black generation the formula is linear and both orders agree, so the min() that moves gray into K is what makes order matter. A press profile adds tone curves and gamut mapping, so its numbers differ, but print buyers see the same muddy midtones
ISO 32000-1 §8.7.4.4 settles which order is right. For a CIE-based /ColorSpace, every gradient calculation happens in that space and conversion to device color comes only after all interpolation is finished. Device spaces get latitude, which explains part of the viewer disagreement: a reader may convert a DeviceRGB mesh before or after interpolating, so two conforming viewers can differ on one file. Axial and radial shadings are exempt, since they compute a single parameter and only then turn it into color. A converter has no such choice, because its CMYK output has to carry the source-space result in a rebuilt function or mesh
What happens to each shading type
| Shading | Where the color comes from | What the converter writes |
|---|---|---|
| Type 1, function-based (§8.7.4.5.2) | A two-input function over /Domain, placed by /Matrix | A 16-bit Type 0 function on a square grid; /Domain and /Matrix kept |
| Type 2, axial (§8.7.4.5.3) | A one-input function of t | A 16-bit Type 0 function, or with TessellateShadingFunctions an error-checked Type 4 mesh clipped to /BBox |
| Type 3, radial (§8.7.4.5.4) | A one-input function of t | A 16-bit Type 0 function |
Types 4–7 with /Function (§8.7.4.5.5–§8.7.4.5.8) | A parameter t per vertex or corner, fed to the function after interpolation | Same geometry and t, rewritten function, or with TessellateShadingFunctions an adaptive Type 4 mesh |
Types 4–7 without /Function | Colors stored at each vertex | Single Gray: an identity function, handled as above; otherwise an adaptive Type 4 mesh |
| Indexed sources | Palette indices | Vertex indices expanded through the palette; Indexed with a /Function refused |
By default an axial shading stays axial, and its /Function is replaced by a 16-bit sampled function in which each sample is the source function value pushed through the ICC transform; MaxShadingFunctionSamples (default 256) spreads those samples over the function's domain, and between neighbors the reader still blends in CMYK, leaving a small error bounded by the spacing. For axial and radial shadings extra samples buy little, because renderers commonly evaluate those functions through a 256-step lookup table (PDFium does). Type 1 is where a bigger budget pays off. The budget is split into a square grid, so the default yields only 16 × 16 samples, while 4096 gives 64 × 64. For the producer side of these dictionaries, see creating axial and radial shadings in Delphi
TessellateShadingFunctions is off by default. Switch it on and axial shadings and mesh functions stop being functions: the converter evaluates parameter, function and ICC transform at every grid point, writes a Type 4 mesh with explicit target colors, and measures the remaining error. An axial shading is unbounded perpendicular to its axis, so the mesh needs an explicit /BBox, and a shading without one stops the run instead of guessing the page box. Our recommendation is to enable tessellation for press jobs, where banding means a reprint and a supplier can still add a missing /BBox, and to keep the default for bulk archives, where finishing every file matters more than proving every gradient. The call and the LittleCMS transform are set up as in converting JPEG 2000 color and alpha channels to CMYK, so the code below shows only the shading options
uses
System.SysUtils, System.Classes, FPdfProduction, FPdfDocumentColour;
function PressGradientOptions: TPdfDocumentColourOptions;
begin
Result := TPdfDocumentColourOptions.Default;
// Axial shadings and mesh functions become error-checked Type 4 meshes;
// an axial shading without /BBox now stops the run instead of converting
Result.TessellateShadingFunctions := True;
// Type 1 shadings split this budget into a square grid: 4096 = 64 x 64
Result.MaxShadingFunctionSamples := 4096;
// MeshColourTolerance (1/512) and MeshGeometryTolerance (0.01) keep defaults
end;
procedure LogShadingWork(const Report: TPdfDocumentColourReport; Log: TStrings);
begin
Log.Add(Format('Shadings %d, function meshes %d (axial %d), adaptive meshes %d',
[Report.ShadingsConverted, Report.FunctionMeshesConverted,
Report.AxialMeshesConverted, Report.AdaptiveMeshesConverted]));
Log.Add(Format('Worst mesh color error %.5f, %d triangles, deepest split %d',
[Report.AdaptiveMeshColourError, Report.AdaptiveMeshTriangles,
Report.AdaptiveMeshMaxDepth]));
Log.Add(Format('Function nodes %d, shared references reused %d, storage %d bytes',
[Report.FunctionNodesParsed, Report.FunctionReferencesReused,
Report.FunctionStorageBytes]));
end;
Function types: where a faithful evaluator parts ways with a naive one
Type 0 sampled functions
In a sampled function (ISO 32000-1 §7.10.2), samples form one continuous bit stream, high-order bit leading, with the first input varying fastest; 1-, 2-, 4- and 12-bit samples are never padded to a byte or a row, so aligning rows as image data does shifts every sample after row zero. /Order 3 requests cubic spline interpolation, which the specification ignores for any dimension with fewer than four samples, and PDFium Component applies that rule per dimension, so a 3 × 64 table is linear along one axis and cubic along the other. Clipping order matters too. A cubic overshoots its samples, and clipping one axis early changes what the other sees, so every dimension is interpolated and decoded before the final clip to /Range
Type 3 stitching functions
Stitching functions (§7.10.4) hide the nastiest input in this area. A Type 3 function may reference the same child object many times, and depth and fan-out limits do not bound the work: six levels that each reference one child 64 times are only seven objects in the file, yet they describe 64^6, about 6.9 × 10^10, leaf paths. ConvertPdfDocumentColours keys parsed functions by indirect object identity: a shared child is parsed once, evaluation follows only the branch the current t selects, and MaxFunctionNodes and MaxFunctionStorageBytes cap the whole graph
Type 4 PostScript calculator functions
Calculator functions (§7.10.5) run a small subset of PostScript, and their failures come from borrowing the host language's arithmetic. Integers and reals stay distinct types even when their values match: not, bitshift, idiv and mod refuse a real, and 32-bit overflow in add, sub or mul yields a real. So { 3 mul round 2 idiv } is invalid, because round applied to a real returns a real; a Double-only reader computes something anyway, while the converter refuses it. Rounding is where Delphi code slips: PostScript's round sends a half to the greater integer, Delphi's Round sends it to the even neighbor, and ties are common. A five-band posterize, { 4 mul round 4 div }, meets one at each band edge, and t = 0.625, a dyadic fraction any subdivision can land on, becomes 2.5. Expected: a conforming evaluator returns 0.75 there and a Round-based one returns 0.5, a full band step in a gray gradient
// PostScript 'round': a half goes to the greater integer, and the result
// keeps the operand type (a real stays a real)
function PsRound(X: Double): Double;
var
Whole, Fraction: Double;
begin
Whole := Int(X); // truncates toward zero, keeps the Double range
Fraction := X - Whole;
if Fraction >= 0.5 then
Whole := Whole + 1
else if Fraction < -0.5 then
Whole := Whole - 1;
Result := Whole;
end;
procedure CompareRounding(Log: TStrings);
const
Probes: array[0..5] of Double = (0.5, 1.5, 2.5, 3.5, -1.5, -2.5);
var
X: Double;
begin
// Expected: 0.5 gives 1 vs 0, 2.5 gives 3 vs 2, -1.5 gives -1 vs -2;
// 1.5, 3.5 and -2.5 agree. Banker's rounding suits ledgers, not shadings
for X in Probes do
Log.Add(Format('%5.1f PostScript %3.0f Delphi Round %3d',
[X, PsRound(X), Round(X)]));
end;
What makes mesh gradients differ from viewer to viewer?
Types 4 to 7 (ISO 32000-1 §8.7.4.5.5–§8.7.4.5.8) carry colors at the vertices or, with a /Function, a parameter t that the reader interpolates and only then passes through the function. Converting only vertex colors would swap ICC transform and interpolation again on every triangle. PDFium Component keeps geometry and t values untouched and rewrites the function behind them. Meshes without a function are rebuilt: source colors are interpolated over a subdivided grid, transformed through ICC, checked at a few extra points inside each small triangle against the exact source-space color, and written as a Type 4 mesh
The encoding is a second source of disagreement. Types 4 and 5 pad each vertex to a whole byte, while Types 6 and 7 pack patches back to back at bit level, where a nonzero flag reuses control points and corner values from the previous patch. Patch output therefore uses 8-bit flags, and 1- and 2-bit color components are raised to 4 bits. That is lossless, since 1 and 3 both divide 15, and it leaves no trailing bits that could pass for one more flag. Readers do not all decode the compact forms the same way:
- Skia can misinterpret the colors of patches written with 2-bit and 4-bit flags
- AGG can read trailing padding in a low-bit-depth patch stream as one more patch, when it is long enough to hold a short flag and color, and paint it over the real ones
- MuPDF can show blank areas or stripes for some packed coordinate layouts and mesh functions, and a blank result for a Type 1 shading with a tiny domain under a very large matrix
- Matching pixels prove little: one shading reached from two Forms with different DefaultRGB profiles can render almost identically while its correct conversions diverge, so the converter compares resolved profiles and refuses the conflict
Indexed colors: expand the palette, never interpolate indices
An Indexed space is quantized, and ISO 32000-1 §8.7.4.4 forbids interpolating in one: its colors are converted to the base space immediately, and no shading whose colors come from a function may use it, which rules out Types 1, 2 and 3 and meshes with a /Function. With palette entries white, bright green and black at 0, 1 and 2, a triangle from index 0 to index 2 passes index 1 halfway, and interpolating indices paints a green stripe through a white-to-black blend. PDFium Component rounds and clamps each vertex index, expands it through the palette, and interpolates in the base space. The default path still accepts an Indexed axial or radial shading from a lenient producer by mapping each sample through the palette; the tessellation path refuses it
Tolerance is a promise about banding, so keep it tight
MeshColourTolerance is the largest difference allowed, per CMYK component on a 0 to 1 scale, between what a reader will blend across an output triangle and the exact source-space color at the check points inside it; the default of 1/512 is about half an 8-bit step. A mesh that cannot meet it within the subdivision and triangle limits stops the conversion, even with FailOnUnsupported set to False, and Report.ErrorText says why. Resist raising the tolerance to 1/64 to push a file through: that is roughly 1.6% ink per channel, a visible step across a smooth sky in a file that looks finished. Spend geometry instead, and only on refusals that more geometry can cure
// Only budget and depth refusals can improve with more geometry;
// non-finite coordinates or colors fail the same way on every attempt
function IsMeshBudgetRefusal(const ErrorText: string): Boolean;
begin
Result := (Pos('Adaptive mesh', ErrorText) = 1) and
((Pos('output triangle budget', ErrorText) > 0) or
(Pos('transform-sample budget', ErrorText) > 0) or
(Pos('exhausted its subdivision depth', ErrorText) > 0));
end;
function ConvertGradientsForPress(Source, Destination: TMemoryStream;
const PressProfile: TBytes; const Transform: IPdfColourTransform;
Log: TStrings): Boolean;
var
Options: TPdfDocumentColourOptions;
Report: TPdfDocumentColourReport;
Issue: TPdfDocumentColourIssue;
begin
Options := PressGradientOptions;
Result := ConvertPdfDocumentColours(Source, Destination, pcsDeviceCMYK,
PressProfile, Transform, Options, Report);
if not Result and IsMeshBudgetRefusal(Report.ErrorText) then
begin
Log.Add('Retrying with a larger mesh budget: ' + Report.ErrorText);
Options.MaxMeshSubdivisionDepth := 12; // highest accepted value
Options.MaxMeshOutputTriangles := 349525; // highest accepted value
Options.MaxMeshTransformSamples := 16777216;
Destination.Clear;
Result := ConvertPdfDocumentColours(Source, Destination, pcsDeviceCMYK,
PressProfile, Transform, Options, Report);
end;
LogShadingWork(Report, Log);
for Issue in Report.Issues do
Log.Add(Format(' object %d: %s', [Issue.ObjectNumber, Issue.Reason]));
if not Result then
Log.Add('Refused: ' + Report.ErrorText);
end;
Because subdivision is uniform, the retry mostly rescues meshes with a handful of patches. Log Report.AdaptiveMeshColourError beside the 1/512 tolerance and "the gradient looks fine" becomes a number a prepress operator can check; shading conversion is available in PDFium Component for Delphi