مقاله فنی

ابرپیوندهای HotPDF در Delphi: نکات annotation برای PrintHyperlink

ابرپیوندهای PDF در اصل annotation های URI هستند: یک مستطیل که بخشی از صفحه را می پوشاند و وقتی روی آن کلیک می شود به viewer می گوید یک URL را باز کند. annotation و متنی که زیر آن دیده می شود دو شیء کاملاً مستقل هستند. PrintHyperlink در HotPDF هر دو را در یک فراخوانی جمع می کند: متن را رسم می کند و مستطیل annotation را از روی metric های متن render شده محاسبه می کند. این راحتی جزئیاتی را پنهان می کند که بهتر است پیش از نوشتن کد production آن را بفهمید. و این همه داستان نیست: AddURILink یک ناحیه clickable را روی محتوایی که خودتان کشیده اید قرار می دهد و AddGoToLink ناوبری داخلی سند را انجام می دهد، که هر دو در ادامه پوشش داده شده اند

PrintHyperlink چگونه کار می کند

PrintHyperlink روی THPDFPage قرار دارد و چهار آرگومان می گیرد: مختصات X و Y بر حسب point با مبدأ پایین چپ و Y رو به بالا، رشته label ای که باید رسم شود، و مقصد URL. در داخل، تابع TextOut را با رنگ hyperlink فعلی صدا می زند و بلافاصله مستطیل annotation را با استفاده از TextWidth و TextHeight بر اساس metric های فونت فعلی محاسبه می کند. این یعنی فونت و اندازه باید پیش از فراخوانی تنظیم شده باشند، و میان رسم label و قرار دادن annotation هم نباید عوض شوند، چون هر دو در همان call resolve می شوند

رنگ پیش فرض clBlue است. SetRGBHyperlinkColor فقط رنگ فراخوانی های بعدی را عوض می کند و annotation هایی را که از قبل نوشته شده اند به صورت retroactive به روز نمی کند. اگر روی یک صفحه به رنگ های متفاوت برای گروه های مختلف لینک نیاز دارید، پیش از هر گروه SetRGBHyperlinkColor را صدا بزنید و بعد از آن reset کنید

در اینجا یک سند حداقلی را می بینید که سه لینک را با دو رنگ متفاوت می نویسد:

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 رو به بالا و بر حسب point یعنی 1/72 اینچ استفاده می کند. یک صفحه A4 برابر 595 در 842 point است؛ یک صفحه US Letter برابر 612 در 792 point است. مقدار Y=750 نزدیک بالای صفحه A4 می نشیند، و Y=50 نزدیک حاشیه پایینی قرار می گیرد. هر کسی که از گرافیک صفحه نمایش یا HTML می آید، به طور غریزی برعکس را فرض می کند و اولین خط لینک را مستقیم بیرون از ناحیه قابل مشاهده قرار می دهد

مستطیل annotation ای که PrintHyperlink محاسبه می کند از همین سیستم مختصات استفاده می کند. اگر بعداً صفحه را بچرخانید، مقیاس دهید، یا اندازه صفحه را بدون محاسبه دوباره X/Y تغییر دهید، متن قابل دیدن و مستطیل clickable از هم فاصله می گیرند. لینک به این معنا هنوز «کار می کند» که کلیک کردن جایی نزدیک متن URL را باز می کند، اما ناحیه حساس دیگر با چیزی که خواننده می بیند منطبق نیست. روی اندازه واقعی صفحه و سطح zoom ای که منتشر می کنید تست کنید، نه فقط روی ماشین توسعه با 100%

یک حالتی که این drift در آن قطعی است این است که PrintHyperlink را با مختصات مناسب صفحه A4 صدا بزنید و بعد بدون تنظیم X/Y به یک صفحه باریک سفارشی سوییچ کنید. در این وضعیت annotation می تواند کلاً بیرون صفحه تمام شود. شیء annotation هنوز داخل PDF نوشته می شود؛ بیشتر viewer ها آن را بی سر و صدا clip می کنند، بنابراین لینک بدون هیچ خطایی صرفاً ناپدید می شود

متن label در برابر URL مقصد

آرگومان های Text و Link مستقل از هم هستند. می توانید متن «Download invoice PDF» را رسم کنید در حالی که مقصد یک URL کامل HTTPS با query parameter است. این جداسازی عمدی است؛ label قابل مشاهده باید برای انسان خوانا باشد و URL می تواند طولانی یا پویا تولید شده باشد

