The bug report read "works from C# on Windows, crashes from Python on macOS." The team had built their macOS binding by copying the Windows declaration file and swapping the binary name. Every symbol resolved, the first call returned garbage, the second call crashed. Nothing was wrong with their PDF logic: the Windows exports use the Stdcall convention while the macOS dylib exports the same functions as Cdecl with a leading underscore, and a foreign-function binding that ignores either detail corrupts the stack before any document is even opened.
PDFlibPas, losLab's source-available PDF engine for Delphi and C++Builder, wraps its whole object model in one flat facade class, TPDFlib, and then ships that facade in three binary shapes: a Windows DLL with roughly 1,250 exported functions, a COM/ActiveX automation object, and a macOS dylib. The PDF semantics are identical across all three. What differs — and what this article maps out — is the ABI: calling conventions, string encodings, handle ownership, and who is allowed to free which buffer.
One facade, three binary shapes
Every public function of TPDFlib has a flat counterpart named DL plus the method name: LoadFromFile becomes DLLoadFromFile, Encrypt becomes DLEncrypt, NewSignProcessFromFile becomes DLNewSignProcessFromFile. The first parameter of nearly every export is an InstanceID returned by DLCreateLibrary, which stands in for the object reference a Delphi caller would hold. This one-to-one mapping is worth internalizing early, because it means the Delphi API reference doubles as the documentation for every other language — whatever the class can do, the DLL can do under a predictable name.
The Windows build produces PDFlibDLL32.dll and PDFlibDLL64.dll; pick the one matching your host process bitness, since a 64-bit Java or .NET process cannot load the 32-bit library no matter how the declaration looks.
Windows: Stdcall instances and the W/A function pairs
Each string-taking export exists twice: a wide version taking PWideChar (UTF-16, the natural fit for .NET, Java, and Python's c_wchar_p) and an A-suffixed version taking PAnsiChar. The two are semantically identical and differ only in encoding, which is exactly why mixing them is so painful to debug: nothing fails, you just get mojibake in metadata or "file not found" for paths containing any character beyond ASCII.
// Windows binding (PDFlibDLL64.dll): Stdcall, plain export names
function DLCreateLibrary: Integer; stdcall;
external 'PDFlibDLL64.dll' name 'DLCreateLibrary';
function DLReleaseLibrary(InstanceID: Integer): Integer; stdcall;
external 'PDFlibDLL64.dll' name 'DLReleaseLibrary';
function DLLoadFromFile(InstanceID: Integer;
FileName, Password: PWideChar): Integer; stdcall;
external 'PDFlibDLL64.dll' name 'DLLoadFromFile';
// macOS binding: same function, Cdecl, and an underscore prefix on the export
function DLCreateLibrary: Integer; cdecl;
external 'PDFlibDylib.dylib' name '_DLCreateLibrary';
Pick one character width per host and codify it in the binding generator. A practical rule: if the host language has native UTF-16 strings, bind the W versions everywhere and never touch the A family again.
macOS: same names, different ABI
The dylib exports the same DL function set, but with two systematic changes: the calling convention is Cdecl, and every export name carries a leading underscore — _DLCreateLibrary, _DLLoadFromFile, and so on. Both changes are mechanical, which makes them perfect candidates for a generated binding and terrible candidates for hand-edited copies of the Windows file. If your binding layer supports it, keep one canonical function list and emit per-platform declarations; the failure mode of not doing so is the stack-corruption story this article opened with, and it reproduces only on the platform you test least.
COM and ActiveX hosts: Safecall and Olevariant payloads
For VB.NET, C#, VBScript, and legacy automation hosts, the OCX build wraps the same facade in an IDispatch automation object, IPDFlibrary, with every method declared Safecall. That convention choice matters for error handling: Safecall translates internal failures into COM HRESULT values, so a C# caller sees a catchable exception instead of a silent error code — the opposite of the flat DLL, where you must check return values yourself.
The second COM-specific rule concerns binary data. There are no pointer parameters in the automation interface; anything binary — image bytes going in, PDF bytes coming out — crosses the boundary as an Olevariant, through methods such as AddImageFromVariant and AppendToVariant. Marshalling a byte array into a variant is one line in .NET, but if you try to pass a raw pointer because "it is the same process anyway," the dispatch layer will reject or mangle it. Finally, remember that COM registration is per-bitness: an OCX registered with the 32-bit regsvr32 is invisible to a 64-bit host, which surfaces as the famously unhelpful "class not registered" at the customer site.
Handle discipline: instances own documents
The flat API is a handle economy. DLCreateLibrary returns an instance; loading returns a document ID within that instance; sign processes, string lists, and direct-access files all return further integer handles scoped to the same instance. The canonical lifecycle looks like this from any FFI host, written here in Pascal for readability:
var
Inst, Doc: Integer;
begin
Inst := DLCreateLibrary; // one instance per worker thread
try
Doc := DLLoadFromFile(Inst, 'in.pdf', ''); // returns a DocumentID, 0 on failure
if Doc <> 0 then
begin
DLEncrypt(Inst, 'owner-secret', 'user-secret', 3,
DLEncodePermissions(Inst, 1, 0, 0, 0, 0, 0, 0, 1));
DLSaveToFile(Inst, 'out.pdf');
end;
finally
DLReleaseLibrary(Inst); // frees every document the instance owns
end;
end;
Two consequences follow. First, DLReleaseLibrary is the only cleanup you strictly need — it tears down every document and process handle under the instance — but leaning on that in a long-running service is a slow leak with extra steps; release documents you are done with. Second, the instance is the natural unit of thread isolation: give each worker thread its own InstanceID and never share one across threads without external locking, exactly as you would not share a TPDFlib object.
Returned strings are borrowed, not owned
Functions that return text, such as DLGetPageText, hand back a PWideChar or PAnsiChar that points into a buffer owned and recycled by the library instance. The contract is: copy immediately, never free.
var
P: PWideChar;
PageText: string;
begin
P := DLGetPageText(Inst, 7); // pointer into a library-owned buffer
PageText := P; // copy now; a later call may reuse the buffer
end;
In C# this means marshalling the IntPtr to a managed string before the next library call; in Python ctypes, slicing the wide string out of the pointer right away. Holding the raw pointer across calls is the kind of bug that passes every unit test and fails under production concurrency. The same ownership rule applies in the other direction to callbacks registered through DLSetProgressCallback: any pointer the library passes into your callback is valid only for the duration of that callback, and the callback itself must stay alive — pinned, in garbage-collected hosts — for as long as the instance might invoke it. A delegate collected mid-job is the canonical cause of "random" access violations in .NET bindings that worked for months.
Finally, build the smoke test into the binding itself. Before shipping a generated declaration set, run one call through each category: a parameterless function (DLCreateLibrary), a string-in function with a non-ASCII path, a string-out function, and an operation that fails on purpose so you can see how error codes surface in your host. Fifteen minutes of this catches convention and encoding mistakes that otherwise surface as customer crash dumps.
Binding questions that come up in support
Which functions should a Python ctypes binding use on Windows? Load the DLL with WinDLL (Stdcall), bind the unsuffixed W functions, and declare string parameters as c_wchar_p. On macOS, switch to CDLL, keep the same function list, and resolve names without the underscore — the loader on macOS handles the prefix convention for you in most FFI layers, but verify with one call before generating hundreds.
Do I need to register anything to use the plain DLL? No. Registration with regsvr32 applies only to the ActiveX build. The DLL deploys by file copy, which is one reason to prefer it for services and containerized Windows workloads.
Is the DLL thread-safe? The safe pattern is one instance per thread. The instance handle carries all mutable state — selected document, render options, extraction settings — so two threads sharing an instance will silently interleave state changes even when their calls succeed.
Related reading
Once the binding is in place, the operations it exposes are the same ones the Delphi articles cover in depth — for instance applying and auditing PDF encryption, or extracting text and images from existing documents.
Binary downloads for all three integration layers ship with the library; see the PDFlibPas product page for editions and licensing.