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

Структура файла PDF: заголовок, тело, таблица перекрестных ссылок (xref) и трейлер

Программа чтения PDF не начинает с начала файла. Она начинает с конца. Последние несколько байтов содержат адрес всего остального, и парсер, который не понимает этого порядка, будет неправильно считывать формат с первой строки. Поэтому самый полезный способ изучить структуру PDF на диске — это изучить ее так, как это делает программа чтения: сначала хвост, затем переход назад к карте, затем разрешение объектов, на которые указывает карта

Сами байты достаточно просты для чтения в текстовом редакторе, когда ничего не сжато. Минимальный одностраничный документ, который рисует "Hello, World!", помещается менее чем в пятьсот байтов, и в нем виден каждый структурный элемент формата. Вот весь файл, в котором отмечены четыре части:

%PDF-1.0                          % Header
%âãÏÓ

1 0 obj                           % Body: the object sequence
<<
/Kids [2 0 R]
/Count 1
/Type /Pages
>>
endobj

2 0 obj
<<
/Rotate 0
/Parent 1 0 R
/Resources 3 0 R
/MediaBox [0 0 612 792]
/Contents [4 0 R]
/Type /Page
>>
endobj

3 0 obj
<< /Font << /F0 << /BaseFont /Times-Italic /Subtype /Type1 /Type /Font >> >> >>
endobj

4 0 obj
<< /Length 65 >>
stream
1. 0. 0. 1. 50. 700. cm BT
  /F0 36. Tf
  (Hello, World!) Tj
ET
endstream
endobj

5 0 obj
<< /Pages 1 0 R /Type /Catalog >>
endobj

xref                              % Cross-reference table
0 6
0000000000 65535 f
0000000015 00000 n
0000000074 00000 n
0000000192 00000 n
0000000291 00000 n
0000000409 00000 n

trailer                           % Trailer
<<
/Root 5 0 R
/Size 6
>>
startxref
459
%%EOF

Четыре части, всегда в таком порядке вниз по файлу: заголовок, тело объектов, таблица перекрестных ссылок и трейлер. Загвоздка в том, что вы читаете их почти в обратном порядке. В ISO 32000-2 §7.5.1 изложена та же четырехчастная анатомия, и причина доступа "задом наперед" сугубо практическая: программа чтения, которая переходит прямо к нужному ей объекту, работает намного быстрее, чем та, которая сканирует каждый байт с самого начала, и этот прямой доступ — именно то, что обеспечивают трейлер и таблица перекрестных ссылок

Заголовок состоит из двух строк, и вторая имеет значение

Первая строка — %PDF-1.0. Знак процента делает ее комментарием с точки зрения синтаксиса, но программы чтения обрабатывают ее как сигнатуру файла и извлекают из нее номер версии. Обработка версий на практике является свободной. Программа чтения, созданная для PDF 2.0, с радостью откроет файл, который объявляет версию 1.0, и большинство программ чтения попытаются открыть файл, чья объявленная версия неверна или чья строка версии скрыта чуть глубже в файле, а не на нулевом байте. Это число — скорее подсказка о том, какие функции ожидать, а не строгий пропуск

Вторая строка — это та, которую люди удаляют случайно, а затем тратят половину дня на отладку. Она тоже является комментарием, но ее полезная нагрузка — четыре байта выше ASCII 127. Они существуют для того, чтобы любая программа, перемещающая файл в "текстовом режиме", распознавала его как двоичный и прекращала переписывать окончания строк. PDF несет в себе сжатые потоки, чьи байты могут случайно совпасть с возвратом каретки (CR) или переводом строки (LF); если инструмент передачи перепишет их, длина потока, записанная в словаре, больше не будет совпадать с байтами на диске, и файл будет поврежден. Комментарий с верхними байтами — это сорокалетняя защита от FTP в режиме ASCII, и он по-прежнему есть в каждом файле, который пишет серьезный инструмент, потому что сбой, который он предотвращает, является тихим и тотальным

Тело содержит объекты, каждый из которых пронумерован

