Страница PDF не хранит пиксели и не хранит дерево объектов-фигур, как это делает SVG. Она хранит программу. Каждая линия, кривая, заливка и размещенное изображение на странице — это результат выполнения последовательности операторов в потоке содержимого сверху вниз в контексте текущего графического состояния. Поймите этот единственный факт, и большая часть поведения формата перестанет быть удивительной: почему для заливки нужен отдельный оператор рисования после построения пути, почему цвета и ширина линий просачиваются от одной фигуры к другой, пока вы не заключите их в скобки состояния, почему один и тот же код рисования может оказаться в совершенно разных местах после единственного преобразования координат. Это экскурсия по данной модели выполнения, как она определена в ISO 32000: операторы, которые вы встречаете при открытии потока содержимого, и правила, определяющие, что появится на странице
Поток содержимого — это постфиксный байт-код
Поток содержимого представляет собой плоскую последовательность байтов из операндов, за которыми следуют операторы. Операнды идут первыми, а оператор, который их потребляет — последним, что является обратным вызову функции и идентично стековой машине: поместите числа, затем введите глагол. Здесь нет вложенности, нет синтаксиса выражений, нет переменных. Контур треугольника — это пять строк такого кода:
100 100 m % moveto: start a new subpath at (100, 100)
200 200 l % lineto: add a segment to (200, 200)
300 100 l % lineto: add a segment to (300, 100)
h % closepath: connect back to the start
S % stroke: paint the path outline
Операторы лаконичны преднамеренно. Реальная страница — это тысячи таких операторов, обычно сжатых с помощью FlateDecode. Ценой этой компактности является то, что поток не несет в себе структуру, к которой можно было бы обратиться с запросом: программа просмотра не может спросить "где заголовок на этой странице", она может только запустить программу и увидеть, какие чернила куда лягут. Это и есть коренная причина, по которой извлечение текста из произвольных PDF-файлов является сложной задачей
Начало координат находится внизу слева, а Y растет вверх
Прежде чем любая координата обретет смысл, нужно знать, где находится (0, 0). PDF помещает начало координат в левый нижний угол страницы, при этом ось X увеличивается вправо, а ось Y увеличивается вверх; измеряется все в пунктах из расчета 72 пункта на дюйм (ISO 32000-2 §8.3.2). На странице формата US Letter верхний край находится на отметке y = 792, а не y = 0. Любой, кто перешел из экранной графики, где начало координат находится в левом верхнем углу, а ось Y растет вниз, при первой попытке делает все наоборот и рисует первую линию за нижним краем страницы. Единица измерения также не зависит от носителя: 72 единицы — это один дюйм независимо от того, рендерится ли страница на экран телефона или на фотонаборный автомат
Большинство библиотек для рисования страниц наследуют это соглашение напрямую. В HotPDF, например, TextOut и все вызовы путей производят измерения от нижнего левого края в пунктах, поэтому значение, близкое к высоте страницы, помещает содержимое наверх:
// HotPDF, Delphi: y measured from the bottom edge upward, in points
Pdf.CurrentPage.SetLineWidth(2.0);
Pdf.CurrentPage.MoveTo(100, 700); // near the top of the page
Pdf.CurrentPage.LineTo(300, 700);
Pdf.CurrentPage.Stroke; // emits the moveto/lineto/stroke operators
Эта последовательность вызовов компилируется точно в операторы m, l и S, показанные выше. Библиотека — это просто машинистка для потока содержимого, и не более того, и знание того, что она генерирует, позволяет понять результат, когда фигура оказывается не там, где вы ожидали
Постройте путь, затем нарисуйте его
PDF разделяет построение пути и рисование пути, и это разделение не является педантизмом. Сначала вы описываете фигуру с помощью операторов построения, которые не добавляют ничего видимого, затем выдаете один оператор рисования, который решает, что делать с накопленным путем. Один и тот же треугольник может быть контуром, сплошной заливкой или и тем, и другим, в зависимости только от глагола, которым вы завершаете
Операторов построения немного. m начинает новый подпуть (subpath) в заданной точке. l добавляет прямой сегмент. c добавляет кубическую кривую Безье по шести операндам (две контрольные точки и конечная точка). re — это сокращение, которое добавляет целый прямоугольник на основе четверки значений x, y, ширина, высота. h замыкает текущий подпуть обратно к его началу. Ни один из них не наносит чернила на страницу; они только накапливают геометрию
200 250 m % start the subpath
300 350 400 450 500 250 c % cubic Bezier: two control points, then endpoint
150 200 re % a 150 x 200 rectangle, added as its own subpath
h % close
В исходном примере использовался ныне устаревший вариант y оператора кривой; c с его тремя явными точками — это форма, которую вы встретите на практике и к которой следует обращаться. Когда путь существует, один оператор рисования завершает его. Словарь небольшой, и его стоит запомнить, потому что каждая фигура на каждой странице заканчивается одним из них:
Sобводит контур пути (штрих) с использованием текущей ширины линии и цвета обводкиfзаполняет внутреннюю область с использованием текущего цвета заливки и правила ненулевого индекса (nonzero winding rule)f*заполняет с использованием правила "четный-нечетный" (even-odd rule), что имеет значение для самопересекающихся фигур и фигур с отверстиямиBзаливает, а затем обводит за одну операцию;bсначала замыкает путьnничего не рисует, именно так путь становится областью отсечения (clip region), не оставляя видимого следа
Правило индексации контура (winding rule) — это часть, в которой часто ошибаются. Ненулевое правило (f, B) подсчитывает знакопеременные пересечения луча из контрольной точки и делает заливку везде, где счетчик не равен нулю, поэтому отверстие остается пустым только в том случае, если его подпуть намотан (wound) в направлении, противоположном внешнему. Правило "четный-нечетный" (f*, B*) переключается на каждом пересечении независимо от направления. Если фигура в форме "пончика" получается сплошной, значит, внутренний круг намотан в том же направлении, что и внешний, и вы либо обращаете его направление, либо переключаетесь на правило "четный-нечетный"
Цвет — это режим, а не параметр
Цвет в потоке содержимого "липкий". Вы устанавливаете цвет, и он остается установленным, пока вы не установите другой цвет или не восстановите более раннее состояние, именно поэтому изменение цвета без скобок состояния незаметно окрашивает все, нарисованное после него. PDF также хранит цвет заливки и цвет обводки как две независимые настройки: строчные операторы для заливки и прописные для обводки. У каждого из цветовых пространств устройств есть свои сокращения:
0.5 g % DeviceGray fill, mid gray (0 = black, 1 = white)
0.2 0.6 0.8 rg % DeviceRGB fill
0.8 0.2 0.1 RG % DeviceRGB stroke (uppercase = stroke)
0.2 0.8 0.0 0.1 k % DeviceCMYK fill
DeviceRGB подходит для вывода на экран, DeviceCMYK — это то, чего ожидает печатное производство, а DeviceGray — это самый компактный выбор для монохромного контента. Пространства устройств удобны, но не откалиброваны: одна и та же тройка RGB может отображаться по-разному на двух мониторах — проблема, для решения которой существуют цветовые пространства на основе профилей ICC и выходные намерения (output intents) PDF/A. Для работы, критичной к цвету, вы выбираете откалиброванное пространство с помощью cs и CS и устанавливаете компоненты с помощью sc и scn, но для обычных документов нагрузку несут аппаратные сокращения. Библиотека оборачивает их в типизированные вызовы. HotPDF, например, принимает один TColor и выдает соответствующие операторы:
Pdf.CurrentPage.SetRGBFillColor(clRed);
Pdf.CurrentPage.Rectangle(100, 100, 200, 150); // x, y, width, height
Pdf.CurrentPage.Fill;
Pdf.CurrentPage.SetRGBFillColor(RGB(0, 255, 0));
Pdf.CurrentPage.Circle(150, 400, 50); // x, y, radius
Pdf.CurrentPage.Fill;
Графическое состояние и стек q/Q
Все, что не является самим путем, живет в графическом состоянии: текущая матрица преобразования, цвета заливки и обводки, ширина линии, шаблон пунктира, область отсечения (clip region), альфа-канал. Состояние глобально и изменчиво, поэтому единственный безопасный способ внести локальное изменение — это сохранить все состояние, изменить его, нарисовать, а затем откатить. Именно это и делают q и Q. q помещает копию текущего состояния в стек; Q извлекает ее, отбрасывая каждое изменение, сделанное со времени соответствующего q
q % save the entire graphics state
2 0 0 2 100 100 cm % concatenate a transform: scale 2x, translate to (100,100)
0.8 g % gray fill, scoped to this block
% ... draw scaled, gray content ...
Q % restore: transform and color revert
Несбалансированные q и Q — частая причина ошибок в потоке содержимого, созданном вручную или склеенном из частей. Лишний q без соответствующего Q оставляет стек глубоким по окончании страницы; лишний Q вызывает опустошение стека (underflow). В любом случае программа просмотра может сохранить старое отсечение или преобразование в силе, и содержимое исчезает или оказывается не в том месте. Когда графика исчезает без причины, которую может объяснить сам путь, сначала проверьте стек состояний
CTM преобразует каждую координату
Текущая матрица преобразования (Current Transformation Matrix, CTM) находится между числами в ваших операторах и фактической страницей. Каждая координата умножается на CTM перед тем, как что-либо будет нарисовано, поэтому изменение матрицы меняет то, где и как появится весь последующий рисунок, не затрагивая ни одной координаты пути. Оператор cm сцепляет (перемножает) новую матрицу с текущей, принимая шесть операндов, которые сопоставляются с аффинной матрицей [a b c d e f]:
1 0 0 1 100 50 cm % translate by (100, 50): e and f carry the offset
2 0 0 1.5 0 0 cm % scale x by 2, y by 1.5: a and d are the scale factors
0.707 0.707 -0.707 0.707 0 0 cm % rotate 45 degrees (cos/sin in a, b, c, d)
Две вещи сбивают людей с толку. Во-первых, cm компонует, а не заменяет, поэтому преобразования накапливаются, и порядок имеет значение: масштабирование, а затем перенос — это не то же самое, что перенос, а затем масштабирование. Во-вторых, вращение и масштабирование выполняются вокруг текущего начала координат, а не вокруг центра вашей фигуры, поэтому, чтобы повернуть что-то на месте, вы переносите это в начало координат, поворачиваете, а затем переносите обратно, и все это оборачивается в q/Q. Эта же матрица — это то, что размещает изображения (последняя часть, заслуживающая внимания)
Изображения и повторно используемое содержимое — это XObject
Растровые изображения не живут внутри потока содержимого (inline). Они хранятся как XObjects изображений (image XObjects) — внешние объекты с собственным словарем, описывающим ширину, высоту, битовую глубину, цветовое пространство и фильтр сжатия, а поток содержимого только ссылается на них. Фотография на базе JPEG объявляет о себе так:
/Photo <<
/Type /XObject
/Subtype /Image
/Width 640
/Height 480
/BitsPerComponent 8
/ColorSpace /DeviceRGB
/Filter /DCTDecode % the image data is a JPEG stream
>>
Изображение XObject рисуется в единичном квадрате: оно всегда занимает область от (0, 0) до (1, 1) в пользовательском пространстве. Вы не передаете ему позицию или размер. Вместо этого вы устанавливаете CTM так, чтобы единичный квадрат отобразился на нужный вам прямоугольник, а затем вызываете его с помощью Do. Вот почему размещение изображения — это всегда преобразование, за которым следует вызов, обернутое в сохранение/восстановление, чтобы масштаб не перетек на следующую операцию:
q
640 0 0 480 50 300 cm % map the unit square to a 640x480 box at (50, 300)
/Photo Do % paint the image XObject
Q
Тот же механизм Do управляет формами XObjects (form XObjects), которые содержат повторно используемый фрагмент графики — логотип или повторяющийся штамп — как собственный поток содержимого с ограничивающей рамкой. Определите его один раз, вызовите много раз с разными CTM, и эти байты появятся в файле только один раз. Большинство библиотек скрывают это за одним вызовом размещения: HotPDF регистрирует растровое изображение с помощью AddImage и размещает его с помощью ShowImage, принимая явные значения x, y, ширины и высоты вместо того, чтобы просить вас строить матрицу вручную:
var
Bmp: TBitmap;
ImgIndex: Integer;
begin
Bmp := TBitmap.Create;
try
Bmp.LoadFromFile('logo.bmp');
ImgIndex := Pdf.AddImage(Bmp, icFlate);
// x, y (bottom-left), width, height, rotation angle
Pdf.CurrentPage.ShowImage(ImgIndex, 50, 300, 200, 150, 0);
finally
Bmp.Free;
end;
end;
Под этой одной строкой библиотека записывает словарь XObject изображения, устанавливает CTM для задания размера и позиционирования единичного квадрата и выдает Do. Но знать модель, лежащую в основе, стоит, потому что она объясняет каждый странный результат: растянутое изображение — это CTM с несовпадающими коэффициентами масштабирования, логотип, идентичный на сорока страницах — это одна форма XObject, вызванная сорок раз, а изображение, которое рендерится перевернутым — это изменение знака в матрице, а не поврежденный файл
К чему это ведет
Графическая модель невелика, если увидеть ее форму. Поток содержимого — это постфиксный байт-код, работающий с изменяемым состоянием; координаты начинаются снизу слева и проходят через CTM; пути строятся тихо и окрашиваются одним преднамеренным оператором; настройки цвета и линий сохраняются до тех пор, пока вы не заключите их в скобки q/Q; изображения и повторно используемая графика — это XObjects, размещаемые путем преобразования единичного квадрата. Почти каждый сбивающий с толку результат рендеринга сводится к одному из этих пяти правил. Если вы хотите увидеть, как эти графические операторы сидят внутри более крупной объектной модели, словарей страниц и таблицы перекрестных ссылок, которые на них указывают, технический обзор структуры файла PDF охватывает этот слой, а создание простого PDF с нуля проходит по байтам от начала до конца. Рисование текста живет в своем собственном семействе операторов и имеет свои собственные ловушки, описанные в сопроводительной статье об обработке текста и шрифтов в PDF
Показанные здесь вызовы рисования Delphi: MoveTo, LineTo, Stroke, Rectangle, Fill, SetRGBFillColor, AddImage и ShowImage, являются частью компонента HotPDF Component для Delphi и C++Builder, который генерирует эти операторы потока содержимого за вас