Technischer Artikel

PDF-DLL-, ActiveX- und dylib-Bindings in Delphi

Hier ist ein Problem, das auftaucht, sobald eine PDF-Bibliothek ihre Heimatsprache verlässt. Sie haben ein Binding, das aus C# unter Windows einwandfrei funktioniert. Sie brauchen dieselben Aufrufe aus Python unter macOS, also kopieren Sie die Windows-Deklarationsdatei, tauschen den Binärnamen aus und starten. Jedes Symbol wird aufgelöst. Der erste Aufruf liefert Müll, der zweite stürzt mit einer Zugriffsverletzung ab, und nichts an Ihrem PDF-Code hat sich geändert. Der Fehler liegt eine Ebene unter dem PDF: Die Windows-Exporte verwenden die Stdcall-Konvention, die macOS-dylib exportiert dieselben Funktionen als Cdecl mit führendem Unterstrich, und eine Foreign-Function-Deklaration, die eines der beiden Details falsch macht, korrumpiert den Stack, bevor ein einziges Dokument geöffnet ist

Diese ganze Fehlerklasse geht auf eine Designentscheidung zurück, die man vorab verstehen sollte. PDF Library for Delphi, die source-available PDF-Engine von losLab für Delphi und C++Builder, kapselt ihr gesamtes Objektmodell in einer einzigen flachen Fassadenklasse, TPDFlib, und liefert diese Fassade dann in drei Binärformen aus: als Windows-DLL mit rund 1.250 exportierten Funktionen, als COM/ActiveX-Automationsobjekt und als macOS-dylib. Die PDF-Semantik ist bei allen dreien identisch. Der Teil, der Sie beißt, sitzt in der ABI darunter: Aufrufkonventionen, String-Kodierungen, Handle-Besitz und die Frage, welche Seite welchen Puffer freigeben darf

Eine Fassade, drei Binärformen

Jede öffentliche Funktion von TPDFlib hat ein flaches Gegenstück namens DL plus Methodenname. LoadFromFile wird zu DLLoadFromFile, Encrypt zu DLEncrypt, NewSignProcessFromFile zu DLNewSignProcessFromFile. Der erste Parameter fast jedes Exports ist eine von DLCreateLibrary zurückgegebene InstanceID, die die Objektreferenz vertritt, die ein Delphi-Aufrufer sonst halten würde. Verinnerlichen Sie diese Zuordnung früh. Sie bedeutet, dass die Delphi-API-Referenz zugleich die Dokumentation für jede andere Sprache ist: Was die Klasse kann, kann die DLL unter einem vorhersehbaren Namen, und Sie können eine Pascal-Methodensignatur lesen, um den Aufruf zu finden, den Sie aus Python oder C# brauchen

Der Windows-Build erzeugt PDFlibDLL32.dll und PDFlibDLL64.dll; wählen Sie diejenige, die zur Bitbreite Ihres Host-Prozesses passt, denn ein 64-Bit-Java- oder .NET-Prozess kann die 32-Bit-Bibliothek nicht laden, egal wie die Deklaration aussieht

Architekturdiagramm einer TPDFlib-Fassade, bereitgestellt als Stdcall-Windows-DLL, Safecall-ActiveX-Automationsobjekt und Cdecl-macOS-dylib
Alle drei Binärdateien teilen sich eine flache PDF-Fassade, unterscheiden sich aber in Aufrufkonvention, String-Behandlung und Registrierungsanforderungen

Windows: Stdcall-Instanzen und die W/A-Funktionspaare

Jeder Export, der Strings entgegennimmt, existiert zweimal. Eine Wide-Version nimmt PWideChar (UTF-16, die natürliche Wahl für .NET, Java und Pythons c_wchar_p), eine Version mit A-Suffix nimmt PAnsiChar. Beide haben identische Semantik und unterscheiden sich nur in der Kodierung, und genau das macht ein Vermischen so mühsam aufzuspüren: Nichts wirft eine Exception, nichts liefert einen Fehlercode, Sie bekommen schlicht Zeichensalat in den Metadaten oder ein unerklärliches „file not found“ für jeden Pfad mit einem Zeichen jenseits von reinem ASCII. Der erste Kodierungsfehler, den ein Team auf diesem Weg trifft, kostet meist einen Nachmittag, weil das Symptom auf die Daten zeigt und die Ursache in der Deklaration liegt

// Windows-Binding (PDFlibDLL64.dll): Stdcall, schlichte Exportnamen
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: dieselbe Funktion, Cdecl, und ein Unterstrich-Präfix am Export
function DLCreateLibrary: Integer; cdecl;
  external 'PDFlibDylib.dylib' name '_DLCreateLibrary';

Legen Sie pro Host eine Zeichenbreite fest und verankern Sie sie im Binding-Generator. Eine praktische Regel: Hat die Host-Sprache native UTF-16-Strings, binden Sie überall die W-Versionen und fassen die A-Familie nie wieder an

macOS: gleiche Namen, andere ABI

