Technical Article

PDF Viewer for Lazarus and Free Pascal with PDFium

The port looked finished on Friday. A Delphi PDF viewer, moved to Lazarus over two days: a handful of unit-name changes, a few {$IFDEF FPC} blocks, and the project compiled clean. Monday's test run told a different story — the search box returned no hits for words clearly visible on the page, and a filename with accented characters refused to open. Nothing in the compiler output had hinted at either failure, because both were string-encoding problems, and encoding mismatches are invisible until real data flows through them. Porting a viewer built on PDFium Component (which ships VCL and LCL editions from one source) is mostly mechanical; the parts that are not mechanical are exactly what this article covers.

Same Pascal, different string payloads

Delphi's native string has been UTF-16 since 2009. Lazarus and Free Pascal default to UTF-8 in LCL applications. The component's text-facing APIs speak UTF-16 through the WString type, which the FPC build aliases to WideString — so every boundary where text crosses between your LCL UI and the PDF engine is a conversion point.

The conversions are automatic in straightforward assignments, but two habits prevent the Monday-morning class of bug. Pass text straight through without byte-level manipulation: code that slices a search term by byte offset works in Delphi (where one Char is one UTF-16 unit) and corrupts multi-byte UTF-8 in the LCL. And test with non-ASCII data from day one — a German filename, a search term with an em dash — because pure-ASCII test data makes every encoding bug invisible.

A small conditional layer instead of a fork

The temptation after the first dozen IFDEFs is to fork the codebase per IDE. Resist it: the differences fit in one shared declaration block, and a fork doubles every future bug fix. The conditional layer stays this small:

{$IFDEF FPC}
uses
  LCLType, Forms, Graphics, Controls;

type
  WString = WideString;   // component text APIs are UTF-16
  TBytes  = array of Byte;
{$ELSE}
uses
  Winapi.Windows, Vcl.Forms, Vcl.Graphics, Vcl.Controls;
{$ENDIF}

Everything below this block — document handling, page navigation, rendering calls — compiles identically in both IDEs, because TPdf and TPdfView expose the same surface in the VCL and LCL editions. The discipline that keeps it true is structural: shared PDF logic lives in units with no framework-specific dialogs or panels, and anything that genuinely differs (print dialogs, file pickers with platform conventions) lives behind a thin interface implemented once per framework. The IFDEF block above is also the right home for any future platform divergence, which keeps the rest of the codebase free of scattered compiler conditions.

Build the form in code, not in two designers

Form streaming is where dual-IDE projects quietly rot: a .dfm and an .lfm describing "the same" form drift apart property by property until the two builds behave differently for reasons nobody can diff. For the viewer core, runtime construction sidesteps the whole problem — one constructor sequence, version-controlled as ordinary code:

procedure TViewerForm.FormCreate(Sender: TObject);
begin
  Pdf := TPdf.Create(Self);

  PdfView := TPdfView.Create(Self);
  PdfView.Parent := Self;
  PdfView.Align := alClient;
  PdfView.Pdf := Pdf;
  PdfView.FitMode := pfmFitWidth;

  if ParamCount > 0 then
  begin
    Pdf.FileName := ParamStr(1);
    Pdf.Active := True;   // opens the document; PageCount valid after this
  end;
end;

The sequence matters less than the linkage: PdfView.Pdf := Pdf binds the visual control to the document component, after which page navigation through PageNumber and zoom through FitMode behave identically under VCL and LCL. One cross-framework behavior to know before users find it: assigning Zoom manually switches FitMode back to pfmNone on both frameworks. If your toolbar offers "fit width" as a sticky preference, restore the fit mode after any programmatic zoom change, or the preference silently stops being sticky.

The binary the IDE never warned you about

The component wraps the PDFium engine, which ships as a platform binary — and binary loading is where "works in the IDE, fails from the installed shortcut" reports come from. Three rules cover nearly all of them. Bitness must match exactly: a 32-bit executable cannot load a 64-bit pdfium library, and the failure message the OS produces ("module not found" on some Windows versions) actively misleads, because the file is right there. Resolve the library path relative to the executable, never the working directory — IDE launches and shell launches differ precisely in their working directory. And surface load failures before the first document opens, with a message naming the expected path and architecture; a support ticket that says "PDFium 64-bit binary missing at <path>" closes in minutes, one that says "viewer crashes on startup" does not.

Version the engine binary together with the executable as well. PDFium moves quickly, and an installer that upgrades the application while leaving an older library on disk produces crashes that no machine in your office can reproduce, because every machine in your office has the matching pair. Treat the library as part of the build artifact: same installer, same version stamp, same rollback.

Registering components in the Lazarus IDE

Runtime construction does not need design-time registration at all, which is the simplest correct setup for a viewer application. If you do want the components on the Lazarus palette, install the package and let its dedicated registration unit (PDFiumLazReg) do the work — that unit is marked design-time in the package precisely because it references IDE property-editor interfaces that must never be linked into your shipping executable.

The symptom of getting this wrong is an application that mysteriously depends on IDE packages, which surfaces as deployment failures on machines that have never seen Lazarus.

Speech and screen readers off Windows

If the Delphi original offered text-to-speech, the port inherits a platform decision. SAPI — the usual TTS backend on Windows — exists only there. Lazarus builds targeting Windows keep full SAPI and NVDA-compatible behavior, so a Windows-to-Windows port loses nothing, and NVDA users interact with the viewer identically under either IDE.

Linux and macOS targets need a different speech backend wired to the same reading APIs, which is an argument for keeping speech behind an interface from the start. The reading-order and word-tracking machinery itself is platform-neutral; only the audio output layer changes. The accessible reader article covers that machinery in depth.

What to test before calling the port done

A parity pass that has caught real regressions, in roughly the order failures occur: open a document whose path contains non-ASCII characters; search for a term containing non-ASCII characters and confirm hit highlighting; run mouse-wheel scroll, drag selection, and keyboard page navigation on each target widget set, since focus and wheel behavior are the LCL's most widget-set-dependent areas; check rendering at 100%, 150%, and 200% display scaling; and run the installed build — not the IDE build — on a machine without the IDE, which is the only test that exercises binary resolution honestly.

Rendering throughput characteristics carry over between editions, so the caching strategy from the render cache and zoom performance article applies unchanged.

FAQ

Is the LCL edition feature-complete compared to the VCL edition?

The core surface — TPdf, TPdfView, rendering, forms, text extraction, and the accessibility APIs — is the same on both. The genuine differences are platform-bound: SAPI speech output is Windows-only, and print and file dialogs follow each framework's own conventions.

Why does my Lazarus build crash at startup when the Delphi build runs fine?

Check binary resolution first: architecture mismatch between the executable and the pdfium library, or a load path that assumed the IDE's working directory. Both produce immediate startup failures that look like component bugs and are deployment bugs.

Can I keep one shared form between the IDEs?

Form description files do not transfer — .dfm and .lfm are different formats with different property sets. Constructing viewer UI at runtime, as shown above, replaces two designer files with one code path and removes the drift problem entirely.

The VCL and LCL editions described here ship together as PDFium Component, with source code and identical public APIs for Delphi, C++Builder, and Lazarus/FPC.