技术文章

Delphi PDF 版本预检:Measure 的 RL 与 GEO 规则

PDFlibPas(PDF Library for Delphi)在写文件之前把每个对象过一遍 PDF 版本规则表,而直到最近,这套 PDF 版本预检都把普通的 CAD 测量字典误认成地理空间字典。一份单页 CAD 图加载得好好的,然后 SaveToFile 返回 0、LastErrorCode 是 602,要求 1.7 ExtensionLevel 3。修正后的规则把直线式 /Measure 字典(/Subtype /RL)当作普通 PDF 1.6 处理,把扩展闸门留给真正的地理空间标记

这份文件是通过语料准入进来的:一页、一个可选内容组、两个直线测量视口,建筑 CAD 软件为了让阅读器能从平面图上读距离而写出的那种输出。它没有任何猎奇之处,而这正是这个拒绝值得在乎的原因。挡住合法文件的预检比慢的预检更糟,因为调用者拿到的是一份看起来权威的诊断,指向文档里根本不存在的特性。修复分两部分:一条规则背后的规范解读,以及一个认识——在它所看的那个层面上,这条规则根本无法区分两种字典类型

PDFlibPas 保存时的版本预检是怎么工作的?

保存闸门 PrepareAndCheckSaveVersion 把每个间接对象与 PDFFeatureRules 比较,在第一条既匹配、又要求超出目标允许范围的规则上失败。目标是文档版本(或 LockSaveVersion 钉住的版本)加上 /Extensions /ADBE 下声明的 Adobe 扩展级别。每条 TPDFFeatureRule 记录带一个 MinVersion、一个 MinExtensionLevel、一个诸如 fmkDictKey 或 fmkDictSubtype 的 MatchKind、一个 Match 字符串、一个人类可读的 Feature 名称和一个可选回调。AddRule 注册普通版本规则;AddExtensionRule 总是把 MinVersion 钉在 17,再往上加扩展级别,所以扩展规则只能被 PDF 1.7 加上正确的 /Extensions 条目满足。闸门绊住时,所需版本与特性名保留给调用者,GetInformation 的键 311、312 和 313 暴露它们

PDFlibPas 保存时的版本预检:PrepareAndCheckSaveVersion 把每个对象与带 MinVersion、扩展级别和匹配类型的 PDFFeatureRules 记录比较,AddExtensionRule 把要求钉在 PDF 1.7 加扩展级别上,目标无法满足的第一条匹配就以错误 602 终止保存
GetInformation 的键 311、312 和 313 把拒绝变成诊断,报告所需版本、触发它的特性和被锁定的目标,调用者可以去修文件或修规则,而不是靠猜
var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('floor-plan.pdf', '') <> 1 then
      raise Exception.Create('load failed');
    if Pdf.SaveToFile('floor-plan-out.pdf') <> 1 then
      if Pdf.LastErrorCode = PDFLIB_ERROR_VERSION_COMPLIANCE then
        // 311:所需版本,312:触发它的特性,
        // 313:保存目标锁定的版本(未锁定时为 '')
        Writeln('Needs ', Pdf.GetInformation(311),
          ' for ', Pdf.GetInformation(312),
          ', locked at [', Pdf.GetInformation(313), ']');
  finally
    Pdf.Free;
  end;
end;

为什么一份普通 CAD 图会报错 602?

规则表里有一条 AddExtensionRule(3, fmkDictKey, 'Measure', '/Measure geospatial dictionary', Nil),任何带一个 /Measure 键的字典都会命中它,而每个测量视口都有这个键。页面的 /VP 数组装着视口字典,每个视口经 /Measure 指向自己的测量字典,而「键存在」的匹配到那里就停了,没看测量字典到底是什么。于是加载期的特性扫描把文档版本号抬到 1.7,但它从不代替输入文件写 /Extensions 声明,保存闸门看到的就是扩展级别 0 的 PDF 1.7,然后报告 1.7 ExtensionLevel 3。拒绝凭空编造扩展声明是刻意的:库不会悄悄把输入文件升级来掩盖一条错误的规则

