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

XPS и OpenXPS в PDF в Delphi: координаты и кисти

HotPDF конвертирует пакеты XPS и OpenXPS в PDF внутри Delphi и C++Builder без драйвера печати, отображая каждую координату fixed-page 96-DPI через одну переворачивающую Y матрицу страницы с масштабом 0.75, публикуя каждый VisualBrush как разделяемый Form XObject и превращая тайловые режимы ImageBrush в нативные тайловые шаблоны PDF вместо повторных отрисовок изображения

Сценарий, втягивающий большинство Windows-мастерских в это, скучен и неизбежен. Что-то уже печатается в Microsoft XPS Document Writer — унаследованный отчёт ERP, подписанная форма, партия выписок — а архивная политика требует PDF. XPS — отличный формат захвата и ужасный, чтобы вручать его системе архивов через десять лет. Значит, spool-файл должен стать PDF страница в страницу, и в момент, когда вы начинаете писать этот конвертер, вы обнаруживаете: интересное — не XML. Интересное в том, что XPS и PDF расходятся в том, где начало координат, сколько стоит единица и чем разрешено быть кисти

Из пакета в PDF за один проход

Точка входа — реестр обработчиков документов, а не специальный класс XPS. THPDFDocumentHandlerRegistry.RegisterStandardHandlers устанавливает обработчики XPS, EPUB и CBZ; распознавание построено на содержимом, поэтому пакет с [Content_Types].xml и хотя бы одной частью .fpage набирает 95, даже когда расширение файла лжёт, тогда как голое расширение .xps или .oxps набирает лишь 10. Этот порядок важен, когда вы принимаете загрузки: атакующий, переименовавший EPUB в .xps, не должен рулить конвейером

var
  Handled: IHPDFHandledDocument;
  Info: THPDFDocumentHandlerInfo;
  Options: THPDFDocumentHandlerOptions;
  Registry: THPDFDocumentHandlerRegistry;
  Output: TFileStream;
begin
  Registry := THPDFDocumentHandlerRegistry.Create;
  Output := TFileStream.Create('spool.pdf', fmCreate);
  try
    Registry.RegisterStandardHandlers;
    Options := THPDFDocumentHandlerOptions.Default;
    if not Registry.OpenFromFile('spool.xps', '', Options, Handled, Info) then
      raise Exception.Create('no registered handler recognised the package');
    if Handled.Format = hdfXPS then
      Handled.WritePDF(Output, Options, Info);
    // Info.UnsupportedFeatureCount — честная оценка этого преобразования
  finally
    Output.Free;
    Registry.Free;
  end;
end;

Всё в преобразовании бюджетируется до попытки. THPDFDocumentHandlerOptions.Default ограничивает записи архива 10 000, распакованные байты архива 1 ГиБ, коэффициент сжатия 200, ресурсы 4 096 и страницы 10 000, а также несёт необязательный CancellationToken, чтобы серверное задание можно было остановить посреди пакета. После прочтите Info.UnsupportedFeatureCount и считайте ненулевое значение реальной находкой: HotPDF намеренно считает то, что не смогла отобразить, вместо рисования приближения и молчания об этом

Зачем странице XPS матрица вместо переписанных координат?

Потому что переписывание координат теряет стек трансформаций. XPS FixedPage специфицирован в единицах 96-DPI с началом в левом верхнем углу и Y, растущим вниз; user space PDF — 72-DPI с началом в левом нижнем и Y, растущим вверх. Наивное исправление — умножать каждое число на 0.75 и вычитать каждый Y из высоты страницы при выдаче. Это работает для одной плоской траектории и рассыпается в момент, когда входит RenderTransform, вложенный Canvas или матрица, локальная к кисти, ведь те трансформации определены в пространстве XPS, а ваше покоординатное переписывание уже его покинуло. Поэтому HotPDF держит проекцию матрицей и компонует её. HPDFXPSPageMatrix возвращает фиксированные константы по разу на страницу, HPDFMultiplyXPSMatrix конкатенирует её с накопленной трансформацией пути, и результат выдаётся одиночным оператором cm перед геометрией. Данные пути затем записываются неизменёнными числами XPS — потому же сокращённый синтаксис геометрии может делить тот же ограниченный парсер, что и данные SVG-путей: адаптер XPS обрабатывает лишь ведущий токен правила заливки F0 или F1. Если вы проследили ту же логику для импорта векторов EMF и WMF, форма аргумента знакома: форматы импорта конвертируются матрицей, никогда — арифметикой над листовыми координатами

