技術文章

PDFlibPas EMF 匯入:PolyDraw、Polyline、Bezier 規則

losLab 的 Delphi PDF 函式庫 PDFlibPas 按 [MS-EMF] 的每一條記錄定義,把 EMF Poly* 記錄轉成 PDF 路徑:32 位元的 EMR_POLYBEZIER 從點 0 起筆、多邊線保持開放且只描邊、EMR_POLYDRAW 裡的 PT_CLOSEFIGURE 是旗標,而且每個點數都要對著記錄大小檢查。這些規則分別落在 v3.539.39、v3.539.41 與 v3.539.43。在它們之前,報表圖表從 ImportEMFFromFile 匯出來,趨勢線的位置可能多出一塊填色的楔形、Bezier 曲線可能彎向錯的控制點、封閉輪廓可能少了最後一邊。這些通通不報錯,而這些規則對任何 Delphi EMF 轉 PDF 的轉換器或 GDI 記錄剖析器都適用

EMF Poly* 記錄在 PDF 轉換時為什麼會出錯?

EMF Poly* 記錄會出錯,是因為每一條都把一部分語意放在點的外面:圖形是開是閉、要不要從目前位置起筆、套哪支筆與哪個筆刷、點在記錄裡從哪裡開始。enhanced metafile 是對裝置內容一連串 GDI 呼叫的錄影,所以轉換器除了座標之外,還得重播裝置內容的狀態。PDF 沒有裝置內容,它有的是路徑、路徑裡的目前點,以及一個在描邊(S)、填色(f)與兩者皆是(B)之間做決定的繪製運算子。兩個模型之間的每一處不匹配,都會變成一次不聲不響的渲染差異

Poly* 家族還有兩種寬度。EMR_POLYLINE 這類 32 位元記錄,各有一個 EMR_POLYLINE16 這樣的 16 位元雙胞胎,後者把點存成 SmallInt 成對值。座標都放得下時,GDI 通常錄成精簡版,所以轉換器的 32 位元處理器可能錯了好幾年,而日常測試圖怎麼畫都碰不到它。最快的稽核辦法,是把同一組點分別餵給兩種記錄、比較得出的路徑。本文涵蓋的記錄全部位於 [MS-EMF] 的繪圖記錄群組(2.3.5 Drawing Record Types)

記錄起點封閉?目前位置
EMR_POLYBEZIER點 0否不使用、不更新
EMR_POLYLINE點 0否(只用筆)不使用、不更新
EMR_POLYLINETO目前位置否(只用筆)使用並更新
EMR_POLYPOLYLINE每條多邊線的第一點否(只用筆)不使用、不更新
EMR_POLYDRAW第一個 PT_MOVETO,或目前位置僅在設定 PT_CLOSEFIGURE 處封閉使用並更新

EMR_POLYBEZIER 曲線到底從哪裡開始?

EMR_POLYBEZIER 曲線從點 0 開始,只有索引 1 以後的點才三個一組,依次是控制點、控制點、終點。所以 7 個點的記錄畫出兩段三次曲線:點 0 是起點,1 到 3 組成第一段,4 到 6 組成第二段。PDFlibPas 的 16 位元處理器一直是這麼做的;32 位元處理器卻從點 0 開始分組,起點被當成第一個控制點吃掉,之後每一段都整整偏移一位。曲線照樣畫出來,只是畫的是另一條。從 v3.539.41 起,兩種寬度都用 m 在點 0 開路,其後每個完整三元組輸出一個 c

PDFlibPas 的 EMR_POLYBEZIER 記錄示意圖:七個點的記錄由點 0 以 m 開路,點 1 到 3 與 4 到 6 各組成一段三次 c 曲線,對比 v3.539.41 起修正的 32 位元處理器與把起點當控制點吃掉的舊分組法
點 0 是起點,其後只有完整的三元組才成為三次曲線段,七點的 PolyBezier 因此渲染成 m 加兩個 c 運算子

如果您自己在寫剖析器:點數不是 1 加 3 的倍數就是格式有問題,尾端多出來的點應該忽略,而不是縫進曲線裡

PolyDraw:PT_CLOSEFIGURE 是旗標,不是點型別

在 EMR_POLYDRAW 裡,PT_CLOSEFIGURE(值 1)是一個位元,會跟 PT_LINETO(2)或 PT_BEZIERTO(4)合併,所以型別位元組可以是 3 或 5。點型別是把該位元遮掉之後的位元組,旗標則表示在結束於這個點的線段之後封閉圖形。舊的 PDFlibPas 處理器用 case 敘述把位元組對單一值比對,型別 3 與 5 的點什麼都匹配不上,整個被跳過。用 PolyDraw 畫的矩形少了封口那一邊,而尾點帶旗標的 Bezier 三元組丟了那個點,之後每一組全部錯位

