Articolo tecnico

PDFlibPas: import EMF, regole PolyDraw, Polyline, Bezier

PDFlibPas, la libreria PDF di losLab per Delphi, converte i record Poly* dell'EMF in tracciati PDF seguendo la definizione di ogni record in [MS-EMF]: un EMR_POLYBEZIER a 32 bit parte dal punto 0, le polyline restano aperte e vengono solo tracciate, PT_CLOSEFIGURE in EMR_POLYDRAW è un flag, e ogni conteggio di punti viene controllato contro la dimensione del record. Quelle regole sono approdate con le versioni v3.539.39, v3.539.41 e v3.539.43. Prima, un grafico di report poteva uscire da ImportEMFFromFile con uno spicchio pieno dove doveva stare una linea di tendenza, una curva Bezier piegata verso il punto di controllo sbagliato o un contorno chiuso a cui mancava l'ultimo lato. Nessuno di questi errori sollevava un'eccezione, e le regole valgono per qualsiasi convertitore EMF in PDF in Delphi o parser di record GDI

Perché i record Poly* dell'EMF vanno storti nella conversione in PDF?

I record Poly* dell'EMF vanno storti perché ciascuno porta parte del proprio significato fuori dai punti: se la figura è aperta, se parte dalla posizione corrente, quale penna e quale pennello si applicano e dove nel record iniziano i punti. Un enhanced metafile è una registrazione di chiamate GDI verso un device context, quindi un convertitore deve riprodurre quello stato del device context oltre alle coordinate. Il PDF non ha un device context. Ha un path, un punto corrente dentro quel path e un operatore di disegno che decide tra stroke (S), fill (f) ed entrambi (B). Ogni disallineamento tra i due modelli diventa una differenza di resa silenziosa

La famiglia Poly* esiste anche in due larghezze. Ogni record a 32 bit come EMR_POLYLINE ha un gemello a 16 bit come EMR_POLYLINE16 che memorizza i punti come coppie di SmallInt. GDI di solito registra la forma compatta quando ogni coordinata ci sta, così i gestori a 32 bit di un convertitore possono restare sbagliati per anni mentre i disegni di prova di tutti i giorni non li raggiungono mai. L'audit più rapido è far passare gli stessi punti attraverso entrambi i record e confrontare i path risultanti. I record trattati qui sono tutti nel gruppo dei record di disegno di [MS-EMF] (2.3.5 Drawing Record Types)

RecordParte daChiuso?Posizione corrente
EMR_POLYBEZIERPunto 0NoNon usata, non aggiornata
EMR_POLYLINEPunto 0No (solo penna)Non usata, non aggiornata
EMR_POLYLINETOPosizione correnteNo (solo penna)Usata e aggiornata
EMR_POLYPOLYLINEPrimo punto di ogni polylineNo (solo penna)Non usata, non aggiornata
EMR_POLYDRAWPrimo PT_MOVETO, o posizione correnteSolo dove PT_CLOSEFIGURE è impostatoUsata e aggiornata

Da dove parte davvero una curva EMR_POLYBEZIER?

Una curva EMR_POLYBEZIER parte dal punto 0, e solo i punti dall'indice 1 in poi sono raggruppati a tre come punto di controllo, punto di controllo, punto finale. Un record con 7 punti disegna quindi due segmenti cubici: 0 è la partenza, da 1 a 3 forma il primo segmento, da 4 a 6 il secondo. Il gestore a 16 bit di PDFlibPas lo faceva già. Quello a 32 bit iniziava a raggruppare dal punto 0, così il punto di partenza veniva consumato come primo punto di controllo e ogni segmento successivo slittava di uno. La curva si rendeva comunque, solo quella sbagliata. Dalla v3.539.41 entrambe le larghezze aprono il path con m al punto 0 ed emettono una c per ogni terna completa dopo di esso

Diagramma PDFlibPas di un record EMR_POLYBEZIER con sette punti in cui il punto zero apre il path con m e i punti da uno a tre e da quattro a sei formano ciascuno un segmento cubico c, a confronto il gestore a 32 bit corretto dalla v3.539.41 con il vecchio raggruppamento che consumava il punto di partenza come punto di controllo
Il punto 0 è il punto di partenza e solo le terne complete dopo di esso diventano segmenti cubici, così una PolyBezier di sette punti si rende come m più due operatori c

Per il parser che scrivi tu: un conteggio che non è 1 più un multiplo di 3 è malformato, e i punti in coda vanno ignorati invece di essere cuciti in una curva

PolyDraw: PT_CLOSEFIGURE è un flag, non un tipo di punto

