Your pdfium.dll loads fine and one procedure is still missing. PDFium Component handles this by splitting its bindings into two classes: required exports resolved through CheckGetProcAddress, which abort the load outright, and optional exports resolved through TryGetProcAddress, which leave a nil pointer and a capability check behind instead
This is not the same problem as a DLL that cannot be found. If your application dies with a bad EXE format error, a missing file, or an architecture mismatch, that story is told in the companion article on deploying pdfium.dll and diagnosing load failures. Here the loader succeeded. The module handle is valid, hundreds of exports resolved, and the run still ends before your first page renders because one entry point that arrived in a newer PDFium build is not in the binary on disk
Why does one missing export break the whole library?
Because a required binding is a hard contract, and it is enforced during a single all-or-nothing bind sequence. PDFium Component resolves its entire export table inside LoadLibrary, one CheckGetProcAddress call after another. The first nil result raises EPdfError and calls UnloadLibrary before it does, which is deliberate: a partial bind would otherwise leave already-resolved pointers aimed into a module that is about to be freed, quietly defeating every Assigned guard downstream
The consequence is the failure mode that brings people here. You upgrade the component, ship the same pdfium.dll you have shipped for two years, and the application will not start. The error names an export for a feature you have never called. Nothing you do at the call site helps, because the call site never runs; the failure happened during binding, before any document was opened
function CheckGetProcAddress(const Name: string): Pointer;
begin
Result := GetProcAddress(PDFiumLibrary, PChar(Name));
if Result = nil then
begin
// A missing required export means the deployed pdfium.dll is older
// than this build of the binding. Drop every pointer resolved so far
// so no caller can reach into the module we are about to free.
UnloadLibrary;
raise EPdfError.Create('Required PDFium export not found: ' + Name);
end;
end;
function TryGetProcAddress(const Name: string): Pointer;
begin
// Optional export. nil is a legitimate answer here; every caller is
// required to test Assigned() before dereferencing the variable.
Result := GetProcAddress(PDFiumLibrary, PChar(Name));
end;
Required or optional: where the line actually sits
The rule PDFium Component applies is blunt. An export is required when its absence makes the component unable to do the job it exists to do, and optional when its absence only removes one leaf feature. FPDF_InitLibrary, FPDF_LoadDocument, FPDF_RenderPageBitmap, FPDF_ClosePage are required, and failing loudly on those is correct: a viewer that cannot render is not a degraded viewer, it is a broken one
Everything reached through the tolerant loader today is a leaf. FPDFBookmark_GetColor arrived after M109 and only supplies the optional /C colour array of an outline entry, so a DLL that predates it simply reports no bookmark colour. The V8 helpers FPDF_GetRecommendedV8Flags and FPDF_GetArrayBufferAllocatorSharedInstance, and the XFA string helpers FPDF_BStr_Init, FPDF_BStr_Set and FPDF_BStr_Clear, are absent from any non-V8 build by construction, so treating them as required would make the plain pdfium.dll unloadable. And the pair that motivated this article: FPDFAttachment_SetDescription and FPDFAttachment_GetDescription, added upstream on 2026-07-13, later than the build date of all four PDFium binaries the project ships under DLLs/Win32 and DLLs/Win64. That last case is the general shape of the problem, not a one-off: a binding layer tracks upstream headers, which move continuously, while the DLL in your installer moves in discrete jumps whenever someone rebuilds it. There is always a window in which the Pascal side knows about exports the deployed binary does not have, and deciding in advance which side of the required/optional line each new export falls on is the only thing that keeps that window survivable
FPDFDoc_GetAttachmentCount := CheckGetProcAddress('FPDFDoc_GetAttachmentCount');
FPDFDoc_AddAttachment := CheckGetProcAddress('FPDFDoc_AddAttachment');
FPDFAttachment_GetName := CheckGetProcAddress('FPDFAttachment_GetName');
FPDFAttachment_GetStringValue := CheckGetProcAddress('FPDFAttachment_GetStringValue');
// Attachment descriptions were added after the bundled DLL revision.
// Keep them optional so older deployments continue to load.
FPDFAttachment_SetDescription := TryGetProcAddress('FPDFAttachment_SetDescription');
FPDFAttachment_GetDescription := TryGetProcAddress('FPDFAttachment_GetDescription');
FPDFAttachment_SetFile := CheckGetProcAddress('FPDFAttachment_SetFile');
FPDFAttachment_GetFile := CheckGetProcAddress('FPDFAttachment_GetFile');
What should a capability gate do at the call site?
It should be asymmetric, and that asymmetry is the whole design. A read that cannot run has an honest empty answer. A write that cannot run has no honest answer at all, so it must raise. PDFium Component splits the attachment description property exactly along that line, and the split is what stops a missing export from turning into silent data loss. TPdf.GetAttachmentDescription tests Assigned(FPDFAttachment_GetDescription) and exits with an empty WString. That is not a lie: on a DLL without the export, the component genuinely cannot tell whether the attachment carries a /Desc entry, and an empty description reads the same way as an attachment that never had one. The rest of the attachment API, covered in the article on working with PDF attachments in Delphi, keeps working untouched
TPdf.SetAttachmentDescription takes the opposite route. It calls Check on the same Assigned test and raises EPdfError with the text "Attachment descriptions are not supported by the loaded PDFium DLL". Returning quietly here would be the worst available option: the caller would set a description, get no error, save the file, and ship a PDF where the description is simply absent. Nobody notices until a downstream consumer asks where it went
function TPdf.GetAttachmentDescription(Index: Integer): WString;
begin
CheckActive;
Check((Index >= 0) and (Index < AttachmentCount), 'Incorrect attachment index');
Result := '';
// Read side degrades: an old DLL cannot report /Desc, and '' is
// indistinguishable from an attachment that carries no description.
if not Assigned(FPDFAttachment_GetDescription) then
Exit;
// ... two-pass buffer sizing against FPDFAttachment_GetDescription ...
end;
procedure TPdf.SetAttachmentDescription(Index: Integer; const Value: WString);
begin
CheckActive;
Check((Index >= 0) and (Index < AttachmentCount), 'Incorrect attachment index');
// Write side refuses: silently dropping the value would produce a file
// the caller believes carries a description and does not.
Check(Assigned(FPDFAttachment_SetDescription),
'Attachment descriptions are not supported by the loaded PDFium DLL');
// ... FPDFDoc_GetAttachment, then FPDFAttachment_SetDescription ...
end;
Probing the capability before you offer the feature
Catching an exception is a poor way to discover what your deployment can do, so PDFium Component exposes the same test as a named function. AttachmentDescriptionFeaturesAvailable calls LoadLibrary and returns whether both halves of the pair resolved. It sits beside V8FeaturesAvailable, XfaBStrHelpersAvailable and XfaFeaturesAvailable, which follow the identical pattern for their own optional groups. Naming the probe matters more than it looks: a boolean called AttachmentDescriptionFeaturesAvailable tells the next maintainer that this feature is conditional on the deployed binary, which a bare Assigned test buried in a property setter never does. It also gives the UI layer something to bind to, so the description edit box is disabled up front rather than accepting input and rejecting it on save
procedure TAttachmentFrame.SyncCapabilities;
begin
// Ask once, at form setup, instead of discovering the limit on save.
DescriptionEdit.Enabled := AttachmentDescriptionFeaturesAvailable;
if not DescriptionEdit.Enabled then
DescriptionEdit.TextHint := 'Requires a newer pdfium.dll';
end;
procedure TAttachmentFrame.SaveDescription(Pdf: TPdf; Index: Integer);
begin
if not AttachmentDescriptionFeaturesAvailable then
Exit;
Pdf.AttachmentDescription[Index] := DescriptionEdit.Text;
end;
Why must binding coverage be proved by a tool?
Because the numbers are past the point where a human can be trusted with them. PDFium Component audited 21 public PDFium headers against a 2026-07-29 upstream baseline and found 470 exported C ABI functions. The binding already covered 468 of them. Nobody located that gap of two by reading headers; a script did, in a second, and it will do it again on the next upstream bump. tools/audit_pdfium_public_api.py is deliberately small: it regex-matches FPDF_EXPORT ... FPDF_CALLCONV name( across every header in the public directory, regex-matches every CheckGetProcAddress('Name') and TryGetProcAddress('Name') in PDFium.pas, and prints the two set differences: missing for exports with no binding, stale for bindings whose export no longer exists upstream. It exits non-zero when either set is non-empty, so it drops into a build step without further ceremony. The current result is 470 of 470 bound, missing 0, stale 0
The stale direction earns its keep as much as the missing one. An export that upstream removes leaves a CheckGetProcAddress line behind that will hard-fail every future load, and that kind of rot is invisible until the day someone updates the DLL. Manual review finds the function you were thinking about; it does not find the one you were not. Note also that the audit deliberately counts both loaders as coverage, which is the right call for API drift and the reason the required/optional split has to be a documented decision rather than a byproduct of whoever added the line
Where optional binding stops being honest
Two boundaries are worth stating plainly, because the pattern is easy to over-apply. The first is that a nil function pointer is only safe if literally every path that touches it tests Assigned first. In a unit that declares hundreds of cdecl function variables, a single unguarded call is an access violation at an address that means nothing in a stack trace. The same discipline that governs calling conventions and lifetimes across the C boundary applies here, and it is the subject of the article on hardening the PDFium binding against ABI and memory-safety faults
The second boundary is scope. Optional binding is not a general licence to make everything tolerant. If FPDF_RenderPageBitmap were optional, the component would load happily and then fail on every page, converting one clear startup error into a scatter of runtime ones with no obvious cause. Required is the correct default. Optional is the exception you reach for when a feature is genuinely a leaf, when the absence has a defensible degraded behaviour on the read side, and when the write side can refuse with a message that names the reason
The loader design, the capability probes and the audit tool described here ship as part of the PDFium Component for Delphi and C++Builder; the product page lists the bundled PDFium binaries and the full API surface they expose