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)
| Record | Beginnt bei | Geschlossen? | Aktuelle Position |
|---|---|---|---|
EMR_POLYBEZIER | Punkt 0 | Nein | Nicht verwendet, nicht aktualisiert |
EMR_POLYLINE | Punkt 0 | Nein (nur Stift) | Nicht verwendet, nicht aktualisiert |
EMR_POLYLINETO | Aktuelle Position | Nein (nur Stift) | Verwendet und aktualisiert |
EMR_POLYPOLYLINE | Erster Punkt jeder Polyline | Nein (nur Stift) | Nicht verwendet, nicht aktualisiert |
EMR_POLYDRAW | Erstes PT_MOVETO oder aktuelle Position | Nur wo PT_CLOSEFIGURE gesetzt ist | Verwendet 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
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_MOVETOim 16-Bit-EMR_POLYDRAW16startete 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_MOVETObeginnt, startet an der aktuellen Position, wie die Record-Definition es vorschreibt, statt einenl- oderc-Operator ohne vorausgehendesmzu schreiben
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
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_PENundWHITE_BRUSHgewählt, ein Metafile, das ohne jedesEMR_SELECTOBJECTzeichnet, bringt also weiterhin schwarze Umrisslinien; der Konverter startete früher ohne Stift und ohne Füllung und schrieb für solche Recordsn(Pfad beenden, nichts zeichnen) - Innerhalb einer
BeginPath-/EndPath-Klammer verwendet einePolylinedie 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.41EMR_POLYLINE/EMR_POLYPOLYLINE: offene Figuren, Stroke mitS, nieh,foderB, weil PDF-Fill offene Subpaths schließtEMR_POLYLINETO: an der aktuellen Position starten, offen bleiben, die aktuelle Position aktualisieren, den Stiftzustand nie anfassen- 32-Bit-
EMR_POLYPOLYLINE: Punkte beginnen bei Byte32 + nPolys * 4, nicht beiaptl[0] EMR_POLYDRAW:PT_CLOSEFIGUREvor der Dispatch ausmaskieren, nach dem abgeschlossenen Segment schließen, an der aktuellen Position starten, wenn der erste Punkt nichtPT_MOVETOist- Der Default-Device-Context-Zustand ist
BLACK_PENplusWHITE_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/cptsin 64-Bit-Arithmetik gegennSizevalidieren, bevor Punkte kopiert werden - Handgebaute Test-EMFs brauchen den vollen 108-Byte-Header, sonst liest
TMetafile.LoadFromStreamsie 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