技術文章

Delphi 中的 HotPDF TextOut:大小、樣式、旋轉、間距

在 (72, 72) 畫一個字串,它會落在頁面上緣下方一英吋處——從內容串流側學 PDF 的人會中計,因為在那個世界 y = 72 是離頁底一英吋。第二個驚喜在幾百行後出現:照 US Letter 排版的報表,第一頁是 612×792 點,之後每一頁都變成 595×842,因為設定紙張尺寸的那一行只執行了一次

兩個現象同根同源。PDF 裡的文字是一小段狀態變更(字型與大小、字元間距、單字間距、定位與旋轉用的文字矩陣)接一個顯示文字運算子,高階文字呼叫只是這串序列的薄包裝。呼叫會讀取狀態,卻不會替你重設狀態;新頁面是從一組固定的預設值出發,不是從上一頁結束時的狀態接續

TextOut 把 (X, Y) 放在頁面哪裡

HotPDF 用 THPDFPage.TextOut(X, Y: Single; angle: Extended; Text: WideString) 畫文字,經 Pdf.CurrentPage.TextOut 呼叫。X、Y 從頁面左上角起量,Y 向下增長;Resolution 用預設的 72 時,一個單位就是一點,即一英吋的 1/72。頁面內部把每個 Y 減去頁高,換成由下往上跑的 PDF 使用者空間。angle 以度為單位;文字是 WideString,Unicode 直接通行。另一個多載收 PWORD 加長度,給手上已經有 UTF-16 緩衝區的程式碼用

頁面幾何同樣是固定的。BeginDoc 加入第一頁,而 BeginDoc 或 AddPage 產生的每一頁都從 A4(595×842 點)與 Arial 12 現用字型出發。US Letter(612×792)離 CurrentPage.Size := Letter 只有一次指派的距離,但這個指派屬於頁面本身,每個新頁面都得重來一次

預設情況下,你傳的 Y 是文字的上緣,基準線在其下一個字型 ascent 處。TextFromBaseLine(True) 把頁面切到基準線定位。同一行混用多種大小時務必開它:頂邊模式下,14 點金額旁邊的 9 點幣別代碼會跟金額對齊上緣,看起來明顯浮高;基準線模式下兩段文字站同一條線,才是排版師傅的排法。下一個範例的最後一段正是如此

大小與樣式都住在 SetFont

TextOut 沒有大小或樣式參數。SetFont(FontName, FontStyle, ASize, FontCharset, IsVertical) 一手包辦,後兩個引數預設 ANSI_CHARSET 與水平文字;它設定的東西會一路生效到下一個 SetFont 為止,其間每個 TextOut 都照辦。第一天上手的困惑多半源於此:某行印出來是粗體,只因為三次呼叫之前有誰設了 [fsBold],而沒有人清掉

var
  W: Single;
begin
  Pdf.BeginDoc;                                     // 第一頁:A4、Arial 12
  Pdf.CurrentPage.TextFromBaseLine(True);           // 以下每個 Y 都是一條基準線

  Pdf.CurrentPage.SetFont('Times New Roman', [], 24);
  Pdf.CurrentPage.TextOut(72, 72, 0, 'Quarterly Report');        // 24 pt 標準體

  Pdf.CurrentPage.SetFont('Times New Roman', [fsBold], 12);
  Pdf.CurrentPage.TextOut(72, 104, 0, 'Revenue');                // 12 pt 粗體

  Pdf.CurrentPage.SetFont('Times New Roman', [fsItalic], 11);
  Pdf.CurrentPage.TextOut(72, 120, 0, 'figures in thousands');   // 11 pt 斜體

  Pdf.CurrentPage.SetFont('Courier New', [fsBold, fsUnderline], 10);
  Pdf.CurrentPage.TextOut(72, 136, 0, '+18.4% YoY');             // 樣式可疊加

  // 同一條基準線混用大小:趁 14 pt 字型還在作用時量測
  Pdf.CurrentPage.SetFont('Arial', [fsBold], 14);
  W := Pdf.CurrentPage.GetWideTextWidth('1,284.50');
  Pdf.CurrentPage.TextOut(72, 160, 0, '1,284.50');
  Pdf.CurrentPage.SetFont('Arial', [], 9);
  Pdf.CurrentPage.TextOut(72 + W + 3, 160, 0, 'EUR');            // 3 pt 間隙
  ...
end;

樣式參數就是普通的 TFontStyles 集合:[] 是標準體,[fsBold, fsItalic] 是粗斜體。fsUnderline 與 fsStrikeOut 畫成細細的實心橫條,壓在文字下方或攔腰劃過,文字旋轉時它們跟著轉。大小以點為單位,與座標同一單位,垂直方向的算術因此很簡單:11 點內文配 16 點行距,行之間大約留五點空氣。沒有自動換行前進,每個 Y 都得自己算——寫段落很囉唆,對欄位位置固定的表單卻恰恰好

GetWideTextWidth 用呼叫當下現用的字型量測,所以最後一段的量測必須趁 14 點粗體還在設定時進行。把它挪到第二個 SetFont 之後,9 點的後綴就會疊在金額的最後幾位數字上

