Технічна стаття

Опційні експорти PDFium: шлюзи можливостей у Delphi

Ваш pdfium.dll завантажується нормально, а одна процедура все одно відсутня. PDFium Component справляється з цим, розділяючи свої прив'язки на два класи: обов'язкові експорти, розв'язані через CheckGetProcAddress, що повністю переривають завантаження, та опційні експорти, розв'язані через TryGetProcAddress, що залишають nil-покажчик і перевірку можливості замість цього

Це не та сама проблема, що DLL, яку не вдається знайти. Якщо ваш застосунок гине з помилкою неправильного формату EXE, відсутнім файлом чи невідповідністю архітектури, ця історія розказана в супутній статті про розгортання pdfium.dll та діагностику відмов завантаження. Тут завантажувач досяг успіху. Дескриптор модуля валідний, сотні експортів розв'язались, і прогон все одно завершується до того, як рендериться ваша перша сторінка, бо одна точка входу, що з'явилась у новішій збірці PDFium, відсутня у бінарнику на диску

Чому один відсутній експорт ламає всю бібліотеку?

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

Наслідок — це той режим відмови, що приводить сюди людей. Ви оновлюєте компонент, постачаєте той самий pdfium.dll, що постачали два роки, і застосунок не запускається. Помилка називає експорт для функції, яку ви ніколи не викликали. Ніщо, що ви робите в точці виклику, не допомагає, бо точка виклику ніколи не виконується; відмова сталась під час зв'язування, до того, як хоч якийсь документ був відкритий

PDFium Component прив'язує свою таблицю експортів Delphi одним проходом: CheckGetProcAddress перериває завантаження при відсутньому обов'язковому експорті, тоді як TryGetProcAddress безпечно деградує необов'язковий
Обов'язкові експорти прив'язуються усе-або-нічого і переривають завантаження на першому nil, тоді як опційні експорти лишають nil-вказівник за перевіркою спроможності через Assigned
function CheckGetProcAddress(const Name: string): Pointer;
begin
  Result := GetProcAddress(PDFiumLibrary, PChar(Name));
  if Result = nil then
  begin
    // Відсутній обов'язковий експорт означає, що розгорнутий pdfium.dll старіший
    // за цю збірку прив'язки. Скиньте кожен покажчик, розв'язаний досі,
    // щоб жоден викликач не міг дістатись до модуля, який ми збираємося звільнити.
    UnloadLibrary;
    raise EPdfError.Create('Required PDFium export not found: ' + Name);
  end;
end;

function TryGetProcAddress(const Name: string): Pointer;
begin
  // Опційний експорт. nil тут - легітимна відповідь; кожен викликач
  // зобов'язаний перевірити Assigned() перед розіменуванням змінної.
  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 у вашому інсталяторі рухається дискретними стрибками щоразу, коли хтось перезбирає його. Завжди є вікно, в якому паскалевий бік знає про експорти, яких розгорнутий бінарник не має, і рішення заздалегідь, на який бік лінії обов'язкового/опційного потрапляє кожен новий експорт, — єдине, що робить це вікно пережиттєвим

FPDFDoc_GetAttachmentCount    := CheckGetProcAddress('FPDFDoc_GetAttachmentCount');
FPDFDoc_AddAttachment         := CheckGetProcAddress('FPDFDoc_AddAttachment');
FPDFAttachment_GetName        := CheckGetProcAddress('FPDFAttachment_GetName');
FPDFAttachment_GetStringValue := CheckGetProcAddress('FPDFAttachment_GetStringValue');
// Опис вкладень додано після ревізії DLL, що постачається разом.
// Тримайте їх опційними, щоб старі розгортання продовжували завантажуватись.
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, де опис просто відсутній. Ніхто не помітить, доки споживач нижче за течією не запитає, куди він подівся

Відсутній експорт опису вкладення PDFium у Delphi повертає порожнє читання через TPdf.GetAttachmentDescription і піднімає виняток при записі, шлюзований AttachmentDescriptionFeaturesAvailable
Читальна сторона деградує до порожньої відповіді, записувана піднімає виняток із названою причиною, а названа проба дозволяє UI вимкнути фічу заздалегідь
function TPdf.GetAttachmentDescription(Index: Integer): WString;
begin
  CheckActive;
  Check((Index >= 0) and (Index < AttachmentCount), 'Incorrect attachment index');
  Result := '';

  // Сторона читання деградує: стара DLL не може повідомити /Desc, а ''
  // не відрізнити від вкладення, яке не несе опису.
  if not Assigned(FPDFAttachment_GetDescription) then
    Exit;
  // ... двоетапний підбір розміру буфера проти FPDFAttachment_GetDescription ...