In EMR_POLYDRAW, PT_CLOSEFIGURE (valore 1) è un bit combinato con PT_LINETO (2) o PT_BEZIERTO (4), quindi un byte di tipo valido può essere 3 o 5. Il tipo del punto è il byte con quel bit mascherato via, e il flag significa chiudi la figura dopo il segmento che termina su questo punto. Il vecchio gestore di PDFlibPas confrontava il byte con valori singoli in uno case, così i punti di tipo 3 e 5 non combaciavano con nulla e venivano saltati del tutto. Un rettangolo disegnato con PolyDraw perdeva il lato di chiusura, e una terna Bezier il cui ultimo punto portava il flag perdeva quel punto, il che disallineava tutte le terne successive

Dalla v3.539.39 il tipo si legge come Types[i] and not PT_CLOSEFIGURE, e la chiusura viene emessa solo dopo un segmento completo: dopo la linea per una PT_LINETO chiusa, e dopo il terzo punto di un gruppo Bezier. Un file malformato che imposta il flag sul primo o secondo punto di una terna non chiude la figura in anticipo. Nella stessa release sono arrivate due correzioni collegate:

  • Ogni PT_MOVETO nel EMR_POLYDRAW16 a 16 bit riavviava l'intero path, così un record con tre figure conservava solo l'ultima; ora la prima move avvia il path e le move successive aprono subpath
  • Un record PolyDraw che non inizia con PT_MOVETO parte dalla posizione corrente, come dice la definizione del record, invece di scrivere un operatore l o c senza una m che lo preceda
Anatomia PDFlibPas di un byte di tipo di EMR_POLYDRAW in cui PT_CLOSEFIGURE è il bit flag zero in OR dentro PT_LINETO o PT_BEZIERTO, così i byte di tipo validi 3 e 5 vanno mascherati con and not PT_CLOSEFIGURE prima del dispatch; il vecchio case saltava entrambi i byte e le figure chiuse perdevano l'ultimo lato
Maschera via il flag di chiusura prima del dispatch ed emetti la chiusura solo dopo una linea completata o una terna Bezier, altrimenti PolyDraw scarta punti in silenzio

Perché una polyline EMF non va mai riempita in PDF?

Una polyline EMF non va mai riempita perché EMR_POLYLINE e EMR_POLYPOLYLINE sono figure aperte disegnate con la sola penna, e riempire un path aperto in PDF lo chiude implicitamente. ISO 32000-1 §8.5.3 stabilisce che gli operatori di fill chiudono qualunque subpath aperto prima di dipingerlo. Un convertitore che emette B o f per una polyline di tre punti dipinge quindi un triangolo pieno nel colore del pennello corrente: lo spicchio pieno sotto la linea di tendenza di un grafico. Prima della v3.539.41 PDFlibPas riempiva entrambe le larghezze di polyline con il pennello, e il record a 32 bit veniva anche chiuso esplicitamente. Oggi entrambe le larghezze terminano con il solo stroke, e la distinzione GDI resta: Polygon chiude e riempie, Polyline mai

Confronto PDFlibPas di una polyline a V aperta esportata da EMR_POLYLINE: un convertitore corretto termina il path con l'operatore di stroke S e ignora il pennello selezionato, mentre emettere f o B chiude implicitamente il subpath aperto secondo ISO 32000-1 8.5.3 e dipinge lo spicchio pieno, il bug da grafico
Un operatore di fill chiude qualunque subpath aperto prima di dipingerlo, quindi le polyline devono terminare con S senza h, f o B sul subpath

PolylineTo parte dalla posizione corrente

EMR_POLYLINETO disegna dalla posizione corrente attraverso ogni punto del record, resta aperto e lascia la posizione corrente sull'ultimo punto. Il vecchio gestore conteneva anche un caso speciale che spegneva la penna quando i primi due punti condividevano la y, e nulla la riaccendeva più, così ogni record successivo nel file perdeva il proprio contorno. Lo stato della penna appartiene a EMR_SELECTOBJECT e EMR_CREATEPEN; un gestore di record di disegno non deve metterci mano. Quel caso speciale è stato rimosso nella v3.539.41, e la forma a un punto del record non legge più oltre i propri punti (corretto nella v3.539.39)

I punti di PolyPolyline iniziano dopo l'array dei conteggi

Il EMR_POLYPOLYLINE a 32 bit memorizza nPolys conteggi e poi cptl punti, e i punti iniziano all'offset di byte 32 + nPolys * 4. La trappola sta nella RTL: l'unità Windows dichiara TEMRPolyPolyline con aPolyCounts e aptl come array a un elemento, quindi aptl[0] è il primo punto solo quando nPolys vale 1. Il codice che indicizza aptl direttamente legge i valori dei conteggi come coordinate per ogni record multilinea. Il vecchio gestore di PDFlibPas calibrava anche il proprio controllo sui limiti su quel layout sbagliato, così i record multilinea validi venivano rifiutati e quelli a linea singola non disegnavano nulla. Dalla v3.539.41 PDFlibPas individua l'array dei punti dall'offset calcolato, come ha sempre fatto il suo gestore PolyPolygon, e disegna ogni polyline come proprio subpath aperto con un solo stroke alla fine. Nella v3.539.43 il gemello a 16 bit ha avuto lo stesso trattamento; disegnava segmento per segmento, il che rompeva le giunzioni di linea e ignorava un NULL_PEN selezionato