從 v3.539.39 起,型別改以 Types[i] and not PT_CLOSEFIGURE 讀取,封閉只在完整線段之後輸出:帶封閉的 PT_LINETO 在那條線之後,Bezier 群則在第三個點之後。畸形檔案就算把旗標設在三元組的第一或第二個點,也不會提前封閉圖形。同一個版本還出了兩個相關修正:

  • 16 位元 EMR_POLYDRAW16 裡的每個 PT_MOVETO 都會把整條路徑重來,一條裝了三個圖形的記錄只剩最後一個;現在第一個 move 開啟路徑,之後的 move 開啟子路徑
  • 不帶開頭 PT_MOVETO 的 PolyDraw 記錄,照記錄定義從目前位置起筆,而不是寫出前面沒有 m 的 l 或 c 運算子
PDFlibPas 的 EMR_POLYDRAW 型別位元組解剖圖:PT_CLOSEFIGURE 是旗標位元 0,OR 進 PT_LINETO 或 PT_BEZIERTO,因此合法的型別位元組 3 與 5 必須先用 and not PT_CLOSEFIGURE 遮罩再分派;舊的 case 敘述把兩種位元組都跳過,封閉圖形少了最後一邊
分派前先把封閉旗標遮掉,封閉只在完成的線段或 Bezier 三元組之後輸出,否則 PolyDraw 會不聲不響地丟點

EMF 多邊線為什麼在 PDF 裡絕不能填色?

EMF 多邊線絕不能填色,因為 EMR_POLYLINE 與 EMR_POLYPOLYLINE 是只用筆畫的開放圖形,而 PDF 裡對開放路徑填色會隱含地把它封起來。ISO 32000-1 §8.5.3 寫明填色運算子在繪製之前會先封閉任何開放的子路徑。對三點多邊線輸出 B 或 f 的轉換器,於是畫出一個以目前筆刷顏色填充的三角形:就是圖表趨勢線底下那塊填色楔形。v3.539.41 之前,PDFlibPas 對兩種寬度的多邊線都拿筆刷填色,32 位元記錄還被明確封閉。如今兩種寬度都只以描邊收尾,GDI 的區分保留下來:Polygon 封閉並填色,Polyline 絕不這麼做

PDFlibPas 對 EMR_POLYLINE 匯出的開放 V 形多邊線之比較:正確的轉換器以描邊運算子 S 收尾並無視選中的筆刷,輸出 f 或 B 則會在 ISO 32000-1 8.5.3 之下隱含封閉開放子路徑,畫出填色楔形這個圖表 bug
填色運算子在繪製前會封閉任何開放子路徑,所以多邊線必須以 S 收尾,子路徑上不得出現 h、f 或 B

PolylineTo 從目前位置起筆

EMR_POLYLINETO 從目前位置出發、穿過記錄裡的每一個點,保持開放,並把目前位置留在最後一點上。舊處理器裡還藏著一個特例:前兩個點的 y 座標相同時把筆關掉,而沒有任何東西會把它打開,於是檔案裡之後的每一條記錄都丟了輪廓。筆的狀態歸 EMR_SELECTOBJECT 與 EMR_CREATEPEN 管,繪圖記錄處理器沒有資格動它。那個特例在 v3.539.41 移除,單點形式的記錄也不再讀過自己的點範圍(v3.539.39 修正)

PolyPolyline 的點從計數陣列之後開始

32 位元的 EMR_POLYPOLYLINE 先存 nPolys 個計數、再存 cptl 個點,點從位元組偏移 32 + nPolys * 4 開始。陷阱藏在 RTL 裡:Windows 單元把 TEMRPolyPolyline 的 aPolyCounts 與 aptl 宣告成單元素陣列,所以 aptl[0] 只有在 nPolys 為 1 時才是第一個點。直接對 aptl 取索引的程式碼,遇到任何多線記錄都會把計數值當座標讀。舊的 PDFlibPas 處理器還照那個錯的版面算邊界檢查,於是合法的多線記錄被拒收,單線的又什麼都不畫。從 v3.539.41 起,PDFlibPas 照算出來的偏移定位點陣列——它的 PolyPolygon 處理器一直就是這麼做的——並把每條多邊線畫成自己的開放子路徑,最後一次描邊。v3.539.43 輪到 16 位元雙胞胎接受同樣的待遇;它先前一段一段地畫,線段的接合因此斷裂,選中的 NULL_PEN 也被無視

