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

Об'єднання та розділення гігабайтних PDF у Delphi через PDF Library for Delphi Direct Access

Об'єднання або розділення двогігабайтного PDF очевидним способом коштує вам одразу двох речей: часу виконання та адресного простору. Очевидний спосіб — завантажити кожен вхідний файл, виконати роботу, записати результат. Саме на завантаженні все й ламається. Архів сканів, що переходить із 300 на 600 DPI, подвоює лінійну роздільну здатність і приблизно вчетверо збільшується на диску, тож те саме завдання складання, яке весь рік справлялося з файлами по 400 МБ, починає захлинатися, щойно вхідний файл перевищує гігабайт, і часто це стається на етапі, де не робиться нічого складнішого за підрахунок сторінок. Складність завдання не зросла. Відкрити, порахувати, вибрати діапазони, з'єднати — це вся суть. Завантаження всього дерева просто перестало бути розумним варіантом за замовчуванням при такому розмірі. PDF Library for Delphi, PDF-бібліотека losLab для Delphi та C++Builder, відповідає на це шаром Direct Access: набором функцій із префіксом DA, що спираються на потоковий читач, який проходить таблицю перехресних посилань на місці, замість того щоб будувати весь документ у пам'яті

Куди йде пам'ять при повному завантаженні

«Звичайне» завантаження PDF означає розбір xref, перетворення кожного непрямого об'єкта на дерево в пам'яті, декодування потоків об'єктів і збирання дерева сторінок, шрифтів та анотацій в об'єкти, якими можна маніпулювати. Для сценаріїв редагування це правильний компроміс. Для об'єднання, розділення та інспекції це здебільшого марнотратство. Архів сканів на 30 000 сторінок може містити мільйони непрямих об'єктів, а завданню розділення потрібно прочитати лише кілька сотень із них: вузли сторінок у запитаному діапазоні плюс усе, на що ці вузли посилаються

Шар Direct Access перевертає цю модель. DAOpenFile і DAOpenFileReadOnly розбирають трейлер і xref — лише кілька кілобайтів у хвості файлу — і повертають дескриптор файлу. Об'єкти вибираються лениво, тоді, коли їх запитує виклик. Практичний наслідок такий: відкриття багатогігабайтного файлу займає приблизно стільки ж часу, скільки й відкриття маленького, а пам'ять відстежує лише те, до чого ви торкнулися, а не те, що містить файл

Порівняння в PDF Library for Delphi: завантаження гігабайтного PDF у повне дерево об'єктів у пам'яті проти відкриття з прямим доступом, де розбір зупиняється на трейлері та xref, а дескриптор обслуговує ліниві пооб'єктні читання
Повне завантаження декодує кожен непрямий об'єкт ще до початку злиття, тож RAM і час відкриття масштабуються з архівом. Шлях прямого доступу повертає робочий хендл після зчитування кілобайтів і дозволяє кожному виклику підтягнути лише потрібні об'єкти

Як обстежити величезний файл, не завантажуючи його

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

var
  Lib: TPDFlib;
  Handle, Pages: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Handle := Lib.DAOpenFileReadOnly('archive-2025.pdf', '');
    if Handle = 0 then
      raise Exception.Create('Direct access open failed');
    Pages := Lib.DAGetPageCount(Handle);
    Writeln('pages : ', Pages);
    Writeln('title : ', Lib.DAGetInformation(Handle, 'Title'));
    Lib.DACloseFile(Handle);
  finally
    Lib.Free;
  end;
end;

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

PageRef — це дескриптор об'єкта, а не номер сторінки

Найпоширеніша помилка з API DA — передати номер сторінки там, де функція очікує PageRef. Майже кожен виклик DA для окремої сторінки приймає дескриптор-посилання на об'єкт сторінки, а не номер сторінки: DAExtractPageText, DARenderPageToFile, DARotatePage і DACapturePage — усі очікують ref. Отримати його можна, перетворивши зрозумілий людині номер через DAFindPage:

