Bài viết kỹ thuật

Import EMF vào PDFlibPas: quy tắc PolyDraw, Polyline, Bezier

PDFlibPas, thư viện PDF cho Delphi của losLab, chuyển các record EMF Poly* thành path PDF bằng cách bám sát từng định nghĩa record trong [MS-EMF]: một EMR_POLYBEZIER 32-bit bắt đầu từ điểm 0, polyline giữ mở và chỉ được stroke, PT_CLOSEFIGURE trong EMR_POLYDRAW là một flag, còn mọi số điểm đều được đối chiếu với kích thước record. Những quy tắc ấy lần lượt vào các bản v3.539.39, v3.539.41 và v3.539.43. Trước đó, một biểu đồ báo cáo thoát ra khỏi ImportEMFFromFile có thể mang một mảnh nền được tô đầy ngay chỗ lẽ ra phải là đường xu hướng, một đường cong Bezier bẻ về điểm control sai, hoặc một nét viền đóng thiếu cạnh cuối. Không trường hợp nào trong số đó báo lỗi, và các quy tắc này áp dụng cho bất kỳ bộ chuyển đổi EMF sang PDF nào trên Delphi hay parser record GDI nào

Vì sao các record EMF Poly* hay hỏng khi chuyển sang PDF?

Các record EMF Poly* hay hỏng vì mỗi loại đều mang một phần ý nghĩa của mình ngoài các điểm: figure mở hay đóng, có bắt đầu từ vị trí hiện tại không, pen và brush nào có hiệu lực, và các điểm bắt đầu ở đâu trong record. Một enhanced metafile là bản ghi lại các lời gọi GDI trên một device context, nên bộ chuyển đổi phải diễn lại cả trạng thái device context lẫn tọa độ. PDF không có device context. Nó có path, một current point bên trong path ấy, và một toán tử vẽ quyết định giữa stroke (S), fill (f) hoặc cả hai (B). Mọi lệch nhau giữa hai mô hình đều biến thành khác biệt render không tiếng động

Họ Poly* còn xuất hiện ở hai độ rộng. Mỗi record 32-bit như EMR_POLYLINE có một bản song sinh 16-bit như EMR_POLYLINE16 lưu các điểm thành cặp SmallInt. GDI thường ghi dạng gọn khi mọi tọa độ vừa chứa, nên các handler 32-bit của bộ chuyển đổi có thể sai trong nhiều năm trong khi các bản vẽ thử hằng ngày chẳng bao giờ chạm tới chúng. Cách audit nhanh nhất là nạp cùng một bộ điểm qua cả hai loại record rồi so path thu được. Các record đề cập ở đây đều thuộc nhóm drawing record của [MS-EMF] (2.3.5 Drawing Record Types)

RecordBắt đầu tạiĐóng?Vị trí hiện tại
EMR_POLYBEZIERĐiểm 0KhôngKhông dùng, không cập nhật
EMR_POLYLINEĐiểm 0Không (chỉ pen)Không dùng, không cập nhật
EMR_POLYLINETOVị trí hiện tạiKhông (chỉ pen)Dùng và cập nhật
EMR_POLYPOLYLINEĐiểm đầu mỗi polylineKhông (chỉ pen)Không dùng, không cập nhật
EMR_POLYDRAWPT_MOVETO đầu tiên, hoặc vị trí hiện tạiChỉ nơi PT_CLOSEFIGURE được đặtDùng và cập nhật

Đường cong EMR_POLYBEZIER thực sự bắt đầu từ đâu?

Đường cong EMR_POLYBEZIER bắt đầu từ điểm 0, và chỉ các điểm từ chỉ số 1 trở đi được nhóm từng ba cái một theo kiểu control point, control point, end point. Một record 7 điểm vì thế vẽ hai khúc cubic: 0 là điểm xuất phát, 1 tới 3 lập khúc đầu, 4 tới 6 lập khúc thứ hai. Handler 16-bit trong PDFlibPas đã làm đúng như vậy từ trước. Handler 32-bit lại bắt đầu nhóm từ điểm 0, nên điểm xuất phát bị ăn vào làm control point đầu tiên và mọi khúc về sau đều lệch đi một. Đường cong vẫn render, chỉ là sai đường. Kể từ v3.539.41, cả hai độ rộng đều mở path bằng m tại điểm 0 và phát một c cho mỗi bộ ba hoàn chỉnh phía sau