end;

procedure TPdf.SetAttachmentDescription(Index: Integer; const Value: WString);
begin
  CheckActive;
  Check((Index >= 0) and (Index < AttachmentCount), 'Incorrect attachment index');
  // Сторона запису відмовляє: тихе відкидання значення створило б файл,
  // який, на думку викликача, несе опис, а насправді не несе.
  Check(Assigned(FPDFAttachment_SetDescription),
    'Attachment descriptions are not supported by the loaded PDFium DLL');
  // ... FPDFDoc_GetAttachment, потім FPDFAttachment_SetDescription ...
end;

Зондування можливості перед пропозицією функції

Перехоплення винятку — поганий спосіб з'ясувати, що може ваше розгортання, тож PDFium Component надає той самий тест як іменовану функцію. AttachmentDescriptionFeaturesAvailable викликає LoadLibrary і повертає, чи розв'язались обидві половини пари. Вона стоїть поряд з V8FeaturesAvailable, XfaBStrHelpersAvailable та XfaFeaturesAvailable, що слідують ідентичному патерну для власних опційних груп. Іменування зонда важливе більше, ніж здається: булеве значення на ім'я AttachmentDescriptionFeaturesAvailable повідомляє наступному супровідникові, що ця функція умовна щодо розгорнутого бінарника, чого голий тест Assigned, похований у сеттері властивості, ніколи не робить. Це також дає шару UI щось для прив'язки, тож поле редагування опису вимикається заздалегідь, а не приймає введення й відхиляє його при збереженні

procedure TAttachmentFrame.SyncCapabilities;
begin
  // Запитати один раз, на етапі налаштування форми, а не виявляти межу при збереженні.
  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 навмисно малий: він шаблонно зіставляє FPDF_EXPORT ... FPDF_CALLCONV name( по кожному заголовку в публічній директорії, шаблонно зіставляє кожен CheckGetProcAddress('Name') та TryGetProcAddress('Name') у PDFium.pas і друкує дві різниці множин: missing для експортів без прив'язки, stale для прив'язок, чий експорт більше не існує у висхідному репозиторії. Він виходить з ненульовим кодом, коли будь-яка множина непорожня, тож він вписується в крок збірки без зайвих церемоній. Поточний результат — 470 з 470 прив'язано, 0 відсутніх, 0 застарілих

Напрямок "застарілих" заробляє свою вагу так само, як і "відсутніх". Експорт, який висхідний репозиторій прибирає, залишає позаду рядок CheckGetProcAddress, що жорстко провалить кожне майбутнє завантаження, і цей вид гниття невидимий до дня, коли хтось оновить DLL. Ручний огляд знаходить функцію, про яку ви думали; він не знаходить ту, про яку ви не думали. Зауважте також, що аудит навмисно рахує обидва завантажувачі як покриття, що правильне рішення для дрейфу API і причина, чому розподіл обов'язкового/опційного має бути задокументованим рішенням, а не побічним продуктом того, хто б не додав рядок

Де опційна прив'язка перестає бути чесною

Дві межі варто прямо сформулювати, бо схему легко надуживати. Перша в тому, що nil-покажчик функції безпечний лише якщо буквально кожен шлях, що торкається його, перевіряє Assigned спершу. У модулі, що декларує сотні змінних функцій cdecl, один неохоронений виклик — порушення доступу за адресою, що нічого не означає у стек-трейсі. Та сама дисципліна, що керує угодами виклику й часом життя через межу C, застосовується тут, і це предмет статті про зміцнення прив'язки PDFium проти помилок ABI та безпеки пам'яті

Друга межа — обсяг. Опційна прив'язка — не загальна ліцензія робити все толерантним. Якби FPDF_RenderPageBitmap був опційним, компонент з радістю завантажився б, а потім зазнавав невдачі на кожній сторінці, перетворюючи одну чітку помилку запуску на розсип помилок під час виконання без очевидної причини. Обов'язковість — правильне значення за замовчуванням. Опційність — виняток, до якого ви сягаєте, коли функція справді листок, коли відсутність має захисну деградовану поведінку на боці читання, і коли бік запису може відмовити з повідомленням, що називає причину

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