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

Линеаризованный PDF в Delphi: hint-таблицы HotPDF

Первый объект линеаризованного PDF обязан называть точную длину файла, в котором лежит, до байта, и обязан уложиться в первые 1024 байта. ISO 32000-1 в приложении F называет эту запись /L, а читалке, обнаружившей расхождение с реальным размером файла, предписано обращаться с документом как с обычным PDF и игнорировать все линеаризационные подсказки. Поэтому писатель должен напечатать длину файла, который ещё не дописал, — рядом со смещением hint-потока, концом первой страницы и положением таблицы перекрёстных ссылок ближе к концу файла. Все четыре числа сдвигаются, как только что-нибудь перед ними меняет размер

Плата за это — Fast Web View. Обычный PDF держит таблицу перекрёстных ссылок в конце, поэтому браузер, получивший 200-страничный отсканированный отчёт, не найдёт страницу 1, пока не придёт последний байт; линеаризованный файл выносит страницу 1 и небольшую секцию перекрёстных ссылок вперёд, и клиент с HTTP-запросами диапазонов рисует первую страницу рано. Дальше — сторона записи. Картина со стороны чтения — в объяснении линеаризации PDF и Fast Web View

Как HotPDF раскладывает линеаризованный файл

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

Диаграмма байтового порядка линеаризованного PDF, записанного HotPDF в Delphi: от словаря параметров линеаризации и первичного hint-потока вниз до главной таблицы перекрёстных ссылок
HotPDF выдаёт части приложения F в фиксированном байтовом порядке, причём словарь параметров и первичный hint-поток лежат достаточно рано, чтобы читалки диапазонов байтов почти немедленно начали отрисовку первой страницы

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

Откройте вывод в hex-редакторе — и нумерация покажется вывернутой. Передний блок, от словаря линеаризации до hint-потока, несёт самые старшие номера объектов в файле, а всё после него нумеруется с 1. Маленькая секция перекрёстных ссылок спереди покрывает ровно этот старший блок, а главная таблица — от 1 до N

Ловушка инициализации записей в списках разбиения

Такие списки естественно пишутся как запись с динамическим массивом плюс отдельный счётчик, и эта форма прячет ловушку Delphi. Компилятор инициализирует управляемые поля локальной записи. Обычный Integer рядом с ними не получает ничего. Под Win32 слот стека часто случайно содержит ноль, так что первая вставка попадает в индекс 0 и все тесты проходят; под Win64 в слоте может лежать мусор, и первая вставка пишет в произвольный индекс

type
  TPartList = record
    Values: TArray<Integer>;  // управляемое: обнуляется компилятором
    Count: Integer;           // неуправляемое: что было в стеке, то и осталось
  end;

procedure Append(var List: TPartList; Value: Integer);
begin
  if List.Count = Length(List.Values) then
    SetLength(List.Values, List.Count * 2 + 16);
  List.Values[List.Count] := Value;
  Inc(List.Count);
end;

procedure BuildParts;
var
  FirstPage, Shared: TPartList;
begin
  FirstPage := Default(TPartList);   // обязательно, не косметика
  Shared := Default(TPartList);
  Append(FirstPage, 12);
  Append(Shared, 40);
end;

Писатель HotPDF присваивает Default каждому списку разбиения до первого касания. Зелёный прогон под Win32 ничего не говорит о том, была ли инициализирована такая запись

Почему раскладка обязана сойтись до записи первого байта?

Потому что передняя часть файла держит смещения, указывающие на заднюю, и число цифр в этих смещениях сдвигает всё, что позади. Трейлер первой страницы несёт запись /Prev с байтовым смещением главной таблицы перекрёстных ссылок. Напишите это смещение как 99998, потом обнаружьте, что таблица реально легла на 100003, — и трейлер вырастает на одну цифру. Секция перекрёстных ссылок первой страницы растёт вместе с ним, каждый объект позади сдвигается на байт, и главная таблица теперь сидит на 100004. Один проход угадывает это только по счастливой случайности

