技術文章

在 Delphi 中使用 losLab PDF 函式庫進行 RTF 轉 PDF

RTF 已經存在了足夠長的時間,以至於它出現在沒有人預期的地方:舊版報表產生器、郵件合併管道,以及早於現代文書處理器的法律文件封存;即時將其轉換為 PDF 是一項反覆出現的需求,而在 Windows 上實際可行的方法不是專用的 RTF 解析器,而是 Windows 本身已透過 TRichEditEM_FORMATRANGE 提供的轉譯路徑;losLab PDF 函式庫 DLL 版本公開了一個虛擬裝置內容,可直接嵌入該管道中

機制:虛擬 DC 和 EM_FORMATRANGE

Rich Edit 控制項可以針對任何裝置內容(而不僅僅是實體印表機)進行內容分頁;EM_FORMATRANGE 訊息會指示控制項將字元範圍排版至給定的 DC 中,並傳回它成功容納的最後一個字元的位置;重複呼叫它,每次遞增 cpMin,即可取得逐頁輸出;losLab PDF 函式庫的 GetCanvasDC 提供一個記憶體內 DC,其大小調整為您指定的任何頁面尺寸;在將頁面轉譯至其中之後,LoadFromCanvasDc 會將結果擷取為 PDF 頁面;這就是整個管道

有一點需要預先做對:TRichEdit 控制項的尺寸必須調整為與目標頁面相符;如果控制項小於或大於 DC 尺寸,分頁將無法與最終 PDF 中的內容對齊;對於 A4 輸出,標準方法是在載入 RTF 檔案之前,使用您用來調整 DC 大小的相同縮放輔助程式,將控制項的像素尺寸設定為與 96 DPI 下的 210 x 297 mm 相符

Delphi 實作

以下使用 PDFlibAX_TLB 匯入單元,它封裝了該函式庫的 DLL 版本;表單裝載了一個 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: TPDFLibrary;

implementation

{$R *.dfm}

procedure TForm1.FormCreate(Sender: TObject);
begin
  PdfDoc := TPDFLibrary.Create(Self);
  // Size the control to A4 at screen DPI so pagination matches the DC
  RichEdit1.Width  := Round(ScaleX(210, mmPixel));
  RichEdit1.Height := Round(ScaleY(297, mmPixel));
  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
    // Obtain a virtual DC sized to A4
    Dc := PdfDoc.GetCanvasDC(
      Round(ScaleX(210, mmPixel)),
      Round(ScaleY(297, mmPixel)));
    // Render the next page of RTF content into the DC
    LastChar := PrintRtfBox(Dc, RichEdit1, LastChar);
    // Capture the DC contents as a PDF document
    PdfDoc.LoadFromCanvasDc(96, 0);
    PdfDocId := PdfDoc.SelectedPdfDocument;
    PdfDoc.SaveToFile(
      ExtractFilePath(Application.ExeName)
      + 'Output' + IntToStr(PageNumber) + '.pdf');
    PdfDoc.RemovePdfDocument(PdfDocId);
    Inc(PageNumber);
  until LastChar = 0;
end;

function TForm1.PrintRtfBox(hDc: HDC; rtfBox: TRichEdit;
  FirstChar: Integer): Integer;
var
  RcDrawTo, RcPage: TRect;
  Fr: TFormatRange;
  NextCharPosition: Integer;
begin
  RcPage.Left   := 0;
  RcPage.Top    := 0;
  RcPage.Right  := rtfBox.Left + rtfBox.Width  + 100;
  RcPage.Bottom := rtfBox.Top  + rtfBox.Height + 100;

  RcDrawTo.Left   := rtfBox.Left;
  RcDrawTo.Top    := rtfBox.Top;
  RcDrawTo.Right  := rtfBox.Left + rtfBox.Width;
  RcDrawTo.Bottom := rtfBox.Top  + rtfBox.Height;

  Fr.hdc         := hDc;
  Fr.hdcTarget   := hDc;
  Fr.rc          := RcDrawTo;
  Fr.rcPage      := 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;  // signals last page
end;

end.

迴圈正在做什麼

PrintRtfBox 填寫 TFormatRange 結構,並透過 SendMessage 將其傳遞給 Rich Edit 控制項;該控制項從 cpMin 開始轉譯字元,在 DC 填滿時停止,並傳回第一個無法容納的字元位置;當傳回值等於或超過總文字長度時,表示每個字元都已轉譯完畢,且函式傳回零,這會終止 repeat...until 迴圈

每次反覆運算會產生一個名為 Output1.pdf、Output2.pdf 等的 PDF 檔案;如果您想要單一的多頁文件,該函式庫的頁面附加 API 允許您事後進行組合,或者您可以重構迴圈以在單一文件工作階段中呼叫 AddPage;上述每次反覆運算呼叫 SaveToFile 緊接在後呼叫 RemovePdfDocument 的模式將記憶體峰值限制在一頁內容的範圍內,這對於非常長的 RTF 檔案非常重要

