技术文章

在 Delphi 中使用 PDFium Component 读取 PDF 字体属性

PDF 中的每个可见字符都带有对绘制它的字体的引用,而 PDFium Component 允许您沿着该引用追溯到字体对象并读取它所知道的内容。访问的单位是字符,而不是文档:您通过在页面文本中的索引选择一个字符,并请求家族名称、基本名称、字重、斜体角度,以及底层字体是否实际包含在文件中。最后一个属性正是大多数分析真正追求的,因为嵌入的字体随文档一起传播,而未嵌入的字体则是一种寄希望于阅读器机器碰巧安装了相同字体的承诺

该组件通过与您用于渲染和文本提取相同的 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 接受两个参数,字符索引和磅值(point size),因为 PDFium 以字形空间单位报告这些指标,只有当您通过设置文本的大小缩放它们时,它们才会变成像素。传递您已经从 FontSize[CharIndex] 读取的值,您将获得与布局其余部分相同磅值的上升(ascent)和下降(descent)。Descent 返回负值,因为它在基线以下进行测量。家族名称和基本名称被故意设计为独立的字符串:基本名称是来自 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 是常规(regular),700 是粗体(bold)。PDFium 报告字体声明的任何内容,这不一定总是整百数;一个字体可以声明为 350 或 650,将等于或高于 600 的任何内容视为“足够粗体会产生影响”,这比精确测试 700 要好得多。斜体角度是一个配套信号:非零值(通常为负数)意味着字体是倾斜(oblique)或真正的斜体(italic)设计,零意味着正体(upright)。结合起来,它们让您可以分辨出粗斜体文本运行和常规文本运行,而无需渲染任何东西,这就是印前检查或无障碍访问审计想要批量进行的检查类型

这些读取都不需要渲染位图。它们来自解析后的文本层,因此,将文档打开至正确的页面就是您所需的所有设置,这使得字体检查在整个档案中运行的成本很低。如果您将其与文本提取搭配使用,相同的字符索引会与您提取的文本对齐,因此字形的字体及其 Unicode 值是针对一个索引的两次读取。关于使用 PDFium Component 从 PDF 文档中提取文本的姊妹篇文章更深入地介绍了文本层的这一方面

此处显示的字体属性是 PDFium Delphi VCL Component 的一部分