HotPDF разрывает круг двумя ходами. Словарь параметров кладётся в фиксированный слот в 384 байта, добитый пробелами, так что /L, /H, /E и /T берут сколько угодно цифр, не сдвигая ни одного объекта; понадобься тексту словаря больше 384 байт, запись подняла бы исключение, а не двигала файл. Остаётся /Prev — единственная свободная переменная, и она прогоняется через цикл неподвижной точки. Каждый объект сначала сериализуется в считающий поток, который запоминает длины и не хранит байтов. Проход раскладки затем назначает смещения секция за секцией и сообщает, куда легла бы главная таблица, и этот ответ становится входом следующего прохода. Цикл останавливается, когда предложенное смещение перестаёт двигаться, и сдаётся исключением после восьми попыток

Блок-схема цикла сходимости линеаризованной раскладки HotPDF в Delphi: предложенные смещения перекрёстных ссылок замеряются снова и снова, пока не достигнут неподвижной точки или исключение не прервёт запись
До восьми измерительных проходов сходят смещение главной таблицы перекрёстных ссылок до фиксации хоть одного байта; несходимость поднимает исключение вместо записи угаданных смещений

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

Hint-таблицы и места, где приложение F читается двояко

Первичный hint-поток несёт две таблицы. Hint-таблица смещений страниц записывает для каждой страницы, сколько объектов держит её секция, какова длина секции в байтах и от каких общих объектов она зависит. Hint-таблица общих объектов перечисляет длину каждой группы общих объектов. Имея обе, читалка может запросить страницу 37 напрямую, а запись /S потока даёт позицию внутри потока, где начинается таблица общих объектов

Две детали приложения F, раздел F.4, легко испортить. Первая: смещения в hint-таблицах — логические, посчитанные так, будто первичного hint-потока не существует. Объект первой страницы и первый общий объект физически лежат после hint-потока, поэтому писатель вычитает длину hint-объекта, прежде чем записывать их смещения. Писатель, записывающий физические смещения, уводит каждый последующий range-запрос ровно на размер hint-объекта

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

Включение линеаризованного вывода из Delphi

На стороне API переключатель — один Boolean. LinearizeOutput по умолчанию False, и писатель сверяется с ним, когда EndDoc сериализует документ, так что задайте его вместе с прочими опциями вывода до BeginDoc и не трогайте дальше. Каждый описанный выше отказ (несходимость, слишком большой словарь параметров, несовпадение на границе, потолок 4 ГиБ, разобранный ниже) всплывает исключением из EndDoc, что делает запасной путь простым

procedure WriteReport(const FileName: string; Linearize: Boolean);
var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.FileName := FileName;
    PDF.Version := pdf17;
    PDF.LinearizeOutput := Linearize;   // задаётся до BeginDoc
    PDF.BeginDoc;                       // создаёт страницу 1
    PDF.CurrentPage.TextOut(72, 72, 0, 'Quarterly summary');
    PDF.AddPage;
    PDF.CurrentPage.TextOut(72, 72, 0, 'Details');
    PDF.EndDoc;                         // здесь происходят раскладка и запись
  finally
    PDF.Free;
  end;
end;

procedure PublishReport(const FileName: string);
begin
  try
    WriteReport(FileName, True);
  except
    on E: Exception do
    begin
      LogWarning('Linearization failed: ' + E.Message);  // ваш собственный логгер
      WriteReport(FileName, False);                      // вместо него обычный PDF
    end;
  end;
end;

Запасной путь даёт обычный PDF, который открывает любой просмотрщик; теряется только быстрая первая страница. Для конвейера отчётов размен очевиден: медленная первая отрисовка — раздражение, а отсутствие отчёта — инцидент

Сторона развёртывания важнее кода. Линеаризация окупается, только когда сервер чтит HTTP-запросы Range; эндпоинт, отдающий файл целиком, покупает вам более медленную запись и больший файл безо всякой выгоды. Держите LinearizeOutput выключенным для файлов, которые уходят по почте, вложениями или скачиваются до того, как их кто-то откроет. Включайте для документов, которые браузер открывает прямо по URL, — после проверки, что ответ несёт Accept-Ranges: bytes

Почему LinearizeOutput выключает потоки объектов?

Потому что каждая запись hint-таблицы описывает диапазон байтов, а объект, упакованный в контейнер /ObjStm, собственного диапазона не имеет: он существует лишь как срез другого сжатого потока, который надо получить и распаковать целиком. Со включённым LinearizeOutput HotPDF пишет традиционные текстовые таблицы перекрёстных ссылок и распакованные косвенные объекты, даже если задан ещё и UseXRefStream или UseObjectStreams. Линеаризованный путь главенствует при сохранении, так что вашему коду нечего разрешать

