Technical Article

Low-Vision PDF Colour Filters in Delphi with PDFium

The feature request read simply: 'White pages are painful to read — please add a dark mode.' The first implementation inverted every pixel of the rendered page, shipped in a week, and generated a second ticket within days: scanned photographs now looked like film negatives, the customer's yellow highlighter marks had turned into an illegible blue smear, and a user asked why the printout came out black. Low-vision display support in a PDF viewer is genuinely valuable and genuinely easy to get half right. The difference between the two outcomes is understanding where in the pipeline each colour decision belongs. The implementation below uses PDFium Component, the PDFium-based viewer component for Delphi, C++Builder, and Lazarus, whose rendering API exposes each of those decision points separately.

UK teams should align this pdfium component low vision color filters workflow with local governance, audit, and data quality requirements before production release

Filters are presentation state, never document state

The architectural rule that prevents the worst category of bug: a reading mode changes how the bitmap is produced or post-processed, and nothing else. The PDF bytes stay untouched, every mode is reversible by re-rendering, and 'save' never persists a filtered appearance into the file. This sounds obvious until a legal reviewer prints a contract under an active filter and files the inverted version — at which point the question 'does printing use the document's own appearance or the screen's' turns out to deserve an explicit answer in your spec, not an accident of code path. Keep the filter setting in viewer state, apply it at render time, and make every export path declare which appearance it uses.

The rule pays for itself twice over. Reversibility comes free — switching modes re-renders from the unchanged source, so there is no undo stack to maintain and no way for a sequence of mode changes to degrade the page. And multi-window scenarios stay coherent: two views of the same document can run different modes, because each view owns its presentation state whilst the document object stays shared.

Render first, transform second

The supported pattern is post-render bitmap processing: RenderPage produces the page raster, then a transform pass adjusts it. The component ships three transforms — InvertPdfBitmap, DuotonePdfBitmap, and GrayscalePdfBitmap — as in-place bitmap operations, which makes the mode switch a clean two-stage function:

function TViewerForm.RenderWithMode(W, H: Integer): TBitmap;
begin
  Result := Pdf.RenderPage(0, 0, W, H, ro0, [reAnnotations]);
  case FReadingMode of
    rmInverted:     InvertPdfBitmap(Result);
    rmHighContrast: DuotonePdfBitmap(Result, clBlack, $0000C8FF);  // dark bg, amber text
    rmGrayscale:    GrayscalePdfBitmap(Result);
  end;
  // rmNormal falls through: the document keeps its own colors
end;

Two consequences of this design are worth internalizing. The transform costs are proportional to bitmap size, so they belong wherever your render results are cached — filter the cached bitmap once, not on every paint. And because the transform runs on the finished raster, it applies uniformly to text, vector art, images, and annotation appearances alike; uniformity is exactly what plain inversion gets wrong for photographs, which explains why the duotone transform — which maps luminance onto a chosen dark-to-light colour ramp instead of negating hues — is the better default for text-heavy documents, with inversion offered as an explicit choice. Readers who ask for sharper glyph edges have a separate lever: the reNoSmoothText render option disables text anti-aliasing at render time and pairs well with high-contrast mode at large zoom.

Two grayscales that disagree

The render options include reGrayscale, which looks like a shortcut past the post-processing step. It is not the same operation:

// Engine-level: grayscale applied during rasterization
GrayA := Pdf.RenderPage(0, 0, W, H, ro0, [reGrayscale]);

// Post-process: render in color, convert the finished bitmap
GrayB := Pdf.RenderPage(0, 0, W, H);
GrayscalePdfBitmap(GrayB);

The engine-level option applies to raster output of image content but does not reach vector fills or text colours, so a page with coloured headings can come back with grey photographs and stubbornly blue headings. GrayscalePdfBitmap on the finished bitmap converts everything, unconditionally. The render option remains useful when you specifically want images desaturated whilst preserving text colour as a signal — some low-vision users prefer exactly that — but if the requirement says 'greyscale page', post-processing is the version that satisfies it. Whichever path you choose, remember both RenderPage overload styles exist: the function form returns a bitmap the caller owns and must free, which matters as soon as filters multiply the number of rendered bitmaps in flight.

Backgrounds, selection marks, and the PageColor trap

Not every comfort adjustment is a transform. Replacing the white page background with a warm tone is often enough for glare-sensitive readers, and it has a dedicated property — with a scope rule that catches people:

// Affects the on-screen view only
PdfView.PageColor := $00D9EDF2;  // warm paper tone behind page content

// RenderPage output ignores PageColor; pass the color explicitly
Bmp := Pdf.RenderPage(0, 0, W, H, ro0, [], $00D9EDF2);

PageColor changes what TPdfView displays, but bitmaps produced through RenderPage keep the default white unless the Color parameter says otherwise. The practical symptom: the screen shows the tinted page, the user exports or prints, and the output reverts to white — file that under the same export-policy decision from the first section.

The remaining colour properties — HighlightColor for search hits, SelectionColor for user text selection, ReadingWordColor for the spoken-word cursor — define overlay marks, and every one of them must be re-checked under every filter you offer. An amber reading cursor that works on white vanishes after inversion; a pale blue selection disappears into a high-contrast background. Maintain per-mode overlay palettes instead of one global set, and test the combinations deliberately: filters plus text-to-speech is a normal configuration for the users this feature serves, not an edge case. The overlay machinery itself is covered by the accessible reader article.

Numbers, verification, and the printing question

WCAG 2.1 gives this feature measurable targets: success criterion 1.4.3 asks for a 4.5:1 contrast ratio for body text, and 1.4.6 raises it to 7:1 for enhanced contrast. Spot-check your high-contrast mode against those ratios with a contrast analyser on actual rendered output — text over images and text in form fields are where ratios silently fail even when the body text passes.

For printing, the defensible default is the document's own appearance, with 'print as displayed' as an explicit user choice; a printed page is evidence in more workflows than viewer authors expect, and an inverted printout of a contract is a support incident with legal flavoring. Since filtered rendering doubles bitmap work on mode switches, the caching strategy in the render cache and zoom performance article is the natural companion read.

FAQ

Does dark mode modify the PDF file?

Not in this design — transforms run on rendered bitmaps and the document bytes never change. Make the same promise in your UI copy, because reviewers and auditors specifically need to know the source file is untouched by display settings.

Why is my exported image white when the screen shows a tinted page?

The tint came from PageColor, which only affects the TPdfView display. Exports go through RenderPage, which uses its own Color parameter — pass the tint there, or accept the document-default appearance for exports and say so in the UI.

Which mode should be the default for low-vision users?

Offer choices instead of electing one: high contrast for most text-heavy reading, inversion for users who specifically want light-on-dark, greyscale for colour-noise reduction, and a background tint for glare sensitivity. Persist the choice per user, restore it on startup, and keep a one-keystroke path back to normal.

Do the filters affect rendering performance?

The transforms are linear passes over the finished bitmap, so their cost scales with pixel count instead of document complexity, and at screen resolutions the pass is far cheaper than the render itself. The practical optimisation is to cache the filtered bitmap and re-run the transform only when the page, zoom, or mode changes — not on every paint message.

The render options, bitmap transforms, and view colour properties used in this article ship with PDFium Component for Delphi, C++Builder, and Lazarus/FPC, with full source so the transform implementations can be audited or extended.