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

Обробка великих PDF у Delphi за допомогою HotPDF Direct File API

Порахувати сторінки в 1,4 ГБ скановому архіві мало б бути дешево. Викличте LoadFromFile на цьому файлі — і дешевизна закінчується: HotPDF розбирає дані перехресних посилань і будує в пам'яті об'єкт для кожного з кількох сотень тисяч непрямих об'єктів документа, і 32-бітний воркер впирається у стелю адресного простору в 2 ГБ десь посередині цього розбору. Операція, яка вам була потрібна, — підрахунок сторінок — жодного з цих об'єктів узагалі не потребувала. Їй було потрібне лише дерево сторінок і нічого більше. Саме цей розрив між тим, що просить завдання, і тим, що дає повне завантаження, — уся причина існування Direct File API

Direct File API дає Delphi та C++Builder доступ до PDF на рівні файлу: підрахунок сторінок, копіювання, дешифрування, інкрементні дописування — усе це читає з диска саме те, що потрібно, а не відновлює всю модель документа в оперативній пам'яті. Уміння тут полягає в тому, щоб підібрати для кожного завдання найлегший рівень, здатний на нього відповісти. Підберіть правильно — і сервіс тримає плоску пам'ять для входів будь-якого розміру. Помилися — і перший же завеликий файл покладе воркер

Діаграма маршрутизації завдань HotPDF Direct File API в Delphi: проби дескрипторів лише для читання, копіювання й шифрування цілого файлу, інкрементні додавання та повні завантаження в пам'ять, ранжовані за профілем пам'яті
Зіставляйте кожну операцію з найлегшим ярусом, здатним на неї відповісти, тримаючи резидентну пам'ять рівною незалежно від розміру входу

У що вам обходиться повне завантаження

LoadFromFile — не ворог. Він відпрацьовує свою пам'ять: щойно дерево в RAM, ви отримуєте довільний доступ до кожної сторінки й кожного об'єкта, що є саме тим, чого вимагають InsertPagesFromDocument, MovePage та пересеріалізація через SaveLoadedDocument. Для справжнього перевпорядкування немає короткого шляху; щоб переставити документ, його треба тримати цілком

Проблеми починаються, коли розмір вхідних файлів вам не підконтрольний. Завантаження клієнтів, вивід сканерів і архіви десятирічної давнини ігнорують усе, що припускав ваш тестовий корпус. Завантажуйте кожен вхід безумовно — і стелю пам'яті визначить єдиний найбільший файл, який хтось коли-небудь надішле. Час розбору залежить від кількості об'єктів, а резидентна пам'ять осідає в кілька разів більшою за розмір файлу, коли враховано структури об'єктів і декодовані потоки, тож гігабайт на диску може означати кілька гігабайтів у резидентній пам'яті

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

Читання файлу через дескриптор

Рівень «лише читання» відкриває файл як дескриптор, відповідає на структурні питання про нього і закриває його. Жодного дерева об'єктів, жодного рендерингу сторінок, жодної пам'яті, що росте разом із вхідними даними

var
  Pdf: THotPDF;
  Handle, PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Handle := Pdf.DAOpenFileReadOnly('archive-2026-06.pdf', '');
    if Handle > 0 then
    try
      PageCount := Pdf.DAGetPageCount(Handle);
      RouteByPageCount('archive-2026-06.pdf', PageCount);
    finally
      Pdf.DACloseFile(Handle);
    end;
  finally
    Pdf.Free;
  end;
end;

Три звички тримають цей рівень чесним. По-перше, перевіряйте значення, що повертається. Недодатний дескриптор означає, що відкриття не вдалося, а виклик DAGetPageCount на мертвому дескрипторі — це той тип помилки, що лишається прихованим аж до дня, коли клієнт надішле пошкоджений файл. По-друге, пов'язуйте кожне успішне відкриття з DACloseFile усередині блоку finally; сервіс, що витікає дескрипторами, не падає — він просто гниє, а це гірше. По-третє, зважайте на те, що насправді робить параметр пароля. DAOpenFileReadOnly приймає його, але для зашифрованих вхідних даних тихо відкочується до повного розбору, щоб прочитати кількість сторінок, тож гарантія плоскої пам'яті випаровується. Спершу проведіть захищені файли через DecryptFile, і решта конвеєра лишиться дешевою

Той самий зонд слугує й воротами сортування. Файли трапляються з неправильним підписом, недовантажені або перейменовані з якогось зовсім іншого формату, і перевірка DAOpenFileReadOnly відхиляє все це просто на вході за мілісекунди, прив'язуючи помилку до конкретного файлу-порушника. Альтернатива — дозволити сміттєвому файлу заїхати глибоко в воркер черги й вибухнути там, де розплутування того, який саме вхід це спричинив, може коштувати цілого дня

Копіювання, дешифрування та шифрування цілих файлів

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

// Структурне копіювання: перевірити й перемістити без розбору дерева об'єктів
Status := Pdf.DACopyFile('incoming\statement.pdf', 'verified\statement.pdf');
LogDirectFileStatus('copy', Status);

// Дешифрувати під час копіювання: маршрут Direct File для захищених вхідних даних
Status := Pdf.DecryptFile('incoming\protected.pdf',
  'verified\plain.pdf', 'batch-password');
LogDirectFileStatus('decrypt-copy', Status);

// Зашифрувати під час копіювання: захистити вивід без повного завантаження
Status := Pdf.EncryptFile('verified\statement.pdf',
  'outbound\statement.pdf', 'owner-secret', '', aes256, [prPrint]);