Sơ đồ PDFlibPas về một record EMR_POLYBEZIER bảy điểm, nơi điểm không mở path bằng m còn điểm một tới ba và bốn tới sáu mỗi bộ lập một khúc cubic c, đối chiếu handler 32-bit đã sửa kể từ v3.539.41 với cách nhóm cũ ăn mất điểm xuất phát làm control point
Điểm 0 là điểm xuất phát và chỉ các bộ ba hoàn chỉnh sau nó mới thành khúc cubic, nên PolyBezier bảy điểm render thành m cộng hai toán tử c

Với parser tự viết của bạn: số điểm không bằng 1 cộng một bội của 3 là malformed, và các điểm thừa phía đuôi nên bị bỏ qua thay vì được nối vào đường cong

PolyDraw: PT_CLOSEFIGURE là flag, không phải kiểu điểm

Trong EMR_POLYDRAW, PT_CLOSEFIGURE (giá trị 1) là một bit được kết hợp với PT_LINETO (2) hoặc PT_BEZIERTO (4), nên một byte kiểu hợp lệ có thể là 3 hoặc 5. Kiểu điểm là byte với bit đó đã bị mask đi, còn flag nghĩa là đóng figure sau khúc kết thúc tại điểm này. Handler cũ của PDFlibPas so byte với từng giá trị đơn lẻ trong một câu lệnh case, nên các điểm kiểu 3 và 5 không khớp gì cả và bị bỏ qua hoàn toàn. Một hình chữ nhật vẽ bằng PolyDraw mất cạnh đóng, còn một bộ ba Bezier có điểm cuối mang flag thì mất đúng điểm đó, khiến mọi bộ ba phía sau lệch nhịp

Kể từ v3.539.39, kiểu được đọc thành Types[i] and not PT_CLOSEFIGURE, và lệnh đóng chỉ được phát sau một khúc trọn vẹn: sau đường thẳng với một PT_LINETO đóng, và sau điểm thứ ba của một nhóm Bezier. Tệp malformed đặt flag lên điểm thứ nhất hoặc thứ hai của một bộ ba sẽ không làm đóng figure sớm. Hai bản sửa liên quan cũng vào cùng đợt phát hành đó:

  • Mọi PT_MOVETO trong EMR_POLYDRAW16 16-bit đều khởi động lại toàn bộ path, nên một record chứa ba figure chỉ giữ lại figure cuối; giờ lệnh move đầu mở path và các move sau mở subpath
  • Record PolyDraw không bắt đầu bằng PT_MOVETO sẽ bắt đầu từ vị trí hiện tại, đúng như định nghĩa record, thay vì viết toán tử l hoặc c mà không có m nào đứng trước
Giải phẫu byte kiểu của EMR_POLYDRAW trong PDFlibPas, nơi PT_CLOSEFIGURE là flag bit không được OR vào PT_LINETO hoặc PT_BEZIERTO, nên các byte kiểu hợp lệ 3 và 5 phải được mask bằng and not PT_CLOSEFIGURE trước khi điều phối; câu lệnh case cũ bỏ qua cả hai byte ấy và các hình đóng mất cạnh cuối
Hãy mask bỏ flag đóng trước khi điều phối và chỉ phát lệnh đóng sau một đường thẳng hoặc bộ ba Bezier hoàn tất, nếu không PolyDraw lặng lẽ làm rơi điểm

Vì sao polyline EMF không bao giờ được tô trong PDF?

Polyline EMF không bao giờ được tô vì EMR_POLYLINE và EMR_POLYPOLYLINE là các figure mở vẽ chỉ bằng pen, còn việc tô một path mở trong PDF thì ngầm khép nó lại. ISO 32000-1 §8.5.3 nói rõ các toán tử fill khép mọi subpath đang mở trước khi vẽ. Một bộ chuyển đổi phát B hoặc f cho polyline ba điểm vì thế sẽ tô một tam giác bằng màu brush hiện hành: chính là cái mảnh nền tô đầy dưới đường xu hướng của biểu đồ. Trước v3.539.41, PDFlibPas tô cả hai độ rộng polyline bằng brush, và record 32-bit còn bị đóng tường minh. Giờ thì cả hai độ rộng đều kết thúc bằng stroke thuần, và sự phân biệt của GDI được giữ nguyên: Polygon đóng và tô, Polyline không bao giờ thế

