傳真閘道器不想要您的 24 位元頁面渲染。儲存了一百萬張看起來像掃描發票的封存管線不想要,在尋找字元之前就把所有東西二值化為黑白的 OCR 前端也不想要。這三者想要的都是同一個東西:一個乾淨的 1 位元點陣圖,每個像素一個位元,每個點非墨即紙。如果您交給它們一個全彩的 BMP,它們無論如何都會丟棄每像素 23 個位元,而且通常會用比您自己所能做的還要糟糕的遞色 (dithering) 處理來進行。有趣的問題是這種向下轉換應該發生在哪裡,而在 PDFlibPas 中的答案證明了在擴展一個您不希望重寫的渲染器時,一些有用的設計思維
PDFlibPas 是一個適用於 Delphi 和 C++Builder 的原生 Object Pascal PDF 函式庫。它的渲染核心將頁面點陣化為點陣圖,並且能夠發出 BMP、PNG、JPEG、WMF 以及少數其他格式。它直到最近還不能做的,是交回一個真正的單色點陣圖,或是只渲染頁面的一部分。這兩項功能都在 v3.83.0 中登陸,並且這兩項功能都是建立在現有渲染器之上的輕量級便利層,而不是對點陣化器本身的變更。這個限制就是整個故事的重點
為什麼在渲染後向下轉換,而不是在渲染器內部
產生 1 位元影像的明顯方法是告訴點陣化器以 1 位元進行繪圖。但這也是會破壞其他所有東西的方法。渲染器的內部點陣圖是在 PDFlibRenderer 建構函式中使用寫死的 PixelFormat := pf24bit 來建立的,而且這個 24 位元表面由每一個渲染路徑所共享:PNG 匯出、裝置內容 (device-context) 預覽、JPEG 輸出,全都如此。如果您在源頭將它翻轉為 pf1bit,您並沒有增加一個單色功能,而是降低了函式庫中每個呼叫者的色彩保真度,並且要為除錯一打的下游回歸問題簽名負責
因此 RenderPageToMonochromeFile 採取了相反的路徑。它正常地渲染頁面,輸出到一個臨時的 24 位元 BMP,然後才在後處理步驟中將其摺疊為 1 位元。渲染器未受觸碰。單色行為完全存在於便利方法中,這意味著它不可能影響到任何沒有呼叫它的人。這是一種值得明確指出的權衡:一個後處理付出了額外分配一次點陣圖和一個暫存檔的代價,作為交換,它讓一個承重的核心完全置身事外。對於一個為了服務傳真和封存這類邊緣案例而存在的功能來說,這是帳本上正確的一方
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create(nil);
try
Pdf.LoadFromFile('invoice.pdf');
// 200 DPI is the classic Group 4 fax resolution; page index is 1-based
Pdf.RenderPageToMonochromeFile(200, 1, 'invoice-page1.bmp');
finally
Pdf.Free;
end;
end;
1 位元摺疊實際上是如何發生的
向下轉換依賴 GDI 而不是手寫的二值化迴圈 (threshold loop),這個選擇對輸出品質至關重要。在該方法內部,24 位元臨時點陣圖被載入到一個 TBitmap 中,接著以相同的尺寸建立第二個 PixelFormat := pf1bit 的 TBitmap,然後像素透過一次 blit 動作移過去:
// inside RenderPageToMonochromeFile, after loading the 24-bit ColorBmp
MonoBmp.PixelFormat := pf1bit;
MonoBmp.Width := ColorBmp.Width;
MonoBmp.Height := ColorBmp.Height;
// HALFTONE tells GDI to dither the 24-bit source down to 1-bit
SetStretchBltMode(MonoBmp.Canvas.Handle, HALFTONE);
StretchBlt(MonoBmp.Canvas.Handle, 0, 0, MonoBmp.Width, MonoBmp.Height,
ColorBmp.Canvas.Handle, 0, 0, ColorBmp.Width, ColorBmp.Height, SRCCOPY);
MonoBmp.SaveToFile('out.bmp');
訣竅在於使用 HALFTONE 的 SetStretchBltMode。即使來源和目的地的尺寸相同(因此不會發生縮放),伸展模式仍然管轄 GDI 如何將色彩對應到 1 位元調色盤。HALFTONE 使它應用半色調遞色 (halftone dithering),將灰色區域和反鋸齒文字邊緣轉化為黑白點圖案,而不是生硬地裁剪至最接近的兩種顏色之一。拿掉模式呼叫,或是使用預設的 BLACKONWHITE,灰階內容就會色調分離成塊狀的二值化形狀。對於掃描文件和 OCR 預處理輸出,遞色結果幾乎總是您所需要的
有一個細節是沒有談判餘地且容易出錯的:臨時渲染結果必須是 BMP。RenderPageToMonochromeFile 使用選項代碼 0(即 BMP)呼叫通用渲染器。RenderPageToFile 上的 options 引數是一個小整數列舉,這些值在這個目的下不能互換:0 是 BMP,1 是 JPEG,2 是 WMF,3 是 EMF,5 是 PNG 等等。向下轉換器隨後在暫存檔上執行 TBitmap.LoadFromStream。如果您透過傳遞 2 來餵給它一個 WMF,那麼該載入作業將會拋出 "Bitmap image is not valid"(點陣圖影像無效),因為 Windows Metafile 是一個向量記錄串流,而不是 DIB。單色向下轉換從頭到尾都是點陣作業,因此中介物必須是點陣格式
僅渲染頁面的子區域
第二個方法 RenderPageRegionToFile 僅渲染頁面的一個矩形,而不是整個頁面。如果您建立過任何文件檢視器,就會熟悉其使用案例:從合約中裁剪出簽章區塊、為大型圖紙的縮放地圖產生圖塊,或者為縮圖提取一個加蓋印章的區域,而不需付出在高 DPI 下點陣化整個頁面的代價。方法特徵非常直覺:
// Clip is "Left,Top,Width,Height" in PDF points (72 pt = 1 inch)
// Here: a 2.5in x 1in box, one inch in from the top-left of the page
Pdf.RenderPageRegionToFile(150, 1, '72,72,180,72', 'sig-block.bmp');
clip 字串是四個以逗號分隔的雙精度浮點數,單位為 PDF 點,在方法內部進行手動解析以避開地區設定和 DelimitedText 的怪癖。從寬度和高度,該方法計算出輸出點陣圖尺寸為 Round(Width * DPI / 72) 乘 Round(Height * DPI / 72),在記憶體中配置一個精確為該尺寸的 pf24bit 點陣圖,並透過 RenderPageToDCClip 渲染到其裝置內容中。結果檔案只包含裁剪過的矩形,其大小對齊的是區域而不是整個頁面
那個毫無作用的 clip 參數
這項工作的精妙之處就在這裡。RenderPageToDCClip 帶有一個 Clip 參數已經很長一段時間了,而且它是一個謊言。呼叫接受了這個引數,把它傳遞給 TPDFPageTree.RenderPageToDC,然後該實作就完全忽略它,從未把它交給渲染器。您可以傳遞您喜歡的任何矩形,但拿回來的卻是整個頁面。任何期望得到裁剪而接上 RenderPageToDCClip 的人都會得到一個全頁渲染,根據他們的佈局,他們甚至可能不會注意到
v3.83.0 接通了這條線路。RenderPageToDC 現在會解析同樣的 "Left,Top,Width,Height" 點陣矩形,並在渲染器繪圖之前,將其作為真正的 GDI 裁剪區域套用到目標裝置內容上。從點到裝置像素的轉換是慣常的 DPI / 72 縮放係數,套用於所有四個邊緣。渲染前後的序列是標準的儲存/裁剪/還原舞步:
// inside TPDFPageTree.RenderPageToDC, when Clip is non-empty
ScaleFactor := DPI / 72;
SaveDC(TargetDC);
IntersectClipRect(TargetDC,
Round(ClipLeft * ScaleFactor),
Round(ClipTop * ScaleFactor),
Round((ClipLeft + ClipWidth) * ScaleFactor),
Round((ClipTop + ClipHeight) * ScaleFactor));
// ... renderer draws the page here ...
// in the finally block:
RestoreDC(TargetDC, -1);
SaveDC / RestoreDC(-1) 對是讓它能夠被安全地重複呼叫的原因:裁剪區域被推入 DC 狀態堆疊中,頁面被繪製,然後無論渲染如何退出,原始的裁剪都會被彈出 (pop) 回來。RestoreDC(TargetDC, -1) 會還原最近儲存的狀態,這正是平衡儲存/還原的標準慣用法。如果跳過還原,一個重複使用同一個 DC 來進行隨後的全頁渲染的呼叫者,將會發現它莫名其妙地被裁剪到最後一個區域。修復這個死參數也免費修復了 RenderPageRegionToFile,因為那個新方法正是走這條路徑
一個要內化的行為要點:clip 是裁剪,它不是縮放。頁面仍然以您所要求的 DPI、在其正常位置點陣化,而裁剪區域只是簡單地捨棄矩形之外的所有東西。您不是在放大區域以填滿輸出;您是在全解析度的渲染圖中切出一個窗戶。如果您想放大一個區域,請提高 DPI。矩形的座標是在點對像素縮放後,在裝置空間中解讀的,從渲染表面的左上角開始測量,所以請從頁面頂部開始向下規劃您的 Left 和 Top。要更深入地了解 PDFlibPas 如何驅動用於螢幕輸出的裝置內容,關於列印預覽和裝置內容輸出的伴隨文章會從顯示端帶您走過相同的 DC 管線
誠實的邊界:1 位元 BMP,不是 G4 TIFF
這很容易被過度推銷為「隨時可用於傳真的輸出」,所以這裡清楚說明其極限。RenderPageToMonochromeFile 產生一個 pf1bit 的 BMP。它並不產生 CCITT Group 4 TIFF,而後者才是真正的傳真工作流程或 TIFF 封存檔通常預期的格式。原因很具體,不是疏忽:PDFlibPas 的 CCITT 單元目前能解碼 G4 串流,但沒有 G4 編碼器。沒有編碼器就沒有地方可以寫入壓縮的單色連續資料,所以單色路徑就停在未壓縮的 1 位元 DIB 上
在實務上,這仍然是有用的。1 位元 BMP 是正確的像素格式,已經過遞色處理並準備就緒,多數的傳真、封存或 OCR 工具鏈會很樂意地攝入它,或者在下游的一個步驟中自己將它轉換為 G4。但如果您需求就是直接從函式庫出來的 Group 4 TIFF,現在還辦不到,您應該自行規劃一個壓縮階段。知道一個功能的界線在哪裡,其價值不亞於知道它能做什麼
這兩個方法都被刻意設計得很小,而這是這篇文章值得帶走的設計教訓:一個位於渲染器之上的便利 API,可以增加真正的能力(單色輸出、區域裁剪),而無需深入點陣化器並使所有其他呼叫者變得不穩定。當您確實需要在不同渲染引擎之間選擇底層點陣化機制時,Delphi 中的多引擎 PDF 渲染概觀深入探討了其權衡。要查看完整的渲染層面和 API 的其餘部分,PDFlibPas Delphi PDF Library 產品頁面擁有全貌