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

Импорт EMF в PDFlibPas: правила PolyDraw, Polyline и Bezier

PDFlibPas, PDF-библиотека losLab для Delphi, переводит записи EMF Poly* в пути PDF, следуя определению каждой записи в [MS-EMF]: 32-битная EMR_POLYBEZIER стартует с точки 0, полилинии остаются открытыми и лишь обводятся пером, PT_CLOSEFIGURE в EMR_POLYDRAW — это флаг, а каждое число точек сверяется с размером записи. Эти правила появлялись по частям в v3.539.39, v3.539.41 и v3.539.43. До них график отчёта мог выйти из ImportEMFFromFile с закрашенным клином там, где должна быть линия тренда, с кривой Безье, согнутой к чужой контрольной точке, или с замкнутым контуром, у которого не хватало последней стороны. Ни одна из этих историй не поднимала ошибку, и правила относятся к любому конвертеру Delphi EMF в PDF и к любому парсеру GDI-записей

Почему записи EMF Poly* ломаются при конверсии в PDF?

Записи EMF Poly* ломаются потому, что часть своего смысла каждая носит не в точках: замкнута фигура или открыта, стартует ли она с текущей позиции, какое перо и кисть действуют и с какого места в записи начинаются точки. Расширенный метафайл — это запись вызовов GDI к контексту устройства, так что конвертер обязан воспроизвести не только координаты, но и состояние этого контекста. В PDF никакого контекста устройства нет. Есть путь, текущая точка внутри него и оператор рисования, который выбирает между обводкой (S), заливкой (f) и тем и другим (B). Любое расхождение двух моделей становится тихой разницей в рендеринге

Семейство Poly* вдобавок существует в двух разрядностях. У каждой 32-битной записи вроде EMR_POLYLINE есть 16-битный близнец вроде EMR_POLYLINE16, хранящий точки парами SmallInt. GDI обычно пишет компактную форму, когда все координаты помещаются, поэтому 32-битные обработчики конвертера могут годами оставаться сломанными — повседневные тестовые рисунки до них просто не добираются. Быстрейший аудит — прогнать одни и те же точки через обе записи и сравнить получившиеся пути. Все разобранные здесь записи относятся к группе рисующих записей [MS-EMF] (2.3.5 Drawing Record Types)

ЗаписьСтартЗамкнута?Текущая позиция
EMR_POLYBEZIERТочка 0НетНе используется, не обновляется
EMR_POLYLINEТочка 0Нет (только перо)Не используется, не обновляется
EMR_POLYLINETOТекущая позицияНет (только перо)Используется и обновляется
EMR_POLYPOLYLINEПервая точка каждой полилинииНет (только перо)Не используется, не обновляется
EMR_POLYDRAWПервый PT_MOVETO или текущая позицияТолько там, где стоит PT_CLOSEFIGUREИспользуется и обновляется

Где на самом деле стартует кривая EMR_POLYBEZIER?

Кривая EMR_POLYBEZIER стартует с точки 0, и лишь точки с индекса 1 и далее группируются по три: контрольная точка, контрольная точка, конечная точка. Запись с 7 точками рисует, значит, два кубических сегмента: 0 — старт, 1–3 — первый сегмент, 4–6 — второй. 16-битный обработчик в PDFlibPas так и делал. 32-битный начинал группировку с точки 0, из-за чего стартовая точка съедалась как первая контрольная, а каждый следующий сегмент сдвигался на одну позицию. Кривая при этом рисовалась — просто не та. Начиная с v3.539.41 обе разрядности открывают путь оператором m в точке 0 и выдают по одному c на каждый полный триплет после него

Схема PDFlibPas записи EMR_POLYBEZIER с семью точками: точка ноль открывает путь оператором m, точки один–три и четыре–шесть дают по одному кубическому сегменту c; исправленный 32-битный обработчик начиная с v3.539.41 противопоставлен старой группировке, съедавшей стартовую точку как контрольную
Точка 0 — стартовая, и лишь полные триплеты после неё становятся кубическими сегментами, так что PolyBezier из семи точек превращается в оператор m плюс два оператора c

Для собственного парсера: число точек, не равное 1 плюс кратное трём, — признак испорченной записи, а хвостовые точки стоит игнорировать, а не пришивать к кривой