So sánh trong PDFlibPas giữa một polyline chữ V mở export từ EMR_POLYLINE: bộ chuyển đổi đúng kết thúc path bằng toán tử stroke S và bỏ qua brush đang chọn, trong khi phát f hoặc B sẽ ngầm khép subpath mở theo ISO 32000-1 8.5.3 và vẽ ra lỗi biểu đồ mảnh nền tô đầy
Toán tử fill khép mọi subpath đang mở trước khi vẽ, nên polyline phải kết thúc bằng S và không được có h, f hay B trên subpath

PolylineTo bắt đầu từ vị trí hiện tại

EMR_POLYLINETO vẽ từ vị trí hiện tại đi qua mọi điểm trong record, giữ mở, và để vị trí hiện tại ở điểm cuối. Handler cũ còn chứa một nhánh đặc biệt tắt pen khi hai điểm đầu chung một tọa độ y, và chẳng có gì bao giờ bật lại, nên mọi record về sau trong tệp đều mất nét viền. Trạng thái pen là chuyện của EMR_SELECTOBJECT và EMR_CREATEPEN; handler của record vẽ không có quyền động vào nó. Nhánh đặc biệt ấy bị loại bỏ trong v3.539.41, và dạng một điểm của record không còn đọc vượt qua các điểm của chính nó nữa (đã sửa trong v3.539.39)

Các điểm PolyPolyline bắt đầu sau mảng counts

EMR_POLYPOLYLINE 32-bit lưu nPolys giá trị count rồi tới cptl điểm, và các điểm bắt đầu tại byte offset 32 + nPolys * 4. Cái bẫy nằm ở RTL: unit Windows khai báo TEMRPolyPolyline với aPolyCounts và aptl là các mảng một phần tử, nên aptl[0] chỉ là điểm đầu tiên khi nPolys bằng 1. Code truy cập aptl trực tiếp sẽ đọc các giá trị count làm tọa độ với mọi record nhiều đường. Handler cũ của PDFlibPas còn đặt bounds check trên layout sai ấy, nên các record nhiều đường hợp lệ bị từ chối còn record một đường thì chẳng vẽ gì. Kể từ v3.539.41, PDFlibPas xác định mảng điểm từ offset đã tính, đúng cách handler PolyPolygon của nó vẫn làm bấy lâu, và vẽ mỗi polyline thành một subpath mở riêng với một lệnh stroke ở cuối. Đến v3.539.43, bản song sinh 16-bit được đối xử tương tự; trước đó nó vẽ từng khúc một, làm gãy các line join và bỏ qua NULL_PEN đang chọn

Pen và brush mặc định, cùng cặp ngoặc path

Hai quy tắc trạng thái khép lại phần sửa polyline trong v3.539.43:

  • Một device context GDI mới tinh đã có sẵn BLACK_PEN và WHITE_BRUSH được chọn, nên metafile vẽ mà chẳng có EMR_SELECTOBJECT nào vẫn ra nét viền đen; bộ chuyển đổi ngày trước khởi đầu không pen không fill và viết n (kết thúc path, chẳng vẽ gì) cho các record như vậy
  • Bên trong cặp ngoặc BeginPath / EndPath, một Polyline chẳng dùng cũng chẳng cập nhật vị trí hiện tại, nên nó phải mở subpath mới tại điểm đầu thay vì nối vào figure trước đó, và không được vẽ gì cho tới khi cặp ngoặc được stroke hoặc fill

Dựng tệp EMF thử nghiệm bằng TMetafileCanvas

Cách nhanh nhất để kiểm một bộ chuyển đổi theo các quy tắc trên là ghi lại ba lời gọi rủi ro vào cùng một enhanced metafile bằng TMetafileCanvas. Bản vẽ dưới đây ghi các đường cong với brush rỗng rồi cố tình chọn brush vàng đặc cho polyline: bộ chuyển đổi đúng phải bỏ qua brush đó với polyline, nên bất kỳ màu vàng nào trong PDF đầu ra đều là bug. PolyDraw không có wrapper cho TCanvas, nên nó được gọi qua Windows API với handle của canvas, dùng các byte kiểu 3 và 5 để thử flag đóng

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