Цена — размер файла: маленькие словари, которые поток объектов сжал бы вместе, теперь остаются россыпью. Если компактный вывод важнее задержки первой страницы, компромисс разобран в потоках объектов и инкрементальных обновлениях HotPDF. Office-экспортёры упираются в то же напряжение с другой стороны, поэтому некоторые из них выдают раскладку, описанную в гибридных потоках перекрёстных ссылок в PDF из Office

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

Потолок 4 ГиБ для линеаризованного вывода

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

Диаграмма трёх контрольных точек HotPDF, сверяющих размеры объектов, длины секций страниц и итоговую длину файла с лимитом 4 ГиБ 32-битных полей hint-таблиц линеаризации в Delphi
Измерения объекта, секции страницы и целого файла каждое сверяются с 32-битным потолком hint-полей до порождения вывода

Проверка срабатывает в трёх точках, все — до того, как первый байт дойдёт до приёмника: на каждом объекте, как только известна его сериализованная длина; на длине каждой секции страницы, пока строятся hint-записи; и на итоговой длине файла, когда размер главной таблицы перекрёстных ссылок определён. Завернувшиеся смещения — худший из способов отказа: просмотрщик, скачивающий файл целиком, может игнорировать подсказки, а клиент диапазонов прыгнет на чужие байты, поэтому настольное тестирование этот отказ никогда не воспроизводит. Для многогигабайтного вывода смотрите в сторону потокового маршрута из Direct File API для больших PDF-конвейеров

Проверка, линеаризован ли входящий файл

THotPDF.IsLoadedLinearized сообщает, объявляет ли загруженный документ себя линеаризованным. Проверка происходит до разбора: HotPDF читает до 1024 байтов с нулевой позиции источника, находит первый объект и ищет внутри него /Linearized со значением 1. Комментарии, литеральные и hex-строки пропускаются, так что имя внутри строки не считается, а сканирование останавливается на stream или endobj. Принимается только 1 или эквивалент вроде 1.0

Ответ кэшируется при загрузке, потому что к моменту вопроса прикладного кода парсер уже сдвинул позицию потока, а LoadFromFile уже освободил свой исходный поток

Флаг сообщает то, что заявляет файл. Приложение F добавляет второе условие: /L должен совпадать с фактической длиной файла, а инкрементальное обновление, дописанное после линеаризации, это совпадение ломает, так что читалки откатываются к обращению с файлом как с обычным. Конвейер, принимающий файлы от редакторов, дописывающих свои сохранения, может сравнить оба числа сам

// Ваш собственный помощник, не часть HotPDF: сверяет /L с реальным размером
// Упрощённый разбор; предполагает одиночный пробел после /L
function DeclaredLengthMatches(const FileName: string): Boolean;
var
  FS: TFileStream;
  Head: AnsiString;
  ActualSize: Int64;
  P, Q: Integer;
begin
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    ActualSize := FS.Size;
    SetLength(Head, 1024);
    SetLength(Head, FS.Read(Head[1], 1024));
  finally
    FS.Free;
  end;
  P := Pos(AnsiString('/L '), Head);
  Result := P > 0;
  if not Result then
    Exit;
  Inc(P, 3);
  Q := P;
  while (Q <= Length(Head)) and (Head[Q] in ['0'..'9']) do
    Inc(Q);
  Result := StrToInt64Def(string(Copy(Head, P, Q - P)), -1) = ActualSize;
end;

procedure InspectUpload(const FileName: string);
var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    if PDF.LoadFromFile(FileName) <= 0 then
      Exit;
    if not PDF.IsLoadedLinearized then
      Writeln('Not linearized')
    else if not DeclaredLengthMatches(FileName) then
      Writeln('Linearized, but modified after linearization')
    else
      Writeln('Ready for Fast Web View');
  finally
    PDF.Free;
  end;
end;

LinearizeOutput и IsLoadedLinearized входят в HotPDF Delphi Component для Delphi и C++Builder; страница продукта документирует, как они взаимодействуют с потоками перекрёстных ссылок, потоками объектов и фиксацией версии