PolyDraw: PT_CLOSEFIGURE — это флаг, а не тип точки

В EMR_POLYDRAW бит PT_CLOSEFIGURE (значение 1) комбинируется с PT_LINETO (2) или PT_BEZIERTO (4), так что корректный байт типа может быть 3 или 5. Тип точки — это байт с замаскированным этим битом, а флаг означает: замкнуть фигуру после сегмента, который заканчивается в этой точке. Старый обработчик PDFlibPas сверял байт с одиночными значениями в case, поэтому точки типов 3 и 5 не совпадали ни с чем и целиком пропускались. Прямоугольник, нарисованный через PolyDraw, терял замыкающую сторону, а у триплета Безье с флагом на последней точке терялась сама точка, что сбивало все последующие триплеты

Начиная с v3.539.39 тип читается как Types[i] and not PT_CLOSEFIGURE, а закрытие выдаётся только после полного сегмента: после линии для закрытого PT_LINETO и после третьей точки группы Безье. Испорченный файл, поставивший флаг на первой или второй точке триплета, не закроет фигуру раньше времени. В том же релизе вышли две связанные правки:

  • Каждый PT_MOVETO в 16-битной EMR_POLYDRAW16 перезапускал весь путь, так что запись с тремя фигурами сохраняла лишь последнюю; теперь первый ход стартует путь, а последующие открывают подпути
  • Запись PolyDraw, не начинающаяся с PT_MOVETO, стартует с текущей позиции, как и сказано в определении записи, вместо того чтобы выдать оператор l или c без предшествующего m
Анатомия байта типа EMR_POLYDRAW в PDFlibPas: бит флага PT_CLOSEFIGURE нулевого разряда вбит через OR в PT_LINETO или PT_BEZIERTO, поэтому корректные байты типов 3 и 5 перед диспетчеризацией маскируются через and not PT_CLOSEFIGURE; старый case-оператор пропускал оба байта, и замкнутые фигуры теряли последнюю сторону
Маскируйте флаг закрытия до диспетчеризации и выдавайте закрытие только после завершённой линии или триплета Безье, иначе PolyDraw молча теряет точки

Почему полилинию EMF нельзя заливать в PDF?

Полилинию EMF нельзя заливать, потому что EMR_POLYLINE и EMR_POLYPOLYLINE — открытые фигуры, рисуемые только пером, а заливка открытого пути в PDF неявно его замыкает. ISO 32000-1 §8.5.3 прямо говорит: операторы заливки закрывают любой открытый подпуть перед отрисовкой. Конвертер, выдающий B или f для полилинии из трёх точек, рисует залитый треугольник текущим цветом кисти — тот самый закрашенный клин под линией тренда графика. До v3.539.41 PDFlibPas заливал обе разрядности полилиний кистью, а 32-битную запись ещё и явно замыкал. Теперь обе заканчиваются чистой обводкой, и различие GDI сохранено: Polygon замыкает и заливает, Polyline — никогда

Сравнение в PDFlibPas открытой V-образной полилинии из EMR_POLYLINE: корректный конвертер завершает путь оператором обводки S и игнорирует выбранную кисть, а выдача f или B неявно замыкает открытый подпуть по ISO 32000-1 8.5.3 и рисует баг с закрашенным клином на графике
Оператор заливки замыкает любой открытый подпуть перед отрисовкой, поэтому полилинии обязаны заканчиваться оператором S без h, f или B на подпути

PolylineTo стартует с текущей позиции

EMR_POLYLINETO рисует от текущей позиции через каждую точку записи, остаётся открытой и оставляет текущую позицию в последней точке. В старом обработчике к тому же жил особый случай: он выключал перо, когда первые две точки имели общий y, и ничто его больше не включало, так что все последующие записи файла теряли контур. Состояние пера — вотчина EMR_SELECTOBJECT и EMR_CREATEPEN; обработчику рисующей записи менять его не положено. Этот особый случай убрали в v3.539.41, а одно-точечная форма записи больше не читает за пределами собственных точек (исправлено в v3.539.39)

Точки PolyPolyline начинаются после массива счётчиков