LogDirectFileStatus('encrypt-copy', Status);

Кожен виклик відпрацьовує своє місце. DACopyFile — це перевірене копіювання з карантинного каталогу в керуване сховище: він відкриває та індексує структуру PDF на льоту, тож усічений або не-PDF вхід провалюється просто тут, а не на три етапи нижче за конвеєром. DecryptFile записує розшифровану копію прямим шляхом перезапису AES-256, що пропускає дерево об'єктів, коли це дозволяють вхідні дані, — аналог для великих файлів потоку дешифрування «завантажити-й-перезберегти», розглянутого в статті про шифрування AES-256. EncryptFile виконує той самий рух у зворотному напрямку, застосовуючи захист паролем під час копіювання на рівні файлу з тими самими параметрами типу ключа й дозволів, які вже використовує шлях у пам'яті

Дописування змін замість переписування

Інкрементне оновлення, визначене в ISO 32000-1 §7.5.6, — це третій рівень. Початкові байти лишаються там, де вони є на диску, а будь-які нові чи змінені об'єкти дописуються після них, а за ними йде новий розділ перехресних посилань, що прив'язується назад до оригіналу. Для 900-мегабайтного архіву, до якого потрібно додати одну сторінку, вартість запису — це лише дельта, а не весь файл

Анатомія інкрементного оновлення PDF від HotPDF: оригінальні байти залишаються недоторканими, додаються дельта нових об'єктів і зчеплена секція перехресних посилань, а попередні ревізії залишаються відновлюваними, доки повне переписування SaveLoadedDocument їх не відкине
Інкрементні збереження дописують лише свою дельту і зберігають попередні ревізії, тож ущільнення — це окремий обдуманий перезапис
// Додати сторінку аудиту до великого архіву без його переписування
Pdf.BeginIncrementalUpdate('archive-2026-06.pdf');
Pdf.AddPage;
Pdf.CurrentPage.SetFont('Arial', [], 10);
Pdf.CurrentPage.TextOut(50, 760, 0, 'Processed by intake service 2026-06-11');
Pdf.SaveIncrementalUpdate('archive-2026-06-stamped.pdf');  // оригінальні байти + дельта

Тут важливі дві дисципліни. BeginIncrementalUpdate має вказувати на оригінальний файл, оскільки дописані дані перехресних посилань прив'язуються назад до зсувів байтів усередині нього. І модель за задумом лише дописує: кожне інкрементне збереження збільшує файл і ніколи його не зменшує. Документ, який щоночі отримує штамп, розбухатиме без обмежень, аж доки періодична пересеріалізація — завантаження й запис назад через SaveLoadedDocument — не ущільнить його. Саме ця природа «лише дописування» робить інкрементне оновлення єдиним безпечним способом торкнутися документа з цифровим підписом — обмеження, розглянуте в статті про цифрові підписи та PAdES. Механізм перехресних посилань, що лежить в основі, розглянуто окремо в статті про потоки об'єктів та інкрементні оновлення

У збереженнях «лише дописування» є пастка, яка вислизає від більшості перевірок. Початкові байти лишаються у файлі, доступні для читання будь-кому, хто захоче подивитися. Інкрементне оновлення, що "замінює" сторінку, не видаляє стару; воно лише витісняє її в поточній ревізії, тоді як попередня ревізія залишається на місці, повністю відновлювана. Тож інкрементні оновлення — неправильний інструмент для видалення конфіденційного вмісту. Щоб справді позбутися історії, яку одержувач не повинен побачити, потрібна повна пересеріалізація: LoadFromFile, а потім SaveLoadedDocument, який записує лише поточний стан і залишає поховані ревізії позаду

Підбір рівня під операцію

Логіка вибору достатньо коротка, щоб тримати її в голові, і варто закодувати її як явне рішення про маршрутизацію на самому початку конвеєра, а не дозволяти кожному завданню імпровізувати власний шлях. Рівень визначає операція, яка вам потрібна:

  • Підрахунок, огляд або класифікація відкриває дескриптор: DAOpenFileReadOnly, DAGetPageCount, DACloseFile
  • Переміщення, дешифрування або шифрування цілого файлу залишається на рівні файлу з DACopyFile, DecryptFile або EncryptFile
  • Перевпорядкування сторінок або злиття документів потребує повного завантаження: LoadFromFile, потім InsertPagesFromDocument або MovePage, потім SaveLoadedDocument
  • Додавання невеликої дельти до величезного або підписаного файлу викликає BeginIncrementalUpdate і зберігає

Змішаним конвеєрам корисно поставити порогове значення розміру перед шляхом повного завантаження. Усе, що перевищує кілька сотень мегабайтів, направляйте через рівні Direct File, а повне завантаження резервуйте для справжнього перевпорядкування на 64-бітному воркері з реальним бюджетом пам'яті. Поріг перетворює аварію через брак пам'яті на рішення про маршрутизацію, яке можна побачити й налаштувати

Незалежно від того, який рівень обробляє завдання, пишіть його вивід під тимчасовим іменем і перейменовуйте на місце лише після того, як результат пройде перевірку. Наполовину записаний файл під кінцевим іменем для наступного етапу конвеєра виглядає точнісінько як добрий, а виклики Direct File роблять перевірку дешевою: підтвердження виводу — це однорядковий зонд дескриптора

Direct File API постачається як частина HotPDF Delphi Component для Delphi та C++Builder. Сторінка продукту містить посилання на повний довідник функцій, зокрема на показані тут виклики інкрементного оновлення