Ось проблема, яка виникає в ту саму мить, коли бібліотека PDF залишає свою рідну мову. У вас є прив'язка, яка бездоганно працює з C# у Windows. Вам потрібні ті самі виклики з Python на macOS, тож ви копіюєте файл декларацій Windows, змінюєте ім'я бінарного файлу й запускаєте. Кожен символ вирішується. Перший виклик повертає сміття, другий падає з порушенням доступу, а жоден рядок вашого коду для PDF не змінився. Причина ховається на рівень нижче за PDF: експорти Windows використовують конвенцію Stdcall, macOS dylib експортує ті самі функції як Cdecl із префіксом підкреслення, і декларація зовнішньої функції, що переплутає будь-яку з цих деталей, зіпсує стек ще до того, як буде відкрито хоч один документ
Увесь цей клас збоїв походить з одного архітектурного рішення, яке варто зрозуміти заздалегідь. PDF Library for Delphi, PDF-рушій losLab із доступним вихідним кодом для Delphi та C++Builder, огортає всю свою об'єктну модель одним пласким фасадним класом, TPDFlib, а потім постачає цей фасад у трьох бінарних формах: DLL для Windows приблизно з 1250 експортованими функціями, об'єкт автоматизації COM/ActiveX і dylib для macOS. Семантика PDF ідентична в усіх трьох. Те, що вас кусає, живе в ABI під низом: конвенції викликів, кодування рядків, володіння дескрипторами і те, якій стороні дозволено звільняти який буфер
Один фасад, три бінарні форми
Кожна публічна функція TPDFlib має плаский відповідник з іменем DL плюс ім'я методу. LoadFromFile стає DLLoadFromFile, Encrypt стає DLEncrypt, NewSignProcessFromFile стає DLNewSignProcessFromFile. Першим параметром майже кожного експорту є InstanceID, який повертає DLCreateLibrary, і він заміняє собою посилання на об'єкт, яке інакше тримав би виклик з Delphi. Засвойте це відображення якомога раніше. Воно означає, що довідник Delphi API одночасно слугує документацією для будь-якої іншої мови: усе, що вміє клас, уміє й DLL під передбачуваним іменем, і ви можете прочитати сигнатуру методу Pascal, щоб дізнатися, який виклик вам потрібен із Python чи C#
Збірка для Windows видає PDFlibDLL32.dll і PDFlibDLL64.dll; оберіть той, що відповідає розрядності вашого процесу-хоста, адже 64-бітний процес Java чи .NET не завантажить 32-бітну бібліотеку, хоч би як виглядала декларація
Windows: екземпляри Stdcall і пари функцій W/A
Кожен експорт, що приймає рядок, існує у двох варіантах. Широка версія приймає PWideChar (UTF-16, природний вибір для .NET, Java та c_wchar_p у Python), а версія із суфіксом A приймає PAnsiChar. Обидві мають ідентичну семантику й різняться лише кодуванням, і саме це робить їхнє змішування настільки болісним для діагностики: нічого не кидає виключення, нічого не повертає код помилки, ви просто отримуєте спотворені символи в метаданих або оманливе «файл не знайдено» для будь-якого шляху із символом поза межами звичайного ASCII. Перша помилка кодування, на яку команда натрапляє в такий спосіб, зазвичай коштує пів дня роботи, бо симптом указує на дані, а причина — у декларації
// Прив'язка для Windows (PDFlibDLL64.dll): Stdcall, звичайні імена експортів
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: та сама функція, Cdecl, і префікс підкреслення в імені експорту
function DLCreateLibrary: Integer; cdecl;
external 'PDFlibDylib.dylib' name '_DLCreateLibrary';
Оберіть одну ширину символів на кожен хост і закодуйте це рішення в генераторі прив'язок. Практичне правило: якщо мова хоста має рідні рядки UTF-16, прив'язуйте версії W всюди й більше ніколи не чіпайте родину A
macOS: ті самі імена, інший ABI
Dylib експортує той самий набір функцій DL із двома системними змінами. Конвенція виклику — Cdecl замість Stdcall, і кожне ім'я експорту несе префікс підкреслення (_DLCreateLibrary, _DLLoadFromFile і так далі). Обидві зміни суто механічні, що робить їх ідеальними для згенерованої прив'язки й небезпечними для вручну відредагованої копії файлу Windows. Тримайте один канонічний список функцій і генеруйте з нього декларації для кожної платформи, якщо ваш інструментарій це дозволяє. Пропустите цей крок — і отримаєте те саме пошкодження стека, описане на початку цієї сторінки, яке відтворюється лише на тій платформі, яку ваш CI перевіряє найрідше
Хости COM та ActiveX: Safecall і навантаження Olevariant
Для VB.NET, C#, VBScript та застарілих хостів автоматизації збірка OCX огортає той самий фасад в об'єкт автоматизації IDispatch, IPDFlibrary, де кожен метод оголошено як Safecall. Ця конвенція змінює те, як до вас доходять помилки. Safecall перетворює внутрішній збій на COM HRESULT, тож викликач на C# перехоплює виключення там, де пласка DLL повернула б тихе ціле число, яке викликач мусив би не забути перевірити. Та сама операція, дві ідіоми збою, залежно від того, який бінарний файл ви завантажили
Бінарні дані підпорядковуються другому, специфічному для COM правилу. Інтерфейс автоматизації взагалі не має параметрів-вказівників. Усе бінарне — байти зображення на вході чи байти PDF на виході — перетинає межу як Olevariant через методи на кшталт AddImageFromVariant та AppendToVariant. Маршалінг масиву байтів у варіант — це один рядок у .NET. Спробуйте натомість передати сирий вказівник, міркуючи, що це однаково той самий процес, і шар диспетчеризації відхилить або спотворить виклик. Ще одна деталь реєстрації підводить розгортання: реєстрація COM прив'язана до розрядності, тож OCX, зареєстрований 32-бітним regsvr32, невидимий для 64-бітного хоста. Ця невідповідність проявляється як славнозвісно некорисне «клас не зареєстровано» на машині клієнта, задовго після того, як вона покинула вашу
Дисципліна дескрипторів: екземпляри володіють документами
Пласке API працює на цілочисельних дескрипторах. DLCreateLibrary повертає екземпляр. Завантаження файлу повертає ID документа всередині цього екземпляра. Процеси підписання, списки рядків і файли прямого доступу — кожен повертає власний цілочисельний дескриптор, і всі вони прив'язані до того самого екземпляра. Життєвий цикл виглядає однаково з будь-якого хоста FFI, тут показано на Pascal, бо так читається найчистіше:
var
Inst, Doc: Integer;
begin
Inst := DLCreateLibrary; // один екземпляр на кожен робочий потік
try
Doc := DLLoadFromFile(Inst, 'in.pdf', ''); // повертає DocumentID, 0 у разі невдачі
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); // звільняє всі документи, якими володіє екземпляр
end;
end;
З цього дерева володіння випливають дві речі. DLReleaseLibrary — єдиний виклик прибирання, який вам суворо потрібен, адже він одним махом руйнує кожен документ і дескриптор процесу під екземпляром. У короткому скрипті цього достатньо. У довготривалому сервісі це перетворюється на повільний витік із зайвим церемоніалом, тож звільняйте документи одразу, щойно закінчили з ними, а не дозволяйте їм накопичуватися, доки не помре сам екземпляр. Екземпляр — це також природна одиниця ізоляції потоків. Дайте кожному робочому потоку власний InstanceID і ніколи не діліться одним між потоками без зовнішнього блокування — з тієї самої причини, з якої ви ніколи не поділили б один об'єкт TPDFlib між потоками
Повернуті рядки позичені, а не належать вам
Функції, що повертають текст, наприклад DLGetPageText, віддають PWideChar чи PAnsiChar, який вказує в буфер, яким володіє та який перевикористовує екземпляр бібліотеки. Контракт такий: копіюйте негайно, ніколи не звільняйте
var
P: PWideChar;
PageText: string;
begin
P := DLGetPageText(Inst, 7); // вказівник у буфер, яким володіє бібліотека
PageText := P; // скопіюйте зараз; наступний виклик може повторно використати буфер
end;
У C# це означає маршалінг IntPtr у керований рядок до наступного виклику бібліотеки. У Python ctypes це означає негайне вирізання широкого рядка з вказівника. Утримуйте сирий вказівник між викликами — і ви написали баг, який пройде кожен модульний тест, а потім відмовить першого ж разу, коли два запити накладуться в продакшені, бо другий виклик перевикористав буфер, який перший ще читав. Те саме правило володіння діє й у зворотному напрямку для зворотних викликів, зареєстрованих через DLSetProgressCallback. Будь-який вказівник, який бібліотека передає у ваш зворотний виклик, дійсний лише в тілі цього виклику, а сам об'єкт зворотного виклику має лишатися живим (закріпленим, у хості зі збиранням сміття) стільки, скільки екземпляр може ще його викликати. Делегат, зібраний посеред завдання, — це підручниковий приклад «випадкового» порушення доступу, яке з'являється у прив'язці .NET, що місяцями працювала без збоїв
Вбудуйте димовий тест у саму прив'язку й запускайте його перед тим, як відвантажувати будь-який згенерований набір декларацій. Виконайте по одному виклику з кожної категорії, яка схильна виявляти помилки ABI: функцію без параметрів на кшталт DLCreateLibrary, щоб довести правильність конвенції, функцію з рядком на вході, якій згодовано шлях із символами поза ASCII, щоб довести правильність кодування, функцію з рядком на виході, щоб довести правильність обробки позиченого буфера, і одну операцію, що навмисно провалюється, щоб побачити, як помилка доходить до вашого хоста. Це п'ятнадцять хвилин роботи, і вона ловить помилки конвенції виклику та кодування, які інакше з'являться через місяці як дамп аварії клієнта
Випадок Python ctypes, конкретно
Python ctypes — прив'язка, яку я найчастіше бачу написаною вручну, і на ній зручно продемонструвати міжплатформний розкол. У Windows завантажуйте бібліотеку через ctypes.WinDLL, щоб ctypes застосував Stdcall, прив'язуйте функції без суфікса W і оголошуйте кожен рядковий параметр як c_wchar_p. У macOS завантажуйте її через ctypes.CDLL для Cdecl, зберігайте той самий список функцій і розв'язуйте імена без префікса підкреслення. Більшість шарів FFI, включно з ctypes, самі згортають конвенцію підкреслення назад за вас на macOS, але це те саме припущення, яке варто підтвердити одним розв'язаним викликом, перш ніж генерувати на його основі сотні декларацій
Слідом за роботою над прив'язкою йдуть два питання розгортання, і на них є чіткі відповіді. Звичайна DLL не потребує реєстрації: regsvr32 стосується лише збірки ActiveX, а DLL постачається простим копіюванням файлу, і це головна причина віддавати їй перевагу для служб Windows і контейнерів, де ви радше взагалі не чіпали б реєстр. Потокобезпека зводиться до правила, яке вже діяло вище: один екземпляр на потік. Дескриптор екземпляра тримає кожен фрагмент змінного стану, який відстежує рушій, — обраний документ, параметри рендерингу, налаштування вилучення, — тож два потоки, що ділять один екземпляр, переплутають стан один одного, навіть якщо кожен окремий виклик повертає успіх
Щойно прив'язка стає надійною, операції по той бік від неї — це саме ті, які докладно висвітлюють статті про Delphi, зокрема застосування та аудит шифрування PDF і вилучення тексту та зображень з наявних документів
Бінарні збірки для всіх трьох рівнів інтеграції постачаються разом з бібліотекою; редакції та ліцензування дивіться на сторінці продукту PDF Library for Delphi