技術文章

PDF 中的複合字形與 CFF seac 變音符號算繪

HotPDF 之所以能正確算繪內嵌字型的變音字母,靠的是處理兩種「用別的字形拼出一個字形」的字型機制:TrueType 複合字形——其元件偏移必須先以字形單位套用、再套字形到設備的矩陣;以及 CFF endchar 的 seac 形式——用 StandardEncoding 碼把基礎字母與變音符號縫在一起。任何一個搞錯,é、Å、ü 或彎引號就會缺一角,周圍的普通字母卻完美無缺

正是最後那個細節,讓這類 bug 代價高昂。報表算繪出來,內文沒問題,唯獨「München」中間有個洞,或引文用一個單引號加一段空白開場。沒有人會懷疑算繪器。第一反應永遠是字型壞了或產生端有問題,工單就這樣轉來轉去一個星期。本文逐一走過每個機制實際錯在哪裡、HotPDF Delphi 元件自 v2.768.10 與 v2.768.11 起怎麼處理,以及 v2.770.83 那個相關的子集化修復——它讓 HotPDF 在寫出字型而非讀入字型時,複合字形也能保持完整

為什麼 PDF 字形裡消失的偏偏是變音符號?

變音符號會消失,因為它是字形裡唯一帶非零偏移的部分。TrueType 與 CFF 字型都靠重用外框省空間:é 不是畫成一個新形狀,而是「e 字形,加上往上往右平移的 acute 字形」。基礎字母幾乎總是坐在偏移 (0, 0) 上,所以偏移套用方式的任何錯誤都動不到它。動的是變音符號,所以落錯位、或乾脆沒被畫出來的也是它

症狀模式本身就是一份診斷。如果變音字母的基礎字母畫出來了、變音符號卻沒有,或 “ 與 ” 這種成對標點顯示成一個記號而不是兩個,就別再盯著字型檔看了。壞掉的字型通常壞的是整個字形,而且對每個檢視器都壞。只有某一個算繪器缺組合字形的零件、Acrobat 卻顯示正常,矛頭指向的是組裝元件的那段程式碼

TrueType 複合字形怎麼放置它的元件?

TrueType 複合字形用每個元件自己的小型仿射轉換來放置它,轉換以字型單位表示,而且必須在字形到設備矩陣之前、先作用在元件外框上。在 OpenType 的 glyf 表裡,複合字形帶負數的輪廓計數,後面跟一串元件記錄。每條記錄有一個旗標字、元件字形索引、兩個位元組或字的引數(ARG_1_AND_2_ARE_WORDS,0x0001),以及可選的縮放:WE_HAVE_A_SCALE(0x0008)帶一個 F2Dot14 值,WE_HAVE_AN_X_AND_Y_SCALE(0x0040)帶兩個,WE_HAVE_A_TWO_BY_TWO(0x0080)帶完整的 2x2 矩陣。設了 ARGS_ARE_XY_VALUES(0x0002)時,兩個引數就是字型單位的 dx/dy 偏移。MORE_COMPONENTS(0x0020)表示還有下一條記錄

所以對每個元件,先用縮放部分與偏移組出元件矩陣 CM,再把它跟 M——把字型單位映射到設備像素的矩陣——結合。結合的順序就是整個 bug 所在。HotPDF 內部的矩陣乘法採行向量(column-vector)慣例,所以 A 與 B 的乘積先套 B 再套 A。正確的組合是先在字形空間套 CM,再套 M。反過來寫,M 先跑,CM 的偏移事後才加上去——已經是設備像素,永遠不會被縮放

// 僅為示意:採行向量慣例的二維仿射記錄,
// Mul(A, B) 對點先套 B 再套 A
type
  TAffine = record
    a, b, c, d, e, f: Double;
  end;

