Техническая статья

Опциональные экспорты PDFium: шлюзы возможностей в Delphi

Ваша pdfium.dll успешно загружается, а одна процедура всё равно отсутствует. PDFium Component справляется с этим, разделяя свои привязки на два класса: обязательные экспорты, разрешаемые через CheckGetProcAddress, которые полностью прерывают загрузку, и опциональные экспорты, разрешаемые через TryGetProcAddress, которые вместо этого оставляют нулевой указатель и проверку возможности

Это не та же проблема, что DLL, которую не удаётся найти. Если ваше приложение падает с ошибкой неверного формата EXE, отсутствующим файлом или несовпадением архитектуры, эта история рассказана в статье о развёртывании pdfium.dll и диагностике сбоев загрузки. Здесь загрузчик преуспел. Дескриптор модуля действителен, сотни экспортов разрешились, а прогон всё равно заканчивается до того, как отрендерится ваша первая страница, потому что одна точка входа, появившаяся в более новой сборке PDFium, отсутствует в бинарнике на диске

Почему один отсутствующий экспорт ломает всю библиотеку?

Потому что обязательная привязка — жёсткий контракт, и он принудительно проверяется в рамках единой последовательности связывания по принципу всё-или-ничего. PDFium Component разрешает всю свою таблицу экспортов внутри LoadLibrary, вызов CheckGetProcAddress за вызовом. Первый нулевой результат вызывает EPdfError и перед этим вызывает UnloadLibrary, и это намеренно: иначе частичное связывание оставило бы уже разрешённые указатели, нацеленные в модуль, который вот-вот будет освобождён, тихо обманывая каждую нижестоящую проверку Assigned

Следствие — тот режим сбоя, что и приводит людей сюда. Вы обновляете компонент, поставляете ту же pdfium.dll, что поставляли два года, и приложение не запускается. Ошибка называет экспорт для функции, которую вы никогда не вызывали. Ничто из того, что вы делаете в месте вызова, не помогает, потому что место вызова никогда не выполняется; сбой произошёл во время связывания, до открытия хотя бы одного документа

function CheckGetProcAddress(const Name: string): Pointer;
begin
  Result := GetProcAddress(PDFiumLibrary, PChar(Name));
  if Result = nil then
  begin
    // A missing required export means the deployed pdfium.dll is older
    // than this build of the binding. Drop every pointer resolved so far
    // so no caller can reach into the module we are about to free.
    UnloadLibrary;
    raise EPdfError.Create('Required PDFium export not found: ' + Name);
  end;
end;

function TryGetProcAddress(const Name: string): Pointer;
begin
  // Optional export. nil is a legitimate answer here; every caller is
  // required to test Assigned() before dereferencing the variable.
  Result := GetProcAddress(PDFiumLibrary, PChar(Name));
end;

Обязательно или опционально: где на самом деле проходит граница

Правило, применяемое PDFium Component, прямолинейно. Экспорт обязателен, когда его отсутствие делает компонент неспособным выполнять работу, ради которой он существует, и опционален, когда его отсутствие лишь убирает одну листовую функцию. FPDF_InitLibrary, FPDF_LoadDocument, FPDF_RenderPageBitmap, FPDF_ClosePage обязательны, и громкий отказ на них корректен: просмотрщик, который не может рендерить, — не деградировавший просмотрщик, а сломанный

Всё, что сегодня достигается через терпимый загрузчик, — это лист. FPDFBookmark_GetColor появилась после M109 и предоставляет лишь опциональный массив цвета /C записи оглавления, так что DLL, предшествующая ей, просто сообщает об отсутствии цвета закладки. Помощники V8 FPDF_GetRecommendedV8Flags и FPDF_GetArrayBufferAllocatorSharedInstance, а также строковые помощники XFA FPDF_BStr_Init, FPDF_BStr_Set и FPDF_BStr_Clear по построению отсутствуют в любой не-V8 сборке, так что трактовка их как обязательных сделала бы обычную pdfium.dll незагружаемой. И пара, вдохновившая эту статью: FPDFAttachment_SetDescription и FPDFAttachment_GetDescription, добавленные в апстриме 2026-07-13, позже даты сборки всех четырёх бинарников PDFium, что проект поставляет под DLLs/Win32 и DLLs/Win64. Этот последний случай — общая форма проблемы, а не разовый случай: слой привязки отслеживает заголовки апстрима, которые двигаются непрерывно, в то время как DLL в вашем инсталляторе двигается дискретными скачками всякий раз, когда кто-то её пересобирает. Всегда есть окно, в котором стороне на Pascal известны экспорты, которых нет у развёрнутого бинарника, и заранее решить, к какой стороне границы обязательное/опциональное относится каждый новый экспорт, — единственное, что делает это окно переживаемым