procedure BuildPolyTestEmf(const FileName: string);
const
  // Một hình vuông đóng (3 = LINETO + CLOSEFIGURE), rồi một figure Bezier
  // đóng có bộ ba control cuối kết thúc bằng 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;    // chỉ viền cho các đường cong
      // Điểm 0 là điểm xuất phát; 1..3 và 4..6 là hai khúc cubic
      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));
      // Chữ V mở với brush đặc đang chọn: chỉ stroke, không bao giờ khép
      // thành tam giác màu vàng
      Canvas.Brush.Style := bsSolid;
      Canvas.Brush.Color := clYellow;
      Canvas.Polyline([Point(20, 240), Point(120, 160), Point(220, 240)]);
    finally
      Canvas.Free;   // kết thúc bản ghi
    end;
    Mf.SaveToFile(FileName);
  finally
    Mf.Free;
  end;
end;

Vì các tọa độ này vừa một SmallInt, GDI thường sẽ lưu biến thể 16-bit. Muốn chạm tới các handler 32-bit, bạn cần một producer ghi chúng ra, hoặc tự tay dựng record. Tệp dựng tay kèm cái bẫy riêng của nó: VCL TMetafile.LoadFromStream chỉ coi stream là EMF khi phần chiều dài còn lại lớn hơn đúng nghĩa 108 byte của TEnhMetaHeader. Một EMF viết tay tối giản với header ngắn, hoặc một tệp rỗng dài đúng 108 byte, bị coi là WMF và bị từ chối với thông báo "Metafile is not valid". Luôn viết đủ header 108 byte, gồm cả các trường extension, trước các record thử nghiệm của bạn

Import EMF vào PDF bằng PDFlibPas

PDFlibPas import một EMF bằng ImportEMFFromFile hoặc ImportEMFFromStream, hai hàm này trả về image ID khác 0 khi thành công và 0 khi thất bại. GeneralOptions = 0 giữ đúng đường vector là nội dung bài viết này; 1 thì rasterize metafile thành bitmap. FontOptions = 1 thêm các font của metafile dưới dạng TrueType không nhúng. Biến thể stream tua stream về vị trí 0 trước khi nạp, nên hãy truyền một stream chỉ chứa mình 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);              // gốc trên-trái cho DrawImage
    PDF.SetMeasurementUnits(0);    // điểm
    // FontOptions 1 = thêm font dưới dạng TrueType không nhúng
    // GeneralOptions 0 = import vector, 1 = bitmap
    ImageID := PDF.ImportEMFFromFile(EmfFile, 1, 0);
    if ImageID = 0 then
      raise Exception.Create('The metafile could not be imported');
    PDF.SelectImage(ImageID);
    // Với EMF, ImageWidth / ImageHeight là kích thước khung tính theo point
    PDF.DrawImage(36, 36, PDF.ImageWidth, PDF.ImageHeight);

    // Trang chỉ gọi form đã import: 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;

Một lần import EMF vector sẽ trở thành form XObject, nên GetPageContentToString chỉ trả về chuỗi save, transform, Do và restore. Các toán tử m, l, c, h và S sinh ra từ các record Poly* nằm trong stream của form XObject, nơi bị nén. Muốn audit chúng, hãy giải nén tệp đã lưu trong một PDF object inspector rồi đọc form stream: với tệp thử nghiệm ở trên, bạn sẽ thấy polyline kết thúc bằng S mà trước nó không có h, một h tại mỗi flag đóng trong các hình PolyDraw, và không có f hay B trên bất kỳ subpath nào trong số này. DrawImage cũng scale EMF import theo tỷ lệ đồng đều theo chiều nhỏ hơn trong Width và Height, nên bản vẽ giữ nguyên tỉ lệ khung hình dù box bạn truyền có không khớp

Với đích Free Pascal, xem cách importer vector EMF của PDFlibPas build dưới Free Pascal; ngữ nghĩa record vẫn như nhau bất kể importer compile ở đâu

Parser EMF nên coi các số điểm đọc từ tệp như thế nào?

