RTF 存在得夠久,久到它出現在沒有人預期的地方:舊版報表產生器、郵件合併管道、早於現代文書處理器的法律文件封存。即時把它轉成 PDF 是反覆出現的需求,而在 Windows 上真正可行的方法,不是專用的 RTF 解析器,而是 Windows 本身透過 TRichEdit 與 EM_FORMATRANGE 早就提供的渲染路徑。losLab PDF Library 公開的虛擬裝置內容,直接嵌進這條管道
機制:虛擬 DC 和 EM_FORMATRANGE
Rich Edit 控制項可以為任何裝置內容——不只實體印表機——給內容分頁。EM_FORMATRANGE 訊息指示控制項把一段字元排版進給定的 DC,並回傳它成功容納的最後一個字元位置。反覆呼叫、每次推進 cpMin,就得到逐頁輸出。losLab PDF Library 的 GetCanvasDC 提供一個記憶體內 DC,尺寸隨您指定的頁面大小;把一頁渲染進去之後,LoadFromCanvasDC 把結果擷取成一份新的單頁 PDF 文件。整條管道就是這樣
有一件事得預先做對:TRichEdit 控制項的尺寸必須與目標頁面相符。控制項比 DC 尺寸大或小,分頁就對不上 PDF 裡的內容。輸出 A4 的標準做法:在載入 RTF 之前,用您替 DC 定尺寸時同一套公釐轉像素換算,把控制項的像素尺寸設成 210 x 297 mm、對齊您稍後傳給 LoadFromCanvasDC 的同一個 DPI
Delphi 實作
以下使用 PDFlibAX_TLB 匯入單元,即函式庫 ActiveX 版的型別程式庫匯入。表單上放一個 TRichEdit 與一顆按鈕;表單的 OnCreate 處理常式負責調整控制項尺寸、載入 RTF,按鈕點擊則驅動轉換迴圈
unit MainUnit;
interface
uses
Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms,
Dialogs, StdCtrls, ComCtrls, PDFlibAX_TLB, ActiveX;
type
TForm1 = class(TForm)
RichEdit1: TRichEdit;
Button1: TButton;
procedure FormCreate(Sender: TObject);
procedure Button1Click(Sender: TObject);
private
function PrintRtfBox(hDc: HDC; rtfBox: TRichEdit;
FirstChar: Integer): Integer;
end;
var
Form1: TForm1;
PdfDoc: IPDFlibrary;
implementation
{$R *.dfm}
procedure TForm1.FormCreate(Sender: TObject);
begin
PdfDoc := CoPDFlibrary.Create;
// 以螢幕 DPI 把控制項調成 A4,讓分頁與 DC 相符
RichEdit1.Width := MulDiv(210, Screen.PixelsPerInch * 10, 254);
RichEdit1.Height := MulDiv(297, Screen.PixelsPerInch * 10, 254);
RichEdit1.Lines.LoadFromFile(
ExtractFilePath(Application.ExeName) + 'document.rtf');
end;
procedure TForm1.Button1Click(Sender: TObject);
var
Dc: HDC;
PageNumber, LastChar, PdfDocId: Integer;
begin
PageNumber := 1;
LastChar := 0;
repeat
// 取得一個以 A4 為尺寸的虛擬 DC
Dc := PdfDoc.GetCanvasDC(
MulDiv(210, Screen.PixelsPerInch * 10, 254),
MulDiv(297, Screen.PixelsPerInch * 10, 254));
// 把 RTF 內容的下一頁渲染進 DC
LastChar := PrintRtfBox(Dc, RichEdit1, LastChar);
// 把 DC 內容擷取成 PDF 文件
PdfDoc.LoadFromCanvasDC(Screen.PixelsPerInch, 0);
PdfDocId := PdfDoc.SelectedDocument;
PdfDoc.SaveToFile(
ExtractFilePath(Application.ExeName)
+ 'Output' + IntToStr(PageNumber) + '.pdf');
PdfDoc.RemoveDocument(PdfDocId);
Inc(PageNumber);
until LastChar = 0;
// 讓控制項丟掉它為這個 DC 快取的排版資料
SendMessage(RichEdit1.Handle, EM_FORMATRANGE, 0, 0);
end;
function TForm1.PrintRtfBox(hDc: HDC; rtfBox: TRichEdit;
FirstChar: Integer): Integer;
var
Fr: TFormatRange;
Dpi, PageWidthTw, PageHeightTw, NextCharPosition: Integer;
begin
// rc 與 rcPage 的單位是 twips(1/1440 英吋),不是像素:
// 用替 DC 與控制項定尺寸的同一個 DPI 換算頁面尺寸
Dpi := Screen.PixelsPerInch;
PageWidthTw := MulDiv(rtfBox.Width, 1440, Dpi);
PageHeightTw := MulDiv(rtfBox.Height, 1440, Dpi);
Fr.hdc := hDc;
Fr.hdcTarget := hDc;
Fr.rcPage := Rect(0, 0, PageWidthTw, PageHeightTw);
Fr.rc := Fr.rcPage; // 整頁都畫;要邊界就縮小它
Fr.chrg.cpMin := FirstChar;
Fr.chrg.cpMax := -1;
NextCharPosition :=
SendMessage(rtfBox.Handle, EM_FORMATRANGE, 1, LPARAM(@Fr));
if NextCharPosition < Length(rtfBox.Text) then
Result := NextCharPosition
else
Result := 0; // 代表最後一頁
end;
end.
迴圈正在做什麼
PrintRtfBox 填寫 TFormatRange 結構,並透過 SendMessage 將其傳遞給 Rich Edit 控制項;該控制項從 cpMin 開始轉譯字元,在 DC 填滿時停止,並傳回第一個無法容納的字元位置;當傳回值等於或超過總文字長度時,表示每個字元都已轉譯完畢,且函式傳回零,這會終止 repeat...until 迴圈
每次迭代產生一個 PDF 檔,依序命名為 Output1.pdf、Output2.pdf 等。想要的是單一多頁文件的話,AddToFileList 加 MergeFileList 可以事後把逐頁檔案組裝起來,也可以改寫迴圈,讓每個新頁文件用 MergeDocument 併進第一份。上面「每輪 SaveToFile 接著 RemoveDocument」的模式,把峰值記憶體壓在一份頁面的內容以內,對很長的 RTF 檔很重要
容易出錯的尺寸細節
LoadFromCanvasDC 的 DPI 引數告訴函式庫多少個您的像素等於一英吋:頁面寬出來是畫布寬除以 DPI 再乘 72 點,高度同理。這裡弄錯,輸出裡的文字大小就會不對,即使畫面看起來完全正確
rc 與 rcPage 的單位,是多數示範這項技巧的 Delphi 範例都搞錯的細節。EM_FORMATRANGE 讀這兩個矩形用的單位是 twips、即 1/1440 英吋,即使它畫進去的 DC 是以像素運作。填進像素值,控制項在 96 DPI 下會把文字排在一張小十五倍的頁面上:每輪只有寥寥數字,迴圈把十頁文件變成幾百份幾乎空白的 PDF。用替 DC 定尺寸的那個 DPI 做 MulDiv(Pixels, 1440, Dpi) 換算,分頁就落在畫布真正結束的地方。rcPage 是整頁;rc 是文字可以佔用的區域,邊界就是把 rc 往內縮出來的,同樣用 twips。迴圈結束時,再送一次帶 nil lParam 的 EM_FORMATRANGE,告訴控制項釋放它為這個 DC 快取的排版資料
最後,控制項必須已經附加到可見的表單視窗時 FormCreate 執行,以便在第一次呼叫 SendMessage 之前其視窗控制代碼是有效的;如果表單尚未顯示,在執行期動態建立的 TRichEdit 在轉譯迴圈開始之前需要明確呼叫 HandleNeeded
處理字型和 RTF 功能
因為轉譯是由 Windows Rich Edit 引擎執行的,所以字型替代遵循其用於顯示和列印的相同規則;RTF 檔案中引用且已安裝在電腦上的字型將會忠實地轉譯,缺失的字型將會被悄悄替代,這可能會改變行長和分頁;對於生產批次轉換,這值得進行明確的測試:載入一份包含您的 RTF 來源使用的每種字體的文件,並確認輸出頁數與您從手動列印預覽中預期的頁數相符
表格、內嵌圖片與大多數 Rich Text 格式功能都不用額外處理,Rich Edit 原生就會渲染。可能讓人意外的是用 twips 表達自訂段落間距或首行縮排的文字:Rich Edit 以 twips(1/1440 英吋)量測,與 TFormatRange 裡矩形同一個單位,而底下的 DC 以目前 DPI 的像素運作。控制項繪製時會自行換算兩者,但若您以程式產生 RTF,應該確認邊界值同樣是 twips
DPI 感知與高 DPI 顯示器
在 150% 縮放(144 DPI)的顯示器上,公釐轉像素得到的像素數比 100% 顯示器多。PDF Library 照單全收您傳給 GetCanvasDC 的任何像素尺寸,再用 LoadFromCanvasDC 的 DPI 引數回推 PDF 裡的實體頁面大小。只要您傳的 DPI 值就是把公釐轉成像素時用的那個,無論顯示器縮放為何,輸出頁面尺寸都會正確
如果應用程式不具 DPI 感知(舊預設值),Windows 會縮放螢幕 DC,像素計算在高 DPI 機器上就會出錯。最簡單的修法是在應用程式資訊清單宣告 DPI 感知;應用程式接著收到真實裝置像素,範例傳給 LoadFromCanvasDC 的 Screen.PixelsPerInch 值也回報真實顯示器 DPI,而不是假定 100% 縮放
輸出結構:每頁一個檔案對比合併文件
上面的迴圈把每一頁寫成獨立的 PDF 檔。這是不是您要的,取決於下游用途。報表產生系統常需要逐頁檔案,因為它們之後靠合併或重排頁面來組出最終文件。一開始就想要單一 PDF 的話,函式庫讓您在一個工作階段內建出來:留住第一次 LoadFromCanvasDC 建立的文件 ID,之後每一輪用 SelectDocument 選中它,改呼叫 MergeDocument 帶進新頁文件的 ID,而不是 SaveToFile,迴圈結束後存檔一次。這樣沒有中繼檔案,對大多數單文件轉換場景是正確的結構
對於大型 RTF 檔案,在迴圈中新增一些進度回饋是值得的,因為轉換率大致與頁數成正比,而 200 頁的文件可能需要幾秒鐘;repeat...until 結構很容易擴充:在每次反覆運算後追蹤進度列更新中的字元偏移量,使用 LastChar 除以來自 RichEdit1.GetTextLen 的總字元數
這裡展示的 GetCanvasDC 與 LoadFromCanvasDC 方法,是適用於 Delphi 與 C++Builder 的 losLab PDF Library 的一部分