技术文章

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 参数是独立的。你可以绘制"下载发票 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 基的:文档的第一页是第 0 页,与 CurrentPageNumber 匹配。目标页面在你发起调用时必须已经存在;如果索引越界,该过程不加注解地返回——没有异常、没有链接、没有警告。对于一个指向前方的目录,先创建所有页面,再切回去加链接

YPos 选择目标页面上的垂直位置,在你绘制调用的同一坐标空间中。默认的 -1(任何负值)写一个空目标坐标,告诉查看器在落到目标页面上时保持其当前垂直位置。传入一个非负值,查看器就滚动使该位置坐在窗口顶部——用你正链接到的标题的 Y 坐标。缩放总是保持不变。与 AddURILink 一样,DescriptionPDFUACompliance 下必须非空,并成为链接的替代文本

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 视图中打开链接还是交给系统浏览器上各有不同

这些没有一项是你能从生成侧修复的 bug;它们是查看器策略决策。你能做的是写出让 URL 在文档正文中也可见的链接标签,这样受限环境中的读者仍能手工复制地址。注解是便利;文本是后备

还有一个值得知道的细节:PDF URI 注解默认不携带任何可见下划线。你在大多数查看器中看到的下划线是由查看器自身根据注解类型绘制的,而不是由内容流中的某个字形。如果你需要一条能挺过打印到非交互渲染器或 PDF 转图片转换的物理下划线,在文本基线下方适当的 Y 偏移处用 LineToStroke 显式绘制它。那是一次独立的绘制操作,不是 PrintHyperlink 替你做的事

此处展示的超链接 API 属于面向 Delphi 和 C++Builder 的 HotPDF Component