32-битная EMR_POLYPOLYLINE хранит nPolys счётчиков, затем cptl точек, причём точки начинаются со смещения байта 32 + nPolys * 4. Ловушка сидит в RTL: юнит Windows объявляет TEMRPolyPolyline с aPolyCounts и aptl как массивы из одного элемента, так что aptl[0] — первая точка лишь при nPolys = 1. Код, индексирующий aptl напрямую, на каждой многосегментной записи читает значения счётчиков как координаты. Старый обработчик PDFlibPas к тому же кроил проверку границ под эту неверную раскладку, из-за чего корректные многосегментные записи отбраковывались, а односегментные не рисовали ничего. Начиная с v3.539.41 PDFlibPas находит массив точек по вычисленному смещению — так же, как всегда делал его обработчик PolyPolygon, — и рисует каждую полилинию собственным открытым подпутем с одной обводкой в конце. В v3.539.43 та же участь постигла 16-битного близнеца; он рисовал посегментно, что ломало стыки линий и игнорировало выбранный NULL_PEN

Перо и кисть по умолчанию, а также скобки путей

Два правила состояния дополняют полилинейные правки в v3.539.43:

  • Свежий контекст устройства GDI уже имеет выбранные BLACK_PEN и WHITE_BRUSH, так что метафайл, рисующий без единого EMR_SELECTOBJECT, всё равно рисует чёрные контуры; конвертер раньше стартовал без пера и заливки и писал для таких записей n (завершить путь, ничего не рисуя)
  • Внутри скобок BeginPath / EndPath полилиния Polyline ни использует, ни обновляет текущую позицию, поэтому обязана открыть новый подпуть со своей первой точки, а не примкнуть к предыдущей фигуре, и пока скобка не обведена и не залита, рисовать нельзя ничего

Собираем тестовый EMF-файл с TMetafileCanvas

Быстрее всего проверить конвертер на этих правилах, записав три рискованных вызова в один расширенный метафайл через TMetafileCanvas. Рисунок ниже записывает кривые с полой кистью, а для полилинии нарочно выбирает сплошную жёлтую: корректный конвертер обязан игнорировать эту кисть для полилинии, так что любой жёлтый в выходном PDF — баг. У PolyDraw нет обёртки в TCanvas, поэтому он вызывается через Windows API с хэндлом канвы, причём байты типов 3 и 5 взяты, чтобы прогнать флаг закрытия

uses
  Winapi.Windows, System.Types, Vcl.Graphics;

procedure BuildPolyTestEmf(const FileName: string);
const
  // Замкнутый квадрат (3 = LINETO + CLOSEFIGURE), затем замкнутая
  // фигура Безье, чей последний триплет кончается 5 = BEZIERTO + CLOSEFIGURE
  DrawPts: array[0..7] of TPoint = (
    (X: 300; Y: 40), (X: 380; Y: 40), (X: 380; Y: 120), (X: 300; Y: 120),
    (X: 420; Y: 120), (X: 440; Y: 40), (X: 520; Y: 40), (X: 540; Y: 120));
  DrawTypes: array[0..7] of Byte = (
    PT_MOVETO, PT_LINETO, PT_LINETO, PT_LINETO or PT_CLOSEFIGURE,
    PT_MOVETO, PT_BEZIERTO, PT_BEZIERTO, PT_BEZIERTO or PT_CLOSEFIGURE);
var
  Mf: TMetafile;
  Canvas: TMetafileCanvas;
begin
  Mf := TMetafile.Create;
  try
    Mf.Enhanced := True;
    Mf.Width := 600;
    Mf.Height := 260;
    Canvas := TMetafileCanvas.Create(Mf, 0);
    try
      Canvas.Pen.Color := clNavy;
      Canvas.Pen.Width := 2;
      Canvas.Brush.Style := bsClear;    // для кривых — только контуры
      // Точка 0 — старт; 1..3 и 4..6 — два кубических сегмента
      Canvas.PolyBezier([Point(20, 120), Point(60, 20), Point(100, 220),
        Point(140, 120), Point(180, 20), Point(220, 220), Point(260, 120)]);
      PolyDraw(Canvas.Handle, DrawPts[0], DrawTypes[0], Length(DrawPts));
      // Открытая V-образная фигура при выбранной сплошной кисти: обводится,
      // но никогда не замыкается в жёлтый треугольник
      Canvas.Brush.Style := bsSolid;
      Canvas.Brush.Color := clYellow;
      Canvas.Polyline([Point(20, 240), Point(120, 160), Point(220, 240)]);
    finally
      Canvas.Free;   // завершает запись
    end;
    Mf.SaveToFile(FileName);
  finally
    Mf.Free;
  end;