字型名稱是對著產生 PDF 的那台機器上安裝的字型解析的,開發者桌面上解析得到的字體,組建伺服器上未必有,成品檔案裡的字體因此取決於是哪台機器產出它。字元集覆蓋是另一半學問:只涵蓋拉丁字母的字型被要求畫西里爾或 CJK,就會端出缺字方塊,這是字型檔本身的屬性,與呼叫無關。當一個字串混用單一字型蓋不住的多種文字系統,TextOutWithFallback 收同樣的四個引數,按段切換字型補上主字型缺的字,完事後再還原原本字型

HotPDF TextOut 頁面,展示 Arial、Times New Roman 與 Courier New 以標準、粗體、斜體樣式呈現多種字元集

angle 參數怎麼旋轉文字?

非零的角度讓文字串繞錨點逆時針轉那麼多度,錨點是文字自己座標系裡的左上角。錨點不動,動的只是字形行進的方向,所以擺過水平標籤的那組座標,擺它的旋轉版也一樣準。就算頁面處於基準線模式,旋轉文字串照樣用左上角當錨點;TextFromBaseLine 只影響水平文字串

HotPDF TextOut 旋轉幾何圖解:0、45、90 度的文字串繞著固定錨點逆時針轉動,並附書脊標籤、浮水印與傾斜欄位標頭的 Delphi TextOut 呼叫範例
非零角度讓文字串繞著不動的錨點(文字左上角)逆時針旋轉;共用基準線時遞增 X,正好比擬水平行遞增 Y
var
  W, AxisMidY: Single;
begin
  AxisMidY := 400;                                // 圖表 Y 軸的中點

  // 90 度的一串文字由下往上讀、從 Y 向上生長,
  // 所以要對齊軸心,得從中點下方半個寬度處起繪
  Pdf.CurrentPage.SetFont('Arial', [fsBold], 11);
  W := Pdf.CurrentPage.GetWideTextWidth('Units sold');
  Pdf.CurrentPage.TextOut(40, AxisMidY + W / 2, 90, 'Units sold');

  // 對角浮水印
  Pdf.CurrentPage.SetFont('Arial', [fsBold], 60);
  Pdf.CurrentPage.TextOut(150, 250, 45, 'DRAFT');

  // 傾斜 60 度的欄位標頭:同一個 Y,遞增 X
  Pdf.CurrentPage.SetFont('Arial', [], 9);
  Pdf.CurrentPage.TextOut(120, 600, 60, 'Q1 actual');
  Pdf.CurrentPage.TextOut(160, 600, 60, 'Q2 actual');
end;

90 度涵蓋軸標籤與書脊標題。90 度時,字形站在錨點 X 的右側、一個行高那麼寬,文字串靠自身寬度往頁頂爬。置中因此是把半個寬度「加」到 Y 而不是減,錨在軸中點的標籤才會整串懸在中點上方。60 度適合欄位標頭,長標籤立在窄欄上方也不會灑到隔壁;45 度是最常見的浮水印角度。共基準線的文字串拿同一個 Y、遞增 X,正如疊起來的水平行共用 X、遞增 Y

為什麼第二頁跑出 A4 的 Arial 12?

AddPage 回傳新頁索引並把它設為現用頁,新頁從預設出發:A4、Arial 12、頂邊定位、字元與單字間距歸零。上一頁的任何設定都不帶過來。這個設計讓每頁自給自足——PDF 頁面的內容串流本來就從全新的圖形狀態開始——但也意味著:在文件開頭設定一次頁面的版面程式碼,到了第二頁會悄悄換掉紙張尺寸與字體。解法是把每頁該做的事收進一個常式,在 BeginDoc 之後與每次 AddPage 之後都呼叫它

HotPDF:US Letter 頁面圖解,展示由左邊界、頂部起始線與固定 Leading 逐步走向底邊界上限的行位置算術,旁邊是具名常數版面迴圈,帶手動 AddPage 防護與 SetFont 重設
具名錨點把一排魔術數字變成算術:每行讓執行中的 Y 前進 Leading,底邊界由自己用 AddPage 把關
procedure StartReportPage(Pdf: THotPDF);
begin
  Pdf.CurrentPage.Size := Letter;               // 新頁面以 A4 起始
  Pdf.CurrentPage.TextFromBaseLine(True);       // 新頁面以頂邊模式起始
  Pdf.CurrentPage.SetFont('Arial', [], 11);     // 新頁面以 Arial 12 起始
end;

procedure WriteReport(Pdf: THotPDF; Lines: TStrings);
const
  LeftMargin   = 72;   // 1 英吋
  TopLine      = 72;   // 第一條基準線,往下 1 英吋
  BottomMargin = 72;
  Leading      = 16;
var
  I: Integer;
  Y: Single;
begin
  StartReportPage(Pdf);
  Y := TopLine;
  for I := 0 to Lines.Count - 1 do
  begin
    if Y > Pdf.CurrentPage.Height - BottomMargin then   // Letter 紙張上是 720
    begin
      Pdf.AddPage;
      StartReportPage(Pdf);
      Y := TopLine;
    end;
    Pdf.CurrentPage.TextOut(LeftMargin, Y, 0, Lines[I]);
    Y := Y + Leading;
  end;
