Technischer Artikel

PDFlibPas EMF-Import: PolyDraw-, Polyline- und Bezier-Regeln

PDFlibPas, die losLab-PDF-Bibliothek für Delphi, setzt die EMF-Poly*-Records in PDF-Pfade um, indem sie sich an jede Record-Definition in [MS-EMF] hält: Ein 32-Bit-EMR_POLYBEZIER beginnt bei Punkt 0, Polylines bleiben offen und werden nur gestrichen, PT_CLOSEFIGURE in EMR_POLYDRAW ist ein Flag, und jede Punktanzahl wird gegen die Record-Größe geprüft. Diese Regeln sind über v3.539.39, v3.539.41 und v3.539.43 hinweg gefallen. Davor konnte ein Report-Chart aus ImportEMFFromFile mit einem gefüllten Keil kommen, wo eine Trendlinie hingehörte, eine Bezier-Kurve zum falschen Kontrollpunkt ausgebogen sein oder eine geschlossene Umrisslinie ihre letzte Seite vermissen lassen. Keines davon hat einen Fehler geworfen, und die Regeln gelten für jeden Delphi-EMF-zu-PDF-Konverter und jeden GDI-Record-Parser

Warum gehen EMF-Poly*-Records bei der PDF-Konvertierung schief?

EMF-Poly*-Records gehen schief, weil jeder einen Teil seiner Bedeutung außerhalb seiner Punkte trägt: ob die Figur offen ist, ob sie an der aktuellen Position beginnt, welcher Stift und Pinsel gelten und wo im Record die Punkte anfangen. Ein Enhanced Metafile ist eine Aufzeichnung von GDI-Aufrufen gegen einen Device Context, also muss ein Konverter diesen Device-Context-Zustand genauso wiederholen wie die Koordinaten. PDF hat keinen Device Context. Es hat einen Pfad, einen aktuellen Punkt in diesem Pfad und einen Zeichnungsoperator, der zwischen Stroke (S), Fill (f) und beidem (B) entscheidet. Jede Diskrepanz zwischen den beiden Modellen wird zu einer stillen Rendering-Differenz

Die Poly*-Familie gibt es zudem in zwei Breiten. Jeder 32-Bit-Record wie EMR_POLYLINE hat einen 16-Bit-Zwilling wie EMR_POLYLINE16, der Punkte als SmallInt-Paare speichert. GDI zeichnet meist die kompakte Form auf, wenn jede Koordinate hineinpasst, deshalb können die 32-Bit-Handler eines Konverters jahrelang falsch bleiben, während alltägliches Testgekrit sie nie erreicht. Der schnellste Audit ist, dieselben Punkte durch beide Records zu schicken und die resultierenden Pfade zu vergleichen. Die Records, um die es hier geht, gehören alle zur Drawing-Record-Gruppe von [MS-EMF] (2.3.5 Drawing Record Types)

RecordBeginnt beiGeschlossen?Aktuelle Position
EMR_POLYBEZIERPunkt 0NeinNicht verwendet, nicht aktualisiert
EMR_POLYLINEPunkt 0Nein (nur Stift)Nicht verwendet, nicht aktualisiert
EMR_POLYLINETOAktuelle PositionNein (nur Stift)Verwendet und aktualisiert
EMR_POLYPOLYLINEErster Punkt jeder PolylineNein (nur Stift)Nicht verwendet, nicht aktualisiert
EMR_POLYDRAWErstes PT_MOVETO oder aktuelle PositionNur wo PT_CLOSEFIGURE gesetzt istVerwendet und aktualisiert

Wo beginnt eine EMR_POLYBEZIER-Kurve tatsächlich?

Eine EMR_POLYBEZIER-Kurve beginnt bei Punkt 0, und erst die Punkte ab Index 1 werden zu Drillingen gruppiert: Kontrollpunkt, Kontrollpunkt, Endpunkt. Ein Record mit 7 Punkten zeichnet also zwei kubische Segmente: 0 ist der Start, 1 bis 3 bilden das erste Segment, 4 bis 6 das zweite. Der 16-Bit-Handler in PDFlibPas hat das schon immer richtig gemacht. Der 32-Bit-Handler begann die Gruppierung bei Punkt 0, der Startpunkt wurde also als erster Kontrollpunkt verbraucht, und jedes spätere Segment verschob sich um eins. Die Kurve wurde trotzdem gerendert, nur eben die falsche. Seit v3.539.41 öffnen beide Breiten den Pfad mit m bei Punkt 0 und geben ein c pro vollständigem Drilling danach aus

