技術文章

PDFlibPas EPS 匯入:PostScript 語意差異(Delphi)

放在 PDF 頁面上的 EPS 標誌,可能出來時外框不見了,單色標記印成自己的反相,或 Æ 變成了 á。每個症狀都標出一處 PostScript 與 PDF 圖形語意相左的地方,所以 Delphi 的 PDFlibPas EPS 匯入會直譯 PostScript 程式,把每處差異寫成原生 PDF 運算元

直譯程式,還是原樣放行

PostScript 有兩種方式待在 PDF 裡,其中只有一種會在畫面上顯示東西。ISO 32000-1 §8.8.2 定義了 PostScript XObject:一個 /Subtype /PS 串流,其位元組原樣、不解譯地複製進列印工作——而且只有輸出設備是 PostScript 印表機時才有這待遇;其他地方一律無效,§8.8 總覽還說不應使用 PostScript XObject。PDF/A 直接排除它們。TPDFlib.NewPostScriptXObject 與 DrawPostScriptXObject 寫的就是這種原樣傳遞形式,而 ImportEPSFromFile 與 DrawPostScript 讓程式經過直譯器,產出普通路徑、行內影像與文字。圖稿走直譯器,PS XObject 留給瞄準某一間印刷室 RIP 的片段——RIP 是印刷機或生產印表機前面的光柵影像處理器。直譯 EPS 也不同於EMF 向量匯入,後者的輸入是一串待重播的 GDI 記錄;EPS 檔是一個程式

procedure AddPrintRoomHook(PDF: TPDFlib);
var
  PSRef: Integer;
begin
  // 原樣存成 /Subtype /PS 串流,以 Do 參照
  // 只在 PostScript 印表機上執行;檢視器什麼都不畫
  PSRef := PDF.NewPostScriptXObject(
    'userdict /PrintRoomMark known { PrintRoomMark } if');
  if PSRef <> 0 then
    PDF.DrawPostScriptXObject(PSRef);
end;
PostScript 執行模型與 PDF 分道之處,以及逐行運算元翻譯會產出什麼
PostScript 行為PDF 行為照字面翻譯的結果
gsave / grestore 儲存並還原目前路徑與目前點q / Q 不碰路徑,繪製會終結路徑(§8.5.1)grestore 之後的外框或延續線段消失
clip 為後續繪製保留目前路徑W 後面跟著繪製運算元,通常是 n,後者會終結路徑剪裁之後的描邊無東西可畫
ImageMatrix 把使用者空間映射到影像空間每份影像填滿單位方塊,第一列在頂端(§8.9.4)由下而上或旋轉的影像落地時翻轉或錯位
true imagemask 畫的是 1 位元預設 Decode [0 1] 的遮罩畫的是 0 位元(§8.9.6.2)單色標誌印成反相
Roman 的 Standard-14 字型預設用 StandardEncodingStandardEncoding 這個名稱沒有特殊意義(§9.6.6.1)重音符號、引號與合字改變字母
%%BoundingBox 與 %%HiResBoundingBox 註解定義圖稿區域放置全看寫入端輸出什麼矩陣圖稿移位或縮放出原本該在的框

grestore 之後外框去哪了?

外框消失,是因為 PDF 從沒存過路徑。ISO 32000-1 §8.5.1 一句話講明:「在 PDF(不同於 PostScript)裡,目前路徑不是圖形狀態的一部分」,而且路徑一經繪製,到 m 或 re 開新路徑之前都沒有目前路徑。PostScript 產生端倚賴的恰好是相反規則。畫一個帶外框的填色形狀,慣用手法是 gsave fill grestore stroke:填色吃掉路徑的存檔副本,grestore 把路徑帶回來,描邊畫的是同一份幾何。把 gsave 對應到 q、fill 對應到 f、grestore 對應到 Q,描邊抵達時路徑已空,於是形狀填好了,外框沒了。grestore 之後想延續還原路徑的 lineto 也以同樣方式失敗——少了一個線段

