Технічна стаття

Гіперпосилання HotPDF у Delphi: поради щодо PrintHyperlink

Гіперпосилання в PDF є анотаціями URI: прямокутником, що вкриває якусь площу сторінки й після клацання каже переглядачеві відкрити URL. Анотація та текст під нею є цілком незалежними об'єктами. PrintHyperlink у HotPDF пакує обидва в один виклик, малюючи текст і обчислюючи прямокутник анотації з метрик відрендереного тексту. Ця зручність ховає деталь, яку варто зрозуміти до того, як ви писатимете продакшн-код. Це також не вся картина: AddURILink кладе клікабельну площу поверх вмісту, який ви намалювали самі, а AddGoToLink опікується внутрішньою навігацією — обидва розглянуто нижче

Як працює PrintHyperlink

PrintHyperlink живе в THPDFPage і приймає чотири аргументи: координати X та Y (у пунктах, початок у лівому нижньому куті, Y зростає вгору), рядок мітки для малювання та цільовий URL. Усередині він викликає TextOut поточним кольором гіперпосилань, а потім негайно обчислює прямокутник анотації з TextWidth та TextHeight за поточними метриками шрифту. Це означає, що шрифт і розмір мають бути задані до виклику й не повинні змінюватися між малюванням мітки та розміщенням анотації, бо обидва розв'язуються в одному виклику

Анатомія одного виклику HotPDF PrintHyperlink, що записує два незалежні об'єкти PDF: видимі гліфи мітки, намальовані через TextOut, і прямокутник анотації-посилання URI, обчислений з TextWidth та TextHeight
Гліфи мітки та прямокутник URI є окремими об'єктами PDF, і саме тому шрифт і колір гіперпосилань мають устоятися до того, як один виклик запише обидва

Типовим кольором є clBlue. SetRGBHyperlinkColor змінює його лише для подальших викликів; він не оновлює заднім числом уже записані анотації. Якщо вам потрібні різні кольори для різних груп посилань на одній сторінці, викликайте 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);

    // Типовий синій для інформаційних посилань
    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');

    // Червоний для посилання-дії
    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);  // відновлюємо типовий

    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%

Один випадок, де розходження гарантоване: якщо ви викличете PrintHyperlink з координатами, придатними для сторінки A4, а потім перемкнетеся на власний вузький формат сторінки, не підправивши значення X/Y, анотація може опинитися цілком за межами сторінки. Об'єкт анотації все одно записується в PDF; більшість переглядачів мовчки його обрізають, тож посилання просто зникає без жодної помилки

Текст мітки проти цільового URL

Аргументи Text та Link є незалежними. Ви можете намалювати "Download invoice PDF", тоді як ціллю буде повністю кваліфікований URL HTTPS із параметрами запиту. Це розділення зроблено навмисно; видима мітка має бути читабельною для людини, а URL може бути довгим або згенерованим динамічно

Проблеми створює те, коли міткою є сам сирий URL, особливо довгий. Якщо URL візуально переноситься на два рядки, а прямокутник анотації обчислено для однорядкового рядка, клікабельним буде лише перший рядок. PrintHyperlink не опікується багаторядковим перетіканням; тримайте мітку достатньо короткою, щоб вона вміщалася в один рядок за поточного розміру шрифту й ширини сторінки, вживайте коротку описову мітку з повним URL як ціллю або застосуйте порядковий обхід, показаний у наступному розділі

Для документів, які архівуватимуться або поширюватимуться без активного інтернет-з'єднання, зважте також, чи має сам URL з'явитися в друкованому вигляді десь у тілі документа, а не лише як метадані анотації. Читач, який друкує PDF на папері, з анотації URI не отримує нічого

Обхід обмеження на багаторядковість

Коли мітка посилання справді має розтягтися більш ніж на один рядок — довгий URL, надрукований буквально, або перенесене речення, яке має бути клікабельним від початку до кінця, — виправлення полягає в тому, щоб перестати трактувати його як одне посилання й трактувати як одне посилання на рядок. Кожен виклик PrintHyperlink обчислює свій прямокутник з того тексту, який малює, тож кілька викликів зі спільною ціллю Link дають кілька коректно розмірених анотацій, які всі відкривають той самий URL. Читач різниці не помітить; кожен рядок відгукується на клацання

