Ecco un problema che si presenta nel momento stesso in cui una libreria PDF esce dal proprio linguaggio di origine. Avete un binding che funziona alla perfezione da C# su Windows. Vi servono le stesse chiamate da Python su macOS, quindi copiate il file di dichiarazioni di Windows, cambiate il nome del binario e lo eseguite. Ogni simbolo si risolve. La prima chiamata restituisce spazzatura, la seconda va in crash con una access violation, e nessuna riga del vostro codice PDF è cambiata. La colpa è di un livello sotto il PDF: gli export di Windows usano la convenzione Stdcall, la dylib macOS esporta le stesse funzioni come Cdecl con un underscore iniziale, e una dichiarazione foreign-function che sbaglia uno dei due dettagli corrompe lo stack prima ancora che venga aperto un solo documento
Tutta questa classe di guasti nasce da una decisione di progetto che vale la pena capire fin da subito. PDF Library for Delphi, il motore PDF source-available di losLab per Delphi e C++Builder, avvolge il proprio intero modello a oggetti in un'unica classe facade flat, TPDFlib, e poi distribuisce quella facade in tre forme binarie: una DLL Windows con circa 1.250 funzioni esportate, un oggetto di automazione COM/ActiveX e una dylib macOS. La semantica PDF è identica in tutte e tre. La parte che vi morde vive nell'ABI sottostante: convenzioni di chiamata, codifiche delle stringhe, proprietà degli handle e quale lato può liberare quale buffer
Una facade, tre forme binarie
Ogni funzione pubblica di TPDFlib ha una controparte flat chiamata DL più il nome del metodo. LoadFromFile diventa DLLoadFromFile, Encrypt diventa DLEncrypt, NewSignProcessFromFile diventa DLNewSignProcessFromFile. Il primo parametro di quasi ogni export è un InstanceID restituito da DLCreateLibrary, che sostituisce il riferimento all'oggetto che un chiamante Delphi terrebbe altrimenti. Fate vostra presto questa corrispondenza. Significa che la documentazione dell'API Delphi funge anche da documentazione per ogni altro linguaggio: qualsiasi cosa sappia fare la classe, la DLL la sa fare sotto un nome prevedibile, e potete leggere la firma di un metodo Pascal per capire la chiamata che vi serve da Python o C#
La build Windows produce PDFlibDLL32.dll e PDFlibDLL64.dll; scegliete quella che corrisponde al bitness del vostro processo host, poiché un processo Java o .NET a 64 bit non può caricare la libreria a 32 bit per quanto bene sia scritta la dichiarazione
Windows: istanze Stdcall e le coppie di funzioni W/A
Ogni export che accetta stringhe esiste due volte. Una versione wide accetta PWideChar (UTF-16, la scelta naturale per .NET, Java e il c_wchar_p di Python), e una versione con suffisso A accetta PAnsiChar. Le due portano una semantica identica e differiscono solo per la codifica, ed è proprio questo a rendere tanto doloroso rintracciare un loro mescolamento: non viene sollevata alcuna eccezione, non torna alcun codice di errore, semplicemente ottenete mojibake nei metadati o uno spurio "file not found" per qualunque percorso con un carattere oltre il puro ASCII. Il primo bug di codifica che un team incontra per questa via costa di solito un pomeriggio, perché il sintomo punta ai dati mentre la causa sta nella dichiarazione
// Binding Windows (PDFlibDLL64.dll): Stdcall, nomi di export semplici
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';
// Binding macOS: stessa funzione, Cdecl, e un prefisso underscore sull'export
function DLCreateLibrary: Integer; cdecl;
external 'PDFlibDylib.dylib' name '_DLCreateLibrary';
Scegliete una sola larghezza di carattere per host e codificatela nel generatore di binding. Una regola pratica: se il linguaggio host ha stringhe UTF-16 native, collegate ovunque le versioni W e non toccate mai più la famiglia A
macOS: stessi nomi, ABI diversa
La dylib esporta lo stesso insieme di funzioni DL con due modifiche sistematiche. La convenzione di chiamata è Cdecl anziché Stdcall, e ogni nome di export porta un underscore iniziale (_DLCreateLibrary, _DLLoadFromFile e così via). Entrambe le modifiche sono puramente meccaniche, il che le rende ideali per un binding generato e pericolose per una copia modificata a mano del file Windows. Tenete un unico elenco canonico di funzioni ed emettete da lì le dichiarazioni per ciascuna piattaforma, se la vostra toolchain lo consente. Saltate questo passaggio e otterrete esattamente la corruzione dello stack descritta in cima a questa pagina, riproducibile solo sulla piattaforma che la vostra CI si trova a esercitare di meno
Host COM e ActiveX: Safecall e payload Olevariant
Per VB.NET, C#, VBScript e gli host di automazione legacy, la build OCX avvolge la stessa facade in un oggetto di automazione IDispatch, IPDFlibrary, con ogni metodo dichiarato Safecall. Quella convenzione cambia il modo in cui gli errori vi raggiungono. Safecall traduce un guasto interno in un HRESULT COM, quindi un chiamante C# intercetta un'eccezione dove la DLL flat avrebbe restituito un intero silenzioso che il chiamante doveva ricordarsi di controllare. La stessa operazione, due modi diversi di fallire, a seconda del binario che avete caricato
I dati binari seguono una seconda regola specifica di COM. L'interfaccia di automazione non ha alcun parametro puntatore. Qualsiasi cosa binaria, i byte di un'immagine in entrata o i byte PDF in uscita, attraversa il confine come Olevariant tramite metodi quali AddImageFromVariant e AppendToVariant. Effettuare il marshaling di un array di byte in un variant è una sola riga in .NET. Provate invece a passargli un puntatore grezzo, con il ragionamento che tanto è lo stesso processo, e il livello di dispatch rifiuta o storpia la chiamata. Un ulteriore dettaglio di registrazione fa inciampare i deployment: la registrazione COM è per bitness, quindi un OCX registrato con il regsvr32 a 32 bit è invisibile a un host a 64 bit. Quella discrepanza affiora come il famigerato e poco utile "class not registered" sulla macchina del cliente, molto tempo dopo che ha lasciato la vostra
Disciplina degli handle: le istanze possiedono i documenti
L'API flat gira su handle interi. DLCreateLibrary restituisce un'istanza. Il caricamento di un file restituisce un ID di documento interno a quell'istanza. Processi di firma, string list e file ad accesso diretto restituiscono ciascuno i propri handle interi, tutti nell'ambito della stessa istanza. Il ciclo di vita appare identico da qualsiasi host FFI, mostrato qui in Pascal perché si legge con chiarezza:
var
Inst, Doc: Integer;
begin
Inst := DLCreateLibrary; // una istanza per thread di lavoro
try
Doc := DLLoadFromFile(Inst, 'in.pdf', ''); // restituisce un DocumentID, 0 in caso di errore
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); // libera ogni documento posseduto dall'istanza
end;
end;
Da quell'albero di proprietà discendono due cose. DLReleaseLibrary è l'unica chiamata di pulizia strettamente necessaria, poiché smonta in un colpo solo ogni documento e ogni handle di processo sotto l'istanza. In uno script breve questo basta. In un servizio a lunga esecuzione diventa una perdita lenta con qualche cerimonia in più, quindi rilasciate i documenti man mano che finite di usarli anziché lasciarli accumulare fino alla morte dell'istanza. L'istanza è anche l'unità naturale di isolamento tra thread. Date a ogni thread di lavoro il proprio InstanceID, e non condividetene mai uno tra thread senza un lock esterno, per la stessa ragione per cui non condividereste mai un singolo oggetto TPDFlib tra thread
Le stringhe restituite sono in prestito, non di vostra proprietà
Le funzioni che restituiscono testo, come DLGetPageText, riconsegnano un PWideChar o un PAnsiChar che punta dentro un buffer posseduto e riciclato dall'istanza della libreria. Il contratto è: copiate subito, non liberate mai
var
P: PWideChar;
PageText: string;
begin
P := DLGetPageText(Inst, 7); // puntatore in un buffer posseduto dalla libreria
PageText := P; // copiate ora; una chiamata successiva può riusare il buffer
end;
In C# questo significa effettuare il marshaling dell'IntPtr in una stringa gestita prima della chiamata successiva alla libreria. In ctypes di Python significa estrarre subito la stringa wide dal puntatore. Trattenete il puntatore grezzo tra una chiamata e l'altra e avrete scritto un bug che supera ogni unit test per poi fallire la prima volta che due richieste si sovrappongono in produzione, perché la seconda chiamata ha riciclato il buffer che la prima stava ancora leggendo. La stessa regola di proprietà vale nella direzione opposta per le callback registrate tramite DLSetProgressCallback. Qualsiasi puntatore che la libreria passa alla vostra callback è valido solo per il corpo di quella callback, e l'oggetto callback stesso deve restare in vita (pinned, in un host con garbage collector) per tutto il tempo in cui l'istanza potrebbe ancora invocarlo. Un delegate raccolto a metà lavoro è la fonte da manuale di quella access violation "casuale" che compare in un binding .NET rimasto pulito per mesi
Integrate uno smoke test nel binding stesso, ed eseguitelo prima che qualsiasi insieme di dichiarazioni generate venga distribuito. Esercitate una chiamata per ciascuna categoria che tende a smascherare gli errori di ABI: una funzione senza parametri come DLCreateLibrary per dimostrare che la convenzione è giusta, una funzione che riceve una stringa alimentata con un percorso contenente caratteri non ASCII per dimostrare che la codifica è giusta, una funzione che restituisce una stringa per dimostrare che la gestione del buffer in prestito è giusta, e un'operazione che fallisce di proposito così potete osservare come un errore raggiunge il vostro host. Sono quindici minuti di lavoro, e intercettano i difetti di convenzione di chiamata e di codifica che altrimenti arriverebbero mesi dopo sotto forma di crash dump di un cliente
Il caso ctypes di Python, in concreto
ctypes di Python è il binding che vedo scritto a mano più spesso, e rende facile dimostrare la divisione tra piattaforme. Su Windows, caricate la libreria con ctypes.WinDLL così che ctypes applichi Stdcall, collegate le funzioni W senza suffisso e dichiarate ogni parametro stringa come c_wchar_p. Su macOS, caricatela con ctypes.CDLL per Cdecl, mantenete l'elenco di funzioni identico e risolvete i nomi senza l'underscore iniziale. La maggior parte dei livelli FFI, ctypes compreso, reintroduce per voi la convenzione dell'underscore su macOS, ma quella è l'unica assunzione da confermare con una singola chiamata risolta prima di generarci sopra centinaia di dichiarazioni
Due domande di deployment seguono il lavoro sul binding e hanno risposte nette. La DLL semplice non richiede alcuna registrazione: regsvr32 si applica solo alla build ActiveX, e la DLL si distribuisce per copia di file, che è la ragione principale per preferirla nei servizi Windows e nei container dove preferireste non toccare affatto il registro di sistema. La thread safety si riduce alla regola già in gioco sopra, una istanza per thread. L'handle di istanza contiene ogni pezzo di stato mutabile che il motore traccia, il documento selezionato, le opzioni di rendering, le impostazioni di estrazione, quindi due thread che condividono un'istanza si intrecciano a vicenda lo stato anche quando ogni singola chiamata restituisce successo
Una volta che un binding è solido, le operazioni dall'altra parte sono esattamente quelle che gli articoli su Delphi trattano in profondità, tra cui applicare e verificare la cifratura PDF e estrarre testo e immagini da documenti esistenti
I download binari per tutti e tre i livelli di integrazione sono inclusi nella libreria; consultate la pagina prodotto di PDF Library for Delphi per edizioni e licenze