技術文章

HotPDF 的 Resolution:繪圖單位與 UserWidth

HotPDF Component 裡,THotPDF.Resolution 定義繪圖單位:每個 X、Y 座標,每個邊界,傳給 SetFont 的字號,以及 TextWidth 與 GetWideTextWidth 的結果,都以 1/Resolution 英吋計量。THPDFPage.Width 與 Height 不跟著走、維持點數,所以版面邊界必須取自唯讀的 UserWidth 與 UserHeight。動 Resolution 的常見原因是移植:一套已經用 1/96 或 1/144 英吋思考的報表引擎,在 PDF 端說同一種單位時搬過來容易得多,不用給每個呼叫點塞一個換算係數。這招很好用,前提是您知道哪些數字搬進了新單位、哪些留在了原地

THotPDF.Resolution 實際改變了什麼?

THotPDF.Resolution 只改變 HotPDF 怎麼讀您傳入的數字;寫出的 PDF 不變。setter 就兩行:SetResolution 存值、設 DocScale := Value / 72。從此 XProjection 與 YProjection 在每個座標進內容串流之前除以 DocScale,SetFont 對字號做同樣的除法再記錄。PDF 使用者空間預設 1/72 英吋(ISO 32000-1 §8.3.2.3),所以預設 Resolution 72 時投影是恆等映射,144 時一個繪圖單位是半點。不會寫出 /UserUnit。那個頁面屬性是 PDF 1.6 加的另一回事,HotPDF 以 THPDFPage.SetUserUnit 暴露它。一個讓 TextOut 教程過來的人吃驚的細節:頁面座標從左上角起算、Y 向下增長,因為 YProjection 算的是 MediaBox 頂減去縮放後的 Y,這在任何 Resolution 下都成立

THotPDF.Resolution 在 Delphi 裡如何定義繪圖單位:setter 把 DocScale 存成 Resolution 除以 72,接著 XProjection、YProjection 與 SetFont 在座標與字號進內容串流前分別除以它,Resolution 72 是恆等映射、144 讓一個繪圖單位等於半點,而頁面仍從左上角起算、Y 向下
輸出檔案裡什麼都沒動——變的只是您傳入數字的含義,這正是同一份內容串流在 72 與 144 下長得一樣的原因
var
  Pdf: THotPDF;
  Page: THPDFPage;
  Margin: Single;
  Title: WideString;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'invoice.pdf';
    Pdf.Resolution := 144;               // 1 繪圖單位 = 1/144 英吋
    Pdf.BeginDoc;
    Page := Pdf.CurrentPage;             // A4: Width = 595, UserWidth = 1190
    Margin := 144;                       // 一英吋的繪圖單位數
    Page.SetFont('Arial', [fsBold], 28); // 28/144 英吋,14 pt 字型
    Title := 'INVOICE 2026-0417';
    // 以同一單位量頁緣、靠右對齊
    Page.TextOut(Page.UserWidth - Margin - Page.GetWideTextWidth(Title),
      Margin, 0, Title);
    Page.SetLineWidth(2);                // 1 pt 的線
    Page.MoveTo(Margin, Margin + 48);
    Page.LineTo(Page.UserWidth - Margin, Margin + 48);
    Page.Stroke;
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Resolution 144 時 Page.Width 為什麼跟我的座標對不上?

THPDFPage.Width 與 Height 以點回報頁面,文件的 Resolution 是什麼都一樣;您的座標卻以 1/Resolution 英吋為單位,所以 144 時頁面看起來只有實際寬度的一半。A4 頁在 Resolution 72 讀到 Width = 595、Height = 842,在 144 也照樣讀到 595 與 842,而右緣實際在 X = 1190。v2.766.0 加入的 UserWidth 與 UserHeight 回傳 Width * DocScale——以您繪圖用的單位表示的頁面尺寸。它們出現之前,函式庫內部混用兩套單位,Resolution 144 的症狀相當戲劇化:段落每個字元就換行,THPDFTable.Render 把每一列推到新頁,HTML 匯入器與 XFA 攤平器都把內容畫成一半大,攤平後的表單擠在左上角。段落排版、表格渲染、HTML 匯入、EMF 置中、WMF 頁面剪裁與版面診斷,現在都讀使用者單位尺寸。您自己的版面程式碼也該如此:任何要跟繪圖座標比較的東西(右邊界、分頁測試、置中計算)都該落在 UserWidth 與 UserHeight 上,永遠別落在 Width 與 Height 上

陷阱一:指派 Width 或 Height 會把頁面切成點數制

設定 Page.Width 或 Page.Height 會悄悄把頁面改成 UserDefined,而 UserDefined 頁完全無視 DocScale,所以之後在上面畫的一切都是點數,不是 1/Resolution 英吋。這個 setter 年紀大、按設計收點數,所以它的含義被保留了下來。UserDefined 頁的投影就是平凡的 X + MinX,SetFont 原樣存字號。Resolution 144 時,結果是一頁內容突然比前一頁大一倍的頁面。函式庫自己就犯過一模一樣的錯:段落續頁過去經 Width 複製前一頁的尺寸,每個溢出頁都切到了點數制。那些頁現在改為複製 Size、Orientation 與頁面 Resolution,只有原始頁本來就是 UserDefined 時才退回 Width 與 Height