HotPDF компонует фиксированную матрицу страницы XPS с накопленной трансформацией пути, поэтому система координат XPS 96-DPI с началом слева сверху доходит до user space PDF 72-DPI с началом слева снизу, выдаваемая одиночным оператором cm на каждый визуал
Проекция остаётся матрицей и компонуется с каждой вложенной трансформацией, поэтому данные пути можно писать неизменёнными числами XPS
function HPDFXPSPageMatrix(PageHeight: Double): THPDFXPSMatrix;
begin
  Result.A :=  0.75;       // единица XPS 96-DPI в пункт PDF 72-DPI
  Result.B :=  0;
  Result.C :=  0;
  Result.D := -0.75;       // Y в XPS растёт вниз, в PDF — вверх
  Result.E :=  0;
  Result.F := PageHeight;  // высота страницы PDF в пунктах
end;

// Одна скомпонованная CTM на визуал, выдаётся до любого оператора пути
Effective := HPDFMultiplyXPSMatrix(HPDFXPSPageMatrix(PageHeightPDF), PathMatrix);
Page.AppendRawContent(HPDFXPSMatrixOperator(Effective));

Как переиспользовать VisualBrush, не рисуя его дважды?

VisualBrush рисует произвольное визуальное дерево — детей Canvas, Path и Glyphs — в область, возможно с повторением по ней. HotPDF компилирует этот визуал один раз в PDF Form XObject и затем размещает его — та же ресурсная стратегия, что описана в статье об импорте SVG через Form XObjects. Работоспособность решают две детали. Во-первых, визуал обязан обходиться как прямые дети XML: плоское сканирование на элементы, достойные тайлинга, вытягивает вложенные визуалы на верхний уровень страницы и разрушает и разграничение ресурсов, и порядок рисования. Во-вторых, содержимое снимается с уже применённой матрицей страницы XPS→PDF, поэтому публикация Form требует умножения на обратную матрицу, иначе каждое размещение заново применяет масштаб 0.75 и переворот Y. Form также должна владеть своими ресурсами: HotPDF копирует только шрифты, XObjects, шаблоны, ExtGStates и цветовые пространства, на которые снятый поток содержимого реально ссылается; клонирование всего ресурсного словаря страницы втянуло бы регистрируемую Form в её собственный ресурсный граф и построило цикл. Шрифты остаются в прямом словаре на обычных страницах и повышаются до разделяемого косвенного словаря, только когда снятое содержимое действительно содержит Tf, поэтому документ без переиспользуемых визуалов не платит за этот механизм. Заметьте границу спецификации, которую стоит знать до заведения бага: ECMA-388, раздел 13.4, требует, чтобы и ViewboxUnits, и ViewportUnits у VisualBrush были Absolute, поэтому относительные единицы — не недостающая возможность, а несоответствующий ввод, и HotPDF отказывается выдумывать для них семантику координат

HotPDF компилирует визуальное дерево XPS VisualBrush один раз в PDF Form XObject, публикует его через обратную фиксированной матрице страницы, чтобы размещения не применяли масштаб заново, и копирует только ресурсы, на которые снятое содержимое реально ссылается
Содержимое снимается с уже применённой матрицей страницы, поэтому Form публикуется через обратную ей и несёт только ресурсы, на которые ссылается её собственный поток содержимого

Тайлинг ImageBrush: четыре режима, четыре размера ячейки

Тайловые режимы XPS отображаются на тайловые шаблоны PDF из ISO 32000-1, раздел 8.7.3, а не разворачиваются в повторные размещения изображения по покрываемой области, что держит размер вывода и время преобразования независимыми от того, сколько страницы покрывает кисть. Отображение механическое, стоит его увидеть: отражение выражается помещением зеркальных размещений внутрь одной ячейки шаблона с увеличением ячейки до соответствия

  • Tile — одно размещение, ячейка остаётся 1×1 вьюпорта
  • FlipX — два размещения, ячейка расширяется до 2×1
  • FlipY — два размещения, ячейка вырастает до 1×2
  • FlipXY — четыре размещения, ячейка расширяется до 2×2

Каждое размещение несёт собственный прямоугольник отсечения: отображение Viewbox, выбежавшее за свою подсейчейку, растеклось бы в соседнее отражение. /Matrix шаблона — та часть, что ловит людей. Тайловый шаблон якорится к user space по умолчанию родительского потока содержимого, а не к состоянию графики, актуальному в момент выбора шаблона, поэтому матрица обязана явно скомпоновать все три слоя — проекцию fixed-page, трансформацию Path и локальную к кисти Transform — вместо опоры на окружающий CTM. HotPDF также валидирует до выделения: RegisterImageTilingPattern ограничивает шаблон 1 024 размещениями и отвергает вырожденные отсечения, необратимые матрицы и недопустимые индексы изображений. Если нужна общая PDF-сторона модели, тайловые шаблоны и цветовое пространство Pattern покрывают лежащие в основе операторы

