Вот проблема, которая всплывает в тот момент, когда PDF-библиотека покидает свой родной язык. У вас есть привязка, безупречно работающая из C# на Windows. Вам нужны те же вызовы из Python на macOS, поэтому вы копируете файл объявлений для Windows, меняете имя бинарника и запускаете. Каждый символ разрешается. Первый вызов возвращает мусор, второй падает с нарушением доступа, а ваш PDF-код при этом не менялся ни на строчку. Причина на уровень ниже PDF: экспорты Windows используют соглашение Stdcall, dylib для macOS экспортирует те же функции как 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. Усвойте это соответствие как можно раньше. Оно означает, что справочник по API Delphi одновременно служит документацией для любого другого языка: всё, что умеет класс, умеет и 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. Маршалинг массива байтов в variant — это одна строка в .NET. Попробуйте вместо этого передать сырой указатель, рассуждая, что это всё равно один и тот же процесс, — и слой диспетчеризации отвергнет или испортит вызов. Ещё одна деталь регистрации сбивает с толку при развёртывании: регистрация COM привязана к разрядности, так что OCX, зарегистрированный 32-битным regsvr32, невидим для 64-битного хоста. Это несовпадение всплывает как знаменито бесполезное «class not registered» на машине клиента, спустя долгое время после того, как файл покинул вашу
Дисциплина дескрипторов: экземпляры владеют документами
Плоский 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 это означает немедленное извлечение широкой строки из указателя. Удержите сырой указатель между вызовами, и вы написали баг, который проходит все модульные тесты, а затем ломается при первом же наложении двух запросов в продакшне, потому что второй вызов переиспользовал буфер, который первый ещё читал. То же правило владения действует и в обратном направлении для callback-функций, зарегистрированных через DLSetProgressCallback. Любой указатель, который библиотека передаёт в ваш callback, действителен только на время тела этого callback, а сам объект callback обязан оставаться живым (закреплённым, в хосте со сборкой мусора) столько, сколько экземпляр может его ещё вызвать. Делегат, собранный сборщиком мусора посреди задачи, — учебниковый источник «случайного» нарушения доступа, которое появляется в привязке .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