Articol tehnic

EMF în PDFlibPas: regulile PolyDraw, Polyline și Bezier

PDFlibPas, biblioteca PDF losLab pentru Delphi, transformă înregistrările EMF Poly* în căi PDF urmărind definiția fiecărei înregistrări din [MS-EMF]: un EMR_POLYBEZIER pe 32 de biți pornește din punctul 0, poliliniile rămân deschise și sunt doar trasate, PT_CLOSEFIGURE din EMR_POLYDRAW e un flag, iar fiecare număr de puncte e verificat față de dimensiunea înregistrării. Regulile acelea au ajuns în v3.539.39, v3.539.41 și v3.539.43. Înainte de ele, o diagramă dintr-un raport putea ieși din ImportEMFFromFile cu o pană umplută acolo unde trebuia să fie o linie de tendință, o curbă Bezier îndoită spre punctul de control greșit sau un contur închis căruia îi lipsea ultima latură. Niciunul dintre acestea nu ridica o eroare, iar regulile se aplică oricărui convertor Delphi de la EMF la PDF sau oricărui parser de înregistrări GDI

De ce dau greș înregistrările EMF Poly* la conversia în PDF?

Înregistrările EMF Poly* dau greș pentru că fiecare își cară o parte din sens în afara punctelor: dacă figura e deschisă, dacă pornește din poziția curentă, ce pen și brush se aplică și unde în înregistrare încep punctele. Un enhanced metafile e o înregistrare a apelurilor GDI către un device context, deci un convertor trebuie să redea starea aceea de device context, nu doar coordonatele. PDF nu are device context. Are o cale, un punct curent în interiorul ei și un operator de desenare care decide între trasare (S), umplere (f) și ambele (B). Fiecare neconcordanță dintre cele două modele devine o diferență de randare tăcută

Familia Poly* vine și în două lățimi. Fiecare înregistrare pe 32 de biți precum EMR_POLYLINE are o soră pe 16 biți precum EMR_POLYLINE16, care stochează punctele ca perechi SmallInt. GDI înregistrează de obicei forma compactă când fiecare coordonată încape, deci handler-ele pe 32 de biți ale unui convertor pot rămâne greșite ani de zile în timp ce desenele de test obișnuite nu ajung niciodată la ele. Auditul cel mai rapid e să treci aceleași puncte prin ambele înregistrări și să compari căile rezultate. Înregistrările acoperite aici sunt toate în grupul de înregistrări de desenare din [MS-EMF] (2.3.5 Drawing Record Types)

ÎnregistrarePornește dinÎnchisă?Poziția curentă
EMR_POLYBEZIERPunctul 0NuNeutilizată, neactualizată
EMR_POLYLINEPunctul 0Nu (doar pen)Neutilizată, neactualizată
EMR_POLYLINETOPoziția curentăNu (doar pen)Utilizată și actualizată
EMR_POLYPOLYLINEPrimul punct al fiecărei poliliniiNu (doar pen)Neutilizată, neactualizată
EMR_POLYDRAWPrimul PT_MOVETO, sau poziția curentăDoar unde PT_CLOSEFIGURE e setatUtilizată și actualizată

De unde pornește de fapt curba unui EMR_POLYBEZIER?

O curbă EMR_POLYBEZIER pornește din punctul 0, iar doar punctele de la indicele 1 înainte sunt grupate în triplete ca punct de control, punct de control, punct final. O înregistrare cu 7 puncte desenează deci două segmente cubice: 0 e startul, 1 până la 3 formează primul segment, 4 până la 6 formează al doilea. Handler-ul pe 16 biți din PDFlibPas făcea deja asta. Handler-ul pe 32 de biți începea gruparea din punctul 0, deci punctul de start era consumat ca prim punct de control și fiecare segment ulterior se deplasa cu unul. Curba apărea tot randată, doar că alta. Din v3.539.41 ambele lățimi deschid calea cu m în punctul 0 și emit câte un c pentru fiecare triplet complet de după

