Technical Article

PDFlibPas DLL, ActiveX i dylib povezivanja izvan Delphi okruženja

Evo problema koji se pojavljuje onog trenutka kada PDF biblioteka napusti svoj izvorni jezik. Imate povezivanje (binding) koje radi savršeno iz C#-a na Windows-u. Potrebni su vam isti pozivi iz Python-a na macOS-u, pa kopirate Windows deklaracionu datoteku, zamenite ime binarnog fajla i pokrenete ga. Svaki simbol se razrešava. Prvi poziv vraća neupotrebljive podatke, drugi se ruši sa greškom o narušavanju pristupa (access violation), a da se pritom nijedan deo vašeg PDF koda nije promenio. Greška je jedan nivo ispod PDF-a: Windows izvozi funkcije koristeći Stdcall konvenciju, dok macOS dylib izvozi iste funkcije kao Cdecl sa vodećom donjom crtom, a deklaracija eksterne funkcije koja pogreši u bilo kom od ovih detalja oštetiće stek pre nego što se otvori ijedan dokument.

Cela ova klasa grešaka proizilazi iz jedne dizajnerske odluke koju vredi razumeti na samom početku. PDFlibPas, losLab PDF motor sa dostupnim izvornim kodom za Delphi i C++Builder, obavija ceo svoj objektni model u jednu ravnu klasu fasade, TPDFlib, a zatim isporučuje tu fasadu u tri binarna oblika: Windows DLL sa oko 1.250 izvezenih funkcija, COM/ActiveX automatizacioni objekat i macOS dylib. Semantika PDF-a je identična u sva tri slučaja. Deo koji vam može zadati probleme živi u ABI-ju ispod haube: konvencije pozivanja, kodiranje stringova, vlasništvo nad opisnicima (handles) i to koja strana sme da oslobodi koji bafer.

Jedna fasada, tri binarna oblika

Svaka javna funkcija klase TPDFlib ima svoj direktan pandan koji počinje sa DL i nazivom metode. LoadFromFile postaje DLLoadFromFile, Encrypt postaje DLEncrypt, a NewSignProcessFromFile postaje DLNewSignProcessFromFile. Prvi parametar skoro svakog izvoza je InstanceID koji vraća funkcija DLCreateLibrary, zamenjujući referencu objekta koju bi pozivalac iz Delphi-ja inače držao. Shvatite ovo mapiranje na samom početku. To znači da se Delphi API referenca može koristiti kao dokumentacija za bilo koji drugi jezik: šta god klasa može da uradi, DLL može da izvrši pod predvidljivim nazivom, a možete pročitati potpis Pascal metode da biste saznali koji poziv vam je potreban iz Python-a ili C#-a.

Windows verzija proizvodi PDFlibDLL32.dll i PDFlibDLL64.dll; izaberite onu koja odgovara bitnosti vašeg procesa domaćina, pošto 64-bitni Java ili .NET proces ne može da učita 32-bitnu biblioteku bez obzira na to kako deklaracija izgleda.

Windows: Stdcall instance i parovi funkcija W/A

Svaki izvoz koji prima string postoji u dve verzije. Široka (wide) verzija prima PWideChar (UTF-16, što je prirodno za .NET, Javu i Python-ov c_wchar_p), dok verzija sa sufiksom A prima PAnsiChar. Obe imaju identičnu semantiku i razlikuju se samo u kodiranju, što je upravo ono što mešanje čini teškim za otkrivanje: ništa ne prijavljuje grešku, ništa ne vraća kod greške, jednostavno dobijate nečitljive znakove u metapodacima ili lažnu grešku „datoteka nije pronađena” za bilo koju putanju koja sadrži znakove van osnovnog ASCII koda. Prva greška sa kodiranjem na koju tim naiđe na ovaj način obično oduzme celo popodne, jer simptomi ukazuju na podatke, a uzrok je u samoj deklaraciji.

// 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';

Izaberite jednu širinu karaktera po sistemu domaćina i definišite je u generatoru povezivanja. Praktično pravilo: ako jezik domaćin ima izvorne UTF-16 stringove, svuda povežite W verzije i više nikada ne koristite familiju A.

