当 FPDFPage_TransFormWithClip 重写一个页面时,你手上已经持有的每一个 FPDF_PAGEOBJECT 句柄,描述的依然是变换之前的那次解析结果。面向 Delphi 和 C++Builder 的 PDFium Component 在 TransformPageContent 内部解决了这个问题:先卸载文本页,重新生成内容,然后重新加载页面,这样之后的查询看到的才是新的坐标
这个症状很安静。你应用一个 0.9 的缩放来加一道打印页边距,然后读取 PageObjectInfo,得到的数字和调用之前一模一样。没有异常,没有错误码,日志里什么都没有。这和编辑之后文本页缓存过期一文里描述的失败不是一回事:那边的缓存是一个单独的 FPDF_TEXTPAGE 句柄,你可以丢掉再重建;这里的问题是你自己的变量里持有的每一个页面对象句柄,再加上一类通过大多数调用方根本不看的返回码来报告失败的 getter 函数
为什么页面对象的边界会悄无声息地过期
因为一个页面对象句柄是指向某一个具体内容流的解析结果的指针,而一次整页变换会用一个新的内容流替换掉那一个。PDFium 不会去翻遍你的调用栈找出该打补丁的句柄。它构建一份全新的对象图,把旧的那份原封不动地留在原地,所以对旧句柄的读取,是对一个已经不再对应文件实际内容的结构的一次完全合法的读取
ISO 32000-1 §7.8.2 把内容流定义为绘制一个页面所用的一系列操作符,§8.3.3 定义了当前变换矩阵如何把用户空间映射到设备空间。一次页面级的变换是通过包裹并重写这些操作符来表达的,而不是原地编辑逐个对象的坐标。所以这些对象携带的坐标可能压根没变;变的是它们被绘制时生效的那个矩阵。任何在旧矩阵下被解析出来的句柄,回答几何问题时依然用的是旧矩阵,而且回答得毫无怨言
FPDFPage_TransFormWithClip 实际重写的是什么
它重写的是页面本身,不是你手上的快照。FPDFPage_TransFormWithClip 接收一个 FS_MATRIX 和一个 FS_RECTF 裁剪矩形,把两者一起应用到整个页面内容上。它是做页边距、拼版缩放,以及把一个尺寸奇怪的页面规整到某个目标框时正确的调用。但如果你指望现有的句柄能跟着一起变,那就用错了;同样值得记住的是,它只触及页面内容:注释是单独的一层,需要用 TransformPageAnnotations,后者会把同样的六个矩阵系数转发给 FPDFPage_TransformAnnots
var
Info: TPdfPageObjectInfo;
Scale: FS_MATRIX;
Clip: TPdfRectangle;
begin
Pdf.PageNumber:= 1;
Info:= Pdf.PageObjectInfo(0); // snapshot taken before the transform
Scale.a:= 0.9; Scale.b:= 0.0;
Scale.c:= 0.0; Scale.d:= 0.9;
Scale.e:= 29.7; Scale.f:= 42.0; // 5% margin, A4 in points
Clip:= Pdf.GetPageBox(pbMedia);
Pdf.TransformPageContent(Scale, Clip);
// Info.Bounds still holds pre-transform geometry, and Info.Handle now
// points into a page that TransformPageContent has already replaced
end;
TransformPageContent 使用的刷新顺序
四个步骤,按这个顺序:卸载文本页、变换、生成内容、重新加载页面。TPdf.TransformPageContent 执行的正是这个顺序。它调用 CheckPageActive,把矩阵和裁剪区域复制进各自的原生记录结构,调用 UnloadTextPage,然后是 FPDFPage_TransFormWithClip,然后是包装了 FPDFPage_GenerateContent 的 UpdatePage,最后是 ReloadPage
每一步都有其存在的理由。UnloadTextPage 排在最前面,因为缓存的 FPDF_TEXTPAGE 持有的是在旧矩阵下算出来的字符框,同时它也会丢弃由此派生出来的网页链接列表,以及任何基于它构建的、正在进行中的查找会话。FPDFPage_GenerateContent 必须在重新加载之前执行,因为变换结果只存在于内存中的页面里,直到它被序列化回内容流为止,否则重新加载只会重新解析那份未经修改的旧流。ReloadPage 最后用 FPDF_LoadPage 针对当前页面索引收尾,这是唯一真正能给你一份全新对象图的步骤
// After the transform, re-enumerate. Do not reuse anything captured earlier.
var
I: Integer;
Info: TPdfPageObjectInfo;
begin
Pdf.TransformPageContent(Scale, Clip); // unload text page, transform,
// generate content, reload page
for I:= 0 to Pdf.ObjectCount- 1 do
begin
Info:= Pdf.PageObjectInfo(I); // handle and bounds from the new parse
if Info.Bounds.Right> PageWidth then
Log('object '+ IntToStr(I)+ ' still overflows after scaling');
end;
end;
ReloadPage 里有一个细节,如果你自己有一天要写这套流程,值得照搬。它先加载新页面,只有在之后才把它提交给字段,所以一次失败的页面加载,会让当前的原生页面和它派生出的所有缓存保持完好,而不是把你丢进一个拆到一半的状态。重新加载不是免费的——你要为一次完整的页面重解析付出代价——但这笔代价只在每次变换时付一次,不是每次查询都付一次,也没有更便宜的正确替代方案
不要把句柄带过重新加载这道边界
重新加载之后,旧句柄不只是过期了,而是悬空了。之前那个 FPDF_PAGE已经被关闭,属于它的那些 FPDF_PAGEOBJECT 值,指向的是已经被释放的内存。TPdfPageObjectInfo 在它的 Handle 字段里暴露了原生句柄,这在把一个对象直接传给某个更底层的调用时确实有用,但如果把它留在一个表单字段或一份列表里、跨越一次会重新加载页面的操作,那就同样确实很危险。把一份快照记录只当作有效到"下一次会重新生成内容的调用"为止,这和PDFium 边界上 ABI 与内存安全笔记里讨论的所有权规则是同一种精神
一个 getter 会不会失败了却依然看起来像有效数据
会,而这正是同一个问题的另一半。FPDFPageObj_GetRotatedBounds 和 FPDFPageObj_GetIsActive 是带输出参数的 getter:它们返回一个 int 成功标志,把真正的答案写进一个引用参数里。对于一个已经创建、但其页面尚未重新解析的对象,两者都可能返回 FALSE。这种情况发生时,那个输出参数不会被写入,而一个用 Default(TPdfPageObjectInfo) 初始化的 Pascal 记录全是零,所以调用方看到的是一个四个点都在原点上的四边形,以及一个为 False 的 Active 标志。一次失败的调用,就这样悄无声息地被冒充成了看着还算合理的数据
TPdfPageObjectInfo 用显式的哨兵值来应对这个问题。HasRotatedBounds 携带 FPDFPageObj_GetRotatedBounds 调用的结果,HasActiveState 携带 FPDFPageObj_GetIsActive 的结果,几何和状态字段只有在对应的哨兵值为 True 时才会被写入。这个记录里其他带输出参数的 getter 也重复着同样的形态,所以 HasMatrix、HasFillColor、HasStrokeColor 和 HasStrokeWidth 表达的都是同一件事:原生调用成功了,旁边那个字段是有意义的
Info:= Pdf.PageObjectInfo(I);
if Info.HasRotatedBounds then
// RotatedBounds is array [1..4] of TPdfPoint, in draw order
UseQuad(Info.RotatedBounds[1], Info.RotatedBounds[2],
Info.RotatedBounds[3], Info.RotatedBounds[4])
else
// the native call failed; fall back to the axis-aligned rectangle
UseRect(Info.Bounds);
if Info.HasActiveState and (not Info.Active) then
SkipObject(I); // genuinely inactive
// if HasActiveState is False, the object state is unknown, not inactive
这个模式适用于每一个遵循"返回码加输出参数"这套约定的 PDFium getter,而这样的 getter 数量还真不少。如果某个包装层把这套约定压缩成了一个普通的函数返回值,它就丢掉了唯一能区分"答案就是零"和"根本没有答案"的信号。每个字段多带一个布尔值,只花一个字节的代价,却能消除一整类"一个走默认值的记录被误当作一次实测结果"的 bug
这个问题还会在哪里咬你一口
三条诚实的限制。第一,刷新是按页进行的:变换第二页,你手上为第一页持有的任何句柄都不受影响,但现在你有两个在不同时刻解析出来的页面,记住哪份快照来自哪一页是你自己的责任。第二,索引在一次内容重新生成之后并不保证稳定——重新加载之后,索引 3 就是新解析结果里的索引 3,所以要靠类型和几何特征重新识别对象,而不要假设位置保持不变。第三,FPDFPage_TransFormWithClip 里的裁剪矩形应用于页面内容,不会调整任何页面框的尺寸;如果你把内容缩小来腾出一道页边距,MediaBox 依然是原来的尺寸,查看器会显示原来那张纸,绘制内容缩小地嵌在里面。这些都不是什么稀奇的东西——这只是一个把指向已解析状态的指针交给调用方、把生命周期管理也一并交给调用方的 C API 会带来的正常后果。修复方式在别处也一样管用:明确定义一份快照何时过期,在那个边界上刷新它,绝不让一次失败的调用冒充成一个值
如果你更广泛地在研究矩阵行为,决定一次变换最终落在哪里的乘法顺序,在前乘、后乘与基点变换那篇文章里有讲。这里描述的变换和页面对象 API,随面向 Delphi 和 C++Builder 的 PDFium Component 一起提供,其产品页带有页面对象快照记录及其哨兵字段的完整参考