规范对直线式的情形毫不含糊。测量字典是 PDF 1.6 引入的,ISO 32000-1 §12.9 给 /Subtype 的默认值 RL——一种由自身一组条目描述的直线坐标系:缩放比、X 和 Y 数字格式、距离与面积。地理空间测量是 Adobe 在 PDF 1.7 之上的扩展级别 3 后来的补充,以 /Subtype /GEO 标识,携带地理点数组、坐标系字典和显示单位——在 Delphi 中读取 GeoPDF 视口、GPTS 与 LPTS 数组走查的就是那些结构。两种字典都挂在同一个 /Measure 键上,所以任何停在键上的规则不可能同时对两者成立。区分性的信息在下面一层,在测量字典自己身上

PDFlibPas 中一个 /Measure 键对应两种字典:直线测量(/RL 或省略子类型)只需要 PDF 1.6,而地理空间字典需要 1.7 ExtensionLevel 3,所以 CB_GeospatialDictionary 按内容裁决,旧的键规则分不出它们
挡住合法文件的预检比慢的预检更糟,因为调用者拿到的是关于文档从未包含的特性的权威诊断,这正是区分性检查下移一层的原因

修正后的规则集仍然强制什么?

修复删掉了那条无条件的键规则,留下那些描述真实版本要求的闸门。带 /VP 或 /UserUnit 的页面仍需要 PDF 1.6(经 CB_PagePDF16Entries),/PtData 键仍需要扩展级别 3,而 CB_GeospatialDictionary 按内容而不是按把字典送进来的那个键判断测量字典是否地理空间

// 已删除:所有带 /Measure 键的字典都算地理空间
// AddExtensionRule(3, fmkDictKey, 'Measure', '/Measure geospatial dictionary', Nil);

AddRule(16, fmkCustom, '', 'Page PDF 1.6 entry /UserUnit /VP', CB_PagePDF16Entries);
AddExtensionRule(3, fmkDictKey, 'PtData', '/PtData geospatial dictionary', Nil);
AddExtensionRule(3, fmkCustom, '', 'geospatial measure dictionary', CB_GeospatialDictionary);

function CB_GeospatialDictionary(Obj: TPDFObject; const Ctx: TPDFRuleContext): Boolean;
var
  Dict: TPDFDictionary;
begin
  Result := False;
  if not (Obj is TPDFDictionary) then
    Exit;
  Dict := TPDFDictionary(Obj);
  Result := (Dict.StringValue('Subtype') = 'GEO') or
    (Dict.FindIndexByKeyName('GCS') >= 0) or (Dict.FindIndexByKeyName('DCS') >= 0) or
    (Dict.FindIndexByKeyName('GPTS') >= 0) or (Dict.FindIndexByKeyName('LPTS') >= 0) or
    (Dict.FindIndexByKeyName('PDU') >= 0);
end;

共享的 Delphi 与 FPC 回归从两侧钉住这条边界。测量字典省略 /Subtype 的视口和写明 /RL 的视口都在 PDF 1.6 下通过,同一页在 PDF 1.5 下仍被拒绝,特性检测也不再为它报告扩展。加上一个 /GPTS 数组会把裁决翻回 1.7 ExtensionLevel 3——声明了扩展级别即可通过——而裸的 /Subtype /GEO 字典没有它就被拒。回调在设计上是保守的:一个还带着游离 /GCS 或 /PDU 键的直线字典会被当成地理空间,因为这些键在 RL 模型里没有意义

LockSaveVersion 是这个变化对调用者可见的地方。TPDFlib.LockSaveVersion 接受 '1.0' 到 '1.7',其他一律返回 0,钉住文档版本并阻止写入侧的调用悄悄抬高它,而保存闸门仍对着锁定值运行。规则修正后,锁定在 1.6 的 CAD 文件干净保存。锁定在 1.6 的真 GeoPDF 仍然得到 602——这是正确答案——而 SetMeasureDictCoordinateSystem 这类地理空间创作调用在你通过 API 构建那类内容时会自己声明扩展级别 3

