技術文章

Reading PDF Font Properties with PDFium Component in Delphi

PDF 中的每個可見字元都攜帶對繪製它的字型的引用,而 PDFium 元件允許您沿著該引用追蹤回字型物件並讀取它所知道的資訊;存取的單位是字元,而不是文件:您透過分頁文字中的索引選擇字元,並查詢其家族名稱、基本名稱、粗細、斜體角度以及底層字型是否實際包含在檔案內部;最後一個屬性是大多數分析真正追求的,因為嵌入的字型會隨文件一起傳輸,而未嵌入的字型則是對閱讀器電腦剛好安裝了相同字型的假設

該元件透過您用於算繪和文字擷取的相同 TPdfTPdfView 物件公開這些屬性;沒有單獨的「字型表」物件需要開啟;分頁文字被解析後,字型屬性就掛在字元索引上,您可以一次讀取一個字形;這種設計符合 PDF 最初儲存資訊的方式:單個分頁可以切換字型數十次,而對「這份文件使用的是什麼字型」的唯一誠實回答是「這取決於您指的是哪個字元」

讀取單個字元背後的字型

最小且有用的操作是獲取字元索引,並傾印出 PDFium 能告訴您的有關其字型的一切資訊;TPdfTPdfView 上的每個字型屬性都按字元位置進行索引,因此該索引會穿過所有屬性;分頁也必須是目前分頁,以便索引針對正確的文字進行解析,一旦您移過了第一頁,這點就很重要了

procedure DescribeFontAt(Pdf: TPdf; CharIndex: Integer);
var
  Report: TStringList;
  PtSize: Single;
begin
  Report := TStringList.Create;
  try
    PtSize := Pdf.FontSize[CharIndex];

    Report.Add('Character : ' + Pdf.Character[CharIndex]);
    Report.Add('Family    : ' + Pdf.FontFamilyName[CharIndex]);
    Report.Add('Base name : ' + Pdf.FontBaseName[CharIndex]);
    Report.Add('Weight    : ' + IntToStr(Pdf.FontWeight[CharIndex]));
    Report.Add('Italic    : ' + IntToStr(Pdf.FontItalicAngle[CharIndex]) + ' deg');
    Report.Add('Size      : ' + FormatFloat('0.0', PtSize) + ' pt');
    Report.Add('Ascent    : ' + FormatFloat('0.0', Pdf.FontAscent[CharIndex, PtSize]));
    Report.Add('Descent   : ' + FormatFloat('0.0', Pdf.FontDescent[CharIndex, PtSize]));
    Report.Add('Embedded  : ' + BoolToStr(Pdf.FontIsEmbedded[CharIndex], True));

    ShowMessage(Report.Text);
  finally
    Report.Free;
  end;
end;

其中一些特徵標記會讓來自其他函式庫的人感到驚訝;FontAscentFontDescent 接受兩個引數:字元索引和點大小,因為 PDFium 在字形空間單位中報告這些度量,只有在您按設定文字的大小對其進行縮放後,它們才會變成像素;傳遞您已經從 FontSize[CharIndex] 讀取的值,您就會得到與其餘版面配置相同的點的上升高度和下降高度;下降高度返回為負值,因為它在基線以下進行測量;家族名稱和基本名稱故意分開為不同的字串:基本名稱是 PDF 中原始的 /BaseFont 項目,通常攜帶像 ABCDEF+ 這樣的子集字首,而家族名稱是算繪器將其解析為的清理後的名稱

將點選轉換為字元索引

在檢視器中,您很少預先知道索引;使用者點選一個字形,您必須將像素座標轉換為它下方的字元;CharacterIndexAtPos 精確地執行了該操作,它接受滑鼠位置和容差,並回傳最近字元的索引,當點選落在空白處或空白分頁時則回傳負值

procedure TfrmMain.PdfViewMouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  Index: Integer;
begin
  if not PdfView.Active then
    Exit;

  // 4 px of slack in each direction so a near-miss still hits the glyph.
  Index := PdfView.CharacterIndexAtPos(X, Y, 4.0, 4.0);
  if Index < 0 then
    Exit;                      // clicked between glyphs; leave the panel alone

  PdfView.CurrentCharIndex := Index;
  DescribeFontAt(PdfView.Pdf, Index);
end;

容差值得調整;太小會讓使用者覺得必須精確點選字母的線條,太大則會讓邊緣的點選捕捉到遠處無關的字元;對於螢幕檢視,三到五個裝置像素是合理的起點;回傳的索引是目前解析後分頁文字的索引,也就是所有字型屬性所期望的索引,因此您可以直接將其傳遞給上面的程式;在 CurrentCharIndex 中儲存它是選填的,但很方便:檢視器將其作為目前焦點字形,如果 UI 的其他部分想要讀取選取項目而不用重新計算,這將很有幫助

嵌入是至關重要的屬性

