技术文章

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

建筑 CAD 软件导出的平面图,第一页上通常有一个带两个视口的 /VP 数组,每个视口又带着 /Measure 条目,好让阅读器直接从图上读出米数。GeoPDF 地图图幅有同样的 /VP 数组和同样的 /Measure 键。平面图只是普通的 PDF 1.6,地图图幅却需要 PDF 1.7 加 Adobe Extension Level 3。一条停在键名上的版本检查会向两者都要扩展,平面图因此在保存时被拒,理由是它从未包含过的地理空间内容

一个 /Measure 键,两种不同的字典

键名只能告诉你这个视口可测量;只有它背后的字典才说得出怎么测。测量字典随 PDF 1.6 到来,ISO 32000-1 §12.9 定义了直线式那一类:/R 里的缩放比字符串,/X 和 /Y 里的数字格式数组,以及距离(/D)和面积(/A)的更多数组。地理空间测量来得更晚,是 PDF 1.7 之上 Adobe Extension Level 3 的一部分。它以 /Subtype /GEO 标识,携带 /GCS 里的地理坐标系、可选的 /DCS 显示坐标系、成对的 /GPTS 和 /LPTS 点数组,以及 /PDU 里的首选显示单位 — 在 Delphi 中读取 GeoPDF 视口、GPTS 与 LPTS 数组 走查的就是这些结构

让人栽跟头的细节是默认值。§12.9 给 /Subtype 的默认值是 RL,不少 CAD 导出器就吃定了这一点,干脆不写这个条目。把缺失的子类型读成"未知,往坏里想"的检查器,会把每一张这样的图都当成地理空间文档。而按 /Measure 键匹配的检查器根本走不到子类型那一步,因为持有键的视口字典,比回答问题的测量字典高出整整一层

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

PDF Library for Delphi 的保存预检如何得出结论

PDF Library for Delphi(PDFlibPas)在 SaveToFile 写出任何输出之前运行一次版本预检。它访问每个间接对象,下钻嵌套的字典和数组,把每个节点对着一张特性规则表测试。每条规则指明一个最低 PDF 版本,扩展特性还外加一个 Adobe 扩展级别;扩展规则只有 PDF 1.7 加上 catalog 中匹配的 /Extensions /ADBE 条目才能满足。检查的对象是文档版本,或者你用 LockSaveVersion 钉住的版本,再加上 catalog 声明的扩展级别。第一条既匹配又超出该目标的规则会终结保存:SaveToFile 返回 0,LastErrorCode 变成 602,即 PDFLIB_ERROR_VERSION_COMPLIANCE 的值

PDFlibPas 保存时的版本预检:PrepareAndCheckSaveVersion 把每个对象与带 MinVersion、扩展级别和匹配类型的 PDFFeatureRules 记录比较,AddExtensionRule 把要求钉在 PDF 1.7 加扩展级别上,目标无法满足的第一条匹配就以错误 602 终止保存
GetInformation 的键 311、312 和 313 把拒绝变成诊断,报告所需版本、触发它的特性和被锁定的目标,调用者可以去修文件或修规则,而不是靠猜

光一个错误码不会告诉你该修什么。三个 GetInformation 键带来其余信息:311 是违规特性需要的版本(例如 1.7 ExtensionLevel 3),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;

对测量内容,规则表里有三条规则,并且刻意不给裸的 /Measure 键设规则。带 /VP 或 /UserUnit 的页面需要 PDF 1.6。/PtData 字典需要扩展级别 3。测量字典在写明 /Subtype /GEO、或含有 /GCS、/DCS、/GPTS、/LPTS、/PDU 中任意一个时需要扩展级别 3。于是省略 /Subtype 的视口测量字典和写明 /RL 的都在 1.6 通过,而同一页在 1.5 会因 /VP 被拒。加一个 /GPTS 数组,答案就变成 1.7 ExtensionLevel 3

内容测试刻意保守。一条还带着游离 /GCS 或 /PDU 的直线式字典会被当成地理空间。我们宁可拒掉这种文件也不愿放行:这些键在直线式模型里没有任何意义,写下它们的要么是地理空间工具,要么是损坏文件的东西,而认识 GEO 的查看器多半会试着解释它们。加载器在另一个方向上同样小心。它的特性扫描可以把文档版本号抬到 1.7,但从不代替输入文件写 /Extensions 声明 — 声明扩展是对内容的主张,只有你的代码或创作调用才该做这件事

保存之前先检查图纸的视口

当 312 点名地理空间特性、而你确信文件只是一张普通图纸时,自己看看那些测量字典。公开的视口调用就够用。GetMeasureDictCoordinateSystem 读取 /Subtype,RL 返回 1,GEO 返回 2,条目缺失时返回 0 — 按 §12.9 这同样意味着直线式。点数组计数会告诉你有没有地理参考数据

// 报告已加载文档中的每个测量字典。
// 这里的调用全部只读;没有一个会往文件里加键。
procedure ReportMeasureDicts(Pdf: TPDFlib);
var
  Page, VP, VPID, MD, Kind: Integer;