Diagramă PDFlibPas a unei înregistrări EMR_POLYBEZIER cu șapte puncte, în care punctul zero deschide calea cu m, iar punctele unu-trei și patru-șase formează fiecare câte un segment cubic c, în contrast cu handler-ul pe 32 de biți reparat din v3.539.41 și vechea grupare care consuma punctul de start ca punct de control
Punctul 0 e punctul de start și doar tripletele complete de după devin segmente cubice, deci un PolyBezier cu șapte puncte se randează ca operatorul m plus doi operatori c

Pentru parser-ul propriu: un număr care nu e 1 plus un multiplu de 3 e malformat, iar punctele rămase la coadă trebuie ignorate, nu cusute într-o curbă

PolyDraw: PT_CLOSEFIGURE e un flag, nu un tip de punct

În EMR_POLYDRAW, PT_CLOSEFIGURE (valoarea 1) e un bit combinat cu PT_LINETO (2) sau PT_BEZIERTO (4), deci un octet de tip valid poate fi 3 sau 5. Tipul punctului e octetul cu acel bit mascat, iar flag-ul înseamnă închide figura după segmentul care se termină în acest punct. Vechiul handler PDFlibPas compara octetul cu valori singulare într-o instrucțiune case, deci punctele de tip 3 și 5 nu se potriveau cu nimic și erau sărite cu totul. Un dreptunghi desenat cu PolyDraw își pierdea latura de închidere, iar un triplet Bezier al cărui ultim punct cară flag-ul pierdea punctul acela, ceea ce scoase din pas toate tripletele ulterioare

Din v3.539.39 tipul se citește ca Types[i] and not PT_CLOSEFIGURE, iar închiderea e emisă doar după un segment complet: după linie pentru un PT_LINETO închis și după al treilea punct al unui grup Bezier. Un fișier malformat care setează flag-ul pe primul sau al doilea punct al unui triplet nu închide figura înainte de vreme. Două reparări înrudite au venit în aceeași versiune:

  • Fiecare PT_MOVETO din EMR_POLYDRAW16 pe 16 biți repornea toată calea, deci o înregistrare cu trei figuri păstra doar ultima; acum prima mutare pornește calea, iar mutările următoare deschid subcăi
  • O înregistrare PolyDraw care nu începe cu PT_MOVETO pornește din poziția curentă, cum spune definiția înregistrării, în loc să scrie un operator l sau c fără un m prealabil
Anatomia octetului de tip EMR_POLYDRAW în PDFlibPas, unde PT_CLOSEFIGURE e bitul de flag zero făcut OR cu PT_LINETO sau PT_BEZIERTO, deci octeții de tip valid 3 și 5 trebuie mascați cu and not PT_CLOSEFIGURE înainte de dispatch; vechiul case sărita ambii octeți, iar figurile închise își pierdeau ultima latură
Mascați flag-ul de închidere înainte de dispatch și emiteți închiderea doar după o linie sau un triplet Bezier complet, altfel PolyDraw aruncă puncte în tăcere

De ce nu trebuie umplută niciodată o polilinie EMF în PDF?

O polilinie EMF nu trebuie umplută niciodată pentru că EMR_POLYLINE și EMR_POLYPOLYLINE sunt figuri deschise desenate doar cu pen-ul, iar umplerea unei căi deschise în PDF o închide implicit. ISO 32000-1 §8.5.3 spune că operatorii de umplere închid orice subcale deschise înainte de a le desena. Un convertor care emite B sau f pentru o polilinie cu trei puncte desenează deci un triunghi umplut în culoarea de brush curentă: pana umplută de sub linia de tendință a unei diagrame. Înainte de v3.539.41, PDFlibPas umplea ambele lățimi de polilinie cu brush-ul, iar înregistrarea pe 32 de biți era și închisă explicit. Astăzi ambele lățimi se termină doar cu trasare, iar distincția GDI e păstrată: Polygon închide și umple, Polyline nu face asta niciodată