預設的筆與筆刷,以及路徑括號

v3.539.43 還有兩條狀態規則,補完多邊線的修正:

  • 嶄新的 GDI 裝置內容已經選好了 BLACK_PEN 與 WHITE_BRUSH,所以從頭到尾不帶任何 EMR_SELECTOBJECT 的 metafile 照樣畫出黑色輪廓;轉換器先前從無筆無填色起步,對這種記錄寫出 n(結束路徑、什麼都不畫)
  • 在 BeginPath / EndPath 括號之內,Polyline 既不使用也不更新目前位置,所以必須在它的第一個點開啟新子路徑、而不是接上前一個圖形,而且在括號被描邊或填色之前什麼都不能畫

用 TMetafileCanvas 打造 EMF 測試檔

要驗證轉換器守不守這些規則,最快的辦法是用 TMetafileCanvas 把三個高風險呼叫錄進同一個 enhanced metafile。下面的繪圖先用中空筆刷錄曲線,再刻意為多邊線選實心黃色筆刷:正確的轉換器對多邊線必須無視那個筆刷,輸出 PDF 裡只要出現黃色就是 bug。PolyDraw 沒有 TCanvas 包裝,所以透過 Windows API 傳畫布 handle 呼叫,並用型別位元組 3 與 5 來考封閉旗標

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

procedure BuildPolyTestEmf(const FileName: string);
const
  // 封閉正方形(3 = LINETO + CLOSEFIGURE),接著一個封閉的
  // Bezier 圖形,其最後的控制點三元組以 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;    // 曲線只描輪廓
      // 點 0 是起點;1..3 與 4..6 是兩段三次曲線
      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));
      // 選實心筆刷畫開放的 V 形:只描邊,絕不封成
      // 黃色三角形
      Canvas.Brush.Style := bsSolid;
      Canvas.Brush.Color := clYellow;
      Canvas.Polyline([Point(20, 240), Point(120, 160), Point(220, 240)]);
    finally
      Canvas.Free;   // 結束錄製
    end;
    Mf.SaveToFile(FileName);
  finally
    Mf.Free;
  end;
end;

這些座標放得進 SmallInt,所以 GDI 通常錄的是 16 位元版。想測到 32 位元處理器,得找一個會寫這種記錄的產生器,或者自己手工打造記錄。手工檔案有自己的陷阱:VCL 的 TMetafile.LoadFromStream 只有在剩餘長度嚴格大於 108 位元組的 TEnhMetaHeader 時才把串流當成 EMF。標頭短一點的極簡手工 EMF,或剛好 108 位元組的空檔,會被當成 WMF,然後以「Metafile is not valid」拒收。寫測試記錄之前,務必先把含擴充欄位的完整 108 位元組標頭寫齊

用 PDFlibPas 把 EMF 匯入 PDF

PDFlibPas 用 ImportEMFFromFile 或 ImportEMFFromStream 匯入 EMF,成功回傳非零的影像 ID,失敗回傳 0。GeneralOptions = 0 保留本文談的向量路徑;1 則把 metafile 點陣化成 bitmap。FontOptions = 1 會把 metafile 字型加成未內嵌的 TrueType 字型。串流版在載入前會把串流倒回位置 0,請傳只含 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);              // DrawImage 以左上角為原點
    PDF.SetMeasurementUnits(0);    // 單位 points
    // FontOptions 1 = 字型加成未內嵌 TrueType
    // GeneralOptions 0 = 向量匯入,1 = bitmap
    ImageID := PDF.ImportEMFFromFile(EmfFile, 1, 0);
    if ImageID = 0 then
      raise Exception.Create('The metafile could not be imported');
    PDF.SelectImage(ImageID);
    // 對 EMF 而言,ImageWidth / ImageHeight 是以 points 計的外框大小
    PDF.DrawImage(36, 36, PDF.ImageWidth, PDF.ImageHeight);

    // 頁面只呼叫匯入的表單: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;