容易出錯的尺寸細節

LoadFromCanvasDc 的 96 DPI 引數會告知函式庫該 DC 是以何種螢幕解析度轉譯的,以便它可以計算 PDF 頁面正確的點到像素對應;如果弄錯了這一點,即使影像在螢幕上看起來是正確的,輸出中的文字也會以錯誤的大小顯示

新增至 RcPage.Right 和 RcPage.Bottom 的 +100 是超出控制項可見邊緣的微小邊界;Rich Edit 使用 rcPage 矩形來決定在何處拆分頁面,如果沒有該邊界,剛好落在邊界上的行可能會在兩個頁面上重複出現;這不是一個魔術常數,您需要它足夠大,以便頁面邊界乾淨地落在控制項的版面配置區域內,而不是最後一個像素上

最後,控制項必須已經附加到可見的表單視窗時 FormCreate 執行,以便在第一次呼叫 SendMessage 之前其視窗控制代碼是有效的;如果表單尚未顯示,在執行期動態建立的 TRichEdit 在轉譯迴圈開始之前需要明確呼叫 HandleNeeded

處理字型和 RTF 功能

因為轉譯是由 Windows Rich Edit 引擎執行的,所以字型替代遵循其用於顯示和列印的相同規則;RTF 檔案中引用且已安裝在電腦上的字型將會忠實地轉譯,缺失的字型將會被悄悄替代,這可能會改變行長和分頁;對於生產批次轉換,這值得進行明確的測試:載入一份包含您的 RTF 來源使用的每種字體的文件,並確認輸出頁數與您從手動列印預覽中預期的頁數相符

表格、內嵌影像和大多數 RTF 格式化功能都可以正常運作,無需任何額外處理,因為 Rich Edit 會以原生方式轉譯它們;可能令人驚訝的一個領域是使用自訂段落間距或以 twips 表示的首行縮排文字:Rich Edit 的內部座標系統是以 twips (1/1440 英吋) 為單位,而您在 TFormatRange 中設定的 DC 座標則是以目前 DPI 的像素為單位;控制項會在內部進行轉換,但如果您是透過程式設計方式建構 RTF,則應驗證您的邊距值是否使用正確的單位

DPI 感知與高 DPI 顯示器

在以 150% 縮放比例 (144 DPI) 執行的顯示器上,ScaleX(210, mmPixel) 將會傳回比 100% 顯示器上更大的像素計數;PDF 函式庫會記錄您傳遞給 GetCanvasDC 的任何像素尺寸,並使用 LoadFromCanvasDc 中的 DPI 引數來回推計算 PDF 中的實體頁面大小;只要您傳遞的 DPI 值與您應用程式正在執行的 DPI 相符,無論顯示器縮放比例如何,輸出頁面大小都將是正確的

如果您的應用程式不具備 DPI 感知(舊的預設設定),Windows 會縮放螢幕 DC,且您的像素計算在高 DPI 電腦上將會出錯;最簡單的修正方法是在應用程式資訊清單中宣告 DPI 感知,應用程式接著會接收真實的裝置像素,且您傳遞給 LoadFromCanvasDc 的 96 應替換為從 GetDeviceCaps(GetDC(0), LOGPIXELSX) 取得的實際顯示 DPI;上面的程式碼範例硬編碼了 96,因為它適用於 100% 縮放環境並能保持範例簡短

輸出結構:每頁一個檔案對比合併文件

上述迴圈會將每個頁面寫入個別的 PDF 檔案;這是否是您想要的,取決於下游用途;報表產生系統通常需要個別頁面,因為它們稍後會透過合併或重新排序頁面來組合最終文件;如果您一開始就想要單一的 PDF,該函式庫允許您在單一工作階段中建立具有多個頁面的文件:在迴圈外部建立一次文件,在迴圈內部呼叫頁面新增方法而不是 SaveToFile,並在迴圈結束後儲存完整文件;這可以避免中間檔案,是大多數單一文件轉換案例的正確結構

對於大型 RTF 檔案,在迴圈中新增一些進度回饋是值得的,因為轉換率大致與頁數成正比,而 200 頁的文件可能需要幾秒鐘;repeat...until 結構很容易擴充:在每次反覆運算後追蹤進度列更新中的字元偏移量,使用 LastChar 除以來自 RichEdit1.GetTextLen 的總字元數

此處顯示的 GetCanvasDC 和 LoadFromCanvasDc 方法是適用於 Delphi 和 C++Builder 的 losLab PDF Library 的一部分