PDFium Component 的 FPDF_RenderPageBitmap 函数接受一个 rotate 参数,而 PDFium 每次都会把它叠加到页面自身 /Rotate 条目中的旋转值上。因此,读取页面已保存的旋转值,再把同一个值传回渲染调用,就会让页面旋转两次。适配缩放计算也会出现同类错误:如果根据页面未旋转时的宽高来确定缩略图尺寸,那么 /Rotate 为 90 或 270 度时宽高已经交换,最终纵横比就会错误
一旦知道该观察什么,这个故障很容易发现;在知道之前却很容易漏掉。假设一批扫描发票同时包含纵向和横向原稿,其中一半在归档前被人用 Acrobat 旋转了 90 度,而基于 PDFium 构建的 Delphi 查看器缩略图条恰好把这些页面渲染成横着、倒置,或者挤进适合另一种方向的框中。整个过程不会抛出异常,也不会记录错误。像素只是错了,而且只错在后来被旋转过的那部分页面上——这种问题正好可以躲过针对未旋转测试 PDF 的完整 QA 检查,最后在生产环境真实文件的第 47 页暴露出来
为什么 PDFium 会把页面旋转两次?
PDFium 每次渲染位图时,都会自动应用页面自身的 /Rotate 值,不论传给渲染器的是什么。PDFiumPas 将 FPDF_RenderPageBitmap 的 rotate 参数暴露为 TPdf.RenderPage、TPdf.RenderTile 和 TPdf.RenderPageThumbnail 上的 TRotation 值 ro0、ro90、ro180 和 ro270。这个参数并不是设置页面最终应处的角度,而是把额外的旋转叠加到页面字典已经指定的旋转上,这正是这些方法默认使用 ro0 的原因
TPdf.PageRotation 通过 FPDFPage_GetRotation 读取同一个 /Rotate 值,而应用程序可能需要它来完成与渲染无关的工作,例如决定如何在页面坐标空间中布局批注。陷阱就在这一行:把 PageRotation 传给 RenderPage 的 Rotation 参数,以为这样就能把页面规范化为正向。一个已经以 /Rotate 90 保存的页面,在任何符合规范的查看器中都会正确显示为旋转状态,PDFium 也一样;如果再叠加一个 ro90,页面就会转成 180 度,而不是预期的 90 度;没有任何旋转的页面则会无缘无故被转过四分之一圈
// Wrong: PageRotation already reflects /Rotate, and PDFium applies
// it automatically on every render -- passing it again as Rotation
// doubles the angle
Bitmap := Pdf.RenderPage(0, 0, TargetW, TargetH, Pdf.PageRotation, []);
// Right: leave Rotation at its ro0 default and let PDFium apply the
// page's own /Rotate exactly once
Bitmap := Pdf.RenderPage(0, 0, TargetW, TargetH, ro0, []);
Rotation 参数究竟用于什么
Rotation 参数在 API 中承担的是另一项确实不同的工作:添加与页面已保存方向无关、只影响视图的旋转,也就是旋转工具栏按钮在不修改底层文件的情况下应用的那种旋转。TPdfView 正是因此把两个概念拆成两个独立属性。TPdfView.PageRotation 映射页面自身的 /Rotate,并且可以通过 FPDFPage_SetRotation 将新值写回文档;TPdfView.Rotation 是临时的、只影响视图的属性,默认值为 ro0,永远不会修改文件。读取前一个属性,再把它写入后一个属性,就完整概括了这个 bug
// View-only: rotates what the user sees, changes nothing in the file
procedure TViewerForm.RotateViewClick(Sender: TObject);
begin
case PdfView.Rotation of
ro0: PdfView.Rotation := ro90;
ro90: PdfView.Rotation := ro180;
ro180: PdfView.Rotation := ro270;
ro270: PdfView.Rotation := ro0;
end;
end;
// Persistent: rewrites the page's own /Rotate entry in the document
procedure TViewerForm.RotatePageClick(Sender: TObject);
begin
case PdfView.PageRotation of
ro0: PdfView.PageRotation := ro90;
ro90: PdfView.PageRotation := ro180;
ro180: PdfView.PageRotation := ro270;
ro270: PdfView.PageRotation := ro0;
end;
end;
为什么适配缩放也会出现同样的问题?
适配缩放出错的原因恰好相反:计算使用了错误的一对数字,而不是错误的角度。典型的缩略图尺寸计算会向 PDFium 请求页面宽高,将其纵横比与可用框进行比较,再计算能放入框内的最大矩形;对于未旋转页面,这种方法运行良好。但如果宽高来自一个报告页面固有未旋转尺寸的调用,那么 /Rotate 为 90 或 270 度时,同样的计算会悄悄失败:带有 /Rotate 90 的 A4 纵向页面仍会报告大约 595 × 842 点,尽管旋转生效后 PDFium 会正确地以大约 842 × 595 渲染,而根据未旋转尺寸计算出的适配框最终完全采用了错误的方向
FPDF_GetPageSizeByIndex 就是一个按设计报告页面固有未旋转尺寸的具体调用,因此它适合在不加载每个页面的情况下扫描页面尺寸,却不适合在忘记考虑旋转时直接用于适配缩放计算。修复方法也很直接:在进行适配运算前检查页面旋转角度,在旋转为 90 或 270 度时交换宽高,用交换后的数值计算适配框,并且在实际渲染调用中仍传入 ro0,因为真正应用旋转的仍然是 PDFium
无需重新实现适配计算,正确生成缩略图
TPdf.RenderPageThumbnail 已经包含了这个修复,因此生成正确缩略图的最短路径是直接调用它,而不是手动重新拼装适配和旋转逻辑。给定从 1 开始的页码以及最大宽高,RenderPageThumbnail 会计算适配框,在内部针对 /Rotate 为 90 或 270 度的情况进行修正,并返回由调用方负责释放的位图,同时不会改变文档当前页面,也不会触发 OnPageChange 事件——对于在同一个 TPdf 实例旁边构建实时查看器的缩略图条来说,这一点很重要
// PageW, PageH are a page's own (unrotated) dimensions in points, for
// example from FPDF_GetPageSizeByIndex, which reports size before
// /Rotate is applied
function FitBox(PageW, PageH: Double; Rotation: TRotation;
MaxW, MaxH: Integer; out FitW, FitH: Integer): Boolean;
var
PgW, PgH, Swap: Integer;
begin
PgW := Round(PageW);
PgH := Round(PageH);
if PgW < 1 then PgW := 1;
if PgH < 1 then PgH := 1;
if Rotation in [ro90, ro270] then
begin
Swap := PgW;
PgW := PgH;
PgH := Swap;
end;
Result := (MaxW > 0) and (MaxH > 0);
if not Result then
Exit;
if PgW * MaxH > PgH * MaxW then
begin
FitW := MaxW;
FitH := (MaxW * PgH) div PgW;
end
else
begin
FitH := MaxH;
FitW := (MaxH * PgW) div PgH;
end;
end;
FitBox 辅助函数本身也值得保留,因为 RenderPageThumbnail 只覆盖单个位图的情况。自定义缩略图网格、打印预览条或页面选择对话框如果要把多个页面分别布局到独立框中,就需要同样具备旋转感知的适配计算,却未必希望为每个图块都创建一张新位图;TPdfView 自身的适合页面和适合宽度缩放模式内部也依赖同一思路:在与可用客户区比较前,根据视图当前旋转状态,在页面宽高之间选择用于计算缩放比例的数值。如果这类查看器的下一个问题是缩放和滚动性能,那么关于 基于 PDFium 的 Delphi 查看器中的渲染缓存与平滑缩放的配套文章正好从正确尺寸计算之后继续展开
如何在客户发现之前识别重复旋转
重复旋转有一个可靠的视觉特征:原本在输入时被旋转 90 度的页面,最终相对于文档其他页面会多转 180 度,而不是保持 90 度,因为额外的 ro90 叠加在页面自身的 ro90 上,而不是替换它。只包含 /Rotate 0 页面的测试样本永远发现不了这个问题,因为 ro0 加 ro0 仍然是 ro0,bug 会继续隐藏;在信任缩略图或适配缩放代码路径之前,测试样本至少要包含一个以 /Rotate 90 保存的页面和一个以 /Rotate 270 保存的页面
使用 PDFium Component 将 PDF 页面渲染为 JPEG中介绍的基础页面到位图流程,已经可以在不添加特殊处理代码的情况下正确渲染旋转页面,正是因为它让 Rotation 保持默认值 ro0,并由 PDFium 自行应用 /Rotate。只有当应用程序开始读回 PageRotation,并把它传到不该出现的位置时,重复旋转 bug 才会出现
这里介绍的旋转感知渲染调用和缩略图尺寸计算属于面向 Delphi 和 C++Builder 的 PDFium Component,与其他基于 TPdf 和 TPdfView 类构建的渲染、查看和文本提取 API 一样