Comparație PDFlibPas a unei polilinii în V deschise exportate din EMR_POLYLINE: un convertor corect termină calea cu operatorul de trasare S și ignoră brush-ul selectat, în timp ce emiterea lui f sau B închide implicit subcalea deschisă conform ISO 32000-1 8.5.3 și desenează pana umplută, bug-ul clasic de diagramă
Un operator de umplere închide orice subcale deschisă înainte de desenare, deci poliliniile trebuie să se termine în S fără h, f sau B pe subcale

PolylineTo pornește din poziția curentă

EMR_POLYLINETO desenează din poziția curentă prin fiecare punct din înregistrare, rămâne deschis și lasă poziția curentă în ultimul punct. Vechiul handler conținea și un caz special care oprea pen-ul când primele două puncte partajau o coordonată y, iar nimic nu-l mai pornea înapoi, deci fiecare înregistrare ulterioară din fișier își pierdea conturul. Starea pen-ului aparține de EMR_SELECTOBJECT și EMR_CREATEPEN; un handler de înregistrare de desenare n-are ce să caute modificând-o. Cazul special acela a fost eliminat în v3.539.41, iar forma cu un singur punct a înregistrării nu mai citește dincolo de propriile puncte (reparat în v3.539.39)

Punctele PolyPolyline încep după tabloul de numărători

EMR_POLYPOLYLINE pe 32 de biți stochează nPolys numărători și apoi cptl puncte, iar punctele încep la offset-ul de octet 32 + nPolys * 4. Capcana e în RTL: unitatea Windows declară TEMRPolyPolyline cu aPolyCounts și aptl ca tablouri cu un element, deci aptl[0] e primul punct doar când nPolys e 1. Codul care indexează aptl direct citește valorile numărătorilor ca coordonate pentru fiecare înregistrare cu mai multe linii. Vechiul handler PDFlibPas își dimensiona și verificarea de margini pe acel layout greșit, deci înregistrările valide cu mai multe linii erau respinse, iar cele cu una singură nu desenau nimic. Din v3.539.41 PDFlibPas localizează tabloul de puncte din offset-ul calculat, felul în care handler-ul lui PolyPolygon a făcut mereu, și desenează fiecare polilinie ca propria subcale deschisă, cu o singură trasare la final. În v3.539.43 sora pe 16 biți a primit același tratament; desenase segment cu segment, ceea ce rupea îmbinările de linie și ignora un NULL_PEN selectat

Pen-ul și brush-ul implicite, și parantezele de cale

Două reguli de stare completează reparările poliliniilor din v3.539.43:

  • Un device context GDI nou are deja BLACK_PEN și WHITE_BRUSH selectate, deci un metafile care desenează fără niciun EMR_SELECTOBJECT desenează totuși contururi negre; convertorul pornea fără pen și fără umplere și scria n (termină calea, nu desena nimic) pentru asemenea înregistrări
  • În interiorul unei paranteze BeginPath / EndPath, un Polyline nici nu folosește, nici nu actualizează poziția curentă, deci trebuie să deschidă o subcale nouă în primul punct, în loc să se conecteze la figura anterioară, iar nimic nu poate fi desenat până când paranteza nu e trasată sau umplută

Construirea unui fișier EMF de test cu TMetafileCanvas

Cea mai rapidă cale de a verifica un convertor față de regulile acestea e să înregistrați cele trei apeluri riscante într-un singur enhanced metafile cu TMetafileCanvas. Desenul de mai jos înregistrează curbele cu un brush gol și apoi selectează intenționat un brush galben plin pentru polilinie: un convertor corect trebuie să ignore brush-ul acela pentru polilinie, deci orice galben în PDF-ul de ieșire e un bug. PolyDraw nu are un înveliș TCanvas, deci e apelat prin API-ul Windows cu handle-ul canvas-ului, folosind octeții de tip 3 și 5 pentru a exercița flag-ul de închidere

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