macOS: ista imena, drugačiji ABI

Dylib izvozi isti skup DL funkcija uz dve sistematske promene. Konvencija pozivanja je Cdecl umesto Stdcall, a svako ime izvoza nosi vodeću donju crtu (_DLCreateLibrary, _DLLoadFromFile itd.). Obe promene su čisto mehaničke, što ih čini idealnim za generisano povezivanje, ali i opasnim za ručno izmenjenu kopiju Windows datoteke. Držite jednu kanonsku listu funkcija i emitujte deklaracije po platformi iz nje ako vam alati to dozvoljavaju. Ako to preskočite, dobićete upravo ono oštećenje steka opisano na početku ove stranice, koje se reprodukuje samo na platformi koju vaš CI sistem najređe testira.

COM i ActiveX domaćini: Safecall i Olevariant prenos podataka

Za VB.NET, C#, VBScript i stare automatizacione domaćine, OCX verzija obavija istu fasadu u IDispatch automatizacioni objekat, IPDFlibrary, pri čemu je svaka metoda deklarisana kao Safecall. Ta konvencija menja način na koji greške stižu do vas. Safecall prevodi interni neuspeh u COM HRESULT vrednost, pa C# pozivalac hvata izuzetak tamo gde bi ravni DLL vratio običan ceo broj koji bi pozivalac morao da se seti da proveri. Isti radni zadatak, dva načina prijave greške, u zavisnosti od toga koju ste binarnu verziju učitali.

Binarni podaci prate još jedno pravilo specifično za COM. Automatizacioni interfejs uopšte nema parametre pokazivača. Sve što je binarno, bilo da su u pitanju bajtovi slike koji ulaze ili PDF bajtovi koji izlaze, prelazi granicu kao Olevariant kroz metode poput AddImageFromVariant i AppendToVariant. Pretvaranje niza bajtova u varijantu je samo jedna linija koda u .NET-u. Ako pokušate da mu prosledite sirovi pokazivač, razmišljajući da je to ionako isti proces, dispečerski sloj će odbiti ili oštetiti poziv. Još jedan detalj oko registracije može otežati instalaciju: COM registracija je zavisna od bitnosti procesa, pa je OCX registrovan pomoću 32-bitnog regsvr32 nevidljiv za 64-bitnog domaćina. Ovaj nesklad se na računaru korisnika prikazuje kao čuvena, ali nekorisna poruka „klasa nije registrovana”, dugo nakon što je napustila vaš računar.

Upravljanje opisnicima: instance poseduju dokumente

Ravan API radi sa celobrojnim opisnicima (handles). Funkcija DLCreateLibrary vraća instancu. Učitavanje datoteke vraća ID dokumenta unutar te instance. Procesi potpisivanja, liste stringova i datoteke sa direktnim pristupom vraćaju svoje celobrojne opisnike, svi u opsegu iste instance. Životni ciklus izgleda isto iz bilo kog FFI okruženja, ovde prikazano u Pascal kodu jer je najčitljiviji:

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;

Dve stvari proizilaze iz tog stabla vlasništva. DLReleaseLibrary je jedini poziv za čišćenje koji vam je striktno potreban, jer odjednom oslobađa svaki dokument i proces u okviru te instance. U kratkom skriptu to je dovoljno. U dugotrajnim servisima to može postati sporo curenje memorije sa previše ceremonije, pa oslobodite dokumente čim završite rad sa njima, umesto da dozvolite da se gomilaju dok instanca ne završi rad. Instanca je takođe prirodna jedinica izolacije niti. Dodelite svakoj niti njen sopstveni InstanceID i nikada nemojte deliti jedan među nitima bez spoljnog zaključavanja, iz istog razloga iz kojeg nikada ne biste delili jedan objekat TPDFlib između niti.

Vraćeni stringovi su pozajmljeni, a ne u sopstvenom vlasništvu

