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

Экспорт страниц PDF в SVG в Delphi с помощью HotPDF

HotPDF экспортирует страницу любого загруженного PDF-документа в автономную разметку SVG одним вызовом — BuildLoadedPageSVG, который возвращает полный SVG-документ в виде строки. Экспортированная разметка содержит геометрию страницы, текст в виде настоящих SVG-элементов text, встроенные растровые изображения и состояние обводки, установленное операторами PDF на каждой операции рисования

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

Почему страница PDF не конвертируется в SVG напрямую?

Нетривиальность конвертации обусловлена тремя несоответствиями, и каждое из них даёт результат, который выглядит правдоподобно — пока не сравнить его с оригиналом бок о бок. Первое — это ось Y. Пользовательское пространство PDF растёт вверх от нижнего левого угла страницы; SVG растёт вниз от верхнего левого угла. Единственное отражение на уровне страницы исправляет координаты рисования, но затем ломает каждый глиф, потому что отражение всего холста зеркалирует и начертания букв

Второе несоответствие — наследование. В PDF операторы q и Q помещают в стек и извлекают из него графическое состояние, включающее ширину линии, тип окончания линии, тип соединения линий, предел скоса, массив штриха, фазу штриха и альфа-канал. В SVG элемент, не задающий атрибут явно, наследует его от родительской группы — а это совершенно другое правило области видимости. Экспортёр, который отслеживает только текущую матрицу преобразования и забывает о состоянии обводки, допускает утечку восстановленного после Q состояния в последующие элементы

Третье — то, что PDF выражает ряд вещей через соглашение, а не через явное значение. Типы окончаний и соединений линий — это целые числа, нулевая ширина линии означает волосяную линию в пространстве устройства, а не невидимую линию, а звёздочные варианты операторов заливки меняют правило заполнения, а не цвет. Каждый из этих случаев требует перевода, а не простого копирования

Один вызов для типового случая

Для обычной задачи экспорта страниц под веб-просмотрщик, инструмент сравнения различий или передачу дизайнерам API сводится к одной функции. BuildLoadedPageSVG принимает индекс страницы (с отсчётом от нуля) для текущего загруженного документа и возвращает SVG-документ в виде AnsiString:

var
  Pdf: THotPDF;
  I: Integer;
  Svg: AnsiString;
  Output: TFileStream;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('statements.pdf', '') <= 0 then
      Exit;                     // LoadFromFile возвращает количество страниц
    for I := 0 to Pdf.LoadedPageCount - 1 do
    begin
      Svg := Pdf.BuildLoadedPageSVG(I);
      if Length(Svg) = 0 then
        Continue;
      Output := TFileStream.Create(Format('page-%d.svg', [I + 1]), fmCreate);
      try
        Output.WriteBuffer(Svg[1], Length(Svg));
      finally
        Output.Free;
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

Тот же экспорт доступен и через утилиту командной строки HotPDF в виде команды export-svg, что полезно в конвейерах сборки и регрессионных скриптах, где нужно получить текстовое, пригодное для сравнения представление страницы без написания кода на Pascal. Поскольку SVG — это текст, он естественно дополняет растровый путь, описанный в статье рендеринг страницы PDF в растровое изображение: растровое изображение показывает, как страница выглядит, а SVG — из чего она состоит

Как текст PDF отображается на элементы text в SVG?

HotPDF составляет цепочку текстовой матрицы как произведение префикса, CTM, текстовой матрицы и отражения глифа, где отражение глифа — это правое умножение на matrix(1,0,0,-1,0,0). Этот правый множитель существует исключительно для того, чтобы компенсировать отражение по вертикали на уровне страницы применительно к формам глифов, поскольку иначе текст SVG, нарисованный в перевёрнутой локальной системе координат, отображался бы вверх ногами. Размещение этой поправки в матрице, а не в специальном коде для частных случаев, означает, что повёрнутый, зеркальный и наклонённый текст выходит правильным без дополнительных ветвлений

Горизонтальное позиционирование использует многозначный синтаксис атрибута x элемента SVG text — по одной координате на символ, накапливаемой из продвижения каждого глифа плюс межсимвольный интервал Tc и межсловный интервал Tw, действующие в данный момент. Горизонтальное масштабирование Tz встраивается в столбцы a и c текстовой матрицы, а не выводится отдельно, поэтому просмотрщик, игнорирующий экзотические текстовые атрибуты, всё равно размещает каждый глиф там, куда его поместил PDF. Текст, полученный через сложное шейпинг-преобразование, описанное в статье шейпинг текста для сложных систем письма, проходит тот же путь, поскольку к моменту интерпретации потока содержимого шейпер уже разрешил кластеры в позиционированные глифы

