技术文章

在 Delphi 中读取和写入 PDF 标记内容

标记内容(marked content)是 ISO 32000-1 §14.6 为页面内容打标签所定义的机制,标签化 PDF 和 PDF/UA 都建立在它之上。PDFium Component 直接把它暴露出来:PageObjectMarks 从一个页面对象上读取每一个 BDC 标签及其属性列表,AddPageObjectMark 写入一个,RemovePageObjectMark 删除一个,PageObjectMarkedContentID 报告把内容与结构树关联起来的 MCID

在结构树能被接回它所描述的内容之前,可访问性工具都只能是猜测。结构树说"这是一个标题";MCID 说明这个标题实际上是哪一页上的哪些标记。两半都必须可读,一个应用才能检查、修复或报告标签情况

一个标记在字节层面是什么

一个带标签名和可选属性列表、由 EMC 闭合的 BDC 算子。在内容流里它看起来像 /P <</MCID 3>> BDC ... EMC:标签 /P 命名角色,字典携带属性,两个算子之间的一切就是标记内容。落在这一段里的页面对象携带该标记,这正是 PDFium 交回的东西,也是 PDFium Component 把它变成一条记录的依据

TPdfContentMark 持有一个句柄、标签 Name 和一个 TPdfContentMarkParam 数组。每个参数有 KeyKind,以及一个由该 kind 选中的、有意义的值字段:pmpIntpmpFloatpmpStringpmpBlob。这个 kind 来自 PDFium 自己的类型报告,而不是来自哪个 getter 碰巧成功——这正是读取一份属性列表和猜测一份属性列表之间的区别

var
  Marks: TPdfContentMarks;
  M: TPdfContentMark;
  P: TPdfContentMarkParam;
  I: Integer;
begin
  Pdf.PageNumber := 1;                    // PageNumber is 1-based
  for I := 0 to Pdf.ObjectCount - 1 do    // page object indexes are 0-based
  begin
    Marks := Pdf.PageObjectMarks(I);
    for M in Marks do
    begin
      Memo1.Lines.Add('mark ' + M.Name +
        ' (MCID ' + IntToStr(Pdf.PageObjectMarkedContentID(I)) + ')');
      for P in M.Params do
        case P.Kind of
          pmpInt:    Memo1.Lines.Add('  ' + P.Key + ' = ' + IntToStr(P.IntValue));
          pmpString: Memo1.Lines.Add('  ' + P.Key + ' = ' + P.StringValue);
          pmpFloat:  Memo1.Lines.Add('  ' + P.Key + ' = ' + FloatToStr(P.FloatValue));
          pmpBlob:   Memo1.Lines.Add('  ' + P.Key + ' = ' +
                       IntToStr(Length(P.BlobValue)) + ' bytes');
        end;
    end;
  end;
end;

为什么 pmpUnknown 意味着两种不同的事

当 PDFium 报告 FPDF_OBJECT_UNKNOWN 时返回 pmpUnknown,而 PDFium 对一个不存在的键也返回它。这两种情况在这一层无法区分,而假装能区分比坦诚承认更糟

对你的代码的实际后果:把 pmpUnknown 当作"这里没有可用值",而不是某种你或许还能解码的类型。如果某个属性对你的工作流要紧,请用一个你认识的 kind 核实它确实存在,不要从一个未知值去推断缺失——一份读不出来的属性列表所对应的标记,是一个你应当报告的标记,而不是一个你应当默默接受的标记

一条标记记录是快照,不是你拥有的句柄

Handle 字段属于库。一旦标记被移除、页面对象被销毁或页面被卸载,它就失效,所以这条记录是一份短命的只读快照。跨一次页面切换缓存它,你持有的就是引擎已回收的内存里的一个指针

这正是 PDFium 中页面对象句柄普遍适用的同一纪律,也在同一个地方咬人:一个装满标记记录的列表控件、一个导航到另一页的用户,以及一次看起来与导航无关的崩溃。把你需要的值——名字、键、数字——拷出来,把句柄放走。变换之后页面对象句柄失效的笔记覆盖了通用规则以及它在别处如何咬人

添加标记,以及那个容易错过的保存步骤

AddPageObjectMark 接收页面对象索引、标签名和一份完整的参数集。参数以一个集合的形式写入,而不是一次一个键地打补丁,所以 TPdfContentMarkParam 没有 Has* 哨兵——这些哨兵所要守护的"更新既有记录的一个字段"的情况根本不会出现

值得直说的那部分:添加一个标记会重建页面内容流,这样标签才能在一次保存里存活。这一点必须明说,因为 SaveAs 自己不会重新生成内容——只活在对象模型里的改动会被丢弃,保存出的文件看起来和你开始的那份一模一样。如果你曾在 PDFium 页面上加了东西、却发现它没出现在输出里,原因通常就在这里

var
  Params: TPdfContentMarkParams;
begin
  SetLength(Params, 1);
  Params[0].Key := 'MCID';
  Params[0].Kind := pmpInt;
  Params[0].IntValue := NextMcid;
  Pdf.AddPageObjectMark(ObjectIndex, 'P', Params);   // rebuilds the content stream
  Pdf.UpdatePage;
  Pdf.SaveAs('tagged-out.pdf');
end;

这能让文档成为什么、不能让它成为什么

光靠标记并不能让一份 PDF 标签化。一份合规的标签化文档需要一棵结构树,其元素引用这些 MCID;需要一个声明文档已标记的 /MarkInfo 条目;还需要含义符合标准规定的角色名。写下一个 /P 标记、配上一个没有任何结构元素指向的 MCID,你得到的是声称自己已标记的内容,和一棵从不提及它的结构树

在这个层面上,标记内容真正发挥价值的地方是检视和修复:审计哪些页面对象已被标签化、找出本应被标记为工件的工件、或把 MCID 与结构树对照以找出孤儿。对于那项工作的结构树那一半,参见 PDF/UA 结构树校验的详解;而对于这些标签最终为之服务的阅读体验,参见在 Delphi 中构建 无障碍 PDF 阅读器的笔记

PDFium Component 为 Delphi、C++Builder 和 Lazarus 应用在 PDFium 引擎之上提供一套高层 VCL API,标记内容、结构树和无障碍校验都能从普通 Pascal 代码触达——完整的 API 面见 PDFium Component 产品页