مشکل زمانی شروع می شود که خود label همان URL خام باشد، به ویژه اگر طولانی باشد. اگر URL از نظر بصری روی دو خط بشکند اما مستطیل annotation برای یک رشته تک خطی محاسبه شده باشد، فقط خط اول clickable خواهد بود. PrintHyperlink جریان چندخطی را handle نمی کند؛ label را آن قدر کوتاه نگه دارید که با فونت و عرض صفحه فعلی در یک خط جا شود، از یک label کوتاه و توصیفی استفاده کنید و URL کامل را به عنوان مقصد نگه دارید، یا از راه حل خط به خطی که در بخش بعدی آمده استفاده کنید

برای سندهایی که قرار است بایگانی شوند یا بدون اتصال فعال اینترنت توزیع شوند، در نظر بگیرید آیا خود URL باید جایی در بدنه سند به شکل چاپی هم ظاهر شود، نه فقط به صورت metadata در annotation. خواننده ای که PDF را روی کاغذ چاپ می کند از annotation URI هیچ بهره ای نمی برد

راه حل محدودیت چندخطی

وقتی label لینک واقعاً باید بیش از یک خط را بپوشاند، مثلاً یک URL بلند که باید عیناً چاپ شود یا جمله ای که باید در تمام طول چند خط clickable باشد، راه حل این است که آن را یک لینک واحد در نظر نگیرید، بلکه یک لینک برای هر خط بسازید. هر فراخوانی PrintHyperlink مستطیل خود را از روی متنی که می کشد محاسبه می کند، بنابراین چند فراخوانی که همگی یک مقصد Link مشترک دارند چند annotation با اندازه درست می سازند که همه یک 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');

تقسیم کردن رشته بر عهده شماست: آن را در همان نقطه هایی بشکنید که با عرض ستون و فونت فعلی به صورت بصری wrap می شود و از TextWidth برای آزمودن هر خط پیشنهادی استفاده کنید. راه دیگر این است که متن wrap شده را خودتان با TextOut های معمولی رسم کنید و بعد یک مستطیل AddURILink روی هر خط بگذارید. این راه زمانی بهتر است که متن از قبل توسط منطق word-wrap خودتان تولید شده باشد، و این ما را به همان تابع می رساند

AddURILink: ناحیه clickable روی هر چیزی که خودتان کشیده اید

PrintHyperlink یک wrapper راحتی است: label خودش را می کشد و مستطیل را از روی metric های همان label استخراج می کند. AddURILink نیمه سطح پایین تر ماجرا را مستقیماً در اختیار شما می گذارد:

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

این تابع فقط annotation را می نویسد، هیچ متنی رسم نمی شود و هیچ رنگی هم عوض نمی شود. Rectangle در همان فضای مختصاتی تفسیر می شود که فراخوانی های رسم شما در آن کار می کنند، بنابراین می توانید دقیقاً همان X/Y هایی را که به TextOut یا یک فراخوانی تصویر داده بودید دوباره استفاده کنید. همین ویژگی آن را به ابزار درست در هر وضعیتی تبدیل می کند که محتوای visible از قبل وجود دارد: hotspot روی یک تصویر، یک سلول جدول، بخشی از متنی که قبلاً کشیده شده، یا یکی از خط های wrap شده در راه حل بخش قبل. annotation یک border با عرض صفر دارد، بنابراین از نظر بصری هیچ چیز تغییر نمی کند؛ ناحیه clickable دقیقاً همان مستطیلی است که شما مشخص می کنید

این تابع annotation dictionary را به شکل THPDFDictionaryObject برمی گرداند. بیشتر caller ها نتیجه را دور می اندازند، اما اگر آن را نگه دارید می توانید پیش از نوشته شدن سند entry های annotation را تغییر دهید

دو جزئیات مربوط به انطباق از قبل در آن ساخته شده اند. در حالت های PDF/A، print flagِ annotation مطابق نیاز استانداردها تنظیم می شود. در حالت PDFUACompliance، پارامتر Description باید یک رشته غیرخالی باشد، زیرا به entryِ /Contents annotation تبدیل می شود و همین چیزی است که فناوری های کمکی برای لینک می خوانند. اگر این مقدار را ندهید، فراخوانی به جای تولید خاموش یک فایل نامنطبق exception پرتاب می کند. PrintHyperlink پیش از این قاعده طراحی شده و هیچ description ای متصل نمی کند، بنابراین برای خروجی PDF/UA بهتر است label را با TextOut بکشید و annotation را با AddURILink و یک description معنادار قرار دهید

قاعده تصمیم گیری ساده است: وقتی لینک یک قطعه متن کوتاه است که هنوز آن را نکشیده اید از PrintHyperlink استفاده کنید؛ وقتی ناحیه clickable توسط محتوایی تعریف می شود که خودتان آن را می کشید یا اندازه می گیرید از AddURILink استفاده کنید

ناوبری داخلی با AddGoToLink