end;

Поскольку эти координаты помещаются в SmallInt, GDI обычно сохранит 16-битные варианты. До 32-битных обработчиков добраться можно лишь продюсером, который их пишет, или записями, собранными вручную. У самодельных файлов своя ловушка: VCL TMetafile.LoadFromStream считает поток EMF, только когда оставшаяся длина строго больше 108-байтового TEnhMetaHeader. Минимальный рукописный EMF с коротким заголовком, равно как и пустой ровно на 108 байт, принимается за WMF и отбраковывается с «Metafile is not valid». Всегда пишите полный 108-байтовый заголовок, включая поля расширений, перед своими тестовыми записями

Импортируем EMF в PDF через PDFlibPas

PDFlibPas импортирует EMF через ImportEMFFromFile или ImportEMFFromStream, возвращающие при успехе ненулевой image ID, а при неудаче 0. GeneralOptions = 0 сохраняет векторный путь, о котором эта статья; 1 вместо этого растрирует метафайл в битмап. FontOptions = 1 добавляет шрифты метафайла как невстроенные TrueType. Вариант со стримом перед загрузкой перематывает поток на позицию 0, так что передавайте поток, содержащий только метафайл

uses
  System.SysUtils, PDFlibrary;

procedure EmfToPdf(const EmfFile, PdfFile: WideString);
var
  PDF: TPDFlib;
  ImageID: Integer;
  PageOps: AnsiString;
begin
  PDF := TPDFlib.Create;
  try
    PDF.SetOrigin(1);              // начало в левом верхнем углу для DrawImage
    PDF.SetMeasurementUnits(0);    // пункты
    // FontOptions 1 = добавить шрифты как невстроенные TrueType
    // GeneralOptions 0 = векторный импорт, 1 = битмап
    ImageID := PDF.ImportEMFFromFile(EmfFile, 1, 0);
    if ImageID = 0 then
      raise Exception.Create('The metafile could not be imported');
    PDF.SelectImage(ImageID);
    // Для EMF ImageWidth / ImageHeight — размер рамки в пунктах
    PDF.DrawImage(36, 36, PDF.ImageWidth, PDF.ImageHeight);

    // Страница лишь вызывает импортированную форму: q ... cm /Name Do Q
    PageOps := PDF.GetPageContentToString;
    if Pos(AnsiString(' Do'), PageOps) = 0 then
      raise Exception.Create('Expected a form XObject invocation');

    if PDF.SaveToFile(PdfFile) <> 1 then
      raise Exception.Create('The PDF could not be saved');
  finally
    PDF.Free;
  end;
end;

Векторный импорт EMF становится form XObject, поэтому GetPageContentToString возвращает лишь последовательность save, transform, Do и restore. Операторы m, l, c, h и S, порождённые из записей Poly*, лежат в потоке form XObject, который сжат. Чтобы их аудитить, распакуйте сохранённый файл в PDF-инспекторе объектов и прочитайте поток формы: в тестовом файле выше полилиния должна заканчиваться оператором S без предшествующего h, по h на каждом флаге закрытия в фигурах PolyDraw, и без f или B на всех этих подпутях. DrawImage вдобавок масштабирует импортированный EMF равномерно по меньшей из величин Width и Height, так что рисунок сохраняет пропорции, даже если переданная рамка им не соответствует

Для таргетов Free Pascal см. как векторный импортёр EMF в PDFlibPas собирается под Free Pascal; семантика записей одна и та же, где бы импортёр ни компилировался

Как парсеру EMF относиться к числам точек из файла?

