The PDFium component finds its native library through a fixed, ordered search chain rather than leaving it to the operating system loader, because a deployment tree that is explicit is a deployment tree you can debug. On Windows that chain looks for a Win32 or Win64 sub-directory the installer already ships. On other targets it builds the sub-directory name from the Free Pascal target macros, as <cpu>-<os>, so the deployment tree reads exactly like the compiled-unit tree. That last decision introduced a bug that is worth the whole article, because the cause was a capital letter and the symptom was silence
The chain, in order
Four locations, tried in sequence, then the platform loader as a last resort. First the preferred layout, a DLLs directory beside the executable containing one sub-directory per target. Second an alternate layout with the target sub-directory directly beside the executable. Third the flat legacy layout, the library sitting next to the executable with no sub-directory at all. Fourth, on Windows only, the system directory, which needs care because a 32-bit process must look in SysWOW64 and a 64-bit process in System32, and on 32-bit Windows the former does not exist so the lookup has to fall back. Only after all of that does the loader get asked to search on its own
There is deliberately no system-directory step off Windows. The platform loader's own search path, driven by the runtime linker configuration and the library path environment, already covers that ground, and duplicating it in Pascal would mean re-implementing rules that vary by distribution. Diagnosing failures in the Windows chain is covered separately in deploying the PDFium DLL and diagnosing load failures
Where the sub-directory name comes from
On Windows it is Win32 or Win64, decided by the bitness of the running process rather than of the operating system, because that is what determines which binary can be loaded. Everywhere else the name is built from the compiler target macros so that a machine building for two architectures produces two clearly separated trees, and so that the folder holding the native library sits next to the folder holding the compiled units with the same name
function BuildDllSubDir(UseV8: Boolean): string;
begin
{$IFDEF MSWINDOWS}
if IsWin64 then
Result := 'Win64'
else
Result := 'Win32';
{$ELSE}
// The compiler macros capitalise the OS ("Linux", "Darwin") while the
// package unit output directory does not, so the two agree only after
// folding. On a case-sensitive file system that difference is the
// whole lookup
Result := LowerCase({$I %FPCTARGETCPU%} + '-' + {$I %FPCTARGETOS%});
{$ENDIF}
end;
Why a capital letter broke the whole chain
The compiler macro spells the target operating system with an initial capital: Win64, Linux, Darwin. The Lazarus package writes its unit output into a directory named from its own target variable, which is lower case: win64, linux, darwin. Two spellings of the same thing, and no way to notice on Windows, where the file system does not distinguish them
On Linux they are two different directories. A deployment that puts the shared object in DLLs/x86_64-linux is invisible to a loader looking for DLLs/x86_64-Linux, so all four explicit steps of the chain miss and the code falls through to letting the platform loader search. Sometimes that works, if the library happens to be installed system-wide, and sometimes it does not, and either way the carefully arranged deployment tree contributes nothing. The failure has no error message because nothing failed: every step correctly reported that the file was not where it looked
The probe program, compiled and run
This class of bug cannot be found by reading, and it cannot be found by compiling either. The usual technique for verifying a platform branch that never compiles on the development machine is to copy the unit to a temporary directory, rename it, replace the platform conditional with a symbol that is never defined, and compile the copy; if it compiles, the uses clause and the call signatures on that path are at least self-consistent. That works well for a self-contained unit
It does not work here. The main binding unit is very large and pulls in the LCL, so it cannot simply be copied and compiled with the Windows symbol switched off. So instead the handful of functions the change touched were transcribed verbatim into a small self-contained program, and that program was run. It printed x86_64-Win64, and the mismatch was visible in one line of output. Compiling the same program would have told you nothing, because the string is perfectly valid; only its value is wrong
program ProbeSubDir;
{$MODE DELPHI}
uses
SysUtils;
begin
// Print, do not assert. The point is to look at the value a macro
// actually expands to on this toolchain
Writeln('raw: ', {$I %FPCTARGETCPU%}, '-', {$I %FPCTARGETOS%});
Writeln('folded: ', LowerCase({$I %FPCTARGETCPU%} + '-' +
{$I %FPCTARGETOS%}));
end.
The general lesson: when a cross-platform change concerns the value of something rather than its type, compile-only verification is not verification. Print it. The wider set of cross-compiler differences between Delphi and Free Pascal is collected in the Delphi and FPC cross-compiler pitfalls article
Let the platform explain its own load failures
The Windows branch of the loader hand-enumerates the reasons a load can fail, because the useful distinctions there, an architecture mismatch, a missing transitive dependency, a path that does not resolve, map to error codes worth naming individually. Off Windows the portable loader unit already returns a descriptive string that covers the same ground, so the non-Windows branch uses it directly rather than re-deriving categories from an error number that means different things on different systems
Resisting the urge to normalize those two into one message is deliberate. A load failure is a deployment problem, and the person reading the message needs the platform's own vocabulary to search for it
A name collision that recurses
One more trap, small and sharp. The portable loader unit exports a procedure called UnloadLibrary, and the binding unit has a procedure of the same name that does its own bookkeeping before releasing the handle. Inside that procedure, an unqualified call to UnloadLibrary resolves to the one in the current unit, which calls itself. The fix is to qualify the call with the unit name
This is the same shape as the identifier-shadowing problems that dominate Free Pascal ports generally: the Windows unit exports integer-typed minimum and maximum functions that shadow the floating-point ones, and a synchronisation type that shadows the class of the same name, and in every case the resolution depends on the order of the uses clause. Qualifying the call site is the fix that does not depend on someone preserving that order later
Deployment checklist
Three things account for most load failures once the path arithmetic is right. The architecture must match the process, not the machine, so a 32-bit application on 64-bit Windows needs the 32-bit binary. The V8-enabled build has a different file name, so a deployment that mixes them will look correct and load nothing. And only one variant can live in a system directory at a time, which is a good reason to prefer the explicit sub-directory layout over installing anything system-wide
For Lazarus specifically, put the native library under DLLs/<cpu>-<os> in lower case, next to the executable, and it will be found by the first step of the chain on every target. The viewer sample that exercises this on Lazarus is described in the Lazarus and FPC viewer article, and current platform support is listed on the PDFium Delphi component product page