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

Лінеаризований PDF у Delphi: таблиці підказок HotPDF

HotPDF записує лінеаризовані PDF-файли, макет, який Acrobat називає Fast Web View, через властивість LinearizeOutput на THotPDF. Встановлення її перед BeginDoc змушує HotPDF переупорядкувати завершений граф об'єктів так, щоб читач, обізнаний з байтовими діапазонами, міг показати першу сторінку, отримавши лише провідну частину файлу, замість завантаження всього документа спочатку. Механізм — це ISO 32000-1, додаток F

Причина, чому це важливо, непоказна. Звичайний PDF розміщує свою таблицю перехресних посилань у кінці, тож переглядач мусить дістатися останнього байта, перш ніж дізнатись, де щось знаходиться. Дайте браузеру 200-сторінковий скановий звіт, і користувач витріщатиметься на спінер протягом усього завантаження, хоча йому потрібна була лише сторінка 1. Лінеаризація виправляє це, сплачуючи вартість у момент запису. Ця стаття саме про цей шлях запису — розбиття на частини, цикл вимірювання та жорсткі межі; для концептуального підґрунтя того, що дає Fast Web View, раніше опубліковане пояснення лінеаризації PDF і Fast Web View охоплює цей ґрунт

Що насправді гарантує лінеаризований макет

Лінеаризований файл — це звичайний PDF з надзвичайно специфічним фізичним впорядкуванням, і кожна гарантія, яку він надає, походить від цього впорядкування, а не від якогось нового типу об'єкта. HotPDF видає частини в послідовності, яку приписує Додаток F: словник параметрів лінеаризації всередині перших 1024 байтів, рання таблиця перехресних посилань, об'єкти рівня документа, первинний потік підказок, перша сторінка та її приватні об'єкти, потім решта сторінок, потім спільні об'єкти, потім усе інше, і нарешті головна таблиця перехресних посилань

Розбиття на частини виводиться, а не декларується. HotPDF обходить граф посилань від кожного об'єкта сторінки і записує для кожного непрямого об'єкта, скільки сторінок його досягають і яка сторінка досягла його першою. Об'єкт, використаний рівно однією сторінкою, стає приватним для цієї сторінки. Об'єкт, до якого дістається більше однієї сторінки, стає спільним. Каталог, плюс усе, на що він посилається під /ViewerPreferences, /OpenAction, /Threads та /AcroForm, плюс словник шифрування, коли захист активний, утворюють групу рівня документа, яка повинна передувати всьому. Вузли дерева сторінок навмисно затримуються, щоб не забруднювати розділ першої сторінки

Словник параметрів несе числа, потрібні читачеві до того, як він щось прочитав: /L для загальної довжини файлу, /H для зсуву та довжини потоку підказок, /O для номера об'єкта першої сторінки, /E для байта, на якому завершується розділ першої сторінки, /N для кількості сторінок та /T для зсуву запису головної таблиці перехресних посилань. Кожне з цих чисел — байтовий зсув у файл, якого ще не існує в момент, коли їх потрібно записати

Чому зсуви таблиці підказок мають сходитись?

Тому що числа у словнику параметрів описують файл, який їх містить, а зміна будь-якого з них змінює файл. Це центральна складність лінеаризованого записувача, і саме тому HotPDF вимірює повторно замість того, щоб писати один раз. Розширте /T з 6 цифр до 7 — словник параметрів зростає на байт; заголовок зростає; кожен об'єкт зсувається; головна таблиця перехресних посилань переміщується; /T тепер потребує іншого значення. Макет має досягти нерухомої точки, перш ніж буде зафіксований хоч один байт реального виводу

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

CandidateMainOffset := 0;
for Attempt := 0 to 7 do
begin
  CalculateLayout(CandidateMainOffset, FirstXRefData,
    HintOffset, EndFirstPage, NewMainOffset);
  if NewMainOffset = CandidateMainOffset then
    Break;
  CandidateMainOffset := NewMainOffset;
end;
if NewMainOffset <> CandidateMainOffset then
  raise Exception.Create('Linearization layout did not converge');

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

Увімкнення з Delphi

Поверхня API — одне булеве значення, і єдина вимога — встановити його до початку генерації. LinearizeOutput за замовчуванням False, а прохід макетування виконується при записі документа, тож присвоєння його після EndDoc нічого не досягає

var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.FileName := 'fast-view.pdf';
    PDF.Version := pdf17;
    PDF.LinearizeOutput := True;      // must precede BeginDoc
    PDF.BeginDoc;
    PDF.Canvas.TextOut(72, 72, 'First page');
    PDF.EndDoc;
  finally
    PDF.Free;
  end;
end;

Одна пересторога розгортання переважає все на боці коду. Лінеаризація окупається лише тоді, коли транспорт підтримує HTTP-запити з діапазонами. Подавайте той самий файл з ендпоінту, що стрімить його цілком, або з конфігурації CDN, що ігнорує Range, — і ви купили собі повільніший шлях запису та більший файл без жодної видимої користі для користувача. Перевірте сервер, перш ніж перевіряти код