Парсер EMF должен считать каждое число точек недоверенным вводом и сверять его с размером записи до копирования хотя бы одной точки. EnumEnhMetaFile гарантирует лишь то, что nSize каждой записи не вылезает за файл. Он не проверяет, согласуется ли cptl с nSize, так что обработчик, копирующий cptl точек через Move, при подделанном или битом счётчике прочитает следующие записи — или уйдёт за конец метафайла. Начиная с v3.539.39 PDFlibPas сверяет фиксированный заголовок плюс счётчик, умноженный на байты на точку, с nSize для PolyDraw, PolyBezier, PolyBezierTo, Polyline, PolylineTo и Polygon в обеих разрядностях, с одним лишним байтом на точку для байтов типов PolyDraw. Для записей PolyPoly пофигурные счётчики к тому же обязаны суммарно не превышать заявленный итог, а фигуры из нуля точек пропускаются

Та же проверка достаточно коротка, чтобы скопировать её в собственный парсер. Эта версия валидирует 32-битную EMR_POLYPOLYLINE и возвращает указатель на её настоящий массив точек:

uses
  Winapi.Windows;

// Возвращает nil, если запись не хранит по-настоящему заявленных точек.
// Точки начинаются после массива счётчиков: через 32 + nPolys * 4 байта, а не
// с aptl[0], который RTL объявляет одноэлементным массивом
function PolyPolylinePoints(Rec: PEnhMetaRecord): PPoint;
var
  P: PEMRPolyPolyline;
  Count: PDWORD;
  PointsOffset, Total: Int64;
  I: Cardinal;
begin
  Result := nil;
  if (Rec^.iType <> EMR_POLYPOLYLINE) or (Rec^.nSize < 32) then
    Exit;
  P := PEMRPolyPolyline(Rec);
  if P^.nPolys = 0 then
    Exit;
  PointsOffset := 32 + Int64(P^.nPolys) * SizeOf(DWORD);
  if PointsOffset + Int64(P^.cptl) * SizeOf(TPoint) > Rec^.nSize then
    Exit;                         // подделанный или обрезанный счётчик
  Total := 0;
  Count := @P^.aPolyCounts[0];    // идём по указателю: [0..0] задевает range checks
  for I := 1 to P^.nPolys do
  begin
    Inc(Total, Count^);
    Inc(Count);
  end;
  if Total > P^.cptl then
    Exit;                         // фигуры требуют больше точек, чем есть
  Result := PPoint(NativeUInt(Rec) + NativeUInt(PointsOffset));
end;

Проверка вмещаемости точек идёт первой, так что к моменту прохода цикла массив счётчиков уже заведомо внутри записи. Арифметика — Int64, потому что nPolys * 4 и cptl * 8, посчитанные в 32 битах, могут переполниться и пройти сравнение

Шпаргалка: правила EMF Poly* для конверсии EMF в PDF

  • EMR_POLYBEZIER: точка 0 — стартовая; группируем от точки 1 по три; для 32-битной записи исправлено в v3.539.41
  • EMR_POLYLINE / EMR_POLYPOLYLINE: открытые фигуры, обводка оператором S, никогда h, f или B, потому что заливка в PDF замыкает открытые подпути
  • EMR_POLYLINETO: старт с текущей позиции, остаёмся открытыми, обновляем текущую позицию, состояние пера не трогаем
  • 32-битная EMR_POLYPOLYLINE: точки начинаются с байта 32 + nPolys * 4, а не с aptl[0]
  • EMR_POLYDRAW: маскируйте PT_CLOSEFIGURE до диспетчеризации, закрывайте после завершённого сегмента, стартуйте с текущей позиции, если первая точка — не PT_MOVETO
  • Состояние контекста устройства по умолчанию — BLACK_PEN плюс WHITE_BRUSH; v3.539.43 и новее его учитывают
  • Внутри BeginPath / EndPath каждая полилиния открывает собственный подпуть, и пока скобка не использована, ничего не рисуется
  • Валидируйте каждый cptl / cpts против nSize в 64-битной арифметике до копирования точек
  • Самодельным тестовым EMF нужен полный 108-байтовый заголовок, иначе TMetafile.LoadFromStream прочитает их как WMF

Если отчёты идут через другой компонент, семантика записей та же; векторный импорт EMF и WMF в HotPDF разбирает, как тот компонент превращает градиентные и штриховые кисти в PDF-паттерны, а векторная графика, шейдеры и градиенты в PDFlibPas — рисование тех же фигур напрямую через API библиотеки, без метафайла

PDFlibPas v3.539.43 и новее включает все перечисленные правила. Детали и триальные загрузки — на странице продукта PDFlibPas Delphi PDF library