技术文章

在 Delphi 中摊平 PDF 页面旋转而不弄坏页面框

HotPDF 用 THotPDF.FlattenLoadedPageRotation 摊平 PDF 页面旋转:这个方法把每个旋转页面的内容包进一个顺时针的 cm 变换,重写页面实际拥有的每个页面框,把注释几何、外观矩阵、显式目标和带标签的结构几何都转过同样的角度,然后把 /Rotate 设为 0。页面在阅读器里看起来一模一样,但坐标系现在是正的。一旦下游工具、打印 RIP 或你自己的盖章代码无视 /Rotate、直接往原始用户空间里放东西,这就要命了

典型的触发者是扫描仪或移动采集应用,把横向页写成竖版媒体加 /Rotate 90。每个阅读器都显示正确,所以没人察觉,直到有人在「右下角」盖页码、它却竖着落在左边缘,或某个只读 /MediaBox 的拼版步骤给横向页排了一个竖版槽位。摊平听上去是一行矩阵的活。实际上它碰五个页面框、三种注释几何、文档的链接目标和结构树,而它们每一个在 ISO 32000-1 里都有自己的规则

/Rotate 把 PDF 页面往哪边转?

/Rotate 让页面为显示和打印顺时针转,以 90 度的倍数(ISO 32000-1 §7.7.3.3,Table 30)。转 90 度时媒体的左缘变成顶边、顶边变成右侧,所以在 y 向下的设备空间里映射是 X = (y - Bottom) * Scale 和 Y = (x - Left) * Scale。转 270 度时右缘变成顶边。/Rotate 还是仅有的四个可继承页面属性之一,与 /Resources、/MediaBox 和 /CropBox 并列(§7.7.3.4),所以没有自带 /Rotate 的页面字典仍可能被某个 /Pages 祖先转动。THotPDF.GetLoadedPageRotation 沿 /Parent 链上溯并把结果归一化到 0–359,你要的是这个值,不是页面上的原始键

方向很容易错成一种能熬过测试的错法,更早的 HotPDF 构建正是这么错的。旧的页面到设备矩阵在 90 和 270 度时交换了 y 分量,那产生的是沿对角线的镜像而不是旋转:矩阵的定向相对未旋转情形翻转了。两个角度看起来「都转了」,位图的宽高也换了,页面到视图再回来的往返也回到起点,于是尺寸检查和往返测试全部通过。唯一可靠的检查是角部标记落在哪儿——逐像素与参考渲染器比对。因为阅读器模型、SIMD 渲染后端和高亮映射抄的是同一个矩阵,它们被一起修正,摊平代码现在用与渲染器相同的顺时针约定

HotPDF 在 Delphi 中摊平页面旋转的方式:以 /Rotate 90 存储的竖版页顺时针显示为 792 × 612 的横版视图,设备映射 X = (y - Bottom) * Scale、Y = (x - Left) * Scale 搬动每个角,而交换矩阵 y 分量产生只有角部标记比对才抓得到的镜像
阅读器为显示顺时针转页而字节保持竖版——GetLoadedPageRotation 先沿 /Parent 链上溯,因为 /Rotate 是四个可继承页面属性之一

FlattenLoadedPageRotation 怎么重写一个页面

FlattenLoadedPageRotation(PageRange, Info) 处理 PageRange 里有效旋转为 90、180 或 270 的每一页,返回摊平的页数。空 PageRange 表示所有页;否则字符串用惯常的 1 起始 '1-3,7' 语法,越界页号抛异常而不是被跳过。原始内容流从不重新编码。方法在页面的 /Contents 前面插一个包含 q 0 -1 1 0 -Bottom Width+Left cm(90 度时)的新流,在末尾追加含 Q 的流,最后把显式的 /Rotate 0 写进页面字典,让 /Pages 节点上的继承值没法再转一次

