Technical Article

„PDFlibPas“ DLL, ActiveX ir dylib susiejimai: to paties PDF variklio iškvietimas iš bet kurios kalbos

Štai problema, kuri iškyla tą akimirką, kai PDF biblioteka palieka savo gimtąją kalbą. Turite susiejimą (binding), kuris puikiai veikia su C# „Windows“ aplinkoje. Jums reikia tų pačių iškvietimų su Python „macOS“ aplinkoje, zodėl nukopijuojate „Windows“ deklaracijų failą, pakeičiate dvejetainio failo (binary) pavadinimą ir jį paleidžiate. Visi simboliai sėkmingai išsprendžiami (resolves). Tačiau pirmasis iškvietimas grąžina neteisingus duomenis (garbage), antrasis lūžta su prieigos pažeidimu (access violation), nors jūsų PDF kodo dalis visiškai nepasikeitė. Klaida slypi vienu sluoksniu žemiau nei pats PDF: „Windows“ eksportuojamos funkcijos naudoja Stdcall susitarimą, „macOS“ dylib eksportuoja tas pačias funkcijas kaip Cdecl su pabraukimo brūkšniu priekyje, o bet koks išorinių funkcijų deklaracijos (FFI) netikslumas sugadina dėklą (stack) dar prieš atidarant pirmąjį dokumentą.

Visa ši problemų klasė kyla iš vieno projektavimo sprendimo, kurį verta suprasti iš anksto. „PDFlibPas“ – „losLab“ PDF variklis su prieinamu pradiniu kodu, skirtas „Delphi“ ir „C++Builder“, apgaubia visą savo objektų modelį viena plokščia fasado (facade) klase – TPDFlib, o tada pateikia šį fasadą trimis dvejetainiais pavidalais: „Windows“ DLL su mažiau 1 250 eksportuojamų funkcijų, COM/ActiveX automatizavimo objektu ir „macOS“ dylib biblioteka. PDF semantika visose trijose versijose yra identiška. Sudėtinga dalis slypi pačioje ABI (Application Binary Interface) struktūroje: iškvietimo susitarimuose, eilučių kodavime, rankenų (handles) nuosavybėje ir tame, kuri pusė turi atlaisvinti atminties buferį.

Vienas fasadas, trys dvejetainiai pavidalai

Kiekviena vieša TPDFlib funkcija turi plokščią atitikmenį, kurio pavadinimas prasideda raidėmis DL ir metodo pavadinimu. Pavyzdžiui, LoadFromFile tampa DLLoadFromFile, EncryptDLEncrypt, NewSignProcessFromFileDLNewSignProcessFromFile. Beveik kiekvieno eksportuojamo metodo pirmasis parametras yra InstanceID, kurį grąžina DLCreateLibrary – jis atstoja objekto nuorodą, kurią kitu atveju turėtų „Delphi“ kviečiančioji programa. Įsisavinkite šį susiejimą anksti. Tai reiškia, kad „Delphi“ API dokumentacija tinka ir visoms kitoms kalboms: ką gali klasė, tą patį nuspėjamu pavadinimu gali atlikti ir DLL, o perskaitę Pascal metodo parašą sužinosite, kokio iškvietimo jums reikia iš Python ar C# programų.

„Windows“ versija sukuria PDFlibDLL32.dll ir PDFlibDLL64.dll failus; pasirinkite tą, kuris atitinka jūsų pagrindinio proceso bitų skaičių, nes 64 bitų „Java“ ar .NET procesas negali įkelti 32 bitų bibliotekos, nepriklausomai nuo to, kaip atrodo deklaracija.

Windows: Stdcall egzemplioriai ir W/A funkcijų poros

Kiekviena funkciją priimanti eilutę eksportuojama du kartus. „Plati“ versija priima PWideChar (UTF-16, kuris natūraliai tinka .NET, Java ir Python c_wchar_p), o versija su „A“ sufiksu priima PAnsiChar. Abi funkcijos atlieka identišką darbą ir skiriasi tik kodavimu, todėl jų sumaišymas sukelia sunkiai atsekamų problemų: neišmetama jokių klaidų, negrąžinami klaidų kodai, o metaduomenyse tiesiog matote sugadintus simbolius (mojibake) arba gaunate neteisingą pranešimą „file not found“ bet kokiam keliui su simboliais už standartinio ASCII ribų. Pirmasis kodavimo sutrikimas komandai paprastai kainuoja visą popietę, nes simptomai rodo į duomenis, o tikroji priežastis slypi deklaracijoje.

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

Pasirinkite vieną eilutės simbolių plotį kiekvienai platformai ir įtraukite jį į susiejimų generatorių. Praktinė taisyklė: jei pagrindinė kalba naudoja UTF-16 eilutes, visur susiekite „W“ versijas ir daugiau nebenaudokite „A“ šeimos funkcijų.

