Technical Article

HotPDF Image Downsampling Kernels and Print Dithering

HotPDF exposes three image downsampling kernels through the ImageDownsampleKernel property and a separate Floyd-Steinberg pass through RenderOutputDither. The first controls how photographs look after you shrink them to hit a size budget, the second controls how they look after a page is reduced to black and white. Neither is on by default, and both are opt-in for the same reason: they cost real time

The pressure that leads people here is familiar. A 60 MB scanned contract has to go out through an email gateway that rejects anything over 10 MB, or a batch of statements has to land on a fax-style monochrome device that renders every grey pixel as either paper or toner. Both problems are resampling problems, and both have a fast answer that looks bad and a slow answer that looks right

What the three kernels actually differ in

THPDFResampleKernel has three values, and they sit at genuinely different points on the speed and quality curve. rkHalftone delegates to the historical GDI StretchBlt path with the HALFTONE mode, which despite the name is bilinear-class filtering: fast, adequate for line art and screenshots, and prone to the crunchy edges you recognise instantly on downscaled photographs. rkBicubic runs a separable Catmull-Rom kernel, and rkLanczos3 runs a separable windowed sinc with a three-lobe support

Both separable kernels run as two passes, horizontal then vertical, with 6 to 12 taps per destination pixel in pure Pascal. That is roughly an order of magnitude slower than the GDI path, which is exactly why rkHalftone stays the default. On a nightly batch of thousands of pages the difference is a scheduling decision, not a preference. On a single document a user is waiting for, Lanczos3 is nearly free and visibly better

Weight curves of the three HotPDF downsampling kernels: rkHalftone delegates to the bilinear-class GDI HALFTONE path with support of one, rkBicubic runs a separable Catmull-Rom cubic with support of two, and rkLanczos3 runs a windowed sinc with support of three, trading roughly an order of magnitude in speed for visibly better photographs
The three kernel values sit at genuinely different points on the speed and quality curve: a bilinear-class GDI path, a Catmull-Rom cubic and a three-lobe windowed sinc, and the separable kernels normalise weights so nothing rings past black or white

Two implementation properties are worth knowing because they determine what the output can and cannot do. Borders clamp by edge replication rather than wrapping or fading, and the weights are normalised per destination pixel. Together those two mean the result never rings below black or above white, so the classic Lanczos overshoot halo around a hard edge does not appear as clipped artefacts in the encoded image