procedure BuildPolyTestEmf(const FileName: string);
const
  // Un pătrat închis (3 = LINETO + CLOSEFIGURE), apoi o figură Bezier
  // închisă al cărei ultim triplet de control se termină cu 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;    // doar contururi pentru curbe
      // Punctul 0 e startul; 1..3 și 4..6 sunt două segmente cubice
      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));
      // Formă în V deschisă cu brush plin selectat: trasată, nu niciodată
      // închisă într-un triunghi galben
      Canvas.Brush.Style := bsSolid;
      Canvas.Brush.Color := clYellow;
      Canvas.Polyline([Point(20, 240), Point(120, 160), Point(220, 240)]);
    finally
      Canvas.Free;   // încheie înregistrarea
    end;
    Mf.SaveToFile(FileName);
  finally
    Mf.Free;
  end;
end;

Pentru că aceste coordonate încap într-un SmallInt, GDI va stoca de obicei variantele pe 16 biți. Ca să ajungeți la handler-ele pe 32 de biți aveți nevoie de un producător care le scrie sau de înregistrări construite de mână. Fișierele construite de mână vin cu propria capcană: TMetafile.LoadFromStream din VCL tratează stream-ul ca EMF doar când lungimea rămasă e strict mai mare decât header-ul TEnhMetaHeader de 108 octeți. Un EMF minimal scris de mână cu un header scurt, sau unul gol care are exact 108 octeți, e luat drept WMF și respins cu „Metafile is not valid”. Scrieți mereu header-ul complet de 108 octeți, inclusiv câmpurile de extensie, înaintea înregistrărilor de test

Importul EMF într-un PDF cu PDFlibPas

PDFlibPas importă un EMF cu ImportEMFFromFile sau ImportEMFFromStream, care întorc un ID de imagine non-zero la succes și 0 la eșec. GeneralOptions = 0 păstrează calea vectorială despre care e vorba în articolul acesta; 1 rasterizează metafile-ul într-o bitmap în schimb. FontOptions = 1 adaugă fonturile metafile-ului ca fonturi TrueType neîncorporate. Varianta cu stream rembobinează stream-ul la poziția 0 înainte de încărcare, deci pasați un stream care conține doar metafile-ul

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 stânga-sus pentru DrawImage
    PDF.SetMeasurementUnits(0);    // puncte
    // FontOptions 1 = adaugă fonturile ca TrueType neîncorporate
    // GeneralOptions 0 = import vectorial, 1 = bitmap
    ImageID := PDF.ImportEMFFromFile(EmfFile, 1, 0);
    if ImageID = 0 then
      raise Exception.Create('The metafile could not be imported');
    PDF.SelectImage(ImageID);
    // Pentru un EMF, ImageWidth / ImageHeight sunt dimensiunea cadrului în puncte
    PDF.DrawImage(36, 36, PDF.ImageWidth, PDF.ImageHeight);

    // Pagina doar invocă forma importată: 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 vectorial devine un form XObject, deci GetPageContentToString întoarce doar secvența de salvare, transformare, Do și restaurare. Operatorii m, l, c, h și S produși din înregistrările Poly* trăiesc în stream-ul form XObject, care e comprimat. Ca să-i auditați, decomprimați fișierul salvat într-un inspector de obiecte PDF și citiți stream-ul form-ului: pentru fișierul de test de mai sus ar trebui să vedeți polilinia terminându-se în S fără vreun h înaintea ei, câte un h la fiecare flag de închidere din figurile PolyDraw și niciun f sau B pe vreo subcale dintre acestea. DrawImage scalează și un EMF importat uniform după valoarea mai mică dintre Width și Height, deci desenul își păstrează raportul de aspect chiar dacă caseta pasață nu i se potrivește

Pentru ținte Free Pascal, vedeți cum se compilează importatorul vectorial EMF din PDFlibPas sub Free Pascal; semantica înregistrărilor e aceeași oriunde s-ar compila importatorul

Cum ar trebui să trateze un parser EMF numărul de puncte din fișier?

