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

Обработка гибридных PDF-файлов из приложений Office в Delphi

Экспортируйте документ из Microsoft Word или Excel с помощью «Сохранить как PDF», и файл на диске, чаще всего, будет гибридным файлом (hybrid-reference file). Он содержит свою информацию о перекрестных ссылках дважды: один раз в виде классической таблицы фиксированной ширины, которой заканчивался каждый PDF вплоть до версии 1.4, и один раз в виде сжатого потока перекрестных ссылок (cross-reference stream), от которого фактически зависит большая часть документа. Один ключ трейлера, /XRefStm, сшивает два представления вместе, и то, увидит ли инструмент весь документ, сводится к тому, следует ли он за этим ключом

В этой статье гибридные файлы рассматриваются со стороны потребления: как выглядят байты в конце файла, как два представления расходятся при редактировании, и как конвейер Delphi может обнаруживать и маршрутизировать гибридные входные данные. То, как загрузчик объединяет представления, и почему порядок не обсуждается, является предметом нашей статьи HotPDF о загрузке гибридных файлов; эта статья посвящена распознаванию самого макета

Почему экспорт из Office пишет индекс дважды

В PDF 1.5 появились две функции, изменившие форму файла: потоки перекрестных ссылок (cross-reference streams), которые хранят индекс объектов в виде сжатых двоичных данных вместо текстовой таблицы, и потоки объектов (object streams), которые упаковывают множество мелких объектов в один контейнер, сжатый методом Flate. Записывающая программа (writer), которая их использует, создает файлы меньшего размера, но читающая программа (reader) для PDF 1.4 не может открыть результат, поскольку структуры, на которые она опирается — ключевое слово xref и словарь trailer — исчезли

ISO 32000-1 §7.5.8.4 определяет компромисс. Гибридный файл записывает и то, и другое: классическую таблицу перекрестных ссылок, обращающуюся к объектам, которых должен достичь старый ридер, среди которых каталог и дерево страниц, и поток перекрестных ссылок, индексирующий все остальное. Объекты, свернутые в потоки объектов, помечаются как свободные (free) в классической таблице, поэтому ридер 1.4 пропускает их без жалоб; их реальные местоположения существуют только в потоке. Классический трейлер затем несет ключ /XRefStm, содержащий смещение в байтах этого потока. Старый просмотрщик никогда не читает ключ и отрисовывает файл из табличного представления. Современный просмотрщик следует за ним и видит документ целиком. Word и Excel годами выдавали именно такой макет, поэтому гибридные файлы — это не экзотический частный случай (corner case), а большая доля того, что получают бизнес-конвейеры

Как выглядит хвост гибридного файла

Макет проще всего понять по байтам. Вот хвост небольшого гибридного файла, смещения сокращены; в реальном экспорте Office значение /XRefStm обычно представляет собой большое смещение ближе к концу файла. Порядок чтения — это проход от хвоста, описанный в нашем обзоре структуры PDF-файла: найти %%EOF, прочитать startxref, перейти к таблице

% ... body objects, including object streams and, at byte 116,
% the cross-reference stream (a stream object with /Type /XRef) ...

xref                    % classic section: what startxref points at
0 4
0000000000 65535 f      % slot 0: head of the free list, always present
0000000017 00000 n      % object 1: the catalog, visible to any reader
0000000000 65535 f      % object 2: marked free -- lives in an object stream
0000000000 65535 f      % object 3: same; only the stream view locates it
trailer
<<
  /Size 4
  /Root 1 0 R
  /XRefStm 116          % byte offset of the cross-reference stream
>>
startxref
7164                    % byte offset of the 'xref' keyword above
%%EOF

В этом дампе механизм переносят две детали. Во-первых, startxref специально указывает на классическую секцию: это адрес, на который должен приземлиться старый ридер. Поток перекрестных ссылок достижим только через ключ /XRefStm внутри словаря трейлера, поэтому парсер, который никогда не ищет этот ключ, никогда не узнает о существовании потока. Во-вторых, объекты 2 и 3 — это ложь доброкачественного характера. Классическая таблица объявляет их свободными, но это реальные объекты, находящиеся внутри сжатого контейнера; маркировка «свободен» — это то, что не дает ридеру 1.4 споткнуться о записи, которые он не может использовать. Потребитель (consumer), доверяющий только классическому представлению, приходит к выводу, что большей части этого документа не существует