var
  Pdf: THotPDF;
  Info: THPDFLoadedResourceOptimizationInfo;
  Changed: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('scanned-contract.pdf');
    Pdf.ImageDownsampleKernel := rkLanczos3;   // set before the call
    Changed := Pdf.DownsampleLoadedImages(150, 82, 4096, Info);
    if Changed > 0 then
    begin
      Writeln('resampled images: ', Info.DownsampledImageCount);
      Writeln('kept calibrated : ', Info.PreservedCalibratedImageCount);
      Pdf.SaveToFile('scanned-contract-150dpi.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

The MinimumSavingsBytes argument, 4096 above, is the guard that keeps the operation honest. Re-encoding an image that was already efficiently compressed can produce a larger stream than the original, and a downsampler that blindly replaces every image will occasionally grow the file it was asked to shrink. The threshold says: only commit the replacement when it saves at least this many bytes. PreservedCalibratedImageCount reports the other conservative decision, images left untouched because they carry a calibrated colour space that resampling would compromise

Why is a wrong polynomial coefficient so hard to spot?

Because a broken interpolation kernel does not crash or throw, it just produces an image that looks subtly wrong in a way nobody can attribute. The Catmull-Rom kernel is piecewise cubic, and its outer branch in nested Horner form is ((-0.5t + 2.5)t - 4)t + 2. Write that middle coefficient as -5 instead of -4 and the function still evaluates, still returns numbers in a plausible range, and still produces an image

The damage shows up as W(1) evaluating to -1 where it must be 0. Negative weights accumulate, the sum clips at zero, and the visible symptom is a gradient whose left end goes black and a step edge that loses its intermediate tones. Nothing in the failure points at a polynomial. The check that catches it in seconds is arithmetic rather than visual: an interpolating kernel must satisfy W(0) = 1 and W(±1) = W(±2) = 0, and any kernel that misses those three points has a coefficient error, full stop. Assert on those three values in a unit test and the whole class of typo defects disappears

Plot of the HotPDF bicubic Catmull-Rom outer branch showing why a wrong coefficient hides: the typo that writes -5 instead of -4 in the nested Horner form still evaluates and leaves W(1) at -1 and W(2) at -2 where zero is required, so asserting W(0) equals 1 plus the two zero constraints catches it in seconds
A broken kernel never crashes, it just returns numbers that look plausible, which is why the eye cannot catch a coefficient typo. W(0) = 1 with zeros at plus and minus one and two is a three-line unit test

Floyd-Steinberg dithering, and where it belongs in the pipeline

The dither pass is a different problem from resampling and lives at a different point in the pipeline. RenderOutputDither applies Floyd-Steinberg error diffusion after page composition, which is the only placement that makes sense for a monochrome print preview or a fax-style export: the operation is about reducing a finished raster to one bit per pixel, not about how individual images were scaled on the way in

The algorithm itself is short. Luminance is thresholded at 50 percent, and the quantization error is diffused to four neighbours with the classic 7/16, 3/16, 5/16 and 1/16 weights, going right, below-left, below and below-right. The output pixel is 0 or 255 in every channel. What the naive alternative gives you instead, a hard threshold with no diffusion, turns a photograph into a silhouette and loses every mid-tone that carried the content

Render pipeline placement of HotPDF Floyd-Steinberg dithering: RenderOutputDither runs after page composition on the finished 24-bit raster, thresholding luminance at 50 percent and diffusing each quantization error right and below with 7/16, 3/16, 5/16 and 1/16 weights through a row buffer that must accumulate, producing one-bit monochrome output
Dithering belongs after composition because it reduces a finished raster to one bit, not because of how images were scaled. The diffusion weights sum to one, and the row buffer has to accumulate rather than overwrite
// Render-time dithering for a monochrome preview device
Pdf.RenderOutputDither := True;

// Or apply the same pass to a bitmap you already own. The bitmap must
// be pf24bit; the function returns False rather than guessing
if not HPDFFloydSteinbergDitherBitmap(Preview) then
  raise Exception.Create('dither expects a 24-bit bitmap');

// Direct kernel access when you resample outside the document pipeline
Small := HPDFResizeBitmapKernel(Large, 640, 480, rkLanczos3);
try
  Small.SaveToFile('thumb.bmp');
finally
  Small.Free;
end;

There is one implementation detail in error diffusion that bites everyone once. The row-to-row error buffer has to accumulate. Each pixel in the next row receives contributions from three different pixels in the current row, the 3/16, 5/16 and 1/16 taps, and if the code assigns instead of adding, each write discards the previous contribution and only the last tap survives. The image still looks dithered, which is what makes it hard to notice, but the texture is wrong and the tonal reproduction drifts. The test that catches it is quantitative: dither a uniform mid-grey field and require the interior coverage to land between 40 and 60 percent

Which combination should a size-reduction pipeline use?

Match the kernel to what the images actually are, and treat dithering as a device concern rather than a compression concern. For photographic scans that must survive a size budget, rkLanczos3 at 150 or 200 DPI keeps the detail people notice whilst cutting the pixel count by a factor of four or more. For screenshots, diagrams and line art, rkHalftone is genuinely fine and much faster, because those images have few tonal gradients to preserve. For a mixed batch where you cannot inspect each image, rkBicubic is the reasonable middle: better than bilinear, roughly half the tap count of Lanczos3

Downsampling is one lever among several, and it is not always the biggest one. Bilevel scans usually respond far better to the encoder covered in native JBIG2 bilevel compression in Delphi, where the win comes from symbol dictionaries rather than pixel counts. Before you decide, it helps to know what is actually in the file, which is what extracting images and their decode filters is for: an inventory of image objects and their existing compression tells you whether resampling has anything to gain

If you are building the preview surface that shows the result, the same rendering path documented in rendering a PDF page to a bitmap is where RenderOutputDither takes effect, so the dithered preview and the dithered output come from one code path rather than two implementations that drift apart

The broad principle behind both features is that quality settings should be explicit and reversible. HotPDF keeps the historical behaviour as the default so an existing application upgrades without a surprise change in output or timing, and puts the better-looking, slower paths one property assignment away. Both are part of the HotPDF Delphi PDF component, alongside the resource optimisation and rendering machinery they build on