Откройте PDF, созданный Microsoft Word или Excel, перелистайте его и ничего необычного не заметите. Загрузите его в программу на Delphi, запросите количество страниц — число будет верным. Затем пересохраните с включённым шифрованием, и шаг сохранения бросит исключение, или вывод откроется с предупреждением о повреждённой таблице перекрёстных ссылок. Файл никогда не был повреждён. Это файл с гибридными ссылками, и именно та структура, которая позволяет просмотрщику пятнадцатилетней давности его открыть, не даёт загрузчику, останавливающемуся слишком рано, успешно обработать его
Это один из наиболее распространённых способов, при котором конвейер PDF, прошедший все внутренние тесты, встречает файл, с которым не справляется. Входные данные всегда генерировались внутри компании, поэтому гибридных файлов среди них не было. Первый гибридный файл появляется в тот день, когда клиент пересылает счёт, экспортированный из электронной таблицы
Что в действительности записывают Word и Excel
ISO 32000-1 описывает структуру гибридных ссылок в §7.5.8.4. Приложение, которое хочет использовать функции PDF 1.5, такие как потоки объектов, при этом позволяя PDF 1.4-совместимому ридеру открыть файл, записывает информацию о перекрёстных ссылках дважды. Существует классическая таблица перекрёстных ссылок - строки ASCII фиксированной ширины, завершавшие каждый PDF до версии 1.4, - и поток перекрёстных ссылок, индексирующий остальное. Трейлер классической секции содержит запись /XRefStm, значение которой является байтовым смещением этого потока
Разделение функций сделано намеренно. Объекты, которые необходимы старому ридеру, — каталог и дерево страниц — адресуются через классическую таблицу. Объекты, помещённые в сжатые потоки объектов, помечены как свободные в классической таблице записью типа f, поэтому ридер 1.4 пропускает их и никогда не сталкивается со структурой, которую не умеет разобрать. Их реальное расположение хранится только в потоке перекрёстных ссылок. Признак такого файла — его конец: короткая классическая секция, покрывающая лишь объекты, которые нужны легаси-ридеру, чей трейлер указывает на /XRefStm, где лежат реальные данные восстановления
/XRefStm в его трейлере ведёт к потоку, хранящему остальной индекс объектовПочему верное количество страниц ничего не доказывает
Поскольку каталог и дерево страниц специально доступны через классическую таблицу, загрузчик, читающий только её, находит /Root, обходит дерево страниц и сообщает правильное количество страниц. Всё, что нужно старому ридеру, присутствует, поэтому файл выглядит корректным. Отсутствующие объекты - те, что упакованы в потоки объектов: словари полей AcroForm, структурные элементы тегированного PDF, длинный хвост мелких словарей, которые никогда не должны были быть видны устаревшему просмотрщику
Пробел не замечается до тех пор, пока что-то не обращается к этим объектам, а полное сохранение обращается ко всем из них. Обход документа для повторного шифрования или перезаписи - именно та операция, которая последовательно запрашивает каждый номер объекта, поэтому симптом проявляется при сохранении, а не при загрузке, вдали от своей причины
Ловушка - детектор, который видит xref и останавливается
Дешёвый способ определить, как индексирован файл, - следовать startxref и проверить первые байты, на которые он указывает. Ключевое слово xref означает классическую таблицу; потоковый объект - поток перекрёстных ссылок. Этот тест верен для любого файла, использующего только одну схему. Он неверен для гибридного файла, чей startxref указывает на классическую секцию исключительно для удовлетворения старых ридеров, тогда как именно в /XRefStm трейлера этой секции реально индексирована большая часть документа. Детектор, возвращающий "классический" при первом встреченном xref, никогда не читает /XRefStm, и каждый объект, находящийся только в потоке, становится невидимым
var
Pdf: THotPDF;
PageCount: Integer;
begin
Pdf := THotPDF.Create(nil);
try
PageCount := Pdf.LoadFromFile('Invoice_XLS.pdf'); // страницы доступны через классическую таблицу
// проверьте или отредактируйте загруженный документ здесь
Pdf.SaveLoadedDocument('Invoice_secured.pdf'); // нужны все объекты, включая существующие только в потоках
finally
Pdf.Free;
end;
end;
Прогоните ту последовательность на загрузчике, построенном вокруг детектора с ранним выходом, и загрузка выглядит корректной; именно при пересохранении отсутствующие объекты заявляют о себе. Исправление заключается не в том, чтобы читать больше байт в начале; оно в том, чтобы распознать гибридный трейлер и последовать /XRefStm, прежде чем решить, что файл завершён
Порядок слияния обязателен
После прочтения обоих индексов их можно объединить только в одном направлении. Поток перекрёстных ссылок должен быть слит первым, а вокруг него заполняются классические записи. Причина - маленький обман в основе формата. Гибридный файл помечает сжатые объекты как свободные в классической таблице, чтобы старые ридеры их игнорировали. Загрузчик, придерживающийся принципа "выигрывает первый увиденный" и читающий сначала классическую таблицу, запишет эти номера объектов как свободные, а затем отбросит записи потока, которые их реально локализуют, поскольку слоты уже заняты. Перевернув порядок, записи типа 2 из потока - каждая содержит номер потока объектов плюс индекс - занимают предназначенные им слоты, а классические записи заполняются вокруг них
Та же дисциплина защищает от воскрешения удалённого объекта более старой ревизией. Инкрементные обновления цепочкой ссылаются назад через /Prev, а запись типа 0 (свободная) - это признак того, что более поздняя секция вывела номер объекта из обращения. Более поздняя, но более старая секция цепочки не должна перезаписывать этот признак устаревшим расположением. Если считать первое вхождение авторитетным для свободных маркеров - удалённый объект остаётся удалённым; при небрежном подходе история файла оживляет содержимое, которое последняя ревизия удалила
Как два вида расходятся друг с другом
Всё это предполагает, что два индекса согласны друг с другом. Прямо из Word они согласны: каждый вид описывает тот же документ в пределах заявленного им охвата. Беда начинается, когда файл проходит через инструмент, понимающий лишь один из них. Штампующие утилиты — обычные подозреваемые. Одна дописывает инкрементальное обновление в классическом стиле с несколькими новыми объектами, новой секцией xref, ссылкой /Prev назад на предыдущую секцию и свежим трейлером. Если тот трейлер теряет /XRefStm, потоковый вид остаётся сиротой. Если он копирует старое значение вперёд, потоковый вид всё ещё описывает документ таким, каким тот был до наложения штампа. В любом случае индексы теперь расходятся
Признак легко распознать, когда его знаешь. Объекты, видимые через один вид, отсутствуют или устарели в другом. Ридер, разрешающий через поток, находит до-правочную версию обновлённого объекта или вовсе не находит записи для дописанного. Ридер, доверяющий лишь таблице, видит правку, но теряет каждый сжатый объект, который мог бы локализовать только поток. Пользователи описывают это как поля форм, видимые в одном просмотрщике и исчезающие в другом, аннотации, которые штампующий проход словно удалил, или обращения, приземляющиеся вовсе на чужой объект
Adobe Acrobat — причина, почему эти файлы так дороги в поиске. Когда заявленный индекс расходится с байтами, Acrobat обычно тихо пересобирает данные перекрёстных ссылок, сканируя заголовки объектов, так что тот, кто произвёл сломанный файл, открывает его, не видит ничего странного и отправляет дальше. Отказ всплывает позже и где-то в другом месте — валидатор предпечатной проверки, сервис подписания, задача архивного приёма — любой потребитель, доверяющий заявленной структуре и сообщающий о недостающих объектах или несовпадении перекрёстных ссылок. «Открывается же в Acrobat нормально» — так начинается почти каждый тикет о рассинхронизации гибридов
Что это означает в HotPDF
Движок разрешает файлы с гибридными ссылками автоматически на каждом пути, где необходимо разобрать данные перекрёстных ссылок. Загрузите документ через LoadFromFile или LoadFromStream, внесите изменения и вызовите SaveLoadedDocument; или выполните однопроходную операцию, например EncryptFile, которая читает входной файл и записывает выходной. В любом случае восстановление читает /XRefStm, сливает секцию потока перед классическими записями и разрешает объекты, находящиеся в потоках, до того как запись перечислит их. Путь шифрования AES-256 — здесь это видно нагляднее всего, поскольку шифрование документа перезаписывает каждый объект и потому требует, чтобы каждый объект уже был локализован
// Однопроходно: прочитать гибридный вход, записать AES-256 зашифрованную копию
Pdf.EncryptFile('Letter_DOC.pdf', 'Letter_secured.pdf',
'owner-secret', '', aes256, [prPrint, prFillAnnotations]);
Стоит запомнить одну деталь, находящуюся выше уровня API. Файлы, поступающие из Word, Excel, PowerPoint и длинного списка конвейеров "Сохранить как PDF", как правило, являются гибридными, поэтому загрузчик, тестируемый только на выводе собственного генератора, может никогда не встретить такой файл при тестировании. Наполните тестовые данные документами, экспортированными из реальных приложений Office, а не только файлами, произведёнными вашим собственным кодом
Проверка подозрительного файла
Два способа быстро ответить на вопрос. Откройте файл в hex-просмотрщике и прочитайте байты после последнего startxref; в гибридном файле вы увидите короткую классическую секцию, трейлер которой содержит /XRefStm. Или сравните количество объектов, которое сообщает полный разбор, с наибольшим номером объекта, объявленным в /Size трейлера. Большой разрыв означает, что объекты прячутся в потоках, которые загрузчик не открывал, - это та же недостача, которая позднее превращается в ошибку при сохранении
Хвост типичного экспорта из Excel делает первую проверку наглядной. Всё, что идёт после последнего ключевого слова xref, - обычный ASCII, поэтому сигнатура читается прямо из hex-просмотрщика (смещения условные, комментарии добавлены)
xref
0 16 % объекты 0-15: то, что нужно легаси-ридеру
0000000000 65535 f
0000000017 00000 n
... % ещё 14 строк; остальное лежит в потоках объектов
trailer
<< /Size 216 % на единицу больше старшего используемого номера объекта
/Root 1 0 R
/Info 15 0 R
/ID [<5C9A...> <5C9A...>]
/XRefStm 87325 % байтовое смещение потока перекрёстных ссылок
>>
startxref
88710 % указывает на классическую секцию выше
%%EOF
Ключ /XRefStm в трейлере и есть признак. Классическая секция над ним перечисляет 16 из 216 номеров объектов, которыми пользуется файл; всё, упакованное в потоки объектов, либо отсутствует в ней, либо помечено свободным. Детектор, останавливающийся на ключевом слове xref, к этому моменту видел лишь тот частичный индекс. Когда вы предпочли бы скриптовать проверку, а не разглядывать, маркер сидит в последних паре килобайт файла, так что ограниченного чтения назад достаточно. Если файл позже обновлялся инкрементально, хвост держит больше одного трейлера, и значимый — последний
// Возвращает смещение /XRefStm из новейшего трейлера в хвосте файла,
// или -1, если маркера нет (файл не гибридный или вообще не PDF)
function FindXRefStm(const FileName: string): Int64;
var
FS: TFileStream;
Tail: AnsiString;
Len, P, Q: Integer;
begin
Result := -1;
FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
Len := 2048; // трейлер живёт в хвосте
if FS.Size < Len then
Len := Integer(FS.Size);
FS.Position := FS.Size - Len; // ограниченное чтение назад: максимум 2 KB
SetLength(Tail, Len);
FS.ReadBuffer(Tail[1], Len);
finally
FS.Free;
end;
P := 0; // держим последний маркер: инкрементные
Q := Pos(AnsiString('/XRefStm'), Tail); // обновления дописывают более новые трейлеры
while Q > 0 do
begin
P := Q;
Q := Pos(AnsiString('/XRefStm'), Tail, P + 1);
end;
if P = 0 then
Exit; // в хвосте нет гибридного маркера
Inc(P, Length('/XRefStm'));
while (P <= Len) and (Tail[P] in [' ', #9, #13, #10]) do
Inc(P); // пропускаем whitespace после ключа
Result := 0;
while (P <= Len) and (Tail[P] in ['0'..'9']) do
begin
Result := Result * 10 + Ord(Tail[P]) - Ord('0');
Inc(P);
end;
end;
// Использование: неотрицательный результат называет байт, где начинается поток
if FindXRefStm('Invoice_XLS.pdf') >= 0 then
Writeln('hybrid-reference file: resave will need the /XRefStm section');
Считайте пробу триажем, а не парсером: она сообщает, какие файлы пакета заслуживают внимания до запуска задания пересохранения, и не более того
Направьте её на папку реальных входов, и файлы рассортируются на три группы. У чисто классического файла в хвосте есть ключевое слово trailer, но нет /XRefStm, так что проба вернёт -1. Файл, всецело перешедший на потоки перекрёстных ссылок, тоже вернёт -1, и правильно: у него нет ключевого слова trailer вовсе, потому что его ключи трейлера живут в словаре потока. Смещение даёт только дважды индексированная раскладка. Если вашему триаж-отчёту нужно различать первые две группы, поищите в том же хвосте trailer, прежде чем отбросить его; ещё один вызов Pos делает своё дело
Прежде чем проба пойдёт без присмотра, укрепите её в двух местах. Смещение — лишь заявление. Перейдите по нему и подтвердите, что объект, чей словарь говорит /Type /XRef, действительно там сидит, потому что повреждённый или обрезанный файл всё ещё может нести ключ, тогда как названный им поток исчез. Такой файл кладётся в другое ведро, нежели здоровый гибрид. Проверка — несколько строк поверх той же работы с файлом
// Offset — значение, вернувшееся из FindXRefStm; FS — TFileStream, открытый
// тем же способом, Head — AnsiString (Min приходит из System.Math)
if Offset >= FS.Size then
Exit(False); // ключ выжил, поток — нет
FS.Position := Offset;
SetLength(Head, Integer(Min(Int64(512), FS.Size - Offset)));
FS.ReadBuffer(Head[1], Length(Head));
Result := (Pos(AnsiString('/Type /XRef'), Head) > 0) or
(Pos(AnsiString('/Type/XRef'), Head) > 0); // синтаксис PDF допускает оба написания
Затем сделайте окно 2 KB параметром. Два килобайта покрывают обычный вывод Office, но необычно большой словарь трейлера может вытолкнуть маркер из диапазона, и расширение окна лучше, чем случайно объявить файл классическим
Маршрутизация гибридных файлов через конвейер
Детектирование окупается, когда меняет то, куда файл пойдёт дальше. Куда именно — зависит от того, что конвейер собирается с ним делать
Файлы, которые только читают, рендерят или валидируют, должны идти в загрузчик, разрешающий оба вида. PDFium Component разбирает цепочку /XRefStm при загрузке, так что таблица объектов, которую видит ваш код, уже слитая, и проверки из нашей статьи о валидации потоков объектов и перекрёстных ссылок применяются без изменений. Когда рассинхронизированный гибрид повреждён настолько, что отказывается грузиться, движок сообщает об этом через код ошибки загрузки, и FPDF_ERR_FORMAT — тот, что даёт структурное повреждение. Чистая загрузка — более слабое свидетельство. PDFium снисходителен по замыслу и без комментариев пересобирает большинство несогласованных файлов, так что успех доказывает лишь восстанавливаемость файла и не говорит ничего о том, согласны ли его два вида. Для того вопроса возвращайтесь к сравнению того, что находит полный обход объектов, с /Size трейлера
Файлы, которые конвейер изменяет, — другая история. Наше предпочтение здесь прямолинейно: перестать делать их гибридными вовсе. Загрузите документ HotPDF и сохраните целиком, и вывод несёт единую, самосогласованную таблицу перекрёстных ссылок в одной форме. Ни /XRefStm, ни второго вида, оставленного дрейфовать, каждый объект принадлежит ровно одной записи индекса. Такая нормализация — то, что нужно до архивного приёма, до строгого нижестоящего RIP или сервиса подписания и после любой правки гибридного входа. Она целиком опирается на описанное ранее слияние с потоком впереди, поскольку полное сохранение может записать лишь те объекты, которые загрузке удалось локализовать
Подписанные документы — исключение. Полная перезапись двигает каждый байт, что инвалидирует любую подпись, вычисленную по исходным диапазонам байтов. Изменение подписанного гибрида должно входить как настоящее инкрементальное обновление, держащее оба вида в согласии, а подписанный файл, который нужно только читать, должен проходить конвейер нетронутым
Писательская сторона этой истории — как потоки объектов и сжатые перекрёстные ссылки производятся в первую очередь — разобрана в нашей статье о потоках объектов и инкрементальных обновлениях. Когда гибридный файл к тому же очень велик, техники загрузки из обзора Direct File API для больших PDF-конвейеров позволяют осмотреть его, не читая целиком в память. Оба материала естественно дополняют описанное здесь восстановление, которое поставляется как часть HotPDF Delphi Component для Delphi и C++Builder рядом с API загрузки, редактирования, шифрования и подписания, разобранными в других статьях этого блога