時間線對照 PostScript 的 gsave fill grestore stroke 與 PDFlibPas Delphi EPS 匯入中照字面的 q f Q S 翻譯
照字面的 q f Q 映射在填色處就終結路徑,所以 PDFlibPas 另行保有一份私有 PostScript 路徑,grestore 之後的描邊找得到幾何
uses
  SysUtils, PDFlibrary;

const
  // 帶外框的填色三角形:填色跑在 gsave/grestore 裡,
  // grestore 之後的描邊重用還原的路徑
  TriangleEPS =
    '%!PS-Adobe-3.0 EPSF-3.0'#10 +
    '%%BoundingBox: 0 0 200 120'#10 +
    'newpath 20 20 moveto 180 20 lineto 100 100 lineto closepath'#10 +
    'gsave 0.85 setgray fill grestore'#10 +
    '3 setlinewidth 0 setgray stroke'#10;

var
  PDF: TPDFlib;
begin
  PDF := TPDFlib.Create;
  try
    PDF.SetOrigin(1);   // Left 與 Top 從左上角量起
    // Width 與 Height 給 0 就維持 200 x 120 的邊界框尺寸
    if PDF.DrawPostScript(72, 72, 0, 0, TriangleEPS) = 1 then
      PDF.SaveToFile('triangle.pdf')
    else
      Writeln('DrawPostScript failed, code ', PDF.LastErrorCode);
    // Expected:淺灰三角形帶 3 單位黑色外框。
    // 照字面的 q/f/Q/S 翻譯會填滿三角形、丟掉外框
  finally
    PDF.Free;
  end;
end.

PDFlibPas 保有自己的 PostScript 路徑,與 PDF 內容串流當下持有的東西互不相干,只有要繪製或剪裁時才把 PDF 路徑寫出來。每個點都在 moveto 或 lineto 執行當下生效的轉換矩陣下定死,跟 PostScript 一樣——之後的 scale 或 concat 後永遠搬不動已在路徑上的點。currentpoint 把存的點經過目前矩陣映射回來,所以它在當下的使用者空間作答。一個 gsave 記錄的是路徑鏈結尾的參照,加上目前點、矩陣、字型與色彩空間,所以巢狀儲存從不複製整條路徑

剪裁是兩種路徑模型再次分道的地方,匯入在這裡也跟著 PostScript:clip 與 eoclip 為 PDF 端輸出 W n,但邏輯路徑原地保留給緊接而來的描邊;rectclip 在 PostScript 裡是開一條全新的空路徑,於是輸出 re W n 並把它清掉。路徑儲存上限 100,000 個節點,重播與其他操作共享運算預算,惡意檔案沒辦法靠循環 gsave 加 stroke 把輸出灌大

放置影像:ImageMatrix 指的方向相反

把 ImageMatrix 當放置矩陣用,EPS 影像落地就是翻的,因為它描述的是相反方向的映射。PDF 裡,§8.9.4 把影像邊界綁在使用者空間的單位方塊上,第一列樣本在頂端,隱含的影像到使用者矩陣是 [1/w 0 0 -1/h 0 1]。PostScript 的 ImageMatrix 則把使用者空間映射進影像空間。所以 PDF 的放置矩陣是 [w 0 0 -h 0 h] 乘上 ImageMatrix 的反矩陣。對常見的由上而下矩陣 [w 0 0 -h 0 h],乘積是單位矩陣,這就是天真的轉換器在順手用起來時看似正確的原因。用 [w 0 0 h 0 0] 寫的由下而上掃描得到 [1 0 0 -1 0 1],單位方塊內的垂直翻轉;旋轉或傾斜的矩陣則不需要特例。有限資料來源提前結束時,PDFlibPas 只畫完整的列,保留原始 ImageMatrix,按實際抵達的列數縮放,被截斷的影像於是以正確比例填滿其區域的頂帶;把存活的列拉伸到整個高度,則會產出一張看起來完好其實扭曲的圖

