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

Линеаризованный PDF в Delphi: hint-таблицы 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 байт, ранняя таблица перекрёстных ссылок, объекты уровня документа, первичный hint-поток, первая страница и её собственные объекты, затем оставшиеся страницы, затем общие объекты, затем всё остальное, и наконец основная таблица перекрёстных ссылок

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

Словарь параметров несёт числа, нужные читалке ещё до того, как она прочла что-либо ещё: /L — общая длина файла, /H — смещение и длина hint-потока, /O — номер объекта первой страницы, /E — байт, где заканчивается секция первой страницы, /N — число страниц и /T — смещение записи основной таблицы перекрёстных ссылок. Каждое из этих значений — смещение байта в файле, которого в момент их записи ещё не существует

Почему смещения hint-таблицы должны сойтись?

Потому что числа в словаре параметров описывают файл, который их содержит, и изменение любого из них меняет файл. Это центральная сложность линеаризованного писателя, и именно поэтому HotPDF измеряет многократно вместо однократной записи. Расширьте /T с 6 цифр до 7 — и словарь параметров вырастет на байт; заголовок вырастет; всё сдвинется; основная таблица перекрёстных ссылок переместится; /T теперь нуждается в другом значении. Раскладка должна достичь неподвижной точки прежде, чем будет зафиксирован хоть один байт реального вывода

HotPDF решает это ограниченной итерацией. Сначала он сериализует каждый объект в считающий поток, который фиксирует длину, не сохраняя байты, так что у каждого объекта появляется известный сериализованный размер. Затем выполняется проход раскладки, назначающий смещения группе уровня документа, hint-потоку, группе первой страницы, группам последующих страниц, общей группе и остатку, и сообщающий, куда попала бы основная таблица перекрёстных ссылок. Этот результат подаётся обратно как вход для следующего прохода. Цикл ограничен восемью попытками, и отсутствие сходимости вызывает исключение, а не выдачу файла с правдоподобно выглядящими, но неверными смещениями

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 выполняет ещё один подтверждающий проход раскладки и перепроверяет длину hint-потока, потому что сам hint-поток кодирует смещения, которые становятся известны только после того, как раскладка устоялась. Выгода от всех этих измерений в том, что 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 range-запросы. Отдайте тот же файл с точки, что стримит его целиком, или из конфигурации CDN, игнорирующей Range, и вы получите более медленный путь записи и файл побольше без какой-либо видимой пользователю выгоды. Проверьте сервер до того, как проверять код

Почему линеаризация переопределяет UseXRefStream и UseObjectStreams?

Потому что линеаризованному писателю нужно, чтобы у каждого объекта было собственное напрямую адресуемое байтовое смещение, а обе эти возможности этого лишают. Поэтому HotPDF выдаёт традиционные текстовые таблицы перекрёстных ссылок и распакованные косвенные объекты всякий раз, когда включён LinearizeOutput, даже если вызывающий код также задал UseXRefStream или UseObjectStreams. Это намеренное переопределение, а не конфликт, который нужно разрешать самостоятельно

Обоснование следует из hint-таблиц. Hint-таблица описывает, где начинается секция страницы и какова её длина, чтобы читалка могла запросить именно этот диапазон. У объекта, упакованного в контейнер /ObjStm, вообще нет независимого смещения; он существует лишь как срез внутри другого сжатого потока, который нужно получить и распаковать целиком. Если вы рассчитывали на потоки объектов ради размера файла, учтите, что линеаризация и сжатие здесь тянут в противоположные стороны, и почитайте о компромиссе в сопутствующей статье о потоках объектов и инкрементных обновлениях в HotPDF. То же напряжение формирует гибридно-ссылочные файлы, которые существуют именно для того, чтобы старые читалки продолжали работать наряду с таблицами на основе потоков, как описано в статье о гибридных потоках перекрёстных ссылок в PDF, созданных Office

Есть и минимальный порог версии. Линеаризация требует PDF 1.2 или новее. Если выбранная версия старше, HotPDF автоматически поднимает её, если только не задан StrictVersionLock, в этом случае запись вызывает исключение вместо тихого повышения версии документа, которую вы зафиксировали намеренно

Стена в 4 ГиБ и почему HotPDF отказывает, а не усекает

Hint-таблицы линеаризации хранят смещения как 32-битные значения, поэтому линеаризованный файл не может адресовать ничего на уровне или за пределами 4 ГиБ, и HotPDF отклоняет такой вывод явным исключением, а не записывает файл с переполненными смещениями. Этот предел — не решение реализации HotPDF, а ширина полей, определённая в приложении F

Проверка применяется в трёх местах, и все три важны. HotPDF проверяет каждый объект, как только известна его сериализованная длина, проверяет длину каждой секции страницы при построении записей hint-таблицы и проверяет итоговую длину файла после того, как определён размер основной таблицы перекрёстных ссылок. Раннее выявление сбоя — вся суть: hint-таблица с молча усечённым смещением создаёт файл, который корректно открывается в читалке, загружающей его целиком, и отказывает только для клиента с диапазонами байтов, ради которого и существовала линеаризация, а это худший из возможных режимов отказа, потому что в вашем тестовом просмотрщике он никогда не воспроизводится. Если вы производите многогигабайтный вывод, линеаризация — не тот инструмент, и стоит посмотреть в сторону потокового подхода, описанного в заметках о 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; страница продукта содержит полный справочник свойств, включая правила взаимодействия с потоками перекрёстных ссылок, потоками объектов и фиксацией версии