Чому лінеаризація перекриває UseXRefStream та UseObjectStreams?

Тому що лінеаризований записувач потребує, щоб кожен об'єкт мав власний прямоадресований байтовий зсув, а обидві ці функції це забирають. Тому HotPDF видає традиційні текстові таблиці перехресних посилань та розпаковані непрямі об'єкти щоразу, коли увімкнено LinearizeOutput, навіть якщо викликач також встановив UseXRefStream чи UseObjectStreams. Це навмисне перекриття, а не конфлікт, який вам треба вирішувати самостійно

Обґрунтування випливає з таблиць підказок. Таблиця підказок описує, де починається розділ сторінки і яка в нього довжина, тож читач може запросити саме цей діапазон. Об'єкт, упакований у контейнер /ObjStm, взагалі не має незалежного зсуву; він існує лише як зріз всередині іншого стисненого потоку, який має бути отриманий та розпакований цілком. Якщо ви розраховуєте на потоки об'єктів заради розміру файлу, розумійте, що лінеаризація та стискання тягнуть тут у протилежні боки, і прочитайте про цей компроміс у супутньому матеріалі про потоки об'єктів та інкрементні оновлення в HotPDF. Та сама напруга формує гібридні файли перехресних посилань, які існують саме для того, щоб старіші читачі продовжували працювати поряд з таблицями на основі потоків, як описано в статті про гібридні потоки перехресних посилань у PDF, згенерованих Office

Є також мінімальна версія. Лінеаризація вимагає PDF 1.2 або новішого. Якщо обрана версія старіша, HotPDF автоматично її підвищує, якщо тільки не встановлено StrictVersionLock, у якому разі запис піднімає виняток замість того, щоб тихо просувати документ, який ви навмисно закріпили

Стіна 4 ГіБ, і чому HotPDF відмовляється замість обрізання

Таблиці підказок лінеаризації зберігають зсуви як 32-бітні значення, тож лінеаризований файл не може адресувати нічого на 4 ГіБ чи за цією межею, і HotPDF відхиляє такий вивід з явним винятком замість запису файлу з загорнутими зсувами. Ліміт — не рішення реалізації HotPDF; це ширина полів, які визначає Додаток F

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

Виявлення лінеаризації у завантаженому файлі

THotPDF.IsLoadedLinearized повідомляє, чи завантажений наразі документ вже було записано в лінеаризованій формі, і відповідає зі знімка, зробленого до розбору, а не з живого потоку. HotPDF читає перші 1024 байти з позиції нуль вихідного потоку, сканує їх на перше ключове слово obj, а потім на запис /Linearized зі значенням 1, і кешує булевий результат

var
  PDF: THotPDF;
  PageCount: Integer;
begin
  PDF := THotPDF.Create(nil);
  try
    PageCount := PDF.LoadFromFile('incoming.pdf');
    if (PageCount > 0) and (not PDF.IsLoadedLinearized) then
      Writeln('Source is not Fast Web View ready');
  finally
    PDF.Free;
  end;
end;

Два обмеження в цьому описі несуть навантаження. Виявлення не може покладатись на позицію потоку, бо на момент, коли код застосунку ставить це питання, парсер уже її пересунув, і не може перечитати за запитом, бо LoadFromFile звільняє внутрішній вихідний потік, щойно завантаження завершено. Звідси конструкція "захопити перед розбором і закешувати". Сканування також навмисно буквальне щодо значення: приймається лише /Linearized 1 або числово еквівалентна форма з цілком нульовою дробовою частиною, бо файл, чий словник параметрів каже щось інше, не робить обіцянку Додатка F

Пастка записів Delphi, варта запозичення

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

type
  THPDFLinearIndexList = record
    Values: THPDFIntegerArray;  // managed field: cleared for you
    Count: Integer;             // plain field: whatever was on the stack
  end;

// Required, not cosmetic:
Part4 := Default(THPDFLinearIndexList);
Part6 := Default(THPDFLinearIndexList);
Part8 := Default(THPDFLinearIndexList);
Part9 := Default(THPDFLinearIndexList);

Поле динамічного масиву підраховане за посиланнями, тож компілятор його обнуляє. Count поряд з ним — звичайне ціле без такої гарантії, і неініціалізований Count надсилає найперше додавання на довільний індекс. Під Win32 слот стека випадково містив нуль, додавання потрапляло на індекс 0, і кожен тест проходив. Під Win64 той самий код писав за межі масиву. Урок узагальнюється далеко за межі лінеаризації: коли запис змішує керовані та некеровані поля, присвоюйте Default(TRecord) і припиніть міркувати про те, які поля покриває компілятор, і ніколи не сприймайте зелений прогін Win32 як доказ правильної ініціалізації

Члени LinearizeOutput та IsLoadedLinearized, описані тут, постачаються зі стандартним HotPDF Component для Delphi та C++Builder; сторінка продукту несе повну довідку властивостей, включно з правилами взаємодії з потоками перехресних посилань, потоками об'єктів та блокуванням версій