技術文章

HotPDF Delphi 超連結:PrintHyperlink 註解要點

PDF 超連結是 URI 註解:一個覆蓋某塊頁面區域的矩形,在被點選時告訴檢視器開啟一個 URL。註解與其下方的文字是完全獨立的物件。HotPDF 的 PrintHyperlink 把兩者綁進一次呼叫,繪製文字並從繪製後的文字度量計算註解矩形。那個便利隱藏了一個在撰寫正式程式碼之前值得理解的細節。它也不是全貌:AddURILink 把一個可點選區域放在你自己繪製的內容上方,而 AddGoToLink 處理內部導覽——兩者都在下面涵蓋

PrintHyperlink 如何運作

PrintHyperlink 位於 THPDFPage 上,接受四個引數:X 與 Y 座標(以點為單位,左下角為原點,Y 向上遞增)、要繪製的標籤字串,以及 URL 目標。它在內部以目前的超連結顏色呼叫 TextOut,然後立即從目前字型度量下的 TextWidthTextHeight 計算註解矩形。這意味著字型與大小必須在呼叫之前設定好,而且不能在繪製標籤與放置註解之間改變,因為兩者是在同一次呼叫中解析的

預設顏色是 clBlueSetRGBHyperlinkColor 只改變之後呼叫的顏色;它不會回頭更新已寫入的註解。如果你需要在同一頁上為不同的連結群組使用不同顏色,請在每個群組之前呼叫 SetRGBHyperlinkColor,之後再重設

以下是一份以兩種不同顏色寫入三個連結的最小文件:

procedure CreateLinkedReport(const FileName: string);
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.BeginDoc;

    Pdf.CurrentPage.SetFont('Arial', [], 11);

    // Default blue for informational links
    Pdf.CurrentPage.TextOut(50, 750, 0, 'Reference links:');
    Pdf.CurrentPage.PrintHyperlink(50, 720, 'Product page', 'https://www.loslab.com/en-us/pdf-library/delphi-pdf-component.html');
    Pdf.CurrentPage.PrintHyperlink(50, 695, 'Online manual', 'https://www.loslab.com/en-us/pdf-library/delphi-pdf-component.html');

    // Red for the action link
    Pdf.CurrentPage.SetRGBHyperlinkColor(clRed);
    Pdf.CurrentPage.PrintHyperlink(50, 660, 'Purchase license', 'https://www.loslab.com/en-us/buy-hotpdf-fastspring.html');
    Pdf.CurrentPage.SetRGBHyperlinkColor(clBlue);  // restore default

    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

座標陷阱

HotPDF 使用左下角為原點、Y 向上增長的座標系統,以點為單位(1/72 英吋)。一份 A4 頁面是 595 x 842 點;一份 US Letter 頁面是 612 x 792 點。Y=750 位於 A4 頁面的接近頂端,而 Y=50 會接近底部邊界。任何來自螢幕繪圖或 HTML 的人都會假設相反,並把第一列連結直接放到可見區域之外

PrintHyperlink 計算的註解矩形使用同一套座標系統。如果你之後旋轉頁面、縮放它,或在未重新計算 X/Y 值的情況下改變頁面大小,可見文字與可點選矩形就會漂離。連結在某種意義上「能用」——點選文字附近的某處會觸發 URL——但熱區不再符合讀者所見。請在你出貨的實際頁面大小與縮放層級上測試,而不只是在開發機上以 100% 測試

有一種情況漂離是保證的:如果你以適合 A4 頁面的座標呼叫 PrintHyperlink,然後切換到自訂的窄格式頁面而不調整 X/Y 值,註解可能完全落到頁面之外。註解物件仍會被寫進 PDF;大多數檢視器會悄悄裁切它,所以連結就這樣消失而沒有任何錯誤

標籤文字與 URL 目標

TextLink 引數是獨立的。你可以繪製「Download invoice PDF」,而目標是一個帶有查詢參數的完整 HTTPS URL。這種分離是刻意的;可見標籤應該是易讀的,而 URL 可以很長或動態產生

造成問題的是,當標籤本身就是原始 URL,尤其是一個長的。如果 URL 視覺上跨兩列換行,但註解矩形是為單列字串計算的,就只有第一列可點選。PrintHyperlink 不處理多列流向;請讓標籤短到能在目前字型大小與頁面寬度下放進一列、使用簡短的描述性標籤而以完整 URL 作為目標,或套用下一節的逐列替代做法

對於會在沒有活躍網路連線的情況下封存或散布的文件,也請考慮 URL 本身是否應該以列印形式出現在文件主體的某處,而不僅僅作為註解中繼資料。一個把 PDF 列印到紙上的讀者,從 URI 註解什麼也得不到