macOS: tie patys pavadinimai, skirtinga ABI

Biblioteka „dylib“ eksportuoja tą patį DL funkcijų rinkinį su dviem sisteminiais pakeitimais. Iškvietimo susitarimas yra Cdecl (vietoj Stdcall), o kiekvienas eksportuojamas pavadinimas prasideda pabraukimo brūkšniu (_DLCreateLibrary, _DLLoadFromFile ir t. t.). Abu pakeitimai yra grynai mechaniniai, zodėl jie puikiai tinka generuojamiems susiejimams, bet yra pavojingi taisant rankiniu būdu nukopijuotą „Windows“ failą. Turėkite vieną kanoninį funkcijų sąrašą ir iš jo kurkite deklaracijas kiekvienai platformai. Priešingu atveju gausite dėklo (stack) sugadinimą, aprašytą šio straipsnio pradžioje, kuris pasireikš tik toje platformoje, kurią jūsų testavimo aplinka tikrina rečiausiai.

COM ir ActiveX platformos: Safecall ir Olevariant duomenys

VB.NET, C#, VBScript ir kitose senesnėse automatizavimo sistemose OCX versija apgaubia tą patį fasadą į IDispatch automatizavimo objektą IPDFlibrary, kurio kiekvienas metodas deklaruotas kaip Safecall. Šis susitarimas pakeičia klaidų perdavimą. Safecall konvertuoja vidinę klaidą į COM HRESULT, todėl C# kviečiančioji programa pagauna išimtį (exception) ten, kur plokščioji DLL versija būtų tiesiog grąžinusi tylų sveikojo skaičiaus kodą, kurį reikėtų tikrinti rankiniu būdu. Ta pati operacija – dvi skirtingos klaidų išraiškos, priklausomai nuo to, kurį dvejetainį failą įkėlėte.

Dvejetainiams duomenims galioja antra specifinė COM taisyklė. Automatizavimo sąsaja neturi jokių rodyklių (pointer) parametrų. Visi dvejetainiai duomenys – tiek įkeliami paveikslėlio baitai, tiek gaunamas PDF – kerta ribą kaip OleVariant tipas per tokius metodus kaip AddImageFromVariant ir AppendToVariant. Masyvo pavertimas į variantą .NET sistemoje atliekamas viena eilute. Jei bandysite perduoti tiesioginę rodyklę, manydami, kad tai tas pats procesas, COM sluoksnis atmes arba sugadins užklausą. Dar viena registravimo detalė dažnai sukelia problemų: COM registracija priklauso nuo bitų skaičiaus, todėl 32 bitų „regsvr32“ įrankiu užregistruotas OCX bus nematomas 64 bitų programai. Šis neatitikimas pasireiškia klaida „klasė neužregistruota“ (class not registered) kliento kompiuteryje.

Rankenų valdymas: egzemplioriams priklauso dokumentai

Plokščias API veikia sveikųjų skaičių rankenomis (handles). Metodas DLCreateLibrary grąžina egzemplioriaus (instance) identifikatorių. Failo įkėlimas grąžina dokumento ID tame egzemplioriuje. Pasirašymo procesai, eilučių sąrašai ir tiesioginės prieigos failai grąžina savo sveikųjų skaičių rankenas, kurios visos priklauso tam pačiam egzemplioriui. Gyvavimo ciklas atrodo vienodai iš bet kurios FFI sistemos, čia parodytas Pascal formatu, nes jis lengvai skaitomas:

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;

Iš šio nuosavybės medžio seka du dalykai. DLReleaseLibrary yra vienintelis valymo iškvietimas, kurio jums būtinai reikia, nes jis vienu metu panaikina visus dokumentų ir procesų identifikatorius, sukurtus tame egzemplioriuje. Trumpame skripte to pakanka. Tačiau ilgai veikiančioje tarnyboje (service) tai sukels lėtą atminties nutekėjimą, todėl užbaikite darbą su dokumentais ir išlaisvinkite juos iškart, užuot laukę, kol bus sunaikintas visas egzempliorius. Egzempliorius taip pat yra natūralus gijų (threads) izoliavimo vienetas. Suteikite kiekvienai gijai atskirą InstanceID ir niekada nesidalykite juo tarp gijų be papildomo sinchronizavimo, lygiai taip pat, kaip nesidalytumėte vienu TPDFlib objektu tarp skirtingų gijų.

Grąžinamos eilutės yra pasiskolintos, o ne nuosavos

