技术文章

Delphi 分带 PDF 渲染中的负 Y 偏移

第一个带包含了被压缩成一条窄带的完整图形,后面的五个带全都空白,这是旧版分带导出的表现。PDFiumPas 在 v3.66.0 中修复了它:RenderPageBanded 现在在每一个带上都向 FPDF_RenderPageBitmap 传入整页目标宽度和高度,同时传入负的垂直偏移,因此原生裁剪只会写入当前带的行,而页面仍保持整页坐标几何关系。这一切背后的使用场景很普通,却无法回避。有人交给你一张 E 尺寸工程图或一张拼接全景页面,希望得到 600 DPI 光栅图。600 DPI 的 ISO A0 纸张是 19866×28086 像素,32 位目标位图需要略多于 2 GB 的连续内存。在 32 位 Delphi 上,这次分配直接失败;在 64 位上,它又经常成功到足以让问题变成客户问题而不是测试问题。分带渲染的存在,就是为了让峰值分配是一条带,而不是整页

为什么每个带都包含整页

旧代码混淆了 PDFium 页面渲染调用中的两对不同参数。FPDF_RenderPageBitmap 接受 start_xstart_ysize_xsize_y,其中 size 对表示整页应缩放到多大,start 对表示缩放后的页面落在目标位图中的什么位置。v3.66.0 之前的分带循环调用库的 RenderPage 辅助函数时,把带顶部作为目标偏移,把带高度作为页面高度。这两个数直接传进原生调用,因此 PDFium 会将整页缩放到只有 BandHeight 行高的矩形中,然后以 y = BandTop 在本身也只有 BandHeight 行高的位图里绘制。看到这里就能准确预测结果。带 0 收到的是被垂直压缩到带高度的整页;所有后续带收到的仍是同一张压缩页面,只是被推到了自己的位图下边缘之外,于是返回背景填充。这个 bug 隐藏在冒烟测试最常用的情况中:页面渲染高度小于带高度,因此只有一个带,错误几何关系碰巧与正确关系重合。任何超过一个带的页面都会立即暴露问题

负偏移保证了什么

修复后的实现通过 RenderTile 路由每个带,这是组件中已经理解该区别的唯一位置。RenderTile 接受完整页面像素坐标中的瓦片原点,以及独立的 PageWidthPageHeight,并向 PDFium 传入 -Left-Top,页面大小保持不变。对偏移取负,会把完整尺寸的页面向上滑动,直到请求的带位于目标位图的第 0 行;PDFium 随后会根据位图边界执行原生裁剪,因此带外内容不会被光栅化。ISO 32000-1 第 8.3.2 节描述的页面到设备映射,从第一个带到最后一个带都保持不变,这正是关键:带 N 应该逐像素等于相同尺寸的整页渲染中从 BandTopBandTop + h 的那些行,回归套件也正是针对 RenderPage 在相同尺寸下的输出逐像素断言这一点

// 手工渲染一个带。目标位图只有 BandHeight 行,
// 但页面目标尺寸仍保持为完整的 Width × Height
Band := Pdf.RenderTile(0, BandTop,          // 页面像素中的瓦片原点
                       Width, BandHeight,   // 目标位图尺寸
                       Width, Height);      // 整页目标尺寸
try
  // Band 现在包含页面的 BandTop .. BandTop + BandHeight - 1 行
finally
  Band.Free;
end;

公开的分带 API 是一个回调循环。RenderPageBanded(Width, Height, BandHeight, BandCallback, Rotation, Options, Color) 返回实际渲染的带数量;参数被拒绝时返回 0,并在整个过程中持有组件渲染锁。回调签名是 TPdfBandCallback = function(BandIndex, BandTopY: Integer; Bitmap: TBitmap): Boolean of object。位图格式为 pf32bit,宽度为 Width 像素,高度不超过 BandHeight;回调处理器返回后位图就会释放,因此需要保留的内容必须自行复制。返回 False 会在当前带之后停止流程,这与Delphi 中可取消的渐进式 PDF 渲染使用同一个协作取消模型,只是粒度从 PDFium 继续执行粒度变成了带粒度