PDFlibPas-Diagramm eines EMR_POLYBEZIER-Records mit sieben Punkten, bei dem Punkt null den Pfad mit m öffnet und die Punkte eins bis drei sowie vier bis sechs jeweils ein kubisches c-Segment bilden, im Kontrast zum seit v3.539.41 korrigierten 32-Bit-Handler mit der alten Gruppierung, die den Startpunkt als Kontrollpunkt verbrauchte
Punkt 0 ist der Startpunkt, und nur vollständige Drillinge danach werden zu kubischen Segmenten, ein PolyBezier mit sieben Punkten rendert also als m plus zwei c-Operatoren

Für Ihren eigenen Parser: Eine Anzahl, die nicht 1 plus ein Vielfaches von 3 ist, ist defekt, und die überzähligen Punkte sollten ignoriert werden, statt in eine Kurve vernäht zu werden

PolyDraw: PT_CLOSEFIGURE ist ein Flag, kein Punkttyp

In EMR_POLYDRAW ist PT_CLOSEFIGURE (Wert 1) ein Bit, das mit PT_LINETO (2) oder PT_BEZIERTO (4) kombiniert wird, ein gültiges Typbyte kann also 3 oder 5 sein. Der Punkttyp ist das Byte mit ausmaskiertem Bit, und das Flag bedeutet: schließe die Figur nach dem Segment, das auf diesem Punkt endet. Der alte PDFlibPas-Handler verglich das Byte in einer case-Anweisung gegen Einzelwerte, Punkte der Typen 3 und 5 passten also auf nichts und wurden komplett übersprungen. Ein mit PolyDraw gezeichnetes Rechteck verlor seine schließende Seite, und ein Bezier-Drilling, dessen letzter Punkt das Flag trug, verlor diesen Punkt, wodurch jeder spätere Drilling aus dem Tritt geriet

Seit v3.539.39 wird der Typ als Types[i] and not PT_CLOSEFIGURE gelesen, und das Close wird erst nach einem vollständigen Segment ausgegeben: nach der Linie bei geschlossenem PT_LINETO und nach dem dritten Punkt einer Bezier-Gruppe. Eine defekte Datei, die das Flag auf dem ersten oder zweiten Punkt eines Drillings setzt, schließt die Figur nicht vorzeitig. Zwei verwandte Fixes kamen im selben Release:

  • Jedes PT_MOVETO im 16-Bit-EMR_POLYDRAW16 startete den ganzen Pfad neu, ein Record mit drei Figuren behielt also nur die letzte; jetzt beginnt das erste Move den Pfad, spätere Moves öffnen Subpaths
  • Ein PolyDraw-Record, der nicht mit PT_MOVETO beginnt, startet an der aktuellen Position, wie die Record-Definition es vorschreibt, statt einen l- oder c-Operator ohne vorausgehendes m zu schreiben
PDFlibPas-Anatomie eines EMR_POLYDRAW-Typbytes, bei dem PT_CLOSEFIGURE das Flag-Bit null ist, per OR in PT_LINETO oder PT_BEZIERTO eingebracht, sodass gültige Typbytes 3 und 5 vor der Dispatch mit and not PT_CLOSEFIGURE maskiert werden müssen; die alte case-Anweisung übersprang beide Bytes, und geschlossene Figuren verloren ihre letzte Seite
Maskieren Sie das Close-Flag vor der Dispatch aus und geben Sie das Close erst nach einer abgeschlossenen Linie oder einem Bezier-Drilling aus, sonst wirft PolyDraw still Punkte weg

Warum darf eine EMF-Polyline in PDF nie gefüllt werden?

Eine EMF-Polyline darf nie gefüllt werden, weil EMR_POLYLINE und EMR_POLYPOLYLINE offene Figuren sind, die nur mit dem Stift gezeichnet werden, und das Füllen eines offenen Pfads in PDF ihn implizit schließt. ISO 32000-1 §8.5.3 besagt, dass die Fill-Operatoren jeden offenen Subpath schließen, bevor sie ihn zeichnen. Ein Konverter, der für eine dreipunktige Polyline B oder f ausgibt, malt also ein gefülltes Dreieck in der aktuellen Pinselfarbe: der gefüllte Keil unter der Trendlinie eines Charts. Vor v3.539.41 füllte PDFlibPas beide Polyline-Breiten mit dem Pinsel, und der 32-Bit-Record wurde zusätzlich explizit geschlossen. Heute enden beide Breiten mit reinem Stroke, und die GDI-Unterscheidung bleibt erhalten: Polygon schließt und füllt, Polyline tut das nie