Поворот и изображения: два отражения, которые легко перепутать местами

Страница с ненулевым значением /Rotate требует предварительного преобразования, составленного из отражения относительно высоты повёрнутого холста и поворота, выраженного в системе отображения с осью Y вверх. Три матрицы поворота — это (0,-1,1,0,0,W) для 90 градусов, (-1,0,0,-1,W,H) для 180 и (0,1,-1,0,H,0) для 270, где W и H — размеры страницы до поворота. Вывод этих матриц вручную провоцирует ошибки знака ровно в трёх местах, поэтому экспортёр составляет их через ту же процедуру умножения матриц, что обрабатывает и все прочие преобразования

Встроенным изображениям требуется собственное отражение, поскольку пространство изображения PDF размещает первую строку сэмплов у верхнего края единичного квадрата, тогда как элемент SVG image использует локальную систему координат с осью Y вниз. Поэтому выводимое преобразование — это CTM, умноженная справа на matrix(1,0,0,-1,0,1). Ошибка здесь даёт вертикально отражённые фотографии на в остальном безупречной странице — дефект, который рецензент заметит мгновенно, а автоматический тест часто нет

Что именно сохраняет устройство графического состояния?

HotPDF передаёт операторы состояния обводки w, J, j, M и d через отдельный опциональный интерфейс устройства, поэтому точность обводки была добавлена без изменения vtable существующего устройства содержимого и без нарушения двоичной совместимости для кода, собранного под более ранние версии. Конкретно, экспортированный SVG получает переведённые ключевые слова, а не сырые целые числа PDF:

// Целочисленные перечисления PDF становятся атрибутами-ключевыми словами SVG
//   тип окончания линии  0, 1, 2  ->  butt, round, square
//   тип соединения линий 0, 1, 2  ->  miter, round, bevel
//
// Нулевая ширина линии в PDF означает волосяную линию в пространстве
// устройства, поэтому экспортёр выводит vector-effect="non-scaling-stroke",
// чтобы обводка оставалась видимой и шириной около одного пикселя
// устройства после применения CTM
//
// f* B* b* выбирают правило чётности и выводят fill-rule="evenodd",
// тогда как f B b сохраняют используемое по умолчанию в SVG правило
// ненулевой обмотки

Восстановление состояния по оператору Q охватывает сразу непрозрачность, ширину линии, тип окончания, тип соединения, предел скоса, массив штриха и фазу штриха. Вложенные Form XObject фиксируют и восстанавливают тот же полный набор на своих границах, поэтому пунктирная рамка, заданная внутри штампа, не может просочиться своим узором в последующее содержимое страницы. Если вы уже отслеживаете поведение отсечения и CTM по другим причинам — это та же модель состояния, что фигурирует в статье импорт векторной графики EMF и WMF, только работающая в обратном направлении

Границы возможностей, которые стоит знать до внедрения

Экспортёр честен в отношении своих границ, и узнать их заранее дешевле, чем обнаружить в продакшене. Цвет попадает в устройство SVG через операторы rg, RG, g и G. Заливки, заданные через цветовое пространство плюс scn — а именно так закрашиваются цвета Separation, DeviceN и ICCBased, — не приходят в устройство в виде разрешённой RGB-тройки, поэтому страницы, использующие плашечные цвета таким способом, экспортируют свою геометрию, но не эти цвета. Для источников, ориентированных на печать, растрируйте страницу или сначала сведите плашечные цвета; сама модель закраски описана в статье рендеринг плашечных цветов Separation и DeviceN

Два небольших замечания экономят время на отладку. Шестнадцатеричные цветовые литералы выводятся в верхнем регистре, поэтому тест, проверяющий #ff0000, падает против совершенно корректного #FF0000. А устройство SVG подсчитывает ссылки через свой интерфейс, что означает: освобождение — это вопрос выхода интерфейса за пределы области видимости, а не вызов Free на объекте — различие, важное, если вы расширяете устройство для вывода собственной разметки наряду с содержимым страницы

Экспорт в SVG естественно сочетается со структурным сравнением, когда нужно узнать, действительно ли сгенерированный документ изменился между двумя сборками. Более широкий набор инструментов для работы с загруженными документами — от рендеринга через редактирование до экспорта — описан на странице компонента HotPDF для Delphi PDF