PDF Library for Delphi: потік переведення номера сторінки в PageRef: DAFindPage живить по-сторінкові виклики прямого доступу, проти сирого цілого ref, що потрапляє на довільний об'єкт і мовчки дає текст неправильної сторінки
Кожен виклик прямого доступу до сторінки споживає PageRef, вироблений DAFindPage, а не номер, видимий людині. Пропуск цього перетворення змушує ціле число видавати себе за id об'єкта, і текст не тієї сторінки може непомітно піти до клієнта
PageRef := Lib.DAFindPage(Handle, 250);          // номер сторінки -> дескриптор об'єкта
if PageRef <> 0 then
begin
  Text := Lib.DAExtractPageText(Handle, PageRef, 0);
  Lib.DARenderPageToFile(Handle, PageRef, 5, 150, 'page250.png');
end;

Якщо натомість передати сире число 250, помилки не виникне. Виклик звернеться до того об'єкта, який випадково опиниться за цим значенням дескриптора, і в кращому разі це впаде з видимою помилкою, а в гіршому — витягне текст із неправильної сторінки в документ, що йде клієнту. Якщо ви обгортаєте шар DA власним сервісним кодом, зробіть цей переклад неможливим пропустити: приймайте номери сторінок лише на межі, одразу викликайте DAFindPage і передавайте всередині лише refs

Об'єднання сотень файлів через іменований список

Для двох файлів достатньо MergeFiles(First, Second, Output). Пакетне складання масштабується краще через списки файлів: зареєструйте вхідні файли під іменем списку, а тоді об'єднайте список за один прохід

PDF Library for Delphi: робочий процес іменованого списку файлів: виписки січня, лютого і березня реєструються під одним іменем списку та зливаються одним проходом; варіанти Fast, стандартний і строгий міняють збереження дерева структури на швидкість
Сотні зареєстрованих входів стискаються в один прохід MergeFileList, чий результат перевіряється за мілісекунди через ще одну read-only пробу. Варіант — рішення на рівні кожного конвеєра, адже Fast відкидає дерево структури Tagged PDF
Lib.AddToFileList('Statements', 'jan.pdf');
Lib.AddToFileList('Statements', 'feb.pdf');
Lib.AddToFileList('Statements', 'mar.pdf');
Lib.MergeFileList('Statements', 'q1-statements.pdf');

// Перевірте результат дешевим способом: знову прямий доступ
Handle := Lib.DAOpenFileReadOnly('q1-statements.pdf', '');
Writeln('merged pages: ', Lib.DAGetPageCount(Handle));
Lib.DACloseFile(Handle);

Родина функцій об'єднання має три варіанти, і різниця тут не лише в швидкості. MergeFileListFast пропускає збереження дерева структури; MergeFileListStrict примусово вмикає суворий режим; версія без суфікса — це збалансований варіант за замовчуванням. Звідси випливає практичне правило: якщо серед вхідних файлів є Tagged PDF, чия структура доступності має вціліти, а найочевидніший приклад — усе, що готується для PDF/UA, беріть варіант за замовчуванням або Strict, бо Fast мовчки відкидає дерево структури. Для звичайних архівів сканів без тегування Fast дає продуктивність задарма. Обирайте варіант на рівні конвеєра, а не на настрій розробника, і фіксуйте застосований варіант у журналі завдання

Розділення без завантаження: вилучення діапазонів

Розділення дотримується тієї самої філософії без завантаження. ExtractFilePages(InputFileName, Password, OutputFileName, RangeList) витягує діапазон сторінок безпосередньо з файлу у файл, зі списком діапазонів на кшталт '1-500', '501-1000' або вибірок через кому, і джерело жодного разу не перетворюється на дерево документа. Коли документ уже завантажено з інших причин, ExtractPageRanges створює новий документ у пам'яті на основі поточного, а CopyPageRanges переносить діапазони з іншого завантаженого документа за ID. Для розділення консолідованих потоків друку по окремих виписках саме форма «файл у файл» не дає чотиригігабайтному вхідному файлу хоч колись роздутися в оперативній пам'яті

Файли, які брешуть про власну геометрію

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