PDFlibPas-Vergleich einer offenen V-Polyline aus EMR_POLYLINE: ein korrekter Konverter beendet den Pfad mit dem Stroke-Operator S und ignoriert den gewählten Pinsel, während f oder B den offenen Subpath gemäß ISO 32000-1 8.5.3 implizit schließt und den gefüllten Keil als Chart-Bug malt
Ein Fill-Operator schließt jeden offenen Subpath vor dem Zeichnen, Polylines müssen also in S enden, ohne h, f oder B auf dem Subpath

PolylineTo beginnt an der aktuellen Position

EMR_POLYLINETO zeichnet von der aktuellen Position durch jeden Punkt im Record, bleibt offen und lässt die aktuelle Position beim letzten Punkt. Der alte Handler enthielt außerdem einen Sonderfall, der den Stift ausschaltete, wenn die ersten beiden Punkte eine y-Koordinate teilten, und nichts schaltete ihn je wieder ein, jeder spätere Record in der Datei verlor also seinen Umriss. Der Stiftzustand gehört zu EMR_SELECTOBJECT und EMR_CREATEPEN; ein Drawing-Record-Handler hat da nichts verloren. Dieser Sonderfall fiel in v3.539.41 weg, und die Ein-Punkt-Form des Records liest nicht mehr über die eigenen Punkte hinaus (gefixt in v3.539.39)

PolyPolyline-Punkte beginnen nach dem Counts-Array

Der 32-Bit-EMR_POLYPOLYLINE speichert nPolys Counts und danach cptl Punkte, und die Punkte beginnen beim Byte-Offset 32 + nPolys * 4. Die Falle sitzt in der RTL: Die Unit Windows deklariert TEMRPolyPolyline mit aPolyCounts und aptl als Ein-Element-Arrays, aptl[0] ist also nur dann der erste Punkt, wenn nPolys 1 ist. Code, der aptl direkt indiziert, liest bei jedem Mehrfachlinien-Record Count-Werte als Koordinaten. Der alte PDFlibPas-Handler bemaß außerdem seine Bounds-Prüfung an diesem falschen Layout, gültige Mehrfachlinien-Records wurden also abgewiesen und einlinige zeichneten nichts. Seit v3.539.41 lokalisiert PDFlibPas das Punktarray über den berechneten Offset, so wie sein PolyPolygon-Handler es immer tat, und zeichnet jede Polyline als eigenen offenen Subpath mit einem Stroke am Ende. In v3.539.43 bekam der 16-Bit-Zwilling dieselbe Behandlung; er hatte segmentweise gezeichnet, was Line Joins brach und einen gewählten NULL_PEN ignorierte

Der Default-Stift und -Pinsel und die Pfad-Klammern

Zwei Zustandsregeln runden die Polyline-Fixes in v3.539.43 ab:

  • Ein frischer GDI-Device Context hat bereits BLACK_PEN und WHITE_BRUSH gewählt, ein Metafile, das ohne jedes EMR_SELECTOBJECT zeichnet, bringt also weiterhin schwarze Umrisslinien; der Konverter startete früher ohne Stift und ohne Füllung und schrieb für solche Records n (Pfad beenden, nichts zeichnen)
  • Innerhalb einer BeginPath-/EndPath-Klammer verwendet eine Polyline die aktuelle Position weder noch aktualisiert sie, sie muss also an ihrem ersten Punkt einen neuen Subpath öffnen, statt an die vorherige Figur anzuschließen, und gezeichnet werden darf nichts, bis die Klammer gestrichen oder gefüllt wird

Eine EMF-Testdatei mit TMetafileCanvas bauen

Am schnellsten prüfen Sie einen Konverter gegen diese Regeln, indem Sie die drei riskanten Aufrufe mit TMetafileCanvas in ein einziges Enhanced Metafile aufzeichnen. Die Zeichnung unten nimmt die Kurven mit transparentem Pinsel auf und wählt dann absichtlich einen deckenden gelben Pinsel für die Polyline: Ein korrekter Konverter muss diesen Pinsel für die Polyline ignorieren, jedes Gelb im Ausgabe-PDF ist also ein Bug. Für PolyDraw gibt es keinen TCanvas-Wrapper, der Aufruf läuft über die Windows API mit dem Canvas-Handle, mit den Typbytes 3 und 5, um das Close-Flag zu üben

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

