第一個帶包含了被壓縮成一條窄帶的完整圖形,後面的五個帶全都空白,這是舊版分帶匯出的表現。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_x、start_y、size_x 和 size_y,其中 size 對表示整頁應縮放到多大,start 對表示縮放後的頁面落在目標點陣圖中的什麼位置。v3.66.0 之前的分帶迴圈呼叫程式庫的 RenderPage 輔助函式時,把帶頂端作為目標偏移,把帶高度作為頁面高度。這兩個數直接傳進原生呼叫,因此 PDFium 會將整頁縮放到只有 BandHeight 行高的矩形中,然後以 y = BandTop 在本身也只有 BandHeight 行高的點陣圖裡繪製。看到這裡就能準確預測結果。帶 0 收到的是被垂直壓縮到帶高度的整頁;所有後續帶收到的仍是同一張壓縮頁面,只是被推到了自己的點陣圖下邊緣之外,於是回傳背景填充。這個 bug 隱藏在冒煙測試最常用的情況中:頁面渲染高度小於帶高度,因此只有一個帶,錯誤幾何關係碰巧與正確關係重合。任何超過一個帶的頁面都會立即暴露問題
負偏移保證了什麼
修復後的實作透過 RenderTile 路由每個帶,這是元件中已經理解該差別的唯一位置。RenderTile 接受完整頁面像素座標中的圖磚原點,以及獨立的 PageWidth 和 PageHeight,並向 PDFium 傳入 -Left 和 -Top,頁面大小保持不變。對偏移取負,會把完整尺寸的頁面向上滑動,直到要求的帶位於目標點陣圖的第 0 行;PDFium 隨後會根據點陣圖邊界執行原生裁剪,因此帶外內容不會被光柵化。ISO 32000-1 第 8.3.2 節描述的頁面到裝置映射,從第一個帶到最後一個帶都保持不變,這正是關鍵:帶 N 應該逐像素等於相同尺寸的整頁渲染中從 BandTop 到 BandTop + 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 攜帶 Format、Width、Height、BandsRendered、BandsEncoded、RowsEncoded、PeakBandBytes、OutputBytes 和 Completed。調整工作規模時真正應該關注的是 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。這一點只是一般條件編譯。陷阱在於,三個單元都匯出名為 clNone 和 clDefault 的壓縮等級常數,而它們會與 graphics 單元中同名的 TColor 成員正面衝突。一旦實作部分的 uses 子句中出現 zlib 單元,渲染程式碼中不限定的 clNone 就可能解析為壓縮等級而不是顏色,而且沒有任何診斷。PDFiumPas 透過明確的顏色哨兵別名 PdfGraphicsColorNone 和 PdfGraphicsColorDefault 固定這一點:只在一個地方繫結完整限定的 graphics 常數,然後在所有比較渲染背景或配色方案哨兵的地方使用別名。三行程式碼就能讓符號解析不再隨編譯器漂移
分帶渲染在遇到裝不進記憶體的頁面之前看起來像便利功能,遇到之後就會變成唯一可行的路徑。修正後的帶幾何關係、順序 PNG 和 TIFF 編碼器以及自訂編碼器接縫,都屬於 PDFium Delphi 元件,回歸套件會在 Delphi、Lazarus 和 C++Builder 上執行完整的帶與整頁像素比較