type
  TBandSink = class
  private
    FCancelled: Boolean;
    FRows: Integer;
  public
    function HandleBand(BandIndex, BandTopY: Integer;
      Bitmap: TBitmap): Boolean;
    property Rows: Integer read FRows;
  end;

function TBandSink.HandleBand(BandIndex, BandTopY: Integer;
  Bitmap: TBitmap): Boolean;
begin
  // 此方法返回时 Bitmap 就会销毁,应在这里消费它
  Inc(FRows, Bitmap.Height);
  Result := not FCancelled;
end;

// ...
Pdf.PageNumber := 1;
Bands := Pdf.RenderPageBanded(19866, 28086, 256, Sink.HandleBand);

如何在没有整页位图的情况下流式输出 PNG 和 TIFF

只有编码器也按顺序工作,按带渲染才有帮助,因此 v3.66.0 增加了 RenderPageBandedToStream,可以直接写入调用方的流。TPdfBandedImageStreamOptions.Default 设置带高度 256 行、PNG 压缩级别 6,以及值为 0 的 MaxOutputBytes,也就是不设上限。返回的 TPdfBandedImageReport 携带 FormatWidthHeightBandsRenderedBandsEncodedRowsEncodedPeakBandBytesOutputBytesCompleted。调整作业规模时真正应该关注的是 PeakBandBytes:它是 Width * BandHeight * 4,因此上面的 A0 纸张峰值约为 19 MB 的带缓冲区,而不是 2 GB 的页面缓冲区

PNG 编码器有意保持狭窄。它输出固定 RGB8,写入位深 8、颜色类型 2 的 IHDR,随后使用过滤器类型 0(ISO/IEC 15948 过滤方法 0,即 None)构建每条扫描线,并通过平台 zlib 压缩流处理。压缩字节以带 CRC 的 IDAT 块按顺序写出。这里有个有趣约束:位于 deflate 层之下的流会响应位置查询,因为压缩流会请求位置,但任何真正的 Seek 都会抛出错误。这是有意为之。一旦 IDAT 块及其 CRC 写入线路,就没有办法回头修正它们;静默 Seek 会破坏一个结构上仍然看似有效的输出

TIFF 编码器写入小端经典 TIFF:先写 II 字节顺序标记和魔数 42,每个带对应一个条带。像素先流出,十项 IFD 则在最后生成,此时条带偏移和字节数已经已知。压缩是标签 259、值 1,因此完全没有熵编码:载荷正好是 Width * Height * 3 个字节,PhotometricInterpretation 是 RGB,PlanarConfiguration 是 chunky,RowsPerStrip 记录带高度,最后的短条带通过自己的 StripByteCounts 条目描述。因此带高度会改变峰值内存和条带数量,却不会改变输出大小,这一点在调优前值得知道。如果你想要小文件而不是无损文件,使用 PDFium VCL 组件将 PDF 页面转换为 JPEG 图像的逐页路径仍然更合适

var
  StreamOptions: TPdfBandedImageStreamOptions;
  Report: TPdfBandedImageReport;
  Output: TFileStream;
begin
  StreamOptions := TPdfBandedImageStreamOptions.Default(pbifPng);
  StreamOptions.BandHeight := 512;
  StreamOptions.CompressionLevel := 6;
  StreamOptions.MaxOutputBytes := Int64(256) * 1024 * 1024;

  Output := TFileStream.Create('sheet-a0-600dpi.png', fmCreate);
  try
    Report := Pdf.RenderPageBandedToStream(Output, 19866, 28086,
      StreamOptions);
  finally
    Output.Free;
  end;

  if not Report.Completed then
    raise Exception.Create('Banded export stopped before the last row');
  // Report.PeakBandBytes = 19866 * 512 * 4,而不是 19866 * 28086 * 4
end;

分带导出在哪里停止