Перша — зсунуті заголовки. Поштові шлюзи та спулери друку іноді дописують байти перед PDF, тож маркер %PDF уже не сидить на зсуві 0, і кожен зсув xref у файлі виявляється неправильним рівно на ту саму величину. Потоковий читач виявляє це й розкриває (DAShiftedHeader на пласкому рівні, ShiftedHeader у TSmartPDFReader), а потім компенсує це під час читання. Саморобна арифметика зі зсувами зазвичай цього не робить, тому класичний симптом звучить як «працює на кожному файлі, який генеруємо ми, але падає на файлах від клієнта X»

Друга — пошкоджені таблиці перехресних посилань. DACopyFile(InputFileName, OutputFileName, PageCount) перепотоковує весь файл у нову копію, перебудовуючи xref, і повертає кількість сторінок як побічний результат. Запуск його як етапу нормалізації перед вибагливим споживачем нижче за потоком перетворює цілий клас непередбачуваних збоїв розбору на один передбачуваний крок ремонту. А коли потрібно зберегти власні правки, DAAppendFile записує їх як інкрементне оновлення, дописуючи нову ревізію замість перезапису гігабайтів, що тримає вартість збереження пропорційною самій зміні, а не розміру файлу

Деталі доставлення: лінеаризація та композиція

Дві суміжні можливості довершують конвеєр для великих файлів. Коли зібраний результат віддається через HTTP для перегляду в браузері, LinearizeFile перевпорядковує його для потокового передавання за діапазонами байтів, тож перша сторінка відображається ще до того, як завершиться завантаження решти 500-мегабайтного пакета. Запускайте це на фінальному етапі, після всього об'єднання, бо будь-яка подальша зміна знову делінеаризує файл. А коли пакети потребують саме композиції, а не простого з'єднання — скажімо, титульний аркуш, що ставиться позаду кожної виписки, або дві вихідні сторінки, накладені на один вихідний аркуш — DACapturePage перетворює будь-яку сторінку на придатний для повторного використання шаблон, який DADrawCapturedPage розміщує на цільовій сторінці в довільному прямокутнику, і все ще без повного завантаження документа для багатогігабайтного джерела

Обмеження і що залишається лише для читання

Самому формату бракує простору задовго до того, як його забракне Direct Access. Зсуви мають тип Int64 на всьому протязі шару DA, тож справжні стелі — це доступний диск і 10-розрядне поле зсуву xref класичних (непотокових) таблиць перехресних посилань. Багатогігабайтні архіви сканів на практиці виглядають цілком буденно, а пам'ять залишається обмеженою незалежно від розміру файлу, бо об'єкти читаються лише тоді, коли їх запитує виклик

Два запитання виникають достатньо часто, щоб відповісти на них прямо. Об'єднання за замовчуванням переносить структуру документа, тож закладки й посилання виживають; саме варіант Fast обмінює дерево структури на швидкість, і це вся причина берегти його для нетегованих вхідних файлів. Безпечна звичка — відкрити об'єднаний результат, пройтися його оглядом і вибірково перевірити кілька внутрішніх посилань перед тим, як його відвантажити. Щодо редагування: існує корисна золота середина між обстеженням лише для читання й повним завантаженням. Операції на рівні сторінки працюють прямо з дескриптором — серед них DARotatePage, DAMovePage і DAHidePage, а також читання полів форм — і DAAppendFile зберігає ці правки як інкрементну ревізію. Редагування на рівні вмісту, тобто все, що переписує операнди позначення всередині сторінки, і далі належить шару повного документа

Пов'язані статті

Якщо ваш об'єднаний результат повинен залишатися доступним, тло про дерево структури розкрито в статті про доступність Tagged PDF, яка пояснює саме те, що варіант об'єднання Fast відкине. Про вилучення вмісту з діапазонів, які ви розділили, читайте в посібнику з вилучення тексту, зображень і шрифтів

Повний перелік функцій Direct Access постачається разом із бібліотекою; редакції та пробні завантаження — на сторінці продукту PDF Library for Delphi