Funkcijos, kurios grąžina tekstą (pavyzdžiui, DLGetPageText), pateikia PWideChar arba PAnsiChar rodyklę, rodančią į bibliotekos egzemplioriaus valdomą ir pakartotinai naudojamą buferį. Taisyklė yra tokia: nukopijuokite reikšmę iškart, niekada jos neatlaisvinkite patys.

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;

C# kalboje tai reiškia, kad prieš kitą bibliotekos iškvietimą turite paversti IntPtr į valdomą string (managed string). Python ctypes aplinkoje tai reiškia, kad iškart turite nuskaityti eilutę iš gautos rodyklės. Jei išlaikysite rodyklę tarp iškvietimų, sukursite klaidą, kuri praeis visus vietinius testus, bet suges gamybinėje aplinkoje, kai dvi užklausos persidengs, nes antrasis iškvietimas perrašys buferį, kurį pirmasis dar tik skaitė. Taika pati taisyklė galioja ir atgalinio iškvietimo funkcijoms (callbacks), registruojamoms per DLSetProgressCallback. Bet kuri rodyklė, kurią biblioteka perduoda jūsų funkcijai, galioja tik tos funkcijos vykdymo metu, o pati funkcija (delegate) privalo išlikti gyva (prisegta atmintyje, angl. pinned) tol, kol egzempliorius gali ją iškviesti. Šiukšlių surinkėjo (garbage collector) pašalinta funkcija viduryje darbo yra klasikinė „atsitiktinių“ klaidų (access violation) priežastis .NET aplinkoje.

Sukurkite greitą patikros testą (smoke test) pačiame susiejimo kode prieš pradėdami naudoti sugeneruotas deklaracijas. Išbandykite po vieną iškvietimą iš kiekvienos kategorijos, kurioje dažniausiai pasitaiko ABI klaidų: funkciją be parametrų (pvz., DLCreateLibrary) iškvietimo susitarimo patikrinimui; funkciją, priimančią eilutę su ne ASCII simboliais, kodavimo patikrinimui; funkciją, grąžinančią eilutę, buferio nuosavybės patikrinimui; ir vieną sąmoningai nesėkmingą operaciją, kad pamatytumėte, kaip klaidos pasiekia jūsų kodą. Tai trunka vos penkiolika minučių, bet padeda išvengti iškvietimų ar kodavimo klaidų, kurios vėliau virstų klientų pranešimais apie programos lūžimą.

Konkretus Python ctypes atvejis

Python ctypes yra susiejimas, kurį dažniausiai tenka matyti parašytą rankiniu būdu, ir jis puikiai tinka kelių platformų skirtumams iliustruoti. „Windows“ aplinkoje įkelkite biblioteką naudodami ctypes.WinDLL, kad ctypes taikytų Stdcall, susiekite „W“ versijos funkcijas ir deklaruokite eilučių parametrus kaip c_wchar_p. „macOS“ aplinkoje įkelkite naudodami ctypes.CDLL, kad būtų taikomas Cdecl, išlaikykite tą patį funkcijų sąrašą ir išspręskite pavadinimus be pradinio pabraukimo brūkšnio. Dauguma FFI sluoksnių, įskaitant ctypes, automatiškai sutvarko pabraukimo brūkšnio reikalavimą „macOS“ aplinkoje, tačiau tai verta patikrinti su viena funkcija prieš pradedant generuoti šimtus deklaracijų.

Įgyvendinant susiejimą, iškyla du diegimo klausimai su aiškiais atsakymais. Paprastam DLL failui nereikia jokios registracijos sistemoje: regsvr32 taikomas tik ActiveX versijai, o DLL platinamas tiesiog kopijuojant failą – tai yra pagrindinė priežastis rinktis jį „Windows“ tarnyboms ir konteineriams, kur nenorima keisti registro reikšmių. Gijų saugumas (thread safety) susiveda į aukščiau aprašytą taisyklę: vienas egzempliorius vienai gijai. Egzemplioriaus rankena (handle) saugo visas kintamas būsenas (pasirinktą dokumentą, atvaizdavimo parinktis, išgavimo nustatymus), todėl dvi gijos, besidalijančios vienu egzemplioriumi, sugadins viena kitos būseną, net jei kiekvienas atskiras iškvietimas bus sėkmingas.

Kai susiejimas veikia stabiliai, tolesnės operacijos yra tiksliai tos pačios, kurias išsamiai aprašo „Delphi“ skirti straipsniai, įskaitant PDF šifravimo taikymą ir auditą bei teksto ir paveikslėlių išgavimą iš esamų dokumentų.

Visų trijų integracijos sluoksnių dvejetainiai failai platinami kartu su biblioteka; leidimus ir licencijavimą rasite puslapyje PDFlibPas produkto puslapyje.