var
  Pdf: THotPDF;
  Info: THPDFRotationFlattenInfo;
  Flattened: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('scanned-batch.pdf') > 0 then
    begin
      // '' = 所有页;0 度的页面会被扫描但原样保留
      Flattened := Pdf.FlattenLoadedPageRotation('', Info);
      Writeln(Format('Scanned %d, flattened %d pages', [Info.ScannedPageCount, Info.FlattenedPageCount]));
      Writeln(Format('Turned %d annotations, %d destinations, %d tagged geometry entries',
        [Info.TransformedAnnotationCount, Info.TransformedDestinationCount,
         Info.TransformedStructureGeometryCount]));
      if Flattened > 0 then
        Pdf.SaveLoadedDocument('scanned-batch-upright.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

THPDFRotationFlattenInfo 记录值得记进日志而不是随手扔掉。ScannedPageCount 是范围的大小,FlattenedPageCount 等于返回值,三个 Transformed... 计数器告诉你文档里有没有指向被转页面的链接、书签或结构几何。批处理里每个文件都报零个 destination 没问题;一个 PDF/UA 标签文件在你期望图框时报零个结构几何,就是该手工检查一下的信号

摊平会重写哪些页面框、按什么顺序?

摊平只重写页面已有的框,而且写任何一个之前先把所有框读完。顺序要紧,因为默认链在那里:GetLoadedPageBox(PageIndex, pbCropBox, ...) 在页面没有 /CropBox 时返回 /MediaBox,而 /BleedBox、/TrimBox 和 /ArtBox 默认落到 CropBox(§14.11.2)。更早的版本确实是一次读、变换、写一个框。它先重写 MediaBox,再去读「CropBox」,拿回的是已经转过的 MediaBox,又转了一次,写出一个页面从来没有过的 CropBox,把一个横版页裁成了正方形。继承规则同样分两路:MediaBox 和 CropBox 沿 /Parent 链查找,而 Bleed、Trim 和 ArtBox 只有待在页面字典自己身上才算数,所以 /Pages 节点上落单的 /TrimBox 视为不存在,不会被抄到页面上

procedure DumpPageGeometry(Pdf: THotPDF; PageIndex: Integer);
var
  L, B, R, T: Single;
begin
  Writeln('Effective /Rotate: ', Pdf.GetLoadedPageRotation(PageIndex));
  if Pdf.GetLoadedPageBox(PageIndex, pbMediaBox, L, B, R, T) then
    Writeln(Format('MediaBox [%g %g %g %g]', [L, B, R, T]));
  // 没有 /TrimBox 键也为 True:值回落到 CropBox,再回落到 MediaBox
  if Pdf.GetLoadedPageBox(PageIndex, pbTrimBox, L, B, R, T) then
    Writeln(Format('TrimBox  [%g %g %g %g]', [L, B, R, T]));
  // 预设 Letter;失败时 GetLoadedPageVisibleBox 不动输出参数
  L := 0; B := 0; R := 612; T := 792;
  Pdf.GetLoadedPageVisibleBox(PageIndex, L, B, R, T);
  Writeln(Format('Visible  [%g %g %g %g]', [L, B, R, T]));
end;

摊平前后各跑一遍这个辅助函数,数字自己会说话。对 MediaBox 为 [0 0 612 792] 的 90 度页,摊平后的 MediaBox 变成 [0 0 792 612];每个重写的框都按同一个顺时针旋转映射,相对原始 MediaBox 原点,所以新 MediaBox 总是从原点起步,其他框在它里面的位置保持不动。GetLoadedPageVisibleBox 返回阅读器显示、打印机打印的那个框——裁到 MediaBox 内并归一化成 Left 小于 Right 的 CropBox——HotPDF 的渲染器、SVG 导出、阅读器和打印路径用的都是这同一个框。要人眼看到的页面尺寸时,调 GetLoadedPageVisibleBox,别去读 /MediaBox

HotPDF 在 FlattenLoadedPageRotation 中写任何框之前先读完所有框的原因:BleedBox、TrimBox 与 ArtBox 默认落到 CropBox,CropBox 又回落到 MediaBox,逐个转框让 CropBox 读到已重写的 MediaBox,第二次旋转写出了页面从未有过的框,把横版页裁成正方形
默认链意味着一个框的输出是另一个框的输入——先全部读完,相对原始 MediaBox 原点做变换,然后再写

只旋转 /Rect 时注释为什么坏?

注释坏,因为外观流不是直接画进 /Rect 的。按 §12.5.5,阅读器先用表单的 /Matrix 变换 /BBox,再把结果的外接框缩放平移进 /Rect。只转 /Rect,一个 200 × 40 的图章被挤进 40 × 200 的槽位,侧躺着没法看。FlattenLoadedPageRotation 因此把页面的顺时针旋转右乘到每个外观 /Matrix 上(90 度时按行向量约定是 [0 -1 1 0 0 0]),覆盖 /N、/R 和 /D 三种外观及其中每个状态。一个外观流可能被多个注释或状态共享,所以每次调用每个流恰好转一次。没有干净答案的唯一样例是跨不同旋转页面共享的流:它跟随第一个到达它的页面

还有两条规则让表单字段和便签留在原地。widget 的 /MK /R 条目(§12.5.6.19)是逆时针角,所以要从它里面减去页面的顺时针角、对 360 取模;跳过这步,下次外观重生成时字段文字方向就错了。带 NoRotate 标志(位 5,值 16,§12.5.3)的注释在旋转页上保持直立、绕其 /Rect 的左上角旋转,所以摊平保住它们的宽高和直立外观,只把那个角挪到旋转后它该在的位置。注释之外,方法还转 /QuadPoints、/Vertices、/L 和 /InkList,重写点名这个页面的显式目标(/XYZ 点、/FitR 矩形,以及在 90 和 270 度互换的 /FitH / /FitV,§12.3.2.2),并变换带标签的几何,比如 /Pg 指向该页的结构元素的属性 /BBox 条目

HotPDF 页面只旋转 /Rect 来摊平时注释坏掉的原因:200 × 40 的图章被缩进 40 × 200 的槽位变得没法看,所以 FlattenLoadedPageRotation 把顺时针旋转右乘到 /N、/R、/D 的每个外观 /Matrix 上,调整逆时针的 /MK /R,并让 NoRotate 注释绕其左上角旋转
阅读器把外观变换后的 BBox 装进 /Rect,所以流本身必须转——每个共享外观一遍,每次调用恰好一次

摊平不管什么?

摊平是对单个页面自有对象的几何重写,有几种情形它安静地、而不是响亮地落在覆盖范围之外

  • 有效旋转已是 0,或 MediaBox 缺失或宽高为零的页面,直接跳过不报错;拿返回值和你预期要改的页数比一比
  • 从页面资源引用的 Form XObject 保留自己在表单空间里的 /BBox,因为外层 cm 已经把它们转了;结构树扫描只跟 /K 和 /A,所以不会第二次走进页面资源或注释
  • destination 靠每摊平一页扫描全部间接对象来找到,所以几百个旋转页的大文档要为每一页付一遍这趟遍历
  • HotPDF 的页面渲染器不画注释,要目检转过的图章得先 FlattenLoadedAnnotations
// 把外观烘进内容流,渲染器才能显示它们,
// 然后在去掉 /Rotate 前后各渲染一次第 1 页
Pdf.FlattenLoadedAnnotations('1');
Before := Pdf.RenderLoadedPageToBitmap(0, 96);
try
  Pdf.FlattenLoadedPageRotation('1', Info);
  After := Pdf.RenderLoadedPageToBitmap(0, 96);
  try
    Assert((Before.Width = After.Width) and (Before.Height = After.Height));
    // 在这里比较角部标记像素,别只比尺寸
  finally
    After.Free;
  end;
finally
  Before.Free;
end;

想再往深挖,这个故事注释那一半接在摊平前合成注释外观里,前后对比背后的渲染器在把已加载 PDF 页面渲染成位图里有讲,已加载 PDF 的涂黑与 N-up 拼接展示了旋转前缀和后缀依赖的同一套内容流追加技术。HotPDF(含 FlattenLoadedPageRotation 和页面框读取器)面向 Delphi 和 C++Builder,见 HotPDF Delphi PDF component 页面