La penna e il pennello di default, e le parentesi di path

Due regole di stato completano le correzioni alle polyline nella v3.539.43:

  • Un device context GDI appena creato ha già BLACK_PEN e WHITE_BRUSH selezionati, così un metafile che disegna senza alcun EMR_SELECTOBJECT produce comunque contorni neri; il convertitore partiva senza penna né fill e scriveva n (chiudi il path, non dipingere niente) per tali record
  • Dentro una parentesi BeginPath / EndPath, una Polyline non usa né aggiorna la posizione corrente, quindi deve aprire un nuovo subpath al proprio primo punto invece di collegarsi alla figura precedente, e nulla può essere dipinto finché la parentesi non è tracciata o riempita

Costruire un file EMF di prova con TMetafileCanvas

Il modo più rapido per verificare un convertitore contro queste regole è registrare le tre chiamate a rischio in un unico enhanced metafile con TMetafileCanvas. Il disegno qui sotto registra le curve con un pennello vuoto e poi seleziona di proposito un pennello giallo pieno per la polyline: un convertitore corretto deve ignorare quel pennello per la polyline, quindi qualsiasi giallo nel PDF di uscita è un bug. PolyDraw non ha un wrapper TCanvas, quindi viene chiamata attraverso la Windows API con l'handle del canvas, usando i byte di tipo 3 e 5 per esercitare il flag di chiusura

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

procedure BuildPolyTestEmf(const FileName: string);
const
  // Un quadrato chiuso (3 = LINETO + CLOSEFIGURE), poi una figura Bezier
  // chiusa la cui ultima terna di controllo termina con 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;    // solo contorni per le curve
      // Il punto 0 è la partenza; 1..3 e 4..6 sono due segmenti cubici
      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));
      // Forma a V aperta con pennello pieno selezionato: solo stroke, mai
      // chiusa in un triangolo giallo
      Canvas.Brush.Style := bsSolid;
      Canvas.Brush.Color := clYellow;
      Canvas.Polyline([Point(20, 240), Point(120, 160), Point(220, 240)]);
    finally
      Canvas.Free;   // chiude la registrazione
    end;
    Mf.SaveToFile(FileName);
  finally
    Mf.Free;
  end;
end;

Poiché queste coordinate ci stanno in uno SmallInt, GDI di norma memorizza le varianti a 16 bit. Per raggiungere i gestori a 32 bit serve un produttore che li scriva, oppure record costruiti a mano. I file costruiti a mano portano la loro trappola: TMetafile.LoadFromStream della VCL tratta lo stream come EMF solo quando la lunghezza rimanente è strettamente maggiore dei 108 byte del TEnhMetaHeader. Un EMF minimale scritto a mano con un header corto, o uno vuoto lungo esattamente 108 byte, viene preso per un WMF e rifiutato con "Metafile is not valid". Scrivi sempre l'header completo di 108 byte, campi di estensione inclusi, prima dei tuoi record di prova

Importare l'EMF in un PDF con PDFlibPas

PDFlibPas importa un EMF con ImportEMFFromFile o ImportEMFFromStream, che restituiscono un ID immagine diverso da zero in caso di successo e 0 in caso di fallimento. GeneralOptions = 0 mantiene l'import vettoriale di cui parla questo articolo; 1 rasterizza invece il metafile in una bitmap. FontOptions = 1 aggiunge i font del metafile come font TrueType non incorporati. La variante via stream riavvolge lo stream alla posizione 0 prima del caricamento, quindi passagli uno stream che contiene solo il metafile

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);              // origine in alto a sinistra per DrawImage
    PDF.SetMeasurementUnits(0);    // punti
    // FontOptions 1 = aggiunge i font come TrueType non incorporati
    // GeneralOptions 0 = import vettoriale, 1 = bitmap
    ImageID := PDF.ImportEMFFromFile(EmfFile, 1, 0);
    if ImageID = 0 then
      raise Exception.Create('The metafile could not be imported');
    PDF.SelectImage(ImageID);
    // Per un EMF, ImageWidth / ImageHeight sono la cornice in punti
    PDF.DrawImage(36, 36, PDF.ImageWidth, PDF.ImageHeight);

    // La pagina invoca solo il form importato: 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;