Die dylib exportiert denselben DL-Funktionssatz mit zwei systematischen Änderungen. Die Aufrufkonvention ist Cdecl statt Stdcall, und jeder Exportname trägt einen führenden Unterstrich (_DLCreateLibrary, _DLLoadFromFile und so weiter). Beide Änderungen sind rein mechanisch, was sie ideal für ein generiertes Binding und gefährlich für eine von Hand bearbeitete Kopie der Windows-Datei macht. Halten Sie eine kanonische Funktionsliste und erzeugen Sie daraus plattformspezifische Deklarationen, wenn Ihr Tooling das erlaubt. Lassen Sie das aus, bekommen Sie genau die am Anfang dieser Seite beschriebene Stack-Korruption, reproduzierbar nur auf der Plattform, die Ihre CI am seltensten durchläuft

COM- und ActiveX-Hosts: Safecall und Olevariant-Nutzlasten

Für VB.NET, C#, VBScript und ältere Automationshosts kapselt der OCX-Build dieselbe Fassade in einem IDispatch-Automationsobjekt, IPDFlibrary, bei dem jede Methode als Safecall deklariert ist. Diese Konvention ändert, wie Fehler bei Ihnen ankommen. Safecall übersetzt einen internen Fehlschlag in ein COM-HRESULT, sodass ein C#-Aufrufer eine Exception fängt, wo die flache DLL einen stillen Integer zurückgegeben hätte, den der Aufrufer zu prüfen nicht vergessen durfte. Dieselbe Operation, zwei Fehleridiome, je nachdem, welche Binärdatei Sie geladen haben

Binärdaten folgen einer zweiten COM-spezifischen Regel. Die Automationsschnittstelle hat überhaupt keine Zeigerparameter. Alles Binäre, hineingehende Bildbytes wie herauskommende PDF-Bytes, überquert die Grenze als Olevariant über Methoden wie AddImageFromVariant und AppendToVariant. Ein Byte-Array in einen Variant zu marshallen ist in .NET eine einzige Zeile. Versuchen Sie stattdessen, einen rohen Zeiger zu übergeben, mit der Begründung, es sei ja ohnehin derselbe Prozess, weist die Dispatch-Schicht den Aufruf ab oder verstümmelt ihn. Ein weiteres Registrierungsdetail bringt Deployments zu Fall: Die COM-Registrierung ist bitbreitenspezifisch, ein mit dem 32-Bit-regsvr32 registriertes OCX ist für einen 64-Bit-Host also unsichtbar. Diese Diskrepanz zeigt sich als das berüchtigt wenig hilfreiche „class not registered“ auf der Kundenmaschine, lange nachdem sie Ihre verlassen hat

Handle-Disziplin: Instanzen besitzen Dokumente

Die flache API arbeitet mit Integer-Handles. DLCreateLibrary liefert eine Instanz. Das Laden einer Datei liefert eine Dokument-ID innerhalb dieser Instanz. Signaturprozesse, String-Listen und Direktzugriffsdateien liefern jeweils eigene Integer-Handles, alle auf dieselbe Instanz bezogen. Der Lebenszyklus sieht aus jedem FFI-Host gleich aus, hier in Pascal gezeigt, weil es sich sauber liest:

var
  Inst, Doc: Integer;
begin
  Inst := DLCreateLibrary;                       // eine Instanz pro Worker-Thread
  try
    Doc := DLLoadFromFile(Inst, 'in.pdf', '');   // liefert eine DocumentID, 0 bei Fehler
    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);                      // gibt jedes Dokument frei, das die Instanz besitzt
  end;
end;

Aus diesem Besitzbaum folgen zwei Dinge. DLReleaseLibrary ist der einzige Aufräumaufruf, den Sie zwingend brauchen, denn er baut jedes Dokument- und Prozess-Handle unter der Instanz in einem Zug ab. In einem kurzen Skript reicht das. In einem lang laufenden Dienst wird daraus ein schleichendes Leck mit zusätzlichem Zeremoniell, geben Sie Dokumente also frei, sobald Sie mit ihnen fertig sind, statt sie sich anhäufen zu lassen, bis die Instanz stirbt. Die Instanz ist auch die natürliche Einheit der Thread-Isolation. Geben Sie jedem Worker-Thread eine eigene InstanceID und teilen Sie nie eine über Threads hinweg ohne externe Sperre, aus demselben Grund, aus dem Sie nie ein einzelnes TPDFlib-Objekt zwischen Threads teilen würden

Zurückgegebene Strings sind geliehen, nicht besessen

Funktionen, die Text zurückgeben, etwa DLGetPageText, liefern einen PWideChar oder PAnsiChar, der in einen Puffer zeigt, den die Bibliotheksinstanz besitzt und wiederverwendet. Der Vertrag lautet: sofort kopieren, nie freigeben

PDF Library for Delphi: Zeitleiste, die das sofortige Kopieren eines geliehenen DLGetPageText-Zeigers dem Festhalten gegenüberstellt, bis die Bibliothek den zugrunde liegenden Puffer wiederverwendet
Zurückgegebene Char-Zeiger leihen Speicher, den die Instanz wiederverwendet, daher muss die Kopie vor dem nächsten Bibliotheksaufruf erfolgen
var
  P: PWideChar;
  PageText: string;