function Mul(const A, B: TAffine): TAffine;
begin
  Result.a := A.a * B.a + A.c * B.b;
  Result.b := A.b * B.a + A.d * B.b;
  Result.c := A.a * B.c + A.c * B.d;
  Result.d := A.b * B.c + A.d * B.d;
  Result.e := A.a * B.e + A.c * B.f + A.e;
  Result.f := A.b * B.e + A.d * B.f + A.f;
end;

// M  : 字型單位 -> 設備像素(縮放極小,y 翻轉)
// CM : 元件轉換;e/f 存字型單位的 dx/dy
Sub := Mul(M, CM);   // 正確:偏移隨外框一起縮小
// Sub := Mul(CM, M); // 錯誤:dx/dy 落成原始設備像素

數字一擺出來,失敗就很明顯。Arial 用 2048 單位的 em,é 的變音元件偏移就是以這些字型單位記的。順序正確時,這個偏移隨外框一起縮到正常縮放下的幾個像素。順序顛倒時,原始的字型單位值被當成設備像素加上去,HotPDF 的測試算繪裡,Arial é 的變音符號落在離字母 243 像素遠的地方,遠遠出了字形範圍、通常也出了任何可見範圍。Arial 的左雙引號是單引號形狀的兩份複本、第二份有平移,於是它畫成一個孤零零的 ‘ 後面跟著一片空白

線性部分很少暴露這個 bug。變音元件通常根本不帶縮放,而均勻縮放本來就與 M 可交換,所以只有平移會暴露顛倒的乘積。這也是為什麼用普通拉丁文字做的快速測試會通過:這個顛倒在那段程式碼裡躺過了好幾個版本,直到 v2.768.10 才修正,而只有帶偏移的元件顯示得出它

遞迴與循環

複合字形可能參照其他複合字形,所以算繪器要遞迴走訪,還必須自我防衛。HotPDF 把巢狀深度封頂在 5 層、每個字形的元件總數封頂在 1024,跳過指名自己字形索引的元件,並把少用的點匹配模式(引數是點編號而非偏移)當成零偏移。懷有惡意或損壞的字型因此頂多丟一個元件,卻掛不住頁面算繪

CFF endchar 的 seac 形式是什麼?

endchar 的 seac 形式,是 CFF Type 2 charstring 表達「這個字形是基礎字母加變音符號」的一種已棄用方式:endchar 帶四個引數時,它們是 adx ady bchar achar。Adobe Technical Note #5177 在與 Type 1 相容性的附錄裡描述了它。名字來自 Type 1 的 seac 運算子(standard encoding accented character),從 Type 1 字型轉換來的 CFF 子集至今仍大量用它來處理帶變音符號的拉丁字母

有三個細節常絆倒實作者。第一,bchar 與 achar 不是 glyph ID,而是 Adobe StandardEncoding 裡的碼,所以算繪器要把每個碼映射成字形名稱,再透過字型的 charset 查這個名稱找出 GID。第二,Type 2 沒有 asb 引數,不像 Type 1 的 seac 要吃 asb adx ady bchar achar。Type 2 裡,變音符號的原點直接放到相對基礎字形原點的 (adx, ady),不做 side bearing 校正。第三,CID keyed 的 CFF 字型根本不允許用 seac,因為沒有字形名稱 charset 可以用來解析 StandardEncoding 碼

還有寬度這回事。每個 Type 2 charstring 的第一個清空堆疊的運算子都可以帶一個可選的前進寬度引數,endchar 正是這類運算子之一。帶一個引數的普通 endchar 帶的是寬度;帶五個引數的 seac endchar 帶的是寬度再加四個 seac 值。規則是拿引數個數對照預期的固定個數,seac 是四個。v2.768.11 之前,HotPDF 把任何 endchar 都當成「拿寬度然後收工」,所以 seac 字形產出空外框:沒有基礎、沒有變音,只有行裡一個尺寸剛好的空洞