if Pdf.LockSaveVersion('1.6') <> 1 then
  raise Exception.Create('unsupported version string');
if Pdf.SaveToFile('floor-plan-16.pdf') <> 1 then
begin
  if Pdf.LastErrorCode = PDFLIB_ERROR_VERSION_COMPLIANCE then
    // 真正超过 1.6 的内容,例如 GEO 测量字典
    raise Exception.CreateFmt('Locked at 1.6 but %s needs %s',
      [string(Pdf.GetInformation(312)), string(Pdf.GetInformation(311))]);
end;
Pdf.UnlockSaveVersion;

为什么版本规则扫描比它应有的速度慢?

扫描在测试之前把每条 TPDFFeatureRule 拷进局部记录,而这个记录带两个 AnsiString 字段,每次拷贝要调整两个引用计数、释放前一个值。预检要访问每棵对象树的每个节点(标量也不例外),所以这个成本等于对象数乘以规则数,而那些根本不适用于目标版本的规则也是先拷贝、后跳过。既然 PDFFeatureRules 在单元初始化时填一次、之后按只读对待,v3.539.17 就把表条目直接传给 MatchSingleRule 和 RuleExceedsTarget,这两个函数的 const Rule 参数以引用方式取值而不碰字符串

PDFlibPas 的规则扫描提速:预检过去在测试前拷贝每条 TPDFFeatureRule 记录、为每个访问到的对象调整 AnsiString 引用计数,现在 const 参数就地读取只读表,把规则匹配一轮的中位数从 0.711 s 削到 0.203 s
收益是真的但范围窄:完整保存还要为延迟特性检测、对象解码和序列化付费,所以测得的比值属于规则匹配路径,不属于总保存时间
// 之前:每条规则、每个访问到的对象都要一次托管记录拷贝
Rule := PDFFeatureRules[X];
if not RuleExceedsTarget(Rule, TargetVersion, TargetExtensionLevel) then
  Continue;

// 之后:const 参数就地读取不可变的表条目
if not RuleExceedsTarget(PDFFeatureRules[X], TargetVersion, TargetExtensionLevel) then
  Continue;
if MatchSingleRule(Obj, Ctx, PDFFeatureRules[X]) then
begin
  RequiredVersion := RequiredVersionString(PDFFeatureRules[X]);
  FeatureName := PDFFeatureRules[X].Feature;
  Result := False;
  Exit;
end;

测得的效果很窄,引用时也应该这么说。基准让一个 20,000 个数值对象的数组对着 PDF 1.4 目标每轮跑十次;用 FPC Win64 以 -O2 构建,五轮的中位数从 0.711 s 降到 0.203 s,两个构建按相反顺序跑得到 0.459 s 对 0.150 s。这在规则匹配路径上大约是 3 倍。真实保存还要为延迟特性检测、对象解码和序列化付费,所以这个比值不会延续到总保存时间上。规则顺序、回调、版本阈值和首失败诊断都没变,也没有为达到这个数字缓存规则或跳过规则

加载的 PDF 没过版本预检时该查什么?

在动版本之前先读键 311 和 312。如果特性名说的是地理空间字典而文件只画了直线测量,那就是这个误报,当前构建不改文件就能保存。如果特性是真的,要么声明扩展,要么锁定到一个诚实包含这些内容的版本;只为让闸门闭嘴而抬版本,会把「下游的消费者能不能读你交付的东西」这个问题藏起来。同样的「有界、有证据支撑的检查」原则驱动着 面向工程文档的 PDF/E-1 作者模式预检,在那里 CAD 图要面对的是一致性标准而不是一个版本号

版本合规检查、测量与地理空间字典以及保存版本锁定,都是 PDF Library for Delphi——面向 Delphi、C++Builder 和 Lazarus 开发者的 PDFlibPas 工具箱的一部分