Технічна стаття

Структура файлу 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

Чотири частини, завжди в такому порядку вниз по файлу: заголовок, тіло з об'єктами, таблиця перехресних посилань (xref) і трейлер. Заковика в тому, що ви читаєте їх у майже зворотному порядку. ISO 32000-2 §7.5.1 викладає таку ж анатомію з чотирьох частин, і причина доступу з кінця до початку суто практична: програма для читання, яка відразу переходить до потрібного об'єкта, працює набагато швидше, ніж та, що сканує кожен байт від початку, і саме такий довільний доступ забезпечують трейлер і таблиця перехресних посилань

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

Перший рядок - це %PDF-1.0. Знак відсотка робить його коментарем з погляду синтаксису, але програми для читання сприймають його як сигнатуру файлу і витягують з нього номер версії. Робота з версіями на практиці є вільною. Програма для читання, створена для PDF 2.0, радо відкриє файл, який заявляє про версію 1.0, і більшість програм для читання спробують відкрити файл, заявлена версія якого неправильна або рядок версії якого захований трохи далі у файлі, а не на нульовому байті. Це число є підказкою про те, на які функції очікувати, а не жорстким бар'єром

Другий рядок - це той, який люди випадково видаляють, а потім витрачають половину дня на зневадження. Це також коментар, але його корисне навантаження становить чотири байти, більші за ASCII 127. Вони існують для того, щоб будь-який інструмент, який переміщує файл у "текстовому режимі", розпізнавав його як двійковий і переставав перезаписувати закінчення рядків. PDF містить стиснуті потоки, байти яких можуть випадково збігатися з поверненням каретки або переведенням рядка; якщо інструмент передачі їх перезапише, довжина потоку, записана в словнику, більше не збігатиметься з байтами на диску, і файл буде пошкоджено. Коментар із високими байтами - це сорокарічний захист від FTP у режимі ASCII, і він досі присутній у кожному файлі, який створює серйозний інструмент, оскільки збій, якому він запобігає, є тихим і тотальним

Тіло містить об'єкти, кожен з яких пронумерований

Усе, з чого складається документ, міститься в тілі як плоска послідовність непрямих об'єктів. Кожен з них відкривається двома цілими числами та ключовим словом obj, містить свій вміст і закривається словом endobj. Об'єкт 1 у наведеному вище прикладі є вузлом дерева сторінок: 1 0 obj, потім словник, потім endobj. Перше ціле число - це номер об'єкта, друге - номер покоління (generation number). Покоління майже завжди дорівнює нулю у щойно записаному файлі; воно зростає лише тоді, коли номер об'єкта повторно використовується в процесі редагування, що трапляється досить рідко, тому ви можете розглядати ненульове покоління як ознаку того, що файл пройшов через поступові (incremental) оновлення. Вміст між ключовими словами тут є словником, записаним між << та >>, але він також міг би бути числом, рядком, масивом або потоком

Те, що робить це графом, а не списком, - це маркер посилання 2 0 R. Це означає "об'єкт 2, покоління 0, де б він не знаходився у файлі". Наведений вище вузол дерева сторінок не містить власне сторінки; він вказує на об'єкт 2, який за тим же механізмом вказує на свої ресурси та потік вмісту. Тіло розкладено в будь-якому порядку, який був зручний для запису, а посилання зшивають його в дерево, що починається з каталогу (catalog). Позиція у файлі не має значення. Ідентичність походить від номера об'єкта, а розташування - з таблиці перехресних посилань

Таблиця перехресних посилань - це індекс байтових зміщень

Таблиця 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 за допомогою арифметики, а не шляхом сканування, тому таблиця, яка надає довільний доступ до тіла, сама по собі є доступною довільно. Рядок 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, обробляє все це, коли він записує файл, включно з вибором між додаванням поступової зміни (incremental revision) і перезаписом компактного файлу. Якщо ви хочете побачити таку ж саму структуру, створену з нуля, а не розібрану, супровідна стаття про створення документа PDF з нуля крок за кроком розповідає про створення заголовка, об'єктів, xref і трейлера по черзі