// Type 2 直譯器裡 endchar 判斷的示意。
// Stack 存 endchar 之前的運算元;Count 是個數。
if (Count >= 4) and not IsCIDFont then
begin
  // 5 個運算元:第一個是前進寬度,其餘是 seac
  if Count = 5 then
    TakeWidthAndShift;                 // 丟掉寬度,留 4 個運算元
  if StackDepth = 4 then
  begin
    BaseGID   := GIDForName(StdEncName(Round(Stack[2])));  // bchar
    AccentGID := GIDForName(StdEncName(Round(Stack[3])));  // achar
    DrawGlyph(BaseGID, Identity);
    DrawGlyph(AccentGID, Translate(Stack[0], Stack[1]));   // adx, ady
    Exit;
  end;
end;
// 其他情況:普通 endchar,可選寬度,收閉路徑

在真正的直譯器裡,兩個元件字形都用同一套 charstring 引擎執行,變音符號的矩陣是目前字形矩陣再複合一個平移到 (adx, ady),事後會還原矩陣,所以同一行後面的字形不受影響。任一名稱解析不出 GID 時,跳過那個元件而不是讓整個字形失敗——當子集被剪得太狠時,這正是您要的寬容行為

從您自己的 Delphi 程式碼驗證修復

驗證複合字形與 seac 處理最快的方法,是把一頁帶變音文字、字型內嵌的頁面,走過兩條公開輸出路徑:THotPDF.RenderLoadedPageToBitmap 出點陣圖,THotPDF.BuildLoadedPageSVG 把 EmbedFonts 設為 True 出向量圖。兩者共用同一套字形外框程式碼,所以都吃到了 v2.768.10 與 v2.768.11 的修復。管線全貌見HotPDF 如何算繪內嵌的 TrueType、CFF 與 Type 3 字形,點陣圖選項見在 Delphi 裡把 PDF 頁面算繪成點陣圖

uses
  System.Classes, Vcl.Graphics, HPDFDoc;

procedure RenderAccentProbe(const FileName: string);
var
  Pdf: THotPDF;
  Bmp: TBitmap;
  Svg: AnsiString;
  Lines: TStringList;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile(FileName) <= 0 then
      Exit;

    // 點陣路徑:144 DPI 讓放錯位的變音符號一眼可見
    Bmp := Pdf.RenderLoadedPageToBitmap(0, 144);
    if Bmp <> nil then
    try
      Bmp.SaveToFile('accent-probe.bmp');
    finally
      Bmp.Free;
    end;

    // 向量路徑:EmbedFonts = True 會寫出字形外框,
    // 複合與 seac 的組裝結果直接顯現在 SVG 路徑本身
    Svg := Pdf.BuildLoadedPageSVG(0, True);
    if Svg <> '' then
    begin
      Lines := TStringList.Create;
      try
        Lines.Text := string(Svg);
        Lines.SaveToFile('accent-probe.svg');
      finally
        Lines.Free;
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

測試文件要有策略地挑。您要的是:一份內嵌 TrueType 子集、內容含 é、Å 與彎雙引號的 PDF;以及一份內嵌 CFF(FontFile3,Type1C)子集、由 Type 1 字型產生的 PDF——seac 至今就活在這種地方,常見於老式排版輸出與某些印刷流程。產生端沒有內嵌的字型所排的文字,在這裡說明不了任何事,因為那些字形來自系統安裝的 Windows 字型,而不是 ISO 32000-1 §9.9 描述的內嵌字型程式

字型子集化時複合字形為什麼會壞?

子集化時複合字形會壞,是因為子集器在元件帶縮放或 2x2 轉換時,用錯誤的步長跨過元件記錄,接著把下一個元件的字形索引讀錯。HotPDF 在 v2.770.83 修掉了這個問題。子集化是算繪 bug 在寫出端的攣生兄弟:HotPDF 內嵌已安裝 TrueType 字型的子集時,必須保留複合字形參照的每個字形,並把那些參照重新編號成新的、緊湊的 glyph ID