procedure BuildPolyTestEmf(const FileName: string);
const
  // Ein geschlossenes Quadrat (3 = LINETO + CLOSEFIGURE), dann eine
  // geschlossene Bezier-Figur, deren letzter Drilling mit 5 = BEZIERTO + CLOSEFIGURE endet
  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;    // nur Umrisslinien für die Kurven
      // Punkt 0 ist der Start; 1..3 und 4..6 sind zwei kubische Segmente
      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));
      // Offene V-Form mit gewähltem deckendem Pinsel: gestrichen, nie
      // zu einem gelben Dreieck geschlossen
      Canvas.Brush.Style := bsSolid;
      Canvas.Brush.Color := clYellow;
      Canvas.Polyline([Point(20, 240), Point(120, 160), Point(220, 240)]);
    finally
      Canvas.Free;   // beendet die Aufzeichnung
    end;
    Mf.SaveToFile(FileName);
  finally
    Mf.Free;
  end;
end;

Weil diese Koordinaten in einen SmallInt passen, speichert GDI normalerweise die 16-Bit-Varianten. Um an die 32-Bit-Handler zu kommen, brauchen Sie einen Producer, der sie schreibt, oder Records, die Sie von Hand bauen. Handgebaute Dateien bringen ihre eigene Falle mit: Die VCL-TMetafile.LoadFromStream behandelt den Stream nur dann als EMF, wenn die verbleibende Länge strikt größer als der 108-Byte-TEnhMetaHeader ist. Ein minimales handgeschriebenes EMF mit kurzem Header oder ein leeres, exakt 108 Bytes langes, wird als WMF genommen und mit „Metafile is not valid“ abgelehnt. Schreiben Sie immer den vollen 108-Byte-Header inklusive der Erweiterungsfelder, bevor Ihre Test-Records kommen

Das EMF mit PDFlibPas in ein PDF importieren

PDFlibPas importiert ein EMF mit ImportEMFFromFile oder ImportEMFFromStream, die bei Erfolg eine von null verschiedene Image-ID zurückgeben und bei Misserfolg 0. GeneralOptions = 0 hält den Vektorpfad, um den es in diesem Artikel geht; 1 rastert das Metafile stattdessen zu einer Bitmap. FontOptions = 1 nimmt die Metafile-Fonts als nicht eingebettete TrueType-Fonts auf. Die Stream-Variante spult den Stream vor dem Laden auf Position 0 zurück, übergeben Sie also einen Stream, der nur das Metafile enthält

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);              // Ursprung oben links für DrawImage
    PDF.SetMeasurementUnits(0);    // Punkte
    // FontOptions 1 = Fonts als nicht eingebettetes TrueType aufnehmen
    // GeneralOptions 0 = Vektorimport, 1 = Bitmap
    ImageID := PDF.ImportEMFFromFile(EmfFile, 1, 0);
    if ImageID = 0 then
      raise Exception.Create('The metafile could not be imported');
    PDF.SelectImage(ImageID);
    // Bei einem EMF sind ImageWidth / ImageHeight die Rahmengröße in Punkten
    PDF.DrawImage(36, 36, PDF.ImageWidth, PDF.ImageHeight);

    // Die Seite ruft nur die importierte Form auf: 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;

Ein Vektor-EMF-Import wird zu einem Form XObject, GetPageContentToString liefert also nur die Save-, Transform-, Do- und Restore-Sequenz. Die aus den Poly*-Records erzeugten m-, l-, c-, h- und S-Operatoren leben im Form-XObject-Stream, der komprimiert ist. Um sie zu prüfen, dekomprimieren Sie die gespeicherte Datei in einem PDF-Objekt-Inspektor und lesen den Form-Stream: In der Testdatei oben sollten Sie sehen, dass die Polyline in S endet, ohne ein h davor, ein h an jedem Close-Flag der PolyDraw-Figuren und kein f oder B auf irgendeinem dieser Subpaths. DrawImage skaliert ein importiertes EMF zudem einheitlich mit dem kleineren von Width und Height, die Zeichnung behält also ihr Seitenverhältnis, selbst wenn die Box, die Sie übergeben, nicht dazu passt

Zu Free-Pascal-Zielen lesen Sie, wie der PDFlibPas-EMF-Vektorimporter unter Free Pascal gebaut wird; die Record-Semantik bleibt dieselbe, egal wo der Importer kompiliert

Wie sollte ein EMF-Parser Punktanzahlen aus der Datei behandeln?

