veraPDF 报告说某个字形宽度与内嵌字体程序不一致,这几乎没告诉你是哪个字形、为什么。PDFlibPas 回答这个问题的方式是:把每个字符码经内嵌 cmap 解析成字形索引,把程序度量归一化到 1000 单位每 em,然后在那里比较
为什么字形宽度会对不上?
因为被比较的两个数住在不同坐标系里,而 PDF 字典里没有任何东西告诉你换算关系。字体字典以字形空间写 /Widths,PDF 把它固定为 em 的千分之一(ISO 32000-1 §9.2.4)。内嵌 TrueType 程序里的 hmtx 表以字体设计单位写步进宽度,head 表决定多少个设计单位构成一个 em:多数 TrueType 字体是 2048,CFF 派生的是 1000,偶尔完全是别的数。直接比原始值,你语料库里每个 2048-upem 的字体看上去都是坏的。这正是 ISO 14289-1 §7.21.5 给任何想通过读字典字段审计宽度的人设下的陷阱
PDFlibPas 在加载时归一化。TPDFTrueTypeParser 在宽度数组里存 Advance * 1000 div unitsPerEm,于是 Parser.GetWidth(GID) 回答的已经是 PDF 所用的千分之一 em,需要设计单位时 GetRawWidth 仍然可用。这还没算更难的那半:从字符码到字形索引。对简单 TrueType 字体,路由取决于 FontDescriptor 中的 Symbolic 标志,即 /Flags 的第 3 位
Parser := TPDFTrueTypeParser.Create;
try
Parser.LoadFromString(FontProgram);
if Symbolic then
begin
// Symbolic 字体直接经程序 cmap 寻址,
// 以 (3,0) 高字节约定作为回退
GID := Parser.GetGlyphIndex(Code);
if GID = 0 then
GID := Parser.GetGlyphIndex($F000 + Code);
end
else
begin
// 非 Symbolic:码 -> 经编码得字形名,名 -> 经
// Adobe Glyph List 得 Unicode,Unicode -> 经程序 cmap 得 GID
UnicodeValue := GetGlyphUnicode(EncodingNames[Code and $FF]);
if UnicodeValue = 0 then
Continue;
GID := Parser.GetGlyphIndex(UnicodeValue);
end;
if (GID > 0) and (GID < Parser.GlyphCount) then
if Abs(PDFWidth - Parser.GetWidth(GID)) > 1 then
Inc(MismatchCount);
finally
Parser.Free;
end;
那段代码里有两个细节有分量。容差是一个单位而不是零,因为归一化是整数除法,合法产出的文件可能差一个单位,这正是诊断 10036 报告的“em 千分之一以内”措辞。而 GID < Parser.GlyphCount 守卫不是装饰。GetWidth 为渲染调用方写得宽容,把越界索引钳到 hmtx 最后一项,表缺失时回退 750。宽容对渲染是对的,对审计是错的,所以审计在问宽度之前先拒绝该索引,而不是信任钳制
CIDFontType2 再多一层间接
PDFlibPas 以同样方式走复合字体,只是在 CID 和字形之间插入 /CIDToGIDMap。宽度从 /W 数组到达,ISO 32000-1 §9.7.4.3 给它两种在同一数组里自由交替的形状:一个起始 CID 后跟一串连续宽度数组,或者一个首 CID、一个末 CID、加一个应用到整段的单个宽度。审计两种都解析,然后把每个结果对交给同一个比较,按诊断 10037 汇总报告。映射这一步是复合字体的不同之处,也是为什么在读任何宽度之前缺失映射的诊断 10021 更要紧:缺失或畸形的 /CIDToGIDMap 不只是违反 §7.21.3.2,它让宽度问题根本无法回答
// /CIDToGIDMap 是名称 /Identity,或一个大端 16 位
// 字形索引流,每 CID 一项(ISO 32000-1 第 9.7.4.2 节)
Obj := DerefIndRef(FDoc, CIDFont.FindValueByKeyName('CIDToGIDMap'));
if (Obj is TPDFName) and (TPDFName(Obj).Name = 'Identity') then
begin
GID := CID;
Result := True;
end
else if Obj is TPDFStream then
begin
Data := TPDFStream(Obj).GetDecodedStream;
P := CID * 2 + 1; // Pascal 字符串从 1 起索引
if (P >= 1) and (P + 1 <= Length(Data)) then
begin
GID := (Integer(Byte(Data[P])) shl 8) or Integer(Byte(Data[P + 1]));
Result := True;
end;
end;
字体程序解码不了时审计者该做什么?
什么都不说。ISO 14289-1 §7.21.4.2 要求的 /CharSet 和 /CIDSet 完整性检查,即诊断 10038 和 10039,正是过急的验证器变成负资产的地方,因为对读报告的人来说,“你的 CharSet 不完整”和“我们的 Type 1 解码器放弃了”无法区分。所以 PDFlibPas 只在三件事全部成功时才报告缺失条目:字体程序解码成功、码到字形映射解析成功、集合本身解码成功。TPDFType1Decoder.LoadPFBFromString 必须返回 True 并给出 charstring 计数,才会把任何字形名与 /CharSet 字符串比对;/CIDSet 路径需要流能解压、字形计数返回正值,才会检测任何一个位。途中任何异常都坍缩为“无发现”,而不是坍缩为缺陷
这是刻意偏向假阴性,值得明说而不是埋起来。损坏的 CFF 表、不支持的 Type 1 变体、或短于字形范围的 /CIDSet,全部产生沉默而不是诊断。理由是 PDF/UA 审计会被转发给没有构建这些工具的作者,一次错告比一次漏报代价更大:作者烧掉一天去证明一份合规文件是合规的,然后不再信任整份报告。Matterhorn 协议以另一种形式做了同样区分,把机器能判定的检查和必须人来判定的检查分开,这些就住在它的 Fonts 检查点(31)。如果你需要更严格的读法,把 PDFlibPas 当快速闸门,再配一个专用验证器当第二意见,这个组合正是 PDF/A 与 PDF/UA 预检演练中描述的那个
页面 /Contents 是列表,不是单个流
内容流审计中最贵的一个错误是把 /Contents 当成一个流。ISO 32000-1 §7.7.3.3 允许页面持有一个流数组,其各部分以空白拼接后才是页面程序;生产器在任意点拆分,一个 BT 可能在一个成员里而配对的 ET 在下一个里。内容处理器要维护状态,包括标记内容嵌套深度、上一个 Tf 选中的字体、文本对象标志,而 Process 在进入时重置这些状态。每个数组成员调一次,第一个之后的每个流都以无当前字体开局,于是标记得完完整整的文本被读成未标记、无字体的噪声。PDFlibPas 先拼接,再处理一次
function ContentObjectData(FDoc: TSmartPDFDocument; Obj: TPDFObject): AnsiString;
var
I: Integer;
begin
Result := '';
Obj := DerefIndRef(FDoc, Obj);
if Obj is TPDFStream then
Result := TPDFStream(Obj).GetDecodedStream
else if Obj is TPDFArray then
for I := 0 to TPDFArray(Obj).Count - 1 do
Result := Result + ContentObjectData(FDoc, TPDFArray(Obj).Item[I]) + #10;
end;
// 对整个拼接结果只调一次 Process,绝不每个成员调一次
Scanner.Process(ContentObjectData(FDoc, PageDict.FindValueByKeyName('Contents')));
哪些 Form XObject 才算无结构?
只有那些页面真正调用的、调用点在标记内容之外、自身内容又显示文本的。诊断 10040 执行 ISO 14289-1 §7.20 的方式是按对象号记录三个独立事实:有文本、被调用过、在标记内容内被调用,只报告前两个的交集减去第三个。两种捷径各有各的错法,而且你都会交付出去:把 /Resources 里每个带文本的 Form 都标出来,惩罚的是没人会绘制的模板库;把每个被调用的 Form 都标出来,惩罚的是不带文本、不需要标记的矢量徽标。调用点按对象号而不是资源名解析,因为同一个 Form 常常在不同页面经不同名字到达。配套诊断 10041 沿同一条拼接程序走 §7.21.8,把每个文本显示操作数经作用域内字体解析,统计落到 .notdef 上的码,无论文本渲染模式如何这都禁止,包括扫描图像背后用的不可见模式。幸存的 Form 该如何包裹是结构树问题,见构建标记 PDF 结构一文
完全没有 FontDescriptor 的字体
未嵌入字体是这项审计的合法输入,不是错误状态,嵌入检查之下的每个助手都必须能挺过它。PDFlibPas 找不到 /FontDescriptor,或描述符没有 FontFile、FontFile2、FontFile3 时,记录诊断 10020;名字属于 Standard 14 时记 10022,§7.21.4 NOTE 5 明确拒绝豁免它们,然后继续走完文件其余部分。这正是报告的意义:作者要的是一次跑完拿到全部发现,而不是每次运行拿一个。所以交给宽度、cmap、CharSet 和 CIDSet 助手的描述符引用可以是 Nil,每个助手都在入口检测它,而不是假设前面的检查已经中止了审计。如果修法是把缺的嵌进去,具体做法见向现有 PDF 嵌入缺失字体一文
运行审计
一次调用,对象可以不是你自己产出的文件。TPDFlib.CheckFileCompliance 接收合规测试选择器,2 表示 ISO 14289-1:2014 下的 PDF/UA-1,返回零,或一个字符串列表句柄,其条目是数字码、冒号、可读消息。这里讨论的字体与内容流发现占据该区间的 10020 到 10041,与 00xxx 的 PDF/A 码在数值上隔开,混合日志依然可读。Options 传 1 会在首个发现处短路,这是你在构建闸门里而非创作工具里想要的行为。对仍在内存中打开的文档,GetPDFUADiagnostics 做等价检查,不必绕磁盘一圈
var
Issues, Count, I: Integer;
begin
// ComplianceTest = 2 选择 PDF/UA-1;Options = 0 报告全部发现
Issues := PDF.CheckFileCompliance('delivery.pdf', '', 2, 0);
if Issues = 0 then
WriteLn('delivery.pdf: PDF/UA-1 conformant')
else
begin
Count := PDF.GetStringListCount(Issues);
for I := 1 to Count do
WriteLn(' ', PDF.GetStringListItem(Issues, I)); // 例如 10037 CIDFontType2 ……
end;
end;
这些都不需要机器上有外部验证器二进制,这正是每次构建都跑的检查和有人想起来才跑的检查之间的差别。本文描述的合规与诊断 API 随标准版 PDFlibPas Delphi PDF Library 交付,其产品页在 PDF/A、PDF/X 和 PDF/E 测试套件旁边列出了 PDF/UA-1 的完整诊断码表