Как два представления расходятся

Гибридный файл, только что вышедший из Word, внутренне согласован: оба представления описывают один и тот же документ, каждое в рамках заявленной области. Проблемы начинаются, когда файл редактируется инструментом, понимающим только одно из представлений. Рассмотрим утилиту для штамповки, которая добавляет инкрементное обновление (incremental update) в классическом стиле: новые объекты, новую секцию xref, цепочку /Prev к предыдущей секции и новый трейлер. Если этот трейлер отбрасывает ключ /XRefStm, потоковое представление становится осиротевшим; если он копирует старое значение вперед, потоковое представление по-прежнему описывает документ таким, каким он был до редактирования. В любом случае, два индекса теперь не согласуются в том, что содержит файл

Полученный файл имеет характерную сигнатуру сбоя: объекты, видимые в одном представлении, отсутствуют или устарели в другом. Ридер, разрешающий (resolves) ссылки через потоковое представление, находит версию обновленного объекта до редактирования, или вообще не находит записи для добавленного. Ридер в табличном представлении видит редактирование, но теряет из виду сжатые объекты, которые находит только поток. На практике это проявляется как поля форм, которые выживают в одном просмотрщике и исчезают в другом, аннотации, которые, похоже, были удалены проходом штамповки, или поиски, которые приземляются на совершенно другой объект

Что делает эти файлы дорогими для отладки, так это то, что Adobe Acrobat обычно открывает их без жалоб: когда индекс не согласуется с байтами, он тихо перестраивает данные перекрестных ссылок, сканируя заголовки объектов, поэтому тот, кто создал сломанный файл, не видит ничего плохого. Сбой всплывает позже, когда файл попадает к строгому потребителю, валидатору допечатной подготовки (preflight validator), сервису подписания, задаче приема в архив, которые доверяют заявленной структуре и сообщают об отсутствующих объектах или несоответствии перекрестных ссылок. «Он отлично открывается в Acrobat» — именно с этих слов начинается почти каждый тикет о рассинхронизации гибридов

Обнаружение гибридного файла на чистом Delphi

Для классификации входных данных не нужна библиотека PDF. Ключ /XRefStm может встречаться только внутри классического словаря трейлера, а активный трейлер находится в пределах последних пары килобайт файла, поскольку спецификация требует, чтобы %%EOF появлялся около физического конца. Считывания ограниченного хвостового окна и поиска в нем достаточно для сортировки (triage):

uses
  System.SysUtils, System.Classes, System.StrUtils, System.Math;

function IsHybridReferencePdf(const FileName: string): Boolean;
const
  TailWindow = 2048;
var
  Stream: TFileStream;
  Buf: TBytes;
  Tail: string;
  Len, TrailerPos, NextPos, KeyPos, StartXrefPos: Integer;
begin
  Result := False;
  Stream := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    if Stream.Size < 48 then
      Exit;
    Len := Min(TailWindow, Integer(Stream.Size));
    SetLength(Buf, Len);
    Stream.Position := Stream.Size - Len;
    Stream.ReadBuffer(Buf[0], Len);
  finally
    Stream.Free;
  end;

  // Every keyword involved is 7-bit ASCII, so a byte-wise decode is safe
  Tail := TEncoding.ANSI.GetString(Buf);

  // Find the LAST 'trailer' keyword: with incremental updates,
  // the newest trailer is the one that governs the file
  TrailerPos := 0;
  NextPos := Pos('trailer', Tail);
  while NextPos > 0 do
  begin
    TrailerPos := NextPos;
    NextPos := PosEx('trailer', Tail, NextPos + 1);
  end;
  if TrailerPos = 0 then
    Exit;  // no classic trailer: a pure xref-stream file, not hybrid

  // A hybrid trailer carries /XRefStm between 'trailer' and 'startxref'
  KeyPos := PosEx('/XRefStm', Tail, TrailerPos);
  StartXrefPos := PosEx('startxref', Tail, TrailerPos);
  Result := (KeyPos > 0) and
    ((StartXrefPos = 0) or (KeyPos < StartXrefPos));