示意圖:為 Delphi 的 PDFlibPas EPS 匯入從 PostScript ImageMatrix 慣例推導 PDF 放置矩陣,含正立與翻轉兩種結果與截斷列處理
放置矩陣是 PDF 單位方塊映射乘上反 ImageMatrix,由上而下的圖稿正立落地,只有由下而上的掃描會翻轉

imagemask 畫的是哪個位元?

極性運算元給 true 時,imagemask 畫的是 1 位元,在 PDF 裡對應 Decode [1 0]。ISO 32000-1 §8.9.6.2 把 [0 1] 定為影像遮罩的預設:0 樣本以目前色彩上標記頁面,1 樣本不動背景;[1 0] 則反過來。PostScript 的布林值說的是哪個樣本值會畫。把它讀成「反轉 PDF 預設」,標誌就印成反相——一塊實心、標記被鏤空的色塊。PDFlibPas 把 true 對應到 [1 0]、false 對應到 [0 1],遮罩以帶 /IM true 的行內影像輸出,用程式當時設好的色彩作畫;字典形式則只接受這兩個 Decode 陣列

Delphi 的 PDFlibPas EPS 匯入把 PostScript imagemask 極性映射到 PDF Decode 陣列:true 畫 1 位元,false 印出反相
PostScript 布林值指名的是會作畫的樣本值,把 true 映射到 Decode [1 0] 就不讓單色標誌印成反相

12 位元樣本藏著一個小一點的落差。PDF 沒有 12 位元影像;§8.9.5.1 允許每分量 1、2、4、8 位元,PDF 1.5 起再加 16 位元。把每個樣本左移四位元塞進 16 位元,什麼都不損失,但最大值變成 65520 而非 65535,於是用預設 Decode 時,每個亮部樣本都暗了約 0.023%。匯入保留這個位移,並把 Decode 上端點拉伸 65535/65520 倍,每個正規化值因此還原。12 位元影像自動把輸出版本拉到 PDF 1.5;PDF/A 或 PDF/X 模式下,或輸出版本鎖在 1.5 以下時,匯入在頁面改動之前就以錯誤 538 停手

Æ 為什麼跑出來變 á?

PostScript 裡的 Roman 字型預設用 StandardEncoding,除非程式自行重編碼,而 PDF 講不出那張表:ISO 32000-1 §9.6.6.1 不給這個名稱任何特殊意義,預先定義的選項只剩 WinAnsiEncoding、MacRomanEncoding 與 MacExpertEncoding。附錄 D.2 的表說明了代碼原封不動交給 WinAnsi 會出什麼事:八進位 341(0xE1)在 StandardEncoding 是 AE、在 WinAnsi 是 aacute,Æ 於是變成 á;0xAE 在一邊是 fi 合字、在另一邊是 ®;0x27 在 StandardEncoding 是 quoteright、在 WinAnsi 是打字式的 quotesingle,排版撇號就此變成打字機撇號。對程式用到的每個 Standard-14 Roman 字型,PDFlibPas 寫出一份私有 Type 1 字型資源,其 /Encoding 字典帶 /BaseEncoding /WinAnsiEncoding 與一份按 StandardEncoding 順序指名全部 256 個代碼的 /Differences 陣列,任何代碼都不再取決於檢視器怎麼填它的基礎表。字寬出自同一套編碼,ToUnicode 對應表讓複製與搜尋拿回 Æ,Symbol 與 ZapfDingbats 保留內建代碼。匯入之前你的程式碼選好的字型原封不動——把快取字型重編碼來遷就 EPS,會動到已經用它排好的文字