Ein EMF-Parser sollte jede Punktanzahl als nicht vertrauenswürdige Eingabe behandeln und sie gegen die Record-Größe prüfen, bevor er einen einzigen Punkt kopiert. EnumEnhMetaFile garantiert nur, dass das nSize jedes Records innerhalb der Datei bleibt. Es prüft nicht, ob cptl zu nSize passt, ein Handler, der cptl Punkte mit Move kopiert, liest also die folgenden Records oder hinter das Ende des Metafiles, wenn die Anzahl gefälscht oder korrupt ist. Seit v3.539.39 prüft PDFlibPas für PolyDraw, PolyBezier, PolyBezierTo, Polyline, PolylineTo und Polygon in beiden Breiten festen Header plus Anzahl mal Bytes pro Punkt gegen nSize, mit einem Extrabyte pro Punkt für PolyDraw-Typbytes. Bei den PolyPoly-Records müssen die Counts pro Figur zudem zusammen höchstens die deklarierte Gesamtsumme ergeben, und Figuren ohne Punkte werden übersprungen

Dieselbe Prüfung ist kurz genug, um sie in den eigenen Parser zu übernehmen. Diese Version validiert einen 32-Bit-EMR_POLYPOLYLINE und liefert einen Zeiger auf sein echtes Punktarray:

uses
  Winapi.Windows;

// Liefert nil, sofern der Record nicht wirklich die deklarierten Punkte hält.
// Die Punkte beginnen nach dem Counts-Array: 32 + nPolys * 4 Bytes hinein,
// nicht bei aptl[0], das die RTL als Ein-Element-Array deklariert
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;                         // gefälschte oder abgeschnittene Anzahl
  Total := 0;
  Count := @P^.aPolyCounts[0];    // per Zeiger gehen: [0..0] löst Range-Checks aus
  for I := 1 to P^.nPolys do
  begin
    Inc(Total, Count^);
    Inc(Count);
  end;
  if Total > P^.cptl then
    Exit;                         // Figuren beanspruchen mehr Punkte, als existieren
  Result := PPoint(NativeUInt(Rec) + NativeUInt(PointsOffset));
end;

Der Punkte-passen-Test läuft zuerst, das Counts-Array liegt also nachweislich im Record, bevor die Schleife es abläuft. Die Arithmetik ist Int64, weil nPolys * 4 und cptl * 8 in 32 Bit berechnet überlaufen und die Vergleichung passieren können

Kurzreferenz: EMF-Poly*-Regeln für die EMF-zu-PDF-Konvertierung

  • EMR_POLYBEZIER: Punkt 0 ist der Startpunkt; ab Punkt 1 in Drillingen gruppieren; für den 32-Bit-Record gefixt in v3.539.41
  • EMR_POLYLINE / EMR_POLYPOLYLINE: offene Figuren, Stroke mit S, nie h, f oder B, weil PDF-Fill offene Subpaths schließt
  • EMR_POLYLINETO: an der aktuellen Position starten, offen bleiben, die aktuelle Position aktualisieren, den Stiftzustand nie anfassen
  • 32-Bit-EMR_POLYPOLYLINE: Punkte beginnen bei Byte 32 + nPolys * 4, nicht bei aptl[0]
  • EMR_POLYDRAW: PT_CLOSEFIGURE vor der Dispatch ausmaskieren, nach dem abgeschlossenen Segment schließen, an der aktuellen Position starten, wenn der erste Punkt nicht PT_MOVETO ist
  • Der Default-Device-Context-Zustand ist BLACK_PEN plus WHITE_BRUSH; berücksichtigt ab v3.539.43
  • Innerhalb von BeginPath / EndPath öffnet jede Polyline ihren eigenen Subpath, und gezeichnet wird nichts, bis die Klammer verwendet wird
  • Jedes cptl / cpts in 64-Bit-Arithmetik gegen nSize validieren, bevor Punkte kopiert werden
  • Handgebaute Test-EMFs brauchen den vollen 108-Byte-Header, sonst liest TMetafile.LoadFromStream sie als WMF

Läuft Ihr Report durch eine andere Komponente, gelten dieselben Record-Semantiken; HotPDF EMF- und WMF-Vektorimport behandelt, wie diese Komponente Verlaufs- und Schraffurpinsel in PDF-Patterns umsetzt, und Vektorgrafiken, Shader und Verläufe in PDFlibPas behandelt das Zeichnen derselben Formen direkt über die Bibliotheks-API statt über ein Metafile

PDFlibPas v3.539.43 oder später enthält jede der obigen Regeln. Details und Testdownloads gibt es auf der Produktseite der PDFlibPas-Delphi-PDF-Bibliothek