end;

Три исхода соответствуют трем макетам. Файл только в классическом формате имеет трейлер, но не имеет /XRefStm: False. Файл, который полностью перешел на потоки перекрестных ссылок, вообще не имеет ключевого слова trailer, ключи его трейлера живут в словаре потока: тоже False, и это правильно, потому что такой файл является сжатым, а не гибридным. Только макет с двойным индексированием возвращает True

Для производственного использования стоит добавить две меры усиления защиты, требующие дополнительных строк. Распарсите целое число после /XRefStm, перейдите к этому смещению и подтвердите, что там действительно находится потоковый объект с /Type /XRef; усеченный файл может нести ключ, в то время как поток исчез, что относится к другой категории, нежели здоровый гибрид. И относитесь к размеру окна как к параметру: 2 КБ покрывают обычный вывод Office, но необычно большой словарь трейлера может вытолкнуть ключевое слово за пределы диапазона, и расширение окна лучше, чем случайное объявление файла классическим

Маршрутизация гибридных файлов через конвейер Delphi

Обнаружение дает вам возможность принять решение о маршрутизации. Для файлов, которые только считываются, отрисовываются или проверяются (validated), используйте загрузчик, который разрешает оба представления, а затем проверяйте поведение, а не байты. PDFium Component парсит цепочку /XRefStm во время загрузки, поэтому таблица объектов, которую видит ваш код — это объединенная таблица, и проверки, описанные в нашей статье о проверке потоков объектов и перекрестных ссылок, применяются без изменений. Если рассинхронизированный гибрид поврежден настолько сильно, что отказывается загружаться, движок сообщает об этом через свой набор ошибок: FPDF_ERR_SUCCESS, FPDF_ERR_UNKNOWN, FPDF_ERR_FILE, FPDF_ERR_FORMAT, FPDF_ERR_PASSWORD, FPDF_ERR_SECURITY и FPDF_ERR_PAGE, причем FPDF_ERR_FORMAT возникает при структурном повреждении. Однако не опирайтесь на этот сигнал: PDFium по своей конструкции снисходителен и тихо перестраивает большинство несогласованных файлов, поэтому успешная загрузка доказывает, что файл можно было восстановить, а не то, что его два представления согласуются. Значимая проверка согласованности — это сравнение того, что находит полный обход объектов, с тем, что объявляет /Size трейлера

Для файлов, которые ваш конвейер модифицирует, самая безопасная политика — сделать так, чтобы они вообще перестали быть гибридными. Загрузка, за которой следует полное сохранение (full save) через HotPDF, перезаписывает документ с одной, самосогласованной перекрестной ссылкой в одной форме: никакого /XRefStm, никакого второго представления, которое могло бы рассинхронизироваться, каждым объектом владеет ровно одна индексная запись. Эта нормализация — именно то, что вам нужно перед приемом в архив, перед строгим последующим RIP или сервисом подписания, и после любого редактирования, примененного к гибридным входным данным. Это работает, потому что загрузчик правильно объединил представления на входе — механизм, который подробно описывает статья HotPDF о гибридных ссылках

Единственный класс файлов, который следует оставить в покое — это документы с цифровой подписью. Полная перезапись перемещает каждый байт, что делает недействительной любую подпись, вычисленную по исходным диапазонам. Изменение подписанного гибрида должно происходить как правильное инкрементное обновление, которое поддерживает оба представления; файл, который нужно только прочитать, должен проходить нетронутым. Нормализация — для файлов, которыми вы владеете; к подписанным файлам вы всегда только добавляете данные (append)

Гибридные PDF-файлы не являются неправильно сформированными (malformed); это собственный мост совместимости формата, и приложения Office будут продолжать их создавать до тех пор, пока ридеры PDF 1.4 сохраняются в установленной базе. Конвейер, который может обнаружить ключ /XRefStm, проверить (validate) объединенный документ с помощью PDFium Component и регенерировать чистый одноиндексный вывод с помощью HotPDF Component, относится к ним так, как они того заслуживают: как к обычным входным данным с одним дополнительным указателем в трейлере