begin
  P := DLGetPageText(Inst, 7);   // Zeiger in einen Puffer im Besitz der Bibliothek
  PageText := P;                 // jetzt kopieren; ein späterer Aufruf kann den Puffer wiederverwenden
end;

In C# heißt das, den IntPtr vor dem nächsten Bibliotheksaufruf in einen verwalteten String zu marshallen. In Python ctypes heißt es, den Wide-String sofort aus dem Zeiger herauszuschneiden. Halten Sie den rohen Zeiger über Aufrufe hinweg, haben Sie einen Fehler geschrieben, der jeden Unit-Test besteht und dann beim ersten Mal versagt, wenn sich in der Produktion zwei Anfragen überlappen, weil der zweite Aufruf den Puffer wiederverwendet hat, den der erste noch las. Dieselbe Besitzregel gilt in umgekehrter Richtung für Callbacks, die über DLSetProgressCallback registriert werden. Jeder Zeiger, den die Bibliothek in Ihren Callback hineinreicht, ist nur für den Rumpf dieses Callbacks gültig, und das Callback-Objekt selbst muss so lange am Leben bleiben (in einem Host mit Garbage Collection: gepinnt), wie die Instanz es noch aufrufen könnte. Ein mitten im Job eingesammelter Delegate ist die Lehrbuchquelle der „zufälligen“ Zugriffsverletzung, die in einem .NET-Binding auftaucht, das monatelang sauber lief

Bauen Sie einen Smoke-Test in das Binding selbst ein und lassen Sie ihn laufen, bevor irgendein generierter Deklarationssatz ausgeliefert wird. Üben Sie je einen Aufruf aus jeder Kategorie aus, die ABI-Fehler typischerweise aufdeckt: eine parameterlose Funktion wie DLCreateLibrary, um zu belegen, dass die Konvention stimmt, eine String-Eingabefunktion mit einem Pfad mit Nicht-ASCII-Zeichen, um zu belegen, dass die Kodierung stimmt, eine String-Ausgabefunktion, um zu belegen, dass die Behandlung geliehener Puffer stimmt, und eine Operation, die absichtlich fehlschlägt, damit Sie sehen, wie ein Fehler Ihren Host erreicht. Das sind fünfzehn Minuten Arbeit, und sie fangen die Aufrufkonventions- und Kodierungsfehler ab, die sonst Monate später als Absturz-Dump eines Kunden eintreffen würden

PDF Library for Delphi: Zwei-mal-zwei-Raster der Smoke-Test-Sonden für Bindings, das Aufrufkonvention, String-Kodierung, geliehene Puffer und das Sichtbarwerden von Fehlern abdeckt
Vier günstige Sonden fangen Konventions-, Kodierungs- und Besitzfehler ab, bevor generierte Deklarationen je eine Kundenmaschine erreichen

Der Python-ctypes-Fall, konkret

Python ctypes ist das Binding, das ich am häufigsten handgestrickt sehe, und es macht die Plattformtrennung leicht demonstrierbar. Laden Sie die Bibliothek unter Windows mit ctypes.WinDLL, damit ctypes Stdcall anwendet, binden Sie die W-Funktionen ohne Suffix und deklarieren Sie jeden String-Parameter als c_wchar_p. Laden Sie sie unter macOS mit ctypes.CDLL für Cdecl, behalten Sie die identische Funktionsliste und lösen Sie die Namen ohne führenden Unterstrich auf. Die meisten FFI-Schichten, ctypes eingeschlossen, ergänzen die Unterstrich-Konvention unter macOS für Sie, aber genau diese Annahme sollten Sie mit einem einzigen aufgelösten Aufruf bestätigen, bevor Sie Hunderte Deklarationen darauf aufbauen

Zwei Deployment-Fragen folgen der Binding-Arbeit und haben klare Antworten. Die reine DLL braucht keine Registrierung: regsvr32 betrifft nur den ActiveX-Build, und die DLL wird per Dateikopie ausgeliefert, der Hauptgrund, sie für Windows-Dienste und Container vorzuziehen, wo Sie die Registry lieber gar nicht anfassen. Thread-Sicherheit reduziert sich auf die oben bereits geltende Regel, eine Instanz pro Thread. Das Instanz-Handle hält jedes Stück veränderlichen Zustands, das die Engine verfolgt, das ausgewählte Dokument, die Render-Optionen, die Extraktionseinstellungen, sodass zwei Threads, die sich eine Instanz teilen, gegenseitig ihren Zustand verschränken, selbst wenn jeder einzelne Aufruf Erfolg meldet

Steht ein Binding erst einmal, sind die Operationen auf seiner anderen Seite genau die, die die Delphi-Artikel ausführlich behandeln, darunter das Anwenden und Auditieren von PDF-Verschlüsselung und das Extrahieren von Text und Bildern aus bestehenden Dokumenten

Binärdownloads für alle drei Integrationsschichten werden mit der Bibliothek ausgeliefert; Editionen und Lizenzierung finden Sie auf der Produktseite von PDF Library for Delphi