Un import EMF vettoriale diventa un form XObject, quindi GetPageContentToString restituisce solo la sequenza save, transform, Do e restore. Gli operatori m, l, c, h e S prodotti dai record Poly* vivono nello stream del form XObject, che è compresso. Per auditarli, decomprimi il file salvato in un PDF object inspector e leggi lo stream del form: per il file di prova qui sopra dovresti vedere la polyline terminare con S senza alcuna h prima, una h a ogni flag di chiusura nelle figure PolyDraw, e nessuna f o B su questi subpath. DrawImage scala inoltre un EMF importato in modo uniforme secondo la minore tra Width e Height, così il disegno mantiene le proporzioni anche se la box che gli passi non corrisponde

Per i target Free Pascal, vedi come l'importer vettoriale EMF di PDFlibPas compila sotto Free Pascal; la semantica dei record è la stessa ovunque compili l'importer

Come deve trattare un parser EMF i conteggi di punti dal file?

Un parser EMF dovrebbe trattare ogni conteggio di punti come input non fidato e controllarlo contro la dimensione del record prima di copiare un solo punto. EnumEnhMetaFile garantisce solo che ogni nSize di record stia dentro il file. Non verifica che cptl torni con nSize, quindi un gestore che copia cptl punti con Move leggerà i record successivi, o oltre la fine del metafile, quando il conteggio è contraffatto o corrotto. Dalla v3.539.39 PDFlibPas controlla header fisso più conteggio per byte per punto contro nSize per PolyDraw, PolyBezier, PolyBezierTo, Polyline, PolylineTo e Polygon in entrambe le larghezze, con un byte extra per punto per i byte di tipo di PolyDraw. Per i record PolyPoly i conteggi per figura devono inoltre sommare a non più del totale dichiarato, e le figure a zero punti vengono saltate

Lo stesso controllo è abbastanza corto da copiare nel tuo parser. Questa versione valida un EMR_POLYPOLYLINE a 32 bit e restituisce un puntatore al suo vero array di punti:

uses
  Winapi.Windows;

// Restituisce nil a meno che il record contenga davvero i punti dichiarati.
// I punti partono dopo l'array dei conteggi: a 32 + nPolys * 4 byte, non su
// aptl[0], che la RTL dichiara come array a un elemento
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;                         // conteggio contraffatto o troncato
  Total := 0;
  Count := @P^.aPolyCounts[0];    // cammina per puntatore: [0..0] fa scattare i range check
  for I := 1 to P^.nPolys do
  begin
    Inc(Total, Count^);
    Inc(Count);
  end;
  if Total > P^.cptl then
    Exit;                         // le figure rivendicano più punti di quanti esistano
  Result := PPoint(NativeUInt(Rec) + NativeUInt(PointsOffset));
end;

Il test di accomodamento dei punti gira per primo, così l'array dei conteggi è noto stare dentro il record prima che il ciclo lo percorra. L'aritmetica è Int64 perché nPolys * 4 e cptl * 8 calcolati su 32 bit possono fare wrap around e passare il confronto

Riferimento rapido: regole Poly* EMF per la conversione EMF in PDF

  • EMR_POLYBEZIER: il punto 0 è il punto di partenza; raggruppa dal punto 1 a terne; corretto per il record a 32 bit nella v3.539.41
  • EMR_POLYLINE / EMR_POLYPOLYLINE: figure aperte, stroke con S, mai h, f o B, perché il fill del PDF chiude i subpath aperti
  • EMR_POLYLINETO: parte dalla posizione corrente, resta aperto, aggiorna la posizione corrente, non tocca mai lo stato della penna
  • EMR_POLYPOLYLINE a 32 bit: i punti iniziano al byte 32 + nPolys * 4, non su aptl[0]
  • EMR_POLYDRAW: maschera via PT_CLOSEFIGURE prima del dispatch, chiudi dopo il segmento completato, parti dalla posizione corrente quando il primo punto non è PT_MOVETO
  • Lo stato di default del device context è BLACK_PEN più WHITE_BRUSH; la v3.539.43 e successive lo rispettano
  • Dentro BeginPath / EndPath, ogni polyline apre il proprio subpath e nulla viene dipinto finché la parentesi non viene usata
  • Valida ogni cptl / cpts contro nSize in aritmetica a 64 bit prima di copiare i punti
  • Gli EMF di prova costruiti a mano richiedono l'header completo di 108 byte, altrimenti TMetafile.LoadFromStream li legge come WMF

Se i tuoi report passano da un componente diverso, la stessa semantica dei record si applica; l'import vettoriale EMF e WMF di HotPDF copre come quel componente trasforma i pennelli a gradiente e a tratteggio in pattern PDF, e grafica vettoriale, shader e gradienti in PDFlibPas copre il disegno delle stesse forme direttamente con l'API della libreria invece che attraverso un metafile

PDFlibPas v3.539.43 o successiva include ogni regola qui sopra. Dettagli e download di prova sono sulla pagina prodotto della PDFlibPas Delphi PDF library