Funkcije koje vraćaju tekst, poput DLGetPageText, vraćaju PWideChar ili PAnsiChar koji pokazuje na bafer u vlasništvu instance biblioteke koji se iznova koristi. Pravilo glasi: kopirajte odmah, nikada ne oslobađajte.

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;

U C#-u to znači pretvaranje IntPtr pokazivača u managed string pre sledećeg poziva biblioteke. U Python ctypes-u, to znači odmah izvlačenje širokog stringa iz pokazivača. Zadržavanje sirovog pokazivača između poziva stvara grešku koja prolazi sve jedinične testove, a zatim otkazuje čim se dva zahteva poklope u produkciji, jer je drugi poziv ponovo iskoristio bafer koji je prvi poziv još uvek čitao. Isto pravilo o vlasništvu važi i u suprotnom smeru za povratne pozive (callbacks) registrovane preko DLSetProgressCallback. Svaki pokazivač koji biblioteka prosledi u vaš callback je validan samo tokom izvršavanja tog callback-a, a sam callback objekat mora ostati živ (fiksiran u memoriji kod sistema sa sakupljačem smeća) sve dok postoji šansa da ga instanca pozove. Delegat obrisan usred posla je školski primer uzroka „nasumičnih” access violation grešaka u .NET-u koje se pojavljuju nakon višemesečnog nesmetanog rada.

Ugradite brzi test (smoke test) u samo povezivanje i pokrenite ga pre nego što pošaljete bilo koji generisani skup deklaracija. Pokrenite po jedan poziv iz svake kategorije koja obično otkriva ABI greške: funkciju bez parametara poput DLCreateLibrary da biste dokazali da je konvencija ispravna, funkciju sa ulaznim stringom koja dobija putanju sa non-ASCII znakovima da biste proverili kodiranje, funkciju sa izlaznim stringom da biste testirali rad sa pozajmljenim baferom i jednu operaciju koja namerno ne uspeva da biste videli kako greška stiže do vašeg okruženja. To je petnaest minuta posla, a sprečava greške sa konvencijama pozivanja i kodiranjem koje bi se inače pojavile mesecima kasnije u obliku izveštaja o rušenju aplikacije kod korisnika.

Konkretan primer: Python ctypes

Python ctypes je povezivanje koje najčešće viđam ručno napisano, i ono čini podelu među platformama lakom za demonstraciju. Na Windows-u učitajte biblioteku pomoću ctypes.WinDLL kako bi ctypes primenio Stdcall, povežite W funkcije bez sufiksa i deklarišite svaki parametar stringa kao c_wchar_p. Na macOS-u je učitajte pomoću ctypes.CDLL za Cdecl, zadržite identičnu listu funkcija i razrešite imena bez vodeće donje crte. Većina FFI slojeva, uključujući ctypes, automatski rešava konvenciju vodeće donje crte na macOS-u, ali to je pretpostavka koju treba potvrditi jednim pozivom pre nego što generišete stotine deklaracija preko nje.

Dva pitanja oko instalacije prate rad na povezivanju i imaju jasne odgovore. Običan DLL ne zahteva registraciju: regsvr32 se odnosi samo na ActiveX verziju, a DLL se isporučuje običnim kopiranjem datoteke, što je glavni razlog zašto se preferira za Windows servise i kontejnere gde uopšte ne želite da menjate registar. Bezbednost niti (thread safety) se svodi na pravilo pomenuto iznad: jedna instanca po niti. Opisnik instance drži svako promenljivo stanje koje motor prati, izabrani dokument, opcije rendersovanja, podešavanja ekstrakcije, tako da dve niti koje dele instancu mešaju stanja jedna drugoj čak i kada svaki pojedinačni poziv vrati uspeh.

Kada povezivanje postane stabilno, operaciju sa druge strane su upravo one koje Delphi članci detaljno pokrivaju, uključujući primenu i reviziju šifrovanja PDF-a i ekstrakciju teksta i slika iz postojećih dokumenata.

Binarni fajlovi za sva tri integraciona nivoa isporučuju se uz biblioteku; pogledajte PDFlibPas stranicu proizvoda za izdanja i licenciranje.