FPDFDoc_GetAttachmentCount    := CheckGetProcAddress('FPDFDoc_GetAttachmentCount');
FPDFDoc_AddAttachment         := CheckGetProcAddress('FPDFDoc_AddAttachment');
FPDFAttachment_GetName        := CheckGetProcAddress('FPDFAttachment_GetName');
FPDFAttachment_GetStringValue := CheckGetProcAddress('FPDFAttachment_GetStringValue');
// Attachment descriptions were added after the bundled DLL revision.
// Keep them optional so older deployments continue to load.
FPDFAttachment_SetDescription := TryGetProcAddress('FPDFAttachment_SetDescription');
FPDFAttachment_GetDescription := TryGetProcAddress('FPDFAttachment_GetDescription');
FPDFAttachment_SetFile        := CheckGetProcAddress('FPDFAttachment_SetFile');
FPDFAttachment_GetFile        := CheckGetProcAddress('FPDFAttachment_GetFile');

Что должен делать шлюз возможности в месте вызова?

Он должен быть асимметричен, и эта асимметрия — весь замысел. У чтения, которое не может выполниться, есть честный пустой ответ. У записи, которая не может выполниться, вообще нет честного ответа, так что она должна вызывать исключение. PDFium Component разделяет свойство описания вложения точно по этой линии, и именно это разделение не даёт отсутствующему экспорту превратиться в молчаливую потерю данных. TPdf.GetAttachmentDescription проверяет Assigned(FPDFAttachment_GetDescription) и выходит с пустой WString. Это не ложь: на DLL без этого экспорта компонент на самом деле не может сказать, несёт ли вложение запись /Desc, а пустое описание читается так же, как вложение, у которого его никогда не было. Остальной API вложений, разобранный в статье о работе с вложениями PDF в Delphi, продолжает работать без изменений

TPdf.SetAttachmentDescription идёт по противоположному пути. Она вызывает Check на той же проверке Assigned и вызывает EPdfError с текстом «Attachment descriptions are not supported by the loaded PDFium DLL». Тихий возврат здесь был бы худшим из доступных вариантов: вызывающий код задал бы описание, не получил бы ошибки, сохранил бы файл и выпустил бы PDF, где описание просто отсутствует. Никто не замечает, пока нижестоящий потребитель не спросит, куда оно делось

function TPdf.GetAttachmentDescription(Index: Integer): WString;
begin
  CheckActive;
  Check((Index >= 0) and (Index < AttachmentCount), 'Incorrect attachment index');
  Result := '';

  // Read side degrades: an old DLL cannot report /Desc, and '' is
  // indistinguishable from an attachment that carries no description.
  if not Assigned(FPDFAttachment_GetDescription) then
    Exit;
  // ... two-pass buffer sizing against FPDFAttachment_GetDescription ...
end;

procedure TPdf.SetAttachmentDescription(Index: Integer; const Value: WString);
begin
  CheckActive;
  Check((Index >= 0) and (Index < AttachmentCount), 'Incorrect attachment index');
  // Write side refuses: silently dropping the value would produce a file
  // the caller believes carries a description and does not.
  Check(Assigned(FPDFAttachment_SetDescription),
    'Attachment descriptions are not supported by the loaded PDFium DLL');
  // ... FPDFDoc_GetAttachment, then FPDFAttachment_SetDescription ...
end;

Зондирование возможности до того, как предложить функцию

