Technical Article

Interpolate First, Convert Second: PDF Shading Functions in Delphi

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

ShadingWhere the color comes fromWhat the converter writes
Type 1, function-based (§8.7.4.5.2)A two-input function over /Domain, placed by /MatrixA 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 tA 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 tA 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 interpolationSame geometry and t, rewritten function, or with TessellateShadingFunctions an adaptive Type 4 mesh
Types 4–7 without /FunctionColors stored at each vertexSingle Gray: an identity function, handled as above; otherwise an adaptive Type 4 mesh
Indexed sourcesPalette indicesVertex 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