這些資源指名字型而不內嵌,與要求內嵌的 profile 相衝。文件設成 PDF/A、PDF/X 或 PDF/UA,或 SetEmbedAllFonts(1) 之後,EPS 文字以錯誤 538 失敗,頁面維持原樣。檔案輸出的正道是改用文字轉外框的 EPS 匯出,任何模式都匯成普通路徑;既有檔案裡已經沒內嵌的字型,則要靠為 PDF/A 內嵌缺失字型處理。EPS 文字按精確名稱對到 14 標準字型;自帶字型程式的圖稿,文字轉外框後即可乾淨匯入,這也避免本機替代字型把每一行都挪動

邊界框、DSC 註解與二進位 EPS 檔

放置是把圖稿的邊界框縮放進你傳入的矩形,所以框必須來自對的地方。PDFlibPas 只從真正的 DSC 註解行讀 %%BoundingBox 與 %%HiResBoundingBox——指的是開在一行開頭的頂層註解——有效的高解析框勝過整數框。樸素的子字串搜尋也會在字面字串、程序主體與二進位影像資料裡找到這些關鍵字,EPS 檔三種都有。標頭值為 (atend) 時,數字延後到 trailer 裡的註解,掃描器讀到那裡才取值;完全沒有框的檔案退回 612 × 792,空框或反轉框以錯誤 124 失敗。Width 與 Height 各自獨立縮放框:兩者都傳 0 保持自然尺寸,或兩者都按框的長寬比傳入——只傳一個非零值,圖稿只會沿單一軸拉伸

二進位資料決定進入點。DrawPostScript 收 WideString,在程式碼裡拼出來的 PostScript 沒問題,但原始影像樣本、經 currentfile 讀進的 Flate 資料與 DOS EPS 外殼帶有的位元組,走一趟 Unicode 文字就活不下來。ImportEPSFromFile 按原始位元組讀檔,認得 C5 D0 D3 C6 預覽標頭,檢查 PostScript 段的偏移與長度落在檔案範圍內,然後只執行該段、無視 TIFF 或 WMF 預覽。兩個呼叫最多收 4 MiB 來源,要求 Options = 0,回傳 1 或 0,附加的要么整份繪圖、要么什麼都沒有。失敗原因在 LastErrorCode,讀取即重置,所以讀一次就要收好

uses
  SysUtils, PDFlibrary;

function PlaceEPS(PDF: TPDFlib; const FileName: string;
  Left, Top: Double): Boolean;
var
  Code: Integer;
begin
  // 0, 0:自然尺寸取自 %%HiResBoundingBox 或 %%BoundingBox
  Result := PDF.ImportEPSFromFile(Left, Top, 0, 0, FileName) = 1;
  if Result then
    Exit;
  Code := PDF.LastErrorCode;   // 讀取會清掉代碼,所以只讀一次
  case Code of
    402:
      Writeln(FileName, ': file cannot be opened');
    PDFLIB_ERROR_POSTSCRIPT_INVALID:      // 124
      Writeln(FileName, ': malformed PostScript, bad bounding box or nothing painted');
    PDFLIB_ERROR_POSTSCRIPT_UNSUPPORTED:  // 538
      Writeln(FileName, ': construct the import does not handle, or Standard-14 text in an archival profile');
    PDFLIB_ERROR_POSTSCRIPT_BUDGET:       // 539
      Writeln(FileName, ': source over 4 MiB or a work limit reached');
  else
    Writeln(FileName, ': import failed with code ', Code);
  end;
end;

var
  PDF: TPDFlib;
begin
  PDF := TPDFlib.Create;
  try
    PDF.SetOrigin(1);
    if PlaceEPS(PDF, 'logo.eps', 40, 40) then
      PDF.SaveToFile('letterhead.pdf');
  finally
    PDF.Free;
  end;
end.

錯誤 538 指名某個運算元時,回編輯器把圖稿重匯出成文字轉外框的普通 EPS:通常比追著那個運算元跑更快,轉好外框的圖稿放進以PDFlibPas Delphi PDF Library 建置的 PDF/A 或 PDF/UA 文件,也不再有 Standard-14 字型衝突