兩條出路,看您需要什麼。標準紙張夠用,就設 Page.Size 與 Page.Orientation,繼續用您的 Resolution 單位畫。真的需要自訂頁面尺寸,就接受它是點數頁、用點數畫;那裡 UserWidth 等於 Width,所以一律讀 UserWidth 的版面程式碼在兩種頁面上都能動。單元測試把這件事釘死:Resolution 144 下 A4 頁回報 UserWidth 1190,而 Width := 500、Height := 400 之後回報 500 與 400。已載入的頁面行為相同,因為從既有 PDF 重建的頁面只知道以點計的 MediaBox、以點繪圖。這份文件自己建立的頁面,在您經 CurrentPageNumber 切走再切回來時保有自己的單位——v2.766.26 起就是如此

HotPDF 裡 Resolution 144 時 Page.Width 為什麼跟座標對不上:Width 與 Height 保持點數而繪圖用 1/144 英吋,A4 頁讀到 595、右緣卻在 UserWidth 1190;指派 Width 會把頁面切成無視 DocScale 的 UserDefined,於是段落逐字換行、表格逐列斷頁、SetFont 字號減半
任何要跟繪圖座標比較的東西都該落在 UserWidth 與 UserHeight——UserDefined 點數頁上兩者恰好相等,同一份版面程式碼兩種頁面都活得下來

陷阱二:為什麼字號出來只有一半大?

以點起步的字號在 Resolution 144 出來只剩一半,因為 SetFont 把字號參數當繪圖單位、存檔前轉成點。內部裡,SetFont 在當前字型物件存 ASize / DocScale * DPI,所以存的值永遠是點。函式庫在這上面栽過兩次:WideTextOutBoxEx 的字型後備與段落續頁,都把那個存的點值又交回 SetFont,被再縮放一次、文字減半。您的程式碼讀不到那個內部值,但同樣的 bug 在任何別處的點值流進 SetFont 時都會現身:VCL 表單的 TFont.Size、報表定義裡的字號、CSS 的 pt 長度。先做換算,係數裡要把頁面自己的 Resolution 與 UserDefined 情況算進去,中繼檔播放重放頁面 Canvas 時就是這麼做的(那條路見HotPDF 如何匯入 EMF 與 WMF 向量圖形):

// 目前頁面每點對應的繪圖單位。與 HotPDF 的投影一致:
// 經 Width/Height 設尺寸的頁面為 1,否則
// (document Resolution / 72) * (page Resolution / 72)
function UnitsPerPoint(Pdf: THotPDF): Single;
begin
  if Pdf.CurrentPage.Size = UserDefined then
    Result := 1
  else
    Result := (Pdf.Resolution / 72) * (Pdf.CurrentPage.Resolution / 72);
end;

procedure SetFontFromVcl(Pdf: THotPDF; Font: TFont);
begin
  // TFont.Size 以點為單位;SetFont 要的是繪圖單位
  Pdf.CurrentPage.SetFont(AnsiString(Font.Name), Font.Style,
    Font.Size * UnitsPerPoint(Pdf));
end;

函式庫對自己的點常數套用同一條規則。每個新頁面起步的 12 點字型,現在乘上內部的每點單位係數,任何 Resolution 下都是 12 點。DrawChart 的邊界、標籤尺寸與線寬全是寫死的點,現在以比例暫時設為 1 的方式執行。刻意留在繪圖單位裡的,是 DrawQRCode 模組尺寸、預設表格字型這類公開參數預設值:它們屬於 API 契約,Resolution 144 下就是 72 之下的一半。如果您的報表尺寸來自範本,HotPDF 報表輸出的字型與影像指南講了那些值通常從哪裡來

怎麼驗證版面與 Resolution 無關?

最可靠的檢查是位元組比較:同一頁在 Resolution 72 渲染一次、在 144 下把每個座標與字號翻倍再渲染一次,未壓縮的內容串流必須完全相同。兩次執行經投影後落在同一批點值上,任何差異都是一個跳過換算的值。HotPDF 測試套件就是這樣檢查段落、表格、HTML 匯入、XFA 攤平、弧線、中繼檔與影像的。同一招幾乎不需要什麼治具就能用在您自己的報表程式碼上:

procedure RenderPage(const FileName: string; Res: Integer; K: Single);
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.Compression := cmNone;       // 內容串流可讀
    Pdf.FileName := FileName;
    Pdf.Resolution := Res;
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 10 * K);
    Pdf.CurrentPage.TextOut(36 * K, 36 * K, 0, 'Line 1');
    Pdf.CurrentPage.Rectangle(36 * K, 60 * K, 200 * K, 40 * K);
    Pdf.CurrentPage.Stroke;
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

// RenderPage('r72.pdf', 72, 1) 與 RenderPage('r144.pdf', 144, 2)
// 必須產出逐位元組相同的頁面內容串流
在 HotPDF Delphi 程式碼裡怎麼驗證 Resolution 無關性:同一版面渲染兩次,一次 Resolution 72、比例 1,一次 144、座標與字號全部翻倍,然後要求未壓縮內容串流逐位元組相同——有出入就指向經 Width 切成 UserDefined 的頁面、或未換算就流進 SetFont 的點值
兩次執行經投影落在同一批點值上,任何差異都是跳過換算的數字——HotPDF 測試套件賴以為生的同一套治具

檢查帶數字的運算元:Td、Tm、Tf、re、w 與 TJ 陣列。檔案層級的位元組在建置日期與 /ID 上仍會不同,所以比串流,別比整檔。有出入幾乎總是指向上面兩個陷阱之一:經 Width 改過尺寸的頁面,或直接塞進 SetFont 的點值。如果連繪圖呼叫本身都還陌生,先從HotPDF TextOut 的尺寸、樣式與旋轉走查開始,等版面讀的是 UserWidth 之後,再回來切 Resolution。完整 API 細節與試用下載在HotPDF Delphi PDF component 頁面