繞過多列限制

當一個連結標籤確實必須跨一列以上——一個逐字列印的長 URL,或一個應該頭尾都可點選的換行句子——修復方式是停止把它當作一個連結,而是把它當作每一列一個連結。每一次 PrintHyperlink 呼叫都從它繪製的文字計算自己的矩形,所以數次共用同一個 Link 目標的呼叫,會產生數個尺寸正確、且全都開啟同一個 URL 的註解。讀者分辨不出差別;每一列都對點選做出反應

procedure PrintWrappedHyperlink(Page: THPDFPage; X, TopY, LineStep: Single;
  const Lines: array of AnsiString; const Link: AnsiString);
var
  I: Integer;
begin
  for I := 0 to High(Lines) do
    Page.PrintHyperlink(X, TopY - I * LineStep, Lines[I], Link);
end;

// Usage: break the label at the positions where your layout wraps it
Pdf.CurrentPage.SetFont('Arial', [], 10);
PrintWrappedHyperlink(Pdf.CurrentPage, 50, 400, 14,
  ['https://www.loslab.com/en-us/pdf-library/',
   'delphi-pdf-component.html'],
  'https://www.loslab.com/en-us/pdf-library/delphi-pdf-component.html');

切分字串是你的責任:在它於目前字型與欄寬下會視覺換行的相同位置切斷它,用 TextWidth 測試每一個候選列。另一個選擇是用普通的 TextOut 呼叫自己繪製換行的文字,然後在每一列上方鋪一個 AddURILink 矩形——當文字已經由你自己的自動換列邏輯產生時,這是更好的路徑,而這也把我們帶到那個函式

AddURILink:在你繪製的任何東西上的可點選區域

PrintHyperlink 是一個便利的包裝器:它繪製自己的標籤並從該標籤的度量推導矩形。AddURILink 則是直接公開的較低層一半:

function AddURILink(Rectangle: TRect; const URL: AnsiString;
  const Description: AnsiString = ''): THPDFDictionaryObject;

它只寫入註解——不繪製文字,也不改變顏色。Rectangle 以與你的繪製呼叫相同的座標空間來詮釋,所以你可以重用你傳給 TextOut 或某個影像呼叫的精確 X/Y 值。這使得每當可見內容已經存在時——一個影像熱點、一個表格儲存格、一塊先前繪製的文字,或如上面替代做法中換行段落的一列——它就是正確的工具。註解帶有零寬度的框線,所以沒有任何可見的變化;可點選的區域正是你指定的矩形

該函式以 THPDFDictionaryObject 回傳註解字典。大多數呼叫者丟棄結果,但保留它能讓你在文件寫入之前調整註解的項目

有兩個合規細節是內建的。在 PDF/A 模式中,註解的列印旗標會如那些標準所要求地被設定。在 PDFUACompliance 下,Description 參數必須是非空字串——它成為註解的 /Contents 項目,也就是輔助技術為該連結朗報的東西——而且該呼叫會引發例外,而不是悄悄發出一份不合格的檔案。PrintHyperlink 早於那條規則,不附加任何描述,所以對於 PDF/UA 輸出,請用 TextOut 繪製標籤,並用 AddURILink 加上一個有意義的描述來放置註解

決策規則很簡單:當連結是一段你還沒繪製的簡短文字時,使用 PrintHyperlink;當可點選區域由你自己繪製或測量的內容來定義時,使用 AddURILink

以 AddGoToLink 進行內部導覽

外部 URL 只是連結註解所做的一半。另一半是文件內部的導覽——一份跳到各章節的目錄、各區段之間的交叉參照。HotPDF 透過 AddGoToLink 公開這一點:

procedure AddGoToLink(Rectangle: TRect; TargetPageIndex: Integer;
  YPos: Single = -1; const Description: AnsiString = '');

有三個語意值得精確說明,因為沒有一個能從簽章猜得出來。TargetPageIndex 是從零起算的:文件的第一頁是第 0 頁,與 CurrentPageNumber 一致。目標頁面在你進行呼叫時必須已經存在;如果索引超出範圍,程序會返回而不加入註解——沒有例外、沒有連結、沒有警告。對於一份向前指的目錄,請先建立所有頁面,再切回去加入連結

YPos 選擇目標頁面上的垂直位置,使用與你的繪製呼叫相同的座標空間。預設值 -1(任何負值)寫入一個空的目的地座標,告訴檢視器在落到目標頁面時保持它目前的垂直位置。傳入一個非負值,檢視器就會捲動使該位置位於視窗頂端——使用你要連結過去的標題的 Y 座標。縮放層級始終保持不變。如同 AddURILinkDescriptionPDFUACompliance 之下必須非空,並成為該連結的替代文字