向量 EMF 匯入會變成一個 form XObject,所以 GetPageContentToString 回傳的只有儲存、轉換、Do 與還原的序列。由 Poly* 記錄產生的 m、l、c、h 與 S 運算子住在 form XObject 串流裡,而且被壓縮著。想稽核它們,用 PDF 物件檢視器把存出來的檔案解壓縮、讀 form 串流:以上面的測試檔來說,您應該看到多邊線以 S 收尾、前面沒有 h,PolyDraw 圖形裡每個封閉旗標處有一個 h,而這些子路徑上沒有任何 f 或 B。DrawImage 還會取 Width 與 Height 中較小者對匯入的 EMF 等比縮放,所以就算您傳的框子比例不合,圖形也不會變形

Free Pascal 目標平台的部分,見PDFlibPas EMF 向量匯入器如何在 Free Pascal 下建置;無論匯入器在哪裡編譯,記錄語意都一樣

EMF 剖析器該怎麼對待檔案裡的點數?

EMF 剖析器應該把每個點數都當成不可信的輸入,在複製任何一個點之前先對著記錄大小檢查。EnumEnhMetaFile 只保證每條記錄的 nSize 不會跑出檔案之外,它不檢查 cptl 與 nSize 是否吻合,所以當計數被偽造或損壞時,用 Move 複製 cptl 個點的處理器會讀到後面的記錄,甚至讀過 metafile 結尾。從 v3.539.39 起,PDFlibPas 對兩種寬度的 PolyDraw、PolyBezier、PolyBezierTo、Polyline、PolylineTo 與 Polygon,都以「固定標頭加計數乘每點位元組」對 nSize 檢查,PolyDraw 的型別位元組每點再加一個位元組。PolyPoly 記錄還要求各圖形的計數加總不得超過宣告總數,零點圖形直接跳過

同樣的檢查短到可以直接抄進您自己的剖析器。下面這個版本驗證 32 位元的 EMR_POLYPOLYLINE,回傳指向真正點陣列的指標:

uses
  Winapi.Windows;

// 除非記錄真的裝著它宣告的那些點,否則回傳 nil。
// 點從計數陣列之後開始:偏移 32 + nPolys * 4 位元組處,
// 而不是 RTL 宣告成單元素陣列的 aptl[0]
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;                         // 計數被偽造或截斷
  Total := 0;
  Count := @P^.aPolyCounts[0];    // 以指標走訪:[0..0] 會觸發範圍檢查
  for I := 1 to P^.nPolys do
  begin
    Inc(Total, Count^);
    Inc(Count);
  end;
  if Total > P^.cptl then
    Exit;                         // 圖形宣稱的點數比實際的多
  Result := PPoint(NativeUInt(Rec) + NativeUInt(PointsOffset));
end;

點數放不放得下先檢查,所以迴圈走訪計數陣列之前,就知道它整個在記錄之內。算術用 Int64,是因為 nPolys * 4 與 cptl * 8 在 32 位元裡算會迴繞、騙過比較

速查:EMF 轉 PDF 的 Poly* 規則

  • EMR_POLYBEZIER:點 0 是起點;從點 1 起三個一組;32 位元記錄於 v3.539.41 修正
  • EMR_POLYLINE / EMR_POLYPOLYLINE:開放圖形,以 S 描邊,絕不用 h、f 或 B,因為 PDF 的填色會封閉開放子路徑
  • EMR_POLYLINETO:從目前位置起筆、保持開放、更新目前位置,絕不碰筆的狀態
  • 32 位元 EMR_POLYPOLYLINE:點從位元組 32 + nPolys * 4 開始,不是 aptl[0]
  • EMR_POLYDRAW:分派前遮掉 PT_CLOSEFIGURE,在完成的線段之後封閉,首點不是 PT_MOVETO 時從目前位置起筆
  • 預設裝置內容狀態是 BLACK_PEN 加 WHITE_BRUSH;v3.539.43 起予以尊重
  • 在 BeginPath / EndPath 之內,每條多邊線開啟自己的子路徑,括號被使用之前什麼都不畫
  • 複製點之前,用 64 位元算術把每個 cptl / cpts 對 nSize 驗一遍
  • 手工打造的測試 EMF 要有完整的 108 位元組標頭,否則 TMetafile.LoadFromStream 會把它們當 WMF 讀

如果您的報表走的是另一個元件,同樣的記錄語意照樣適用;HotPDF 的 EMF 與 WMF 向量匯入說明那個元件怎麼把漸層與 hatch 筆刷變成 PDF 圖樣,PDFlibPas 的向量圖形、shader 與漸層則說明不改經 metafile、直接用函式庫 API 畫同樣的形狀

PDFlibPas v3.539.43 以上包含上述全部規則。詳情與試用版下載請見 PDFlibPas Delphi PDF 函式庫產品頁