Parser EMF nên coi mọi số điểm là input không đáng tin và đối chiếu với kích thước record trước khi sao chép điểm nào cả. EnumEnhMetaFile chỉ bảo đảm nSize của từng record nằm gọn trong tệp. Nó không kiểm tra cptl có khớp nSize hay không, nên một handler chép cptl điểm bằng Move sẽ đọc luôn các record kế tiếp, hoặc vượt quá cuối metafile, khi count bị giả mạo hay hỏng. Kể từ v3.539.39, PDFlibPas kiểm tra header cố định cộng count nhân số byte mỗi điểm với nSize cho PolyDraw, PolyBezier, PolyBezierTo, Polyline, PolylineTo và Polygon ở cả hai độ rộng, cộng thêm một byte mỗi điểm cho các byte kiểu của PolyDraw. Với các record PolyPoly, tổng count từng figure cũng không được vượt quá tổng đã khai báo, còn các figure không điểm nào thì bị bỏ qua

Phép kiểm ấy ngắn đủ để chép vào parser tự viết của bạn. Bản dưới đây kiểm chứng một EMR_POLYPOLYLINE 32-bit và trả về con trỏ tới mảng điểm thật của nó:

uses
  Winapi.Windows;

// Trả về nil trừ khi record thực sự chứa các điểm mà nó khai báo.
// Các điểm bắt đầu sau mảng counts: vào 32 + nPolys * 4 byte, chứ không
// phải tại aptl[0], nơi RTL khai báo như một mảng một phần tử
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;                         // count bị giả mạo hoặc bị cắt cụt
  Total := 0;
  Count := @P^.aPolyCounts[0];    // đi bằng con trỏ: [0..0] vấp range check
  for I := 1 to P^.nPolys do
  begin
    Inc(Total, Count^);
    Inc(Count);
  end;
  if Total > P^.cptl then
    Exit;                         // các figure đòi nhiều điểm hơn mức có
  Result := PPoint(NativeUInt(Rec) + NativeUInt(PointsOffset));
end;

Phép kiểm điểm-có-vừa chạy trước, nên mảng counts được biết là nằm trong record trước khi vòng lặp đi qua nó. Phép tính dùng Int64 vì nPolys * 4 và cptl * 8 tính trong 32 bit có thể wrap around rồi vẫn qua được phép so sánh

Tóm tắt nhanh: quy tắc EMF Poly* khi chuyển EMF sang PDF

  • EMR_POLYBEZIER: điểm 0 là điểm xuất phát; nhóm từ điểm 1, từng ba cái một; bản 32-bit đã sửa trong v3.539.41
  • EMR_POLYLINE / EMR_POLYPOLYLINE: figure mở, stroke bằng S, đừng bao giờ h, f hay B, vì fill trong PDF khép các subpath đang mở
  • EMR_POLYLINETO: bắt đầu từ vị trí hiện tại, giữ mở, cập nhật vị trí hiện tại, không bao giờ động vào trạng thái pen
  • EMR_POLYPOLYLINE 32-bit: các điểm bắt đầu tại byte 32 + nPolys * 4, chứ không phải tại aptl[0]
  • EMR_POLYDRAW: mask bỏ PT_CLOSEFIGURE trước khi điều phối, đóng sau khúc đã hoàn tất, bắt đầu từ vị trí hiện tại khi điểm đầu không phải PT_MOVETO
  • Trạng thái mặc định của device context là BLACK_PEN cộng WHITE_BRUSH; v3.539.43 trở lên tôn trọng điều đó
  • Bên trong BeginPath / EndPath, mỗi polyline mở subpath riêng và không vẽ gì cho tới khi cặp ngoặc được dùng
  • Kiểm chứng mọi cptl / cpts với nSize bằng số học 64-bit trước khi chép điểm
  • EMF thử nghiệm dựng tay cần đủ header 108 byte, nếu không TMetafile.LoadFromStream sẽ đọc chúng như WMF

Nếu báo cáo của bạn đi qua một component khác, ngữ nghĩa record vẫn y nguyên; import vector EMF và WMF của HotPDF nói về cách component đó biến brush gradient và hatch thành PDF pattern, còn vector graphics, shader và gradient trong PDFlibPas nói về việc vẽ chính các hình ấy trực tiếp bằng API của thư viện thay vì qua metafile

PDFlibPas v3.539.43 trở lên gồm đủ mọi quy tắc trên. Chi tiết và bản dùng thử nằm trên trang sản phẩm thư viện PDFlibPas Delphi PDF