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

Создание минимального PDF вручную: пять необходимых вам объектов

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

В этом руководстве мы создадим самый маленький PDF, который действительно что-то рендерит: одна страница, слова «Hello, World!» встроенным шрифтом, на бумаге формата US Letter. Завершенный файл требует ровно пять объектов и несколько строк учета вокруг них. Сначала мы напишем объекты, затем соберем заголовок, таблицу перекрестных ссылок и трейлер, которые свяжут их в файл, который примет программа просмотра.

Пять объектов, на которых настаивает программа просмотра

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

  • Catalog (Каталог) — это корень. Это объект, на который указывает трейлер, и его единственная обязательная запись здесь — это ссылка на дерево страниц.
  • Pages (Страницы) — это узел дерева страниц. В нем перечислены страницы в документе и сообщается, сколько их.
  • Page (Страница) описывает одну физическую страницу: ее размер, ресурсы, с которыми она рисуется, и какой поток содержимого ее закрашивает.
  • Content stream (Поток содержимого) содержит операторы рисования, постфиксные команды, которые размещают текст и графику на этой странице.
  • Font (Шрифт) объявляет гарнитуру, на которую ссылается поток содержимого. Используйте один из 14 стандартных шрифтов, и вам не придется ничего встраивать.

Каждый объект имеет номер и может быть адресован. Косвенный объект записывается как N 0 obj ... endobj, где N — номер объекта, а 0 — номер его поколения (всегда 0 в файле, который вы пишете с нуля). В любом другом месте файла вы указываете на этот объект с помощью ссылки: 5 0 R означает «объект 5». Эти ссылки и есть связующие провода. В нашей нумерации каталог содержит 2 0 R, чтобы добраться до дерева страниц, дерево страниц содержит ссылку обратно вниз на страницу и так далее. Ошибитесь в номере, и программа просмотра пойдет по висячему указателю в никуда.

Имена, словари и потоки

Три элемента синтаксиса несут на себе почти всё. Имя начинается с косой черты: /Type, /Page, /F0. Имена — это чувствительные к регистру идентификаторы, а не строки, и PDF использует их в качестве ключей словарей и для пометки того, чем является объект. Словарь — это набор пар ключ-значение, заключенный в двойные угловые скобки, где каждый ключ является именем: << /Type /Page /MediaBox [0 0 612 792] >>. Значениями могут быть числа, имена, массивы в квадратных скобках, ссылки или вложенные словари. Большинство объектов PDF являются словарями.

Поток — это словарь, за которым следует блок байтов между ключевыми словами stream и endstream. Именно там находятся операторы рисования страницы, а в реальных файлах там также находятся сжатые изображения и встроенные шрифты. Словарь потока описывает байты; в производственном файле он должен содержать запись /Length, указывающую точное количество байтов, и часто /Filter, например /FlateDecode, когда данные сжаты. Мы собираемся использовать инструмент для заполнения /Length, потому что подсчет байтов вручную — это та часть этого упражнения, которая не имеет образовательной отдачи и имеет высокий шанс ошибки на единицу, что ломает файл.

Написание объектов

Вот пять объектов по порядку. Деталь координат, которую следует иметь в виду перед чтением потока содержимого: PDF измеряет от левого нижнего угла страницы в пунктах, где один пункт равен 1/72 дюйма, а Y увеличивается вверх. Страница US Letter имеет размер 612 на 792 пункта, поэтому 50 700 находится рядом с левым верхним углом, а не с нижним.

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

2 0 obj
<< /Type /Pages
   /Kids [3 0 R]
   /Count 1
>>
endobj

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

4 0 obj
<< /Type /Font
   /Subtype /Type1
   /BaseFont /Helvetica
>>
endobj

5 0 obj
<< /Length 44 >>
stream
BT
/F0 36 Tf
50 700 Td
(Hello, World!) Tj
ET
endstream
endobj

Прочитайте ссылки, и структура станет понятной. Объект 1, каталог, направляет свою запись /Pages на объект 2. Объект 2, дерево страниц, перечисляет объект 3 в /Kids и объявляет /Count 1. Объект 3, страница, направляет /Parent обратно вверх к объекту 2 (дерево и страница ссылаются друг на друга, что обязательно), задает свой размер с помощью /MediaBox, предоставляет шрифт под локальным именем /F0 в своем /Resources и указывает объект 5 в качестве своего содержимого. Объект 4 — это шрифт: /BaseFont /Helvetica выбирает одну из 14 стандартных гарнитур, которая уже есть в любой соответствующей стандарту программе просмотра, поэтому встраивать нечего. Объект 5 — это поток содержимого.

О чем на самом деле говорит поток содержимого