Перехват исключения — плохой способ узнать, что умеет ваше развёртывание, поэтому PDFium Component предоставляет ту же проверку как именованную функцию. AttachmentDescriptionFeaturesAvailable вызывает LoadLibrary и возвращает, разрешились ли обе половины пары. Она стоит рядом с V8FeaturesAvailable, XfaBStrHelpersAvailable и XfaFeaturesAvailable, которые следуют идентичному паттерну для своих собственных опциональных групп. Название зонда значит больше, чем кажется: булев признак с именем AttachmentDescriptionFeaturesAvailable сообщает следующему сопровождающему, что эта функция условна относительно развёрнутого бинарника, чего голая проверка Assigned, зарытая в сеттере свойства, никогда не делает. Это также даёт слою UI к чему привязаться, так что поле редактирования описания отключается заранее, а не принимает ввод и отклоняет его при сохранении

procedure TAttachmentFrame.SyncCapabilities;
begin
  // Ask once, at form setup, instead of discovering the limit on save.
  DescriptionEdit.Enabled := AttachmentDescriptionFeaturesAvailable;
  if not DescriptionEdit.Enabled then
    DescriptionEdit.TextHint := 'Requires a newer pdfium.dll';
end;

procedure TAttachmentFrame.SaveDescription(Pdf: TPdf; Index: Integer);
begin
  if not AttachmentDescriptionFeaturesAvailable then
    Exit;
  Pdf.AttachmentDescription[Index] := DescriptionEdit.Text;
end;

Почему покрытие привязки нужно доказывать инструментом?

Потому что числа уже за той чертой, где человеку можно доверить их проверку. PDFium Component проверил 21 публичный заголовок PDFium против апстрим-базы от 2026-07-29 и нашёл 470 экспортируемых функций C ABI. Привязка уже покрывала 468 из них. Никто не находил этот разрыв в две функции чтением заголовков; это сделал скрипт, за секунду, и он сделает это снова при следующем апстрим-обновлении. tools/audit_pdfium_public_api.py намеренно маленький: он сопоставляет по regex FPDF_EXPORT ... FPDF_CALLCONV name( по каждому заголовку в публичной директории, сопоставляет по regex каждый CheckGetProcAddress('Name') и TryGetProcAddress('Name') в PDFium.pas и печатает разности двух множеств: missing для экспортов без привязки, stale для привязок, чей экспорт больше не существует в апстриме. Он завершается ненулевым кодом, когда любое из множеств непусто, так что он встраивается в шаг сборки без лишних церемоний. Текущий результат — 470 из 470 привязано, missing 0, stale 0

Направление stale окупает себя не меньше, чем missing. Экспорт, удалённый апстримом, оставляет позади строку CheckGetProcAddress, которая будет жёстко проваливать каждую будущую загрузку, и такой вид разложения невидим до того дня, когда кто-то обновит DLL. Ручной обзор находит функцию, о которой вы думали; он не находит ту, о которой вы не думали. Отметим также, что аудит намеренно засчитывает оба загрузчика как покрытие, что является правильным решением для дрейфа API и причиной того, почему разделение обязательное/опциональное должно быть документированным решением, а не побочным продуктом того, кто добавил строку

Где опциональная привязка перестаёт быть честной

Стоит прямо назвать две границы, потому что паттерн легко переприменить. Первая в том, что нулевой указатель на функцию безопасен только тогда, когда буквально каждый путь, касающийся его, сначала проверяет Assigned. В модуле, объявляющем сотни переменных функций cdecl, один незащищённый вызов — это нарушение доступа по адресу, который ничего не значит в трассировке стека. Та же дисциплина, что управляет соглашениями о вызовах и временем жизни через границу C, применяется и здесь, и это тема статьи об укреплении привязки PDFium против сбоев ABI и безопасности памяти

Вторая граница — область применения. Опциональная привязка — не общая лицензия делать всё терпимым. Если бы FPDF_RenderPageBitmap была опциональной, компонент бы охотно загрузился, а затем проваливался на каждой странице, превращая одну ясную ошибку запуска в разброс ошибок времени выполнения без очевидной причины. Обязательное — правильное значение по умолчанию. Опциональное — исключение, к которому вы обращаетесь, когда функция действительно является листом, когда отсутствие имеет обоснованное деградировавшее поведение на стороне чтения и когда сторона записи может отказать с сообщением, называющим причину

Описанные здесь конструкция загрузчика, зонды возможностей и инструмент аудита поставляются как часть PDFium Component для Delphi и C++Builder; страница продукта перечисляет пакетные бинарники PDFium и полную поверхность API, которую они предоставляют