Все, из чего состоит документ, живет в теле (body) в виде плоской последовательности косвенных объектов. Каждый из них открывается двумя целыми числами и ключевым словом obj, содержит свое содержимое и закрывается endobj. Объект 1 в примере выше — это узел дерева страниц: 1 0 obj, затем словарь, затем endobj. Первое целое число — это номер объекта, второе — номер генерации (поколения). В свежезаписанном файле поколение почти всегда равно нулю; оно увеличивается только тогда, когда номер объекта повторно используется при правках, что бывает достаточно редко, поэтому вы можете рассматривать ненулевое поколение как признак того, что файл прошел через инкрементные обновления. Содержимое между ключевыми словами здесь представляет собой словарь, записанный между << и >>, но это может быть с таким же успехом число, строка, массив или поток

То, что делает это графом, а не списком — это токен ссылки 2 0 R. Это означает "объект 2, генерация 0, где бы он ни находился в файле". Узел дерева страниц выше не содержит свою страницу; он указывает на объект 2, который с помощью того же механизма указывает на свои ресурсы и поток содержимого. Тело компонуется в любом порядке, который писатель счел удобным, а ссылки сшивают его в дерево с корнем в каталоге. Позиция в файле не имеет значения. Идентичность исходит из номера объекта, а местоположение берется из таблицы перекрестных ссылок

Таблица перекрестных ссылок (xref) — это индекс байтовых смещений

Таблица xref — это то, что превращает номера объектов в позиции в файле. Именно благодаря ей программа чтения может открыть тысячестраничный документ и отрендерить 850-ю страницу, не анализируя предшествующие ей 849 страниц. Каждая запись точно фиксирует, где начинается ее объект, считая в байтах от начала файла:

xref
0 6                  % 6 entries, starting at object 0
0000000000 65535 f   % entry 0: head of the free list
0000000015 00000 n   % object 1 begins at byte 15
0000000074 00000 n   % object 2 begins at byte 74
0000000192 00000 n   % object 3 begins at byte 192
0000000291 00000 n   % object 4 begins at byte 291
0000000409 00000 n   % object 5 begins at byte 409

Фиксированная ширина сделана преднамеренно. Каждая запись занимает ровно двадцать байт: десятизначное смещение, пробел, пятизначное поколение, пробел, односимвольный тип и двухбайтовый конец строки. Поскольку строки одинаковы, программа чтения может индексировать прямо к записи для объекта n с помощью арифметики, а не сканирования, поэтому таблица, дающая прямой доступ к телу, сама по себе доступна напрямую (randomly accessible). Строка 0 6 — это заголовок подраздела: она говорит, что следующие записи описывают шесть объектов, начиная с номера 0

Объект 0 является особенным и присутствует всегда. Его тип f означает свободен (free), его поколение 65535, и он возглавляет связанный список свободных номеров объектов. В файле, который никогда не редактировался, список свободных объектов — это просто эта единственная запись, формальность. Он оправдывает свое существование во время инкрементных обновлений, когда удаление объекта добавляет его номер в этот список, чтобы при последующем редактировании можно было вернуть его. Остальные записи имеют тип n — используются (in-use), и их десятизначный номер — это смещение, к которому вы бы перешли, чтобы прочитать определение этого объекта

Трейлер является точкой входа и располагается в конце

Трейлер — это первое, что фактически потребляет программа чтения, даже несмотря на то, что он написан последним. Парсер открывает файл, переходит в конец и идет назад в поисках %%EOF. Прямо над ним находится startxref, за которым следует единственное число, и это число является байтовым смещением ключевого слова xref. С помощью него программа чтения переходит непосредственно к таблице перекрестных ссылок, не отсканировав ни одного объекта:

trailer
<<
/Root 5 0 R          % the document catalog
/Size 6              % one more than the highest object number
>>
startxref
459                  % byte offset of the xref table
%%EOF

Словарь трейлера несет два значения, которые необходимы программе чтения, прежде чем она сможет делать что-либо еще. /Root указывает на каталог документа, здесь это объект 5, который является вершиной графа объектов и маршрутом к дереву страниц. /Size — это количество записей, которые должна содержать таблица перекрестных ссылок, что на единицу больше самого большого номера объекта из-за свободной записи в нулевом слоте. От %%EOF вытекает вся последовательность чтения: найти маркер, прочитать startxref, чтобы найти таблицу, загрузить таблицу, чтобы узнать, где живет каждый объект, прочитать /Root, чтобы найти каталог, и оттуда разрешать объекты по требованию. Заголовок, находящийся на самом верху, почти не используется до поздних этапов. Карта в самом низу — это то, что нужно программе чтения в первую очередь