end;

TextOut 底下沒有流式排版,對內容串流而言,超過頁底的 Y 仍是合法座標:文字照寫,只是落在媒體框之外,任何檢視器都不顯示。所以迴圈在每一行之前先檢查 Y,自己翻新頁。上限從 CurrentPage.Height 讀而非寫死 720,日後有人把輔助常式切回 A4 時迴圈照樣正確——同樣一英吋邊界在 A4 上的上限是 770

最快的檢查就是讀者列印對話框會做的那個:打開輸出檔跳到第二頁,看文件屬性——Letter 頁回報 8.5×11 英吋,A4 頁約 8.27×11.69。第一頁與第二頁對不起來,就表示某個每頁設定落在輔助常式之外

字元與單字間距同樣是頁面狀態

有時字串沒錯、寬度不對:標題該橫跨一條固定橫線、代碼的數字鬆開比較好讀、一排標籤的間隙需要撐開。ISO 32000-1 為此給了兩個文字狀態參數:每個字形之後附加的字元間距 Tc(§9.3.2),與每個空白處附加的單字間距 Tw(§9.3.3)。HotPDF 用 SetCharacterSpacing(Spacing: Single) 與 SetWordSpacing(Spacing: Single) 設定,在預設解析度下以點為單位,而且像字型一樣,設了就一路生效到被改掉

HotPDF:字元間距 Tc 把額外空間均勻加在 SUMMARY 的每個字形之後,對比單字間距 Tw 只把空間聚在空白字元;兩者都接在設定、繪製、重設的循環裡,避免間距狀態滲入後續段落
Tc 的調整攤在每個字形上,Tw 只作用在空白字元;下一行就重設,繪圖狀態才保持乾淨

單字間距在規範裡埋了一個坑。§9.3.3 只對單位元組字碼 32 套用 Tw,並明講多位元組字碼裡的 32 不算,所以這個運算子對兩位元組字碼寫成的文字(內嵌 Unicode 字型通常就是這麼寫的)本身毫無作用。HotPDF 的 Unicode 文字串正是這樣寫的,於是在這條路徑上,它把單字間距換成每個空白字形之後的顯式位移補上,SetWordSpacing 兩種情況都拉得開間隙。觸發條件仍是樸素的空格 U+0020:完全沒有 ASCII 空格的中文或日文段落拿不到任何額外空間,全形空白(U+3000)被當成普通字形

標題要打到目標寬度時,字元間距是對的工具,因為它把變化量均勻攤開,而不是全堆在空白處。GetWideTextWidth 會把當前生效的字元與單字間距算進去,所以先在兩者歸零的狀態下量測,再推出間距:

const
  Heading = 'SUMMARY';
  Target  = 200;        // 標題應橫跨的橫線寬度,單位 pt
var
  Natural, Tc: Single;
begin
  Pdf.CurrentPage.SetFont('Arial', [fsBold], 14);
  Pdf.CurrentPage.SetCharacterSpacing(0);       // 在無殘留間距的狀態下量測
  Natural := Pdf.CurrentPage.GetWideTextWidth(Heading);

  // Tc 加在每個字形之後,但只有字形之間的空隙看得出來
  Tc := (Target - Natural) / (Length(Heading) - 1);

  Pdf.CurrentPage.SetCharacterSpacing(Tc);      // 負值會收緊
  Pdf.CurrentPage.TextOut(72, 72, 0, Heading);
  Pdf.CurrentPage.SetCharacterSpacing(0);       // 本文開始前先重設

  Pdf.CurrentPage.SetWordSpacing(6);
  Pdf.CurrentPage.SetFont('Arial', [], 11);
  Pdf.CurrentPage.TextOut(72, 100, 0, 'Name Department Extension');
  Pdf.CurrentPage.SetWordSpacing(0);
end;

重設那幾行比設定那幾行更要緊。一個標題留下 4 點字元間距不清,底下每個段落都繼承這份拉伸,讀起來是種說不上來的怪,快速校對抓不到,印出來才現形。設值、畫需要它的那一段、下一行歸零,後面的程式碼就不必知道前面哪一節動過什麼。同一個頁面物件還提供 SetHorizontalScaling(Tz,預設 100),可以在不改變高度的前提下壓縮文字串;以及 SetTextRenderingMode,用 trStroke 之類的值畫外框字——兩者的持久方式一模一樣

HotPDF TextOut 頁面,比較水平文字縮放、字元間距、單字間距,以及填滿與描邊渲染模式

如果這是你的第一頁輸出,HotPDF 哈囉世界範例講文件建立與存檔;文字要填滿整欄的,PDF 文字的完全齊行有完整走法;由上而下的 CJK 則看HotPDF 的直書排版。TextOut、SetFont、上面的間距呼叫與頁面尺寸設定,都隨 HotPDF Delphi Component 提供,支援 Delphi 與 C++Builder