Technical Article

Дървета от PDF страници: Защо редът на страниците не е редът на обектите

Страница 1 на PDF файл не е обект 1. Това различие е най-честият източник на грешки при извличане на страници в PDF парсерите, а решението е в четенето на спецификацията, а не на байтовете на файла.

Обекти, препратки и каталог

PDF файлът Рµ СЃСЉРІРєСѓРїРЅРѕСЃС‚ РѕС‚ номерирани обекти. Всеки РѕС‚ тях притежава уникален номер РЅР° обекта Рё номер РЅР° генерацията, записани като N G obj, където G почти винаги Рµ 0 РІСЉРІ файлове, които РЅРµ СЃР° били обновявани инкрементално. Обектите СЃРµ реферират един РґСЂСѓРі СЃ нотацията N G R, така че 3 0 R означава „текущатР?версия РЅР° обект 3вЂ? Крайната част (trailer) сочи РєСЉРј коренов обект каталог, чийто запис /Pages РІРѕРґРё РєСЉРј дървото РѕС‚ страници. Всичко достъпно РІ PDF документа започва РѕС‚ този корен, Р° РЅРµ РѕС‚ РїСЉСЂРІРёСЏ байт РЅР° тялото РЅР° файла.

Таблицата с препратки (xref) (или потокът за препратки в PDF 1.5+) съпоставя номерата на обектите с отместванията във файла. Нейната задача е да осигури произволен достъп, а не подредба. Програма, която изгражда документ инкрементално, може да добави нови обекти в края с по-големи номера, докато тези обекти логически предхождат съществуващите в последователността на страниците. Това не е дефект, а е част от самия дизайн.

Дървото от страници (ISO 32000-1 §7.7.3)

Последователността на страниците се съхранява в дървото от страници. Кореновият каталог съдържа препратка /Pages, която сочи към възел от тип /Pages. Масивът /Kids на този възел изброява неговите деца в реда на четене. Всяко дете е или краен възел (листо) от тип /Page, или друг междинен възел /Pages, съдържащ свой собствен масив /Kids. Страница 1 е първото листо, достигнато при обхождане в дълбочина от ляво на дясно на масивите Kids. Записът /Count на всеки междинен възел кешира общия брой на крайните страници наследници, така че визуализаторът да може да прескочи директно на страница 500, без да обхожда цялото дърво.

Ето как изглежда минимално дърво от три страници в необработен PDF синтаксис:

16 0 obj
<<
  /Type /Pages
  /Count 3
  /Kids [20 0 R  1 0 R  4 0 R]
  /MediaBox [0 0 612 792]
>>
endobj

20 0 obj
<< /Type /Page  /Parent 16 0 R  /Contents 21 0 R  /Resources 22 0 R >>
endobj

1 0 obj
<< /Type /Page  /Parent 16 0 R  /Contents 2 0 R   /Resources 3 0 R >>
endobj

4 0 obj
<< /Type /Page  /Parent 16 0 R  /Contents 5 0 R   /Resources 6 0 R >>
endobj

Масивът Kids съдържа [20 0 R, 1 0 R, 4 0 R]. Логическата страница 1 е обект 20, логическата страница 2 е обект 1, логическата страница 3 е обект 4. Всеки код, който итерира номерата на обектите от 1 нагоре, ще ги срещне в ред 1, 4, 20 и ще произведе последователността страница 2, страница 3, страница 1. В резултат документът се рендерира в разбъркан ред, който може да изглежда съвсем нормално във визуализатор, който следва дървото, и напълно погрешно в такъв, който не го прави.

Наследяване

Междинните възли могат да съдържат свойства, които техните наследници наследяват. Най-често наследяваните записи са /MediaBox (размери на страницата), /CropBox, /Resources (шрифтове и изображения) и /Rotate. Крайна страница, която не съдържа /MediaBox, не е повредена; тя приема стойността от най-близкия родителски възел, който я дефинира. Страница, която изрично дефинира /MediaBox, презаписва стойността от родителя само за тази конкретна страница.

Това е важно при парсването. Четенето на обект /Page изолирано и предположението, че неговите свойства са пълни, ще доведе до грешно отчитане на размерите за всяка страница, която разчита на наследяване. Правилният четец обхожда веригата /Parent нагоре, като събира свойствата, които все още не е срещнал, докато достигне корена.

Вложени дървета

Нищо в спецификацията не ограничава дървото до едно ниво. Голям документ може да групира страниците под междинни възли, които до известна степен съответстват на глави:

2 0 obj   % root Pages node, Count = 8
<< /Type /Pages  /Count 8  /Kids [3 0 R  4 0 R] >>
endobj

3 0 obj   % first chapter, 5 pages
<< /Type /Pages  /Parent 2 0 R  /Count 5
   /Kids [10 0 R  11 0 R  12 0 R  13 0 R  14 0 R]
   /MediaBox [0 0 612 792] >>
endobj

4 0 obj   % second chapter, 3 pages
<< /Type /Pages  /Parent 2 0 R  /Count 3
   /Kids [20 0 R  21 0 R  22 0 R]
   /MediaBox [0 0 612 792] >>
endobj

Алгоритъмът за обхождане е същият: посещават се Kids подред, извършва се рекурсия във всеки възел /Pages и се събират крайните възли /Page. Стойностите /Count позволяват на визуализатора да пропусне цяло поддърво, когато преминава към страница, разположена след него, поради което тези стойности трябва да бъдат точни. Някои PDF редактори от края на 90-те и началото на 2000-те години не ги преизчисляваха след директни редакции, така че по-сигурният подход изисква парсерът да проверява /Count спрямо действителния брой листа, вместо да му се доверява сляпо за заделяне на памет за масива.

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

Грешката в подредбата на страниците възниква най-често в два сценария. Първият е персонализиран парсер, който търси обекти от тип /Page, вместо да следва дървото. Той открива всяка страница, но по номера на обекта, а не в реда на четене. Решението винаги е едно и също: започва се от крайната част (trailer), намира се кореновият каталог, следва се /Pages и се обхождат масивите Kids.

Вторият сценарий е файл с инкрементални актуализации. Когато PDF редактор добавя промени, без да пренаписва целия файл, новите обекти на страниците получават по-високи номера, докато масивът Kids в оригиналното дърво продължава да управлява тяхната логическа позиция. Страница, която първоначално е била обект 5, бива заменена от нов обект 143, но масивът Kids вече препраща към 143 на мястото на 5, така че логическият ред се запазва. Обхождането по номера на обектите би поставило заменената страница на грешна позиция в последователността.

Линеаризираните (оптимизирани за мрежата) PDF файлове добавят трети вариант: файлът е физически пренареден, така че съдържанието на първата страница да се намира близо до началото за бързо показване при бавна връзка. Структурата на дървото от страници остава меродавна за реда, но таблицата с препратки (xref) сочи към пренаредените отмествания. Парсер, който разчита на физическата позиция във файла, а не на таблицата xref, ще прочете погрешно дори първата страница на линеаризиран файл.

Component-ът HotPDF управлява вътрешно обхождането на дървото от страници, наследяването на свойства и сливането на xref таблиците при инкрементални актуализации. Директната работа с неговите обекти за страници означава, че подредбата от масива Kids вече е приложена; индексите на страниците съответстват на логическите страници, а не на номерата на обектите.