Порівняння перенесеної мітки гіперпосилання HotPDF, яка отримує одну анотацію, що вкриває лише перший рядок, і варіанта з одним викликом PrintHyperlink на кожен відрендерений рядок зі спільною цільовою 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;

// Використання: розбийте мітку в тих позиціях, де компонування її переносить
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 тлумачиться в тому самому координатному просторі, що й ваші виклики малювання, тож ви можете перевикористати ті самі значення X/Y, які передавали в TextOut чи у виклик зображення. Це робить його правильним інструментом щоразу, коли видимий вміст уже існує: гаряча зона на зображенні, комірка таблиці, блок тексту, намальований раніше, або один рядок перенесеного абзацу, як в обході вище. Анотація несе рамку нульової ширини, тож видимо нічого не змінюється; клікабельною зоною є рівно той прямокутник, який ви задали

Функція повертає словник анотації як 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 того заголовка, на який посилаєтеся. Масштаб завжди лишається незмінним. Як і в AddURILink, Description має бути непорожнім за PDFUACompliance і стає альтернативним текстом посилання

HotPDF: зв'язаний зміст, побудований через AddGoToLink, де нумерований з нуля TargetPageIndex дає переходи зі сторінки змісту на сторінки розділів, і кожен заголовок приземляється вгорі вікна
Прямокутники виходять за межі тексту, тож відгукуються цілі рядки, а фіксований Y приземлення ставить заголовок кожного розділу вгорі вікна
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;                        // сторінка 0 стає сторінкою змісту

    // Спершу створюємо сторінки розділів, щоб цілі посилань існували
    for I := 0 to High(Chapters) do
    begin
      Pdf.AddPage;                       // сторінки 1..3
      Pdf.CurrentPage.SetFont('Arial', [fsBold], 14);
      Pdf.CurrentPage.TextOut(50, 780, 0, Chapters[I]);
    end;

    // Повертаємося на сторінку 0 й малюємо записи змісту з їхніми посиланнями
    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),    // вкриває запис із запасом
        I + 1,                           // з нуля: розділи є сторінками 1..3
        780,                             // приземлення із заголовком угорі
        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;

    // Заголовок
    Pdf.CurrentPage.SetFont('Arial', [fsBold], 16);
    Pdf.CurrentPage.TextOut(50, 750, 0, WideString(ProductName));

    // Заповнювач для абзацу основного тексту
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 710, 0, 'See the links below for full documentation.');

    // Посилання в підвалі
    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 на новій сторінці, прямокутник анотації буде обчислено за типовими метриками сторінки, які можуть відрізнятися від очікуваних вами

Де обробка анотацій різниться між переглядачами

Анотації URI в PDF визначені в ISO 32000-1 §12.6.4.7, і кожен відповідний переглядач має їх дотримуватися. На практиці кілька поведінок різняться від переглядача до переглядача. Adobe Acrobat показує запит безпеки при першому клацанні для URL, яких немає в списку довірених доменів; багато браузерів і легких читалок цього не роблять. Деякі корпоративні переглядачі PDF у замкнених середовищах вимикають анотації URI цілком за політикою, тож клацання не робить нічого й без видимої помилки. Мобільні застосунки для PDF різняться тим, чи відкривають вони посилання у власному вебперегляді, чи передають системному браузеру

Жодна з цих речей не є вадою, яку ви можете виправити з боку генерації; це рішення політики переглядача. Що ви можете зробити - це писати мітки посилань так, щоб URL був видимий і в тілі документа, тож читач у обмеженому середовищі все одно зможе скопіювати адресу вручну. Анотація є зручністю; текст є запасним варіантом

Ще одна деталь, яку варто знати: анотації URI в PDF типово не несуть жодного візуального підкреслення. Підкреслення, яке ви бачите в більшості переглядачів, малює сам переглядач на основі типу анотації, а не гліф у потоці вмісту. Якщо вам потрібне фізичне підкреслення, яке переживе друк у неінтерактивний рендерер чи конвертацію PDF у зображення, малюйте його явно через LineTo та Stroke на відповідному зсуві Y під базовою лінією тексту. Це окрема операція малювання, а не те, чим опікується PrintHyperlink за вас

Показаний тут API гіперпосилань є частиною HotPDF Delphi Component для Delphi та C++Builder