procedure BuildLinkedTOC(const FileName: string);
const
  Chapters: array[0..2] of string =
    ('Introduction', 'Installation', 'API Reference');
var
  Pdf: THotPDF;
  I, Y: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.BeginDoc;                        // page 0 becomes the TOC page

    // Create the chapter pages first so the link targets exist
    for I := 0 to High(Chapters) do
    begin
      Pdf.AddPage;                       // pages 1..3
      Pdf.CurrentPage.SetFont('Arial', [fsBold], 14);
      Pdf.CurrentPage.TextOut(50, 780, 0, Chapters[I]);
    end;

    // Switch back to page 0 and draw the TOC entries with their links
    Pdf.CurrentPageNumber := 0;
    Pdf.CurrentPage.SetFont('Arial', [fsBold], 16);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Contents');
    Pdf.CurrentPage.SetFont('Arial', [], 11);

    Y := 720;
    for I := 0 to High(Chapters) do
    begin
      Pdf.CurrentPage.TextOut(70, Y, 0, Chapters[I]);
      Pdf.CurrentPage.AddGoToLink(
        Rect(70, Y + 14, 300, Y - 3),    // covers the entry with padding
        I + 1,                           // zero-based: chapters are pages 1..3
        780,                             // land with the heading at the top
        AnsiString('Go to ' + Chapters[I]));
      Y := Y - 25;
    end;

    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

每個項目都得到一個比文字更寬的矩形,好讓整列對指標做出反應,而每個連結都讓章節標題(繪製於 Y=780)落在視窗頂端。如果你之後在章節之前插入一頁,每個 TargetPageIndex 都會偏移一;請從你的頁面建立迴圈計算索引,而不是把它們寫死

一份完整的文件產生範例

以下模式顯示一個更真實的情境:產生一份帶有標頭區段、主體文字與一列頁尾連結的簡短報表,全部來自程式碼,而非來自一個帶有 TEdit 欄位的表單:

procedure GenerateProductSheet(
  const FileName, ProductName, ProductURL, SupportURL: string);
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Compression := cmFlateDecode;
    Pdf.BeginDoc;

    // Header
    Pdf.CurrentPage.SetFont('Arial', [fsBold], 16);
    Pdf.CurrentPage.TextOut(50, 750, 0, WideString(ProductName));

    // Body paragraph placeholder
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 710, 0, 'See the links below for full documentation.');

    // Footer links
    Pdf.CurrentPage.SetFont('Arial', [], 10);
    Pdf.CurrentPage.TextOut(50, 80, 0, 'Links:');
    Pdf.CurrentPage.PrintHyperlink(50, 60, 'Product page', ProductURL);
    Pdf.CurrentPage.PrintHyperlink(200, 60, 'Support', SupportURL);

    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

請注意 SetFont 在每一群文字呼叫之前都被呼叫。字型不會跨 AddPage 持續存在,而如果你在新頁面上的 PrintHyperlink 之前忘了設定它,註解矩形會以頁面的預設度量來計算,那可能與你預期的不同

各檢視器之間註解處理的差異

PDF URI 註解定義於 ISO 32000-1 §12.6.4.7,每個合格的檢視器都應遵循它們。實際上,有少數行為因檢視器而異。Adobe Acrobat 對不在信任網域清單中的 URL,在首次點選時顯示安全提示;許多瀏覽器與輕量級閱讀器則不會。某些鎖定環境中的企業 PDF 檢視器依政策完全停用 URI 註解,所以點選毫無作用,也沒有可見的錯誤。行動 PDF 應用程式在於應用程式的 Web 檢視內開啟連結、或交給系統瀏覽器方面各有不同

這些都不是你能從產生端修復的錯誤;它們是檢視器的政策決定。你能做的,是寫出讓 URL 也在文件主體中可見的連結標籤,好讓一個在受限環境中的讀者仍能手動複製位址。註解是便利;文字是後援

還有一個細節值得知道:PDF URI 註解預設不帶任何視覺底線。你在大多數檢視器中看到的底線,是檢視器本身依據註解型別繪製的,而非內容串流中的字元。如果你需要一條能在列印到非互動繪製器、或 PDF 轉影像轉換中存活的實體底線,請在文字基準線下方適當的 Y 偏移處,用 LineToStroke 明確繪製它。那是一次分開的繪製操作,不是 PrintHyperlink 替你處理的東西

此處展示的超連結 API,是供 Delphi 與 C++Builder 使用的HotPDF Component 的一部分