Технічна стаття

Імпорт 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 із залитим сектором там, де мала бути лінія тренду, з кривою Безьє, зігнутою до неправильної контрольної точки, чи із закритим контуром без останньої сторони. Жодна з цих ситуацій не піднімала помилки, а правила стосуються будь-якого конвертера EMF у PDF на Delphi чи парсера записів GDI

Чому записи EMF Poly* ламаються при конвертації в PDF?

Записи EMF Poly* ламаються тому, що частина їхнього сенсу лежить за межами точок: чи фігура відкрита, чи вона стартує з поточної позиції, яке перо і пензель застосовуються, і де в записі починаються точки. Enhanced metafile — це запис викликів 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 плюс кратне 3, — деформована, а хвостові точки треба ігнорувати, а не вшивати в криву

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 перезапускав увесь шлях, тож запис із трьома фігурами зберігав лише останню; тепер перший move стартує шлях, а наступні відкривають підшляхи
  • Запис 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

Найшвидший спосіб перевірити конвертер проти цих правил — записати три ризиковані виклики в один enhanced metafile через 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, які повертають ненульовий ідентифікатор зображення в разі успіху і 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] вмикає перевірки діапазону
  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