舊的走訪器只看引數大小旗標與 more-components 旗標,所以每條記錄前進 6 或 8 位元組,忽略了縮放旗標額外帶的 2、4 或 8 位元組轉換資料。走過一個帶縮放的元件之後,它讀到的下一個「字形索引」其實是轉換資料,於是真正的元件可能被漏在子集之外,重新編號那一步還可能把新的 glyph ID 直接寫進轉換資料裡。結果是某些檢視器裡變音字母的變音符號錯位、變形、或根本不存在,而且只發生在會縮放元件的字型上——這就是大多數測試從來沒抓到它的原因

步長 bug 旁邊還坐著兩個問題。重新編號字形索引不是等冪的,而巢狀複合字形被編號了兩次:一次是掃過每個標記複合字形的那一遍,一次是遞迴深入它們的時候。HotPDF 現在對每個複合字形恰好編號一次,「這個複合字形需要哪些字形」這一步改由工作佇列驅動,互相參照的複合字形循環因此會終止,超出字型 maxp 字形數的索引會被拒絕,空字形也絕不會被當成複合字形解析

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'accents-subset.pdf';
    Pdf.FontEmbedding := True;          // 內嵌用到的字形
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 14);
    // é、Å、ü 與彎引號在 Arial 裡都是複合字形
    Pdf.CurrentPage.UnicodeTextOut(50, 720, 0,
      'Café, Ångström, München, “quoted”');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

把那份檔案丟回上面的探測程序、再丟給另一個檢視器跑一圈。自 v2.770.83 起,每個複合字形的每個元件都能在子集化中存活,包括藏在縮放或 2x2 轉換後面的元件,變音符號因此在 HotPDF、Acrobat 與瀏覽器檢視器裡呈現一致

複合字形處理沒有涵蓋的部分

複合字形修復涵蓋的是以偏移為基礎的放置——幾乎所有真實字型用的都是這種——其餘情況則保守地近似。TrueType 的點匹配複合字形(兩個引數指名要對齊的點、而非給偏移)以零偏移繪製。CID keyed CFF 字型裡的 seac 依設計忽略,因為規格禁止在那裡使用。子集被剪得太狠、seac 的基礎或變音符號碼查不到字形時,就畫找得到的那個元件。這些情況沒有一個會讓算繪器崩潰;但如果您產出的 PDF 是給封存或 PDF/A 用的,更安全的做法是內嵌使用偏移複合、且 Type 2 charstring 不帶 seac 的字型,讓每一個消費端——而不只是某一個算繪器——看到同樣的字母

FAQ

為什麼在我的 PDF 算繪器裡,變音字母畫出來卻沒有變音符號?

變音字母通常是複合字形或 CFF seac 字形,而變音符號是唯一帶偏移的元件。在設備矩陣之後才套元件偏移、或無視 endchar 裡 seac 的算繪器,就會丟掉或放錯變音符號,基礎字母卻畫得好好

HotPDF 支援 CFF endchar 的 seac 形式嗎?

支援。自 v2.768.11 起,HotPDF 把帶四個引數(含寬度則五個)的 endchar 畫成基礎字形加上平移到 (adx, ady) 的變音符號,兩者都經由 StandardEncoding 名稱與字型 charset 解析,點陣算繪與內嵌字型的 SVG 匯出都適用

HotPDF 對 TrueType 字型做子集化時,複合字形保得住嗎?

保得住。自 v2.770.83 起,HotPDF 正確跨過 2、4 或 8 位元組的元件轉換資料,把每個被參照的元件留在子集裡,巢狀複合字形只編號一次,遇到循環或越界的字形參照也會安全停手

複合字形算繪、CFF seac 支援與安全的 TrueType 子集化,都是 HotPDF Delphi PDF Component 的一部分;這是給 Delphi 與 C++Builder 用的原生 VCL 函式庫,建立、編輯、擷取與算繪 PDF 文件都不需要外部 DLL