Инкрементное обновление добавляет вторую карту вместо перезаписи

Такой дизайн "хвостом вперед" окупается, когда файл изменяется. PDF можно редактировать, не перезаписывая ни одного байта, уже имеющегося на диске. Новые и измененные объекты добавляются в конец, за ними следует свежий раздел перекрестных ссылок и свежий трейлер, а исходный файл под ними остается нетронутым. Единственная новая деталь учета — это запись /Prev в новом трейлере, содержащая байтовое смещение предыдущей таблицы перекрестных ссылок:

% ... original file, unchanged, ends here ...

6 0 obj                          % an object added by this edit
<< /Type /Annot /Subtype /Text /Rect [100 700 120 720] >>
endobj

xref                             % a second xref section, for the new object only
6 1
0000000612 00000 n

trailer
<<
/Root 5 0 R
/Size 7
/Prev 459                        % byte offset of the earlier xref table
>>
startxref
680                              % offset of this new xref section
%%EOF

Программа чтения по-прежнему начинает с финального %%EOF, по-прежнему следует по startxref к самой последней таблице, но теперь следует по цепочке /Prev назад к более старым таблицам, объединяя их так, что побеждает новейшая запись для любого номера объекта. Разделы перекрестных ссылок образуют связанный список вниз по файлу, каждый из которых переопределяет предыдущий для объектов, которых он касается. Объект, который был заменен при правке, по-прежнему физически существует по своему старому смещению; он просто больше недоступен, потому что более поздняя запись xref указывает на что-то более новое

Этот механизм делает подписанные PDF-файлы проверяемыми. Цифровая подпись охватывает определенный диапазон байтов файла, и поскольку инкрементное обновление только добавляет данные, подписанные байты никогда не перемещаются. Подпись по-прежнему валидируется на исходном диапазоне, в то время как более поздние ревизии находятся за его пределами, каждая со своим собственным xref и трейлером. Это также является причиной того, что PDF может нести восстанавливаемую историю: каждый вытесненный объект все еще находится на диске под более ранним разделом перекрестных ссылок, что является функцией для отслеживания версий и обузой для тех, кто думал, что "удалить" означает, что байты исчезли

Ценой является рост размера. Каждая правка добавляет данные; ничего не возвращается на месте, поэтому файл, пересмотренный много раз, накапливает мертвые объекты и длинную цепочку разделов xref. Лекарство — это полная перезапись: загрузите документ и сохраните его заново, что перенумерует сохранившиеся объекты, отбросит недоступные и выдаст единственную чистую таблицу перекрестных ссылок. Эти две стратегии торгуются друг против друга напрямую. Добавление выполняется быстро и сохраняет подписи и историю; перезапись выполняется медленнее и отбрасывает и то, и другое, взамен предлагая компактный файл

Чтение четырех частей на практике

Знания структуры достаточно для ручной отладки большинства проблем типа "этот файл не открывается". Если программа чтения отклоняет PDF, обычные виновники находятся на двух концах, а не в середине. Усеченная загрузка теряет трейлер, поэтому startxref или %%EOF отсутствует, и программа чтения не имеет точки входа; толерантные программы чтения прибегают к сканированию всего файла для восстановления xref, что является именно тем медленным путем, которого должна была избежать таблица. Неудачная передача в текстовом режиме повреждает байты потока или смещения перестают соответствовать реальности, и объекты загружаются из неправильной позиции. Когда смещения в таблице больше не указывают на реальные ключевые слова obj, файл структурно сломан, даже если каждый объект по отдельности в порядке

Для нового кода урок из структуры заключается в том, чтобы позволить библиотеке владеть учетом байтов. Смещения в таблице перекрестных ссылок должны совпадать с фактическими позициями каждого объекта до байта, трейлер должен указывать на правильную таблицу, а инкрементные обновления должны правильно связываться через /Prev. Нативный компонент, такой как HotPDF Component для Delphi и C++Builder, обрабатывает все это при записи файла, включая выбор между добавлением инкрементной версии и перезаписью в компактную. Если вы хотите увидеть ту же структуру, созданную с нуля, а не разобранную на части, в сопроводительной статье о создании документа PDF с нуля рассматривается порядок выдачи заголовка, объектов, xref и трейлера