Un parser EMF ar trebui să trateze fiecare număr de puncte ca input nesigur și să-l verifice față de dimensiunea înregistrării înainte să copieze primul punct. EnumEnhMetaFile garantează doar că fiecare nSize al înregistrării rămâne în interiorul fișierului. Nu verifică că cptl e de acord cu nSize, deci un handler care copiază cptl puncte cu Move va citi înregistrările următoare sau dincolo de capătul metafile-ului când numărul e falsificat sau corupt. Din v3.539.39 PDFlibPas verifică header-ul fix plus numărul ori octeții per punct față de nSize pentru PolyDraw, PolyBezier, PolyBezierTo, Polyline, PolylineTo și Polygon în ambele lățimi, cu un octet în plus per punct pentru octeții de tip PolyDraw. Pentru înregistrările PolyPoly numărătorile per figură trebuie și ele să însumeze cel mult totalul declarat, iar figurile cu zero puncte sunt sărite

Aceeași verificare e suficient de scurtă încât s-o copiați în parser-ul propriu. Versiunea aceasta validează un EMR_POLYPOLYLINE pe 32 de biți și întoarce un pointer către tabloul lui real de puncte:

uses
  Winapi.Windows;

// Întoarce nil dacă înregistrarea nu chiar deține punctele declarate.
// Punctele încep după tabloul de numărători: la 32 + nPolys * 4 octeți, nu la
// aptl[0], pe care RTL-ul îl declară ca tablou cu un element
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;                         // număr falsificat sau trunchiat
  Total := 0;
  Count := @P^.aPolyCounts[0];    // mers pe pointer: [0..0] declanșează range checks
  for I := 1 to P^.nPolys do
  begin
    Inc(Total, Count^);
    Inc(Count);
  end;
  if Total > P^.cptl then
    Exit;                         // figurile pretind mai multe puncte decât există
  Result := PPoint(NativeUInt(Rec) + NativeUInt(PointsOffset));
end;

Testul de încăpere a punctelor rulează primul, deci tabloul de numărători e cunoscut ca fiind în interiorul înregistrării înainte ca bucla să-l parcurgă. Aritmetica e Int64 pentru că nPolys * 4 și cptl * 8 calculate pe 32 de biți pot da wrap around și trece comparația

Referință rapidă: regulile EMF Poly* pentru conversia EMF în PDF

  • EMR_POLYBEZIER: punctul 0 e punctul de start; grupează din punctul 1 în triplete; reparat pentru înregistrarea pe 32 de biți în v3.539.41
  • EMR_POLYLINE / EMR_POLYPOLYLINE: figuri deschise, trasare cu S, niciodată h, f sau B, pentru că umplerea PDF închide subcăile deschise
  • EMR_POLYLINETO: pornește din poziția curentă, rămâne deschis, actualizează poziția curentă, nu atinge niciodată starea pen-ului
  • EMR_POLYPOLYLINE pe 32 de biți: punctele încep la octetul 32 + nPolys * 4, nu la aptl[0]
  • EMR_POLYDRAW: mascați PT_CLOSEFIGURE înainte de dispatch, închideți după segmentul complet, porniți din poziția curentă când primul punct nu e PT_MOVETO
  • Starea implicită de device context e BLACK_PEN plus WHITE_BRUSH; v3.539.43 și ulterior o respectă
  • În interiorul BeginPath / EndPath, fiecare polilinie își deschide propria subcale și nimic nu e desenat până când paranteza nu e folosită
  • Validați fiecare cptl / cpts față de nSize în aritmetică pe 64 de biți înainte de a copia punctele
  • Fișierele EMF de test construite de mână au nevoie de header-ul complet de 108 octeți, altfel TMetafile.LoadFromStream le citește ca WMF

Dacă rapoartele trec printr-o componentă diferită, aceeași semantică de înregistrări se aplică; importul vectorial EMF și WMF din HotPDF acoperă felul în care componenta aceea transformă brush-urile de gradient și hatch în pattern-uri PDF, iar grafica vectorială, shader-ele și gradiente în PDFlibPas acoperă desenarea acelorași forme direct cu API-ul bibliotecii în locul unui metafile

PDFlibPas v3.539.43 sau mai nou include fiecare regulă de mai sus. Detalii și descărcări de probă sunt pe pagina de produs a bibliotecii PDFlibPas Delphi PDF