两个上限约束输出,而且有意在不同位置失败。第一个是调用方预算:MaxOutputBytes 由有界写入流执行,在任何会越过限制的写入之前抛出 EPdfError,因此预算是硬上限而不是事后报告。第二个是结构限制。经典 TIFF 使用 32 位偏移保存条带,因此 BeginImage 会在写入单个像素之前,根据 Width * Height * 3 加上头部和目录的大小对照该上限进行验证并拒绝任务;同一检查也会预先对照 MaxOutputBytes,因为预算无法覆盖自身像素载荷的 TIFF 不值得启动。PNG 没有对应限制,因为 IDAT 块是纯顺序的,不存在会溢出的 32 位偏移表

也要看清停止的导出会留下什么。当流程没有到达最后一行时,Completed 保持为 False,编码器通过 EndImage(False) 拆除,这会有意不写 PNG IEND 块或 TIFF IFD。因此部分文件是无效的,每个解码器都会明确告诉你这一点,而不是给出一张看起来合理却缺少行的图像。清理过程被包裹起来,因此 EndImage 内部的次要失败不会替换原始异常;这正是命名真正原因的堆栈跟踪和命名清理人员的堆栈跟踪之间的区别。如果需要持久化进度,请在自己的回调中按带做检查点;PDFium Delphi 渲染缓存与缩放指南中的带级缓存策略也适用于这里

接入自己的编解码器

当目标不是 PNG 或 TIFF 时,RenderPageBandedToEncoder 接受一个 TPdfBandedImageEncoder 后代,并驱动同一个循环。生命周期明确且很短:BeginImage(Width, Height),然后严格按升序对每个带调用一次 WriteBand(BandIndex, BandTopY, Bitmap),最后调用 EndImage(Completed)GetBytesWritten 则为 Report.OutputBytes 提供数据。内置编码器遇到乱序带会直接拒绝,而不是尝试缓存;你写的任何编码器也应该如此,因为会静默重排条带的编解码器会生成一个可以打开却会撒谎的文件。JPEG 2000 瓦片、一次输入一个 MCU 行带的 JPEG 写入器,或直接送入打印后台处理程序,都可以从这个接缝接入

type
  TCodecBandEncoder = class(TPdfBandedImageEncoder)
  private
    FNextBand: Integer;
    FWritten: Int64;
  public
    procedure BeginImage(Width, Height: Integer); override;
    function WriteBand(BandIndex, BandTopY: Integer;
      Bitmap: TBitmap): Boolean; override;
    procedure EndImage(Completed: Boolean); override;
    function GetBytesWritten: Int64; override;
  end;

function TCodecBandEncoder.WriteBand(BandIndex, BandTopY: Integer;
  Bitmap: TBitmap): Boolean;
begin
  if BandIndex <> FNextBand then
    raise EPdfError.Create('Bands must arrive in order');
  Bitmap.PixelFormat := pf32bit;
  // 在这里将 Bitmap.ScanLine[0 .. Bitmap.Height - 1] 送入编解码器
  Inc(FNextBand);
  Result := True;
end;

一个值得知道的跨编译器陷阱

每个受支持工具链对 zlib 单元的拼写都不同:Delphi XE5 及之后使用 System.ZLib,FPC 使用 zstream,旧版 Delphi 使用普通的 ZLib。这一点只是常规条件编译。陷阱在于,三个单元都导出名为 clNoneclDefault 的压缩级别常量,而它们会与 graphics 单元中同名的 TColor 成员正面冲突。一旦实现部分的 uses 子句中出现 zlib 单元,渲染代码中不限定的 clNone 就可能解析为压缩级别而不是颜色,而且没有任何诊断。PDFiumPas 通过显式的颜色哨兵别名 PdfGraphicsColorNonePdfGraphicsColorDefault 固定这一点:只在一个地方绑定完整限定的 graphics 常量,然后在所有比较渲染背景或配色方案哨兵的地方使用别名。三行代码就能让符号解析不再随编译器漂移

分带渲染在遇到装不进内存的页面之前看起来像便利功能,遇到之后就会变成唯一可行的路径。修正后的带几何关系、顺序 PNG 和 TIFF 编码器以及自定义编码器接缝,都属于 PDFium Delphi 组件,回归套件会在 Delphi、Lazarus 和 C++Builder 上执行完整的带与整页像素比较