URL های خارجی فقط نیمی از کاربرد annotation های لینک هستند. نیمه دیگر ناوبری داخل خود سند است، مثل فهرست مطالبی که به فصل ها می پرد یا cross-reference هایی میان بخش ها. HotPDF این قابلیت را از طریق AddGoToLink در اختیار می گذارد:

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

سه معنا را باید دقیق بگوییم چون از روی signature حدس زدنی نیستند. TargetPageIndex از صفر شروع می شود: نخستین صفحه سند صفحه 0 است و با CurrentPageNumber هم سازگار است. صفحه مقصد باید هنگام فراخوانی از قبل وجود داشته باشد؛ اگر index بیرون از بازه باشد، procedure بدون افزودن annotation برمی گردد، نه exception، نه warning، نه لینک. برای فهرست مطالبی که به جلو اشاره می کند، اول همه صفحه ها را بسازید و بعد برگردید و لینک ها را اضافه کنید

YPos موقعیت عمودی را روی صفحه مقصد تعیین می کند، آن هم در همان سیستم مختصاتی که برای رسم استفاده می کنید. مقدار پیش فرض -1 یعنی هر مقدار منفی، یک مقصد با مختصات عمودی null می نویسد، یعنی viewer در هنگام رسیدن به صفحه مقصد جای عمودی فعلی خود را حفظ می کند. اگر مقدار غیرمنفی بدهید، viewer آن قدر scroll می کند که آن موقعیت در بالای پنجره قرار بگیرد؛ از همان مختصات Y تیتر هدف استفاده کنید. مقدار zoom همیشه بدون تغییر می ماند. همانند AddURILink، در حالت PDFUACompliance پارامتر Description باید غیرخالی باشد و متن جایگزین لینک می شود

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;

برای هر entry یک مستطیل کمی عریض تر از خود متن تعیین شده است تا کل سطر به pointer پاسخ دهد، و هر لینک طوری فرود می آید که تیتر فصل، که در Y=780 رسم شده، در بالای پنجره دیده شود. اگر بعداً صفحه ای پیش از فصل ها insert کنید، همه TargetPageIndex ها یک واحد جابه جا می شوند؛ بهتر است index ها را از روی حلقه ساخت صفحه محاسبه کنید نه اینکه آن ها را hard-code کنید

یک مثال کامل تولید سند

الگوی زیر سناریویی واقعی تر را نشان می دهد: تولید یک گزارش کوتاه با بخش header، متن بدنه و یک ردیف لینک در footer، همه با کد و نه با یک فرم دارای 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 آن را تنظیم کنید، مستطیل annotation بر اساس metric های پیش فرض همان صفحه محاسبه می شود که ممکن است با چیزی که انتظار دارید فرق داشته باشد

رفتار annotation در viewer های مختلف چگونه فرق می کند

annotation های URI در PDF در ISO 32000-1 §12.6.4.7 تعریف شده اند و هر viewer سازگار باید از آن ها پیروی کند. در عمل اما چند رفتار بسته به viewer متفاوت است. Adobe Acrobat هنگام نخستین کلیک روی URL هایی که در فهرست trusted domain نیستند یک پیام امنیتی نشان می دهد؛ بسیاری از browser ها و reader های سبک چنین کاری نمی کنند. بعضی viewer های سازمانی PDF در محیط های قفل شده annotation های URI را به طور کامل با policy غیرفعال می کنند، بنابراین کلیک هیچ کاری نمی کند و هیچ خطای قابل مشاهده ای هم وجود ندارد. اپلیکیشن های PDF روی موبایل هم در این که لینک را داخل web view خود باز کنند یا آن را به browser سیستم بسپارند با هم فرق دارند

هیچ کدام از این ها bug هایی نیستند که بتوانید از سمت تولید فایل اصلاحشان کنید؛ این ها تصمیم های policy خود viewer هستند. کاری که می توانید انجام دهید این است که label های لینک را طوری بنویسید که خود URL هم در بدنه سند دیده شود تا خواننده در محیط محدودشده همچنان بتواند نشانی را دستی کپی کند. annotation ابزار راحتی است؛ متن fallback واقعی است

یک جزئیات دیگر هم بد نیست بدانید: annotation های URI در PDF به طور پیش فرض هیچ underline دیداری را حمل نمی کنند. underline ای که در بیشتر viewer ها می بینید خود viewer بر اساس نوع annotation رسم می کند، نه اینکه به صورت glyph در content stream وجود داشته باشد. اگر underline فیزیکی ای می خواهید که در renderer های غیرتعاملی یا تبدیل PDF به تصویر هم باقی بماند، آن را باید با LineTo و Stroke در offset مناسب Y زیر baseline متن خودتان بکشید. این یک عمل رسم جداگانه است، نه چیزی که PrintHyperlink برای شما انجام دهد

API مربوط به hyperlink که اینجا نشان داده شد بخشی از HotPDF Component برای Delphi و C++Builder است