// Проекция fixed-page свёрнута в матрицу шаблона, затем локальная к кисти
PatternMatrix := HPDFMatFromOps( 0.75 * PathMatrix.A, -0.75 * PathMatrix.B,
                                 0.75 * PathMatrix.C, -0.75 * PathMatrix.D,
                                 0.75 * PathMatrix.E,
                                 PageHeight - 0.75 * PathMatrix.F);
if Brush.HasTransform then
  PatternMatrix := HPDFMatMul(PatternMatrix, BrushMatrix);

PatternName := Document.RegisterImageTilingPattern(Resource.ImageIndex,
  Brush.Viewport.Left, Brush.Viewport.Top,
  Brush.Viewport.Left + CellWidth, Brush.Viewport.Top + CellHeight,
  CellWidth, CellHeight, Placements, pttNoDistortion, PatternMatrix);

Что происходит, когда радиальный градиент — не круг?

XPS определяет RadialGradientBrush с GradientOrigin, Center, RadiusX и RadiusY, так что кисть — эллипс. Затенение PDF типа 3 (ISO 32000-1, раздел 8.7.4.5.4) интерполирует между двумя кругами и не имеет способа выразить эллипс напрямую. Усреднить два радиуса в одно число — заманчивый краткий путь, и он заметно неверен на любой кисти, не близкой к круглой. HotPDF вместо этого уводит проблему в систему координат: масштабирует Y на RadiusY / RadiusX, регистрирует честное круговое затенение в том масштабированном пространстве, выбирает шаблон и немедленно выдаёт обратный масштаб, чтобы геометрия пути, записанная следом, оставалась в исходном user space XPS

ScaleY := Brush.RadiusY / Brush.RadiusX;
PatternName := Document.RegisterMultiStopRadialGradient(
  Brush.StartX, Brush.StartY / ScaleY, 0,
  Brush.EndX,   Brush.EndY   / ScaleY, Brush.RadiusX,
  StopPositions, StopColours, 3);

if Abs(ScaleY - 1) > 0.000001 then
  Page.AppendRawContent('1 0 0 ' + HPDFPDFNumber(ScaleY) + ' 0 0 cm'#10);
Page.SetFillPattern(PatternName);   // шаблон захватывает CTM прямо здесь
if Abs(ScaleY - 1) > 0.000001 then
  Page.AppendRawContent('1 0 0 ' + HPDFPDFNumber(1 / ScaleY) + ' 0 0 cm'#10);

Порядок в том фрагменте — весь трюк, и это не вопрос стиля. Шаблон затенения PDF захватывает текущую матрицу трансформации в момент выбора его текущим цветом, поэтому временный масштаб обязан быть выдан до SetFillPattern или SetStrokePattern, а обратный должен идти после выбора, но до операторов пути. Перепутаете порядок в любую сторону — и у вас градиент, корректный на первой траектории и дрейфующий на каждой следующей. Родственное ограничение касается режима относительных координат: RadiusX и RadiusY должны разрешаться против ширины и высоты пути по отдельности, ведь масштабирование обоих одной длиной ребра молча меняет пропорции эллипса на любом неквадратном пути

HotPDF отображает эллиптический XPS RadialGradientBrush на затенение PDF типа 3 масштабированием Y, регистрацией кругового затенения в масштабированном пространстве и выдачей обратного масштаба только после того, как шаблон захватил текущую матрицу трансформации
Эллипс поглощается системой координат, а не затенением, и временный масштаб обязан обрамлять выбор шаблона именно в таком порядке

Где преобразование честно о своих пределах

Одни конструкции XPS конвертируются приближённо, другие не конвертируются вовсе, и выбор дизайна на всём пути — считать их, а не подделывать. Части TIFF и JPEG XR растеризуются через WIC и не дают обещаний о сохранённой альфе, тогда как PNG с валидным альфа-каналом разбивается на базовое изображение плюс /SMask. Собственный размер изображения выводится как pixel * 96 / DPI — сначала читаются pHYs PNG или плотность JFIF JPEG, с откатом к 96 DPI, поэтому плохой заголовок плотности приземляется в предсказуемый размер, а не в произвольный. Неразрешённые ресурсы матриц, нестандартные относительные трансформации, ColorConvertedBitmap, неподдерживаемые режимы растекания градиента и искажённая геометрия все инкрементируют UnsupportedFeatureCount, а искажённый ввод отказывает закрыто вместо деградации в молча иное рисование

Это полезная поза для архивного конвертера: преобразование, молча приближающее, хуже того, что сообщает, какие четыре элемента оно не смогло представить, ведь только второе даёт вам, что проверить, прежде чем документ запечатается в систему архивов. Если вы оцениваете конвертацию XPS и OpenXPS вместе с остальным документным конвейером — компоновкой страниц, шрифтами, подписанием, выводом PDF/A — страница HotPDF Delphi PDF component перечисляет полный набор возможностей и поддерживаемые версии Delphi и C++Builder