begin
  for Page := 1 to Pdf.PageCount do
  begin
    Pdf.SelectPage(Page);
    for VP := 1 to Pdf.GetPageViewPortCount do
    begin
      VPID := Pdf.GetPageViewPortID(VP);
      MD := Pdf.GetViewPortMeasureDict(VPID);
      if MD = 0 then
        Continue;
      Kind := Pdf.GetMeasureDictCoordinateSystem(MD); // 0 = 省略(RL),1 = RL,2 = GEO
      Writeln(Format('page %d viewport "%s": subtype %d, GPTS %d, LPTS %d',
        [Page, string(Pdf.GetViewPortName(VPID)), Kind,
         Pdf.GetMeasureDictGPTSCount(MD), Pdf.GetMeasureDictLPTSCount(MD)]));
    end;
  end;
end;

那个循环里刻意少了两处调用:GetMeasureDictGCSDict 和 GetMeasureDictDCSDict。这两个是为创作而建的取用即创建式访问器。在没有 /GCS 条目的测量字典上,GetMeasureDictGCSDict 会加一个空的条目,并为文档声明扩展级别 3。把它当探针用在一张直线式平面图上,字典这下真的含有 /GCS 了;接下来一次锁定在 1.6 的保存会被拒绝,而且是正确的拒绝,因为内容是你的诊断刚刚写进去的。想知道坐标系是否存在,子类型和点数组计数就能无副作用地回答

把输出锁定在 PDF 1.6

当你的下游有版本天花板时,钉住版本能让输出保持诚实。TPDFlib.LockSaveVersion 接受 '1.0' 到 '1.7' 的字符串,其他一律返回 0。它固定文档版本,阻止写入侧调用自行抬高,但保存预检仍对着锁定值运行。锁定在 1.6 的 CAD 图干净保存。锁定在 1.6 的真 GeoPDF 拿到 602,而这就是你想要的答案,因为另一种下场是一个头部承诺低于内容所需的文件

if Pdf.LockSaveVersion('1.6') <> 1 then
  raise Exception.Create('unsupported version string');
try
  if Pdf.SaveToFile('floor-plan-16.pdf') <> 1 then
    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))]);
finally
  Pdf.UnlockSaveVersion;
end;

动版本之前先读 311 和 312。如果特性是真的地理空间内容,你有两个诚实的选项:让文档保持 1.7 解锁并声明扩展,或者从发给 1.6-only 消费者的那份副本里把地理空间数据拿掉。只为让错误消失而抬高锁定是要避免的选项,它把真正的问题藏了起来 — 下游的阅读器处理得了你要交付的东西吗?通过 API 构建地理空间内容时,SetMeasureDictCoordinateSystem、SetMeasureDictGPTSCount 这类 SetMeasureDict* 设置器会自己声明扩展级别 3,所以创作出来的文件带着标签走进预检。同样的有界、有证据支撑的检查习惯还体现在 面向工程文档的 PDF/E-1 作者模式预检 里,在那里 CAD 图面对的是一致性标准而不是一个版本号

规则扫描为什么就地读表

预检的成本约等于对象节点数乘以规则数,而节点数把每个数组里的每个数字和名称都算了进去,这让每条规则的固定开销比看上去更重要。表里每条规则是一个带两个 AnsiString 字段的记录:匹配串和特性名。把这样的记录拷进局部变量,会让 Delphi 或 FPC 做两次引用计数加减、再释放局部变量原先持有的东西。每条规则对每个节点来一遍,意味着连那些不适用于目标版本、下一行就会被扔掉的规则,也要付一遍字符串簿记的钱

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

规则表在单元初始化时填一次,此后再不修改,所以扫描用 const 记录参数把表条目交给匹配代码,就地读取。const 记录参数按引用传递,测试一条规则不碰任何字符串字段,引用计数原地不动。效果集中在规则匹配路径上。真实保存还要解码对象、跑延迟特性检测、序列化输出,这些阶段不受规则传参方式影响。规则顺序、阈值和 311、312 的首失败诊断都不依赖它,两次保存之间也不缓存任何规则结果,所以一份编辑后保存两次的文档,两次都做完整检查

同样的模式值得在你自己的 Delphi 代码里检查一遍:凡是循环走一张含字符串、接口或动态数组的记录表的地方都算:

type
  TMyRule = record
    Name: string;      // 托管字段:拷贝要付引用计数的代价
    Threshold: Integer;
  end;

// 读者自己的代码,仅作模式示意
function Applies(const Rule: TMyRule; Value: Integer): Boolean;
begin
  Result := Value >= Rule.Threshold;  // const:无拷贝,无引用计数流量
end;

// 在热点循环里直接传表条目,而不是
// 先赋给一个局部记录
for I := 0 to High(Rules) do
  if Applies(Rules[I], V) then
    Inc(Hits);

测量视口、地理空间字典、版本锁定和保存时合规检查都随 PDF Library for Delphi,面向 Delphi、C++Builder 和 Lazarus 项目 交付