對於大多數實際工作,唯一值得回答的問題是每個字型是否已嵌入;一個字型全部包含在內的文件在印刷局的 RIP、同事的筆記型電腦以及完全沒有 GUI 的伺服器上算繪出來的效果是完全相同的;一個依賴於未嵌入 Helvetica 的文件是在賭每台電腦都剛好有相匹配的字型,當賭注失敗時,閱讀器會使用相近的字型替代,度量衡會發生變化,一個精心配置的表單會發生微調進而損壞;走訪分頁文字並按嵌入狀態對字型進行分組,可以廉價地為您提供該答案

procedure ReportNonEmbeddedFonts(Pdf: TPdf);
var
  Embedded, External: TStringList;
  I: Integer;
  Name: string;
begin
  Embedded := TStringList.Create;
  External := TStringList.Create;
  try
    Embedded.Sorted := True;
    Embedded.Duplicates := dupIgnore;
    External.Sorted := True;
    External.Duplicates := dupIgnore;

    for I := 0 to Pdf.CharacterCount - 1 do
    begin
      Name := Pdf.FontBaseName[I];
      if Name = '' then
        Continue;              // generated spaces and the like have no font
      if Pdf.FontIsEmbedded[I] then
        Embedded.Add(Name)
      else
        External.Add(Name);
    end;

    if External.Count > 0 then
      ShowMessage(IntToStr(External.Count) +
        ' non-embedded font(s):' + sLineBreak + External.Text)
    else
      ShowMessage('All ' + IntToStr(Embedded.Count) +
        ' font(s) on this page are embedded.');
  finally
    Embedded.Free;
    External.Free;
  end;
end;

有兩個細節可以確保準確性;首先,CharacterCount 是按分頁計算的,因此整個文件的稽核意味著依次將 Pdf.PageNumber 設定為每個分頁並再次執行循環,然後合併結果;其次,文字層包含產生的字元,例如閱讀器在單字之間推斷出的空格,而這些字元背後沒有字型物件;基本名稱為空的檢查會跳過它們,而不是記錄一個幻影;基本名稱是此處去重的正確鍵,因為它攜帶的子集字首區分了同一個家族的兩個不同子集,這通常是您想要知道的

將嵌入的字型提取出來

當字型已嵌入時,您可以直接讀取其位元組;FontData 回傳原始的字型程式,即 PDF 攜帶的相同 TrueType 或 CFF 資料,這足以寫入獨立的字型檔案,或針對已知庫對該字型進行特徵碼識別;當字型未嵌入時,它會回傳一個空陣列,因此嵌入檢查和長度檢查共同保護了寫入操作

procedure SaveEmbeddedFont(Pdf: TPdf; CharIndex: Integer;
  const OutputFile: string);
var
  Data: TBytes;
  Stream: TFileStream;
begin
  if not Pdf.FontIsEmbedded[CharIndex] then
  begin
    ShowMessage('That glyph''s font is not embedded; nothing to extract.');
    Exit;
  end;

  Data := Pdf.FontData[CharIndex];
  if Length(Data) = 0 then
    Exit;

  Stream := TFileStream.Create(OutputFile, fmCreate);
  try
    Stream.WriteBuffer(Data[0], Length(Data));
  finally
    Stream.Free;
  end;
  ShowMessage('Wrote ' + IntToStr(Length(Data)) + ' bytes.');
end;

這些位元組是嵌入的子集,而不是原始的零售字型,因此您獲得的內容通常僅覆蓋文件實際使用的字形;這對於鑑識和驗證來說完全正確,但對於重複使用來說則不合適;一個包含三十個字形的 Times New Roman 子集不是您可以安裝並用來打字的字型;請將提取視為檢查交付內容的一種方式,而不是字型恢復工具;如果您需要匹配的基本名稱來標記輸出,請隨資料一起讀取 FontBaseName[CharIndex],如果想要純粹的家族名稱,請去除前導的子集標籤

理解粗細數值

FontWeight 回傳數值粗細類別,與 CSS 使用的 100 到 900 比例相同,其中 400 是常規,700 是粗體;PDFium 會報告字型宣告的任何內容,這並不總是整百的數值;字型可以宣告 350 或 650,將任何大於或等於 600 的內容視為「足夠粗以至於重要」,比測試精確的 700 更有意義;斜體角度是伴隨訊號:非零值(通常為負值)表示該字型是斜體或真實斜體設計,零表示直立;它們加在一起,可以讓您在不用算繪任何內容的情況下區分粗斜體與常規,這是發行前檢查或無障礙稽核想要批次進行的檢查

這些讀取都不需要算繪的點陣圖;它們來自解析後的文字層,因此在正確的分頁上開啟文件就是您所需的全部設定,這使得字型檢查在整個封存檔中執行的成本非常低;如果您將其與文字擷取配合使用,相同的字元索引與您拉出的文字對齊,因此字形的字型及其 Unicode 值是對一個索引的兩次讀取;關於 使用 PDFium 元件從 PDF 文件中擷取文字 的配套文章更深入地介紹了文字層的該方面

此處顯示的字型屬性是 PDFium Delphi VCL 元件 的一部分