Тело потока — это крошечная программа на языке описания страниц PDF, который является постфиксным: сначала идут операнды, затем оператор, который их использует. Пять строк делают всю работу. BT и ET открывают и закрывают текстовый объект; всё, что позиционирует или отображает текст, должно находиться между ними. /F0 36 Tf устанавливает текущий шрифт на ресурс с именем /F0 с размером 36 пунктов (Tf означает «установить текстовый шрифт и размер»). 50 700 Td перемещает позицию текста на (50, 700) в координатах страницы. (Hello, World!) Tj отображает строку, которую PDF записывает как буквальный текст в круглых скобках, используя Tj для ее отрисовки в текущей позиции. Если вы опустите BT/ET, строгая программа просмотра отклонит текстовые операторы; если вы забудете установить шрифт перед Tj, то не будет текущего шрифта для рисования.

Запись /Length 44 в словаре потока — это количество байтов между stream и endstream, и оно должно быть точным. Это то значение, которое стоит передать инструменту, а не считать переводы строк вручную, тем более что от того, записывает ли ваш редактор окончания строк как LF или CRLF, изменяется общая сумма.

Заголовок, xref и трейлер

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

%PDF-1.7

Символ % начинает комментарий в синтаксисе PDF, но программа просмотра обрабатывает этот конкретный комментарий как подпись формата и считывает из него версию. Настоящий генератор файлов сразу за ним помещает вторую строку комментариев с байтами, у которых установлен старший бит, в качестве подсказки для инструментов передачи файлов, что файл является двоичным и не должен искажаться как текст.

В конце файла находится таблица перекрестных ссылок (xref), индекс, делающий возможным произвольный доступ. Она записывает смещение в байтах каждого объекта от начала файла, чтобы программа просмотра могла перейти прямо к объекту 3 без предварительного парсинга объектов 1 и 2. Таблица жестко структурирована: записи имеют фиксированную ширину, по 20 байт каждая, включая окончание строки, в формате 10-значного смещения, 5-значного поколения, ключевого слова (n для используемого, f для свободного) и двухбайтового терминатора. Правильная таблица для наших шести записей (объект 0 всегда является началом списка свободных объектов) выглядит так:

xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000235 00000 n
0000000308 00000 n
trailer
<< /Size 6
   /Root 1 0 R
>>
startxref
408
%%EOF

Эти смещения — хрупкая часть написания PDF вручную. Каждое из них является точной позицией в байтах, где начинается соответствующий N 0 obj, и каждое смещение сдвигается в тот момент, когда вы добавляете символ где-либо выше него. Трейлер — это точка входа, которую программа просмотра использует последней и первой: /Root 1 0 R указывает каталог, /Size 6 указывает количество объектов, а startxref 408 дает смещение в байтах самого слова xref. Программа просмотра открывает файл, переходит в конец, считывает startxref, переходит к таблице перекрестных ссылок, и оттуда достигает каталога и всего, что находится под ним. %%EOF отмечает последний байт.

Позвольте инструменту исправить количество байтов

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

pdftk hello-draft.pdf output hello.pdf

Она анализирует ваши объекты, пересчитывает каждое смещение в байтах, заполняет правильные значения /Length, записывает правильную таблицу xref и трейлер, и выдает hello.pdf. Откройте его в любой программе просмотра, и вы получите одну страницу с надписью «Hello, World!» 36-пунктовым шрифтом Helvetica около верха. Программа Qpdf делает ту же работу, и многие программы просмотра также исправляют слегка поврежденный файл на лету. Смысл использования здесь инструмента заключается не в лени; дело в том, что арифметика смещений — это единственная часть формата с нулевым концептуальным содержанием и самым высоким уровнем ошибок, поэтому ее автоматизация позволяет структуре оставаться той вещью, которую вы изучаете.

Почему это масштабируется до реальных документов

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

От написания вручную к библиотеке

Набор объектов вручную — это учебное упражнение, а не производственный метод. Как только вам понадобятся реальные шрифты, перенос текста, изображения или что-то большее, чем тривиальная страница, учет байтов, который pdftk исправлял за вас, становится всей работой, и вам нужна библиотека, которая берет это на себя. Записываются все те же пять объектов, но библиотека вычисляет каждое смещение, управляет словарями шрифтов и ресурсов, и сжимает потоки содержимого без вашего отслеживания хотя бы одного байта. В Delphi и C++Builder компонент HotPDF сводит весь этот файл к нескольким вызовам: настройте документ, вызовите BeginDoc, SetFont и TextOut, чтобы разместить то же приветствие, а затем EndDoc, чтобы записать правильный каталог, дерево страниц, xref и трейлер. Понимание объектов под капотом — вот что позволяет вам рассуждать о выводе, когда документ рендерится не так, как вы ожидали.