Tutorial path · Previous: navigation · Next: images

Goal and prerequisites

Create two clickable areas on a page: one opens the GitHub repository, the other displays a second page. This tutorial composes classes derived from specification tables to show how to use a feature without a specialized domain facade.

Read pages and text and destinations, then install dependencies with composer install. The script examples/links.php runs independently on PHP 8.3 or later with the extensions required by Composer.

Run and inspect

From the root of a core checkout:

php examples/links.php
php examples/links.php links-demo.pdf

The first call produces examples/links.pdf. The first page displays two blue text strings; the second leads to the page “Vous avez suivi le lien interne”. The GitHub link offers to open an external address, according to the reader's settings.

Preview of the page with two links

The image helps locate the text. Test links in the original PDF with an interactive reader: a page image does not carry annotations.

Drawing and interaction

ShowText produces visible glyphs. Neither their color nor their content creates a link. Interaction belongs to another object: an annotation with /Subtype /Link, referenced in the page's /Annots array.

Its /Rect is the active area, in page coordinates. The four numbers give two opposite corners, lower x, lower y, upper x, upper y, whereas the graphics operator re uses x, y, width, height. The script places text inside these areas and uses /Border [0 0 0] to suppress the border drawn by the reader.

Read the construction

  1. Create the target page. The script first builds the second PageObject. The internal link targets this instance; final order is still determined by calls to addKid().
  2. Describe the external action. URIActionTable206 contains SEntry(new NameType('URI')) and URIEntry(new LiteralString(...)). /S is the action type; /URI contains its ASCII address. The annotation references this dictionary through its /A entry.
  3. Describe the internal destination. The second annotation receives DestEntry(new Fit($targetPage)). There is no /A action here: /Dest is an explicit destination. The specification forbids adding /Dest when /A is present on the same link; the script follows that composition rule.
  4. Compose common entries. TypeEntry, RectEntry, BorderEntry and ContentsEntry come from Table 164. SubtypeEntry, AEntry and DestEntry come from Table 173. LinkAnnotationTable173 composes the common Table 164 definition, including its required entries.
  5. Attach annotations. AnnotsEntry belongs to Table 30, which describes the page. Its ArrayType contains IndirectReference instances to both annotations. Each annotation therefore retains its own PDF object identity.
  6. Assemble the page tree. The first PageObject receives /Annots before attachment. The page containing the links is added first, followed by the target, to the same PageTreeNode.

ContentsEntry(new TextString(...)) supplies a text description of each annotation. This description helps interpret the object; by itself, it does not constitute a complete PDF accessibility structure.

Why these class names?

The names LinkAnnotationTable173 and URIActionTable206 retain the connection to ISO tables. Their entries and definitions are PHP objects, and known constraints are checked during construction or serialization. The number distinguishes tables with similar titles and makes their source easier to find.

Definitions also expose conditional rules in their descriptions. They do not automatically guarantee every possible relationship between entries. Here, the code deliberately chooses only one of the two targets /A and /Dest, supplies the correct names /Link and /URI, and uses indirect references in /Annots.

Mapping to ISO 32000-1:2008

Object or entry Reference
Page /Annots §7.7.3.3, Table 30
Common annotation entries §12.5.2, Table 164
/Link annotation §12.5.6.5, Table 173
Explicit /Fit destination §12.3.2.2, Table 151
/URI action §12.6.4.7, Table 206

See the specification preserved in the core and the definitions guide.

Variations and limitations

To target a named destination, replace Fit with a LiteralString containing a key published in /Names /Dests, as in the navigation tutorial. To move a link, change both text position and annotation rectangle: their association is a layout decision in this script.

The active area is rectangular; it is not calculated from font metrics. The visible text's ASCII values avoid encoding issues with standard fonts here. There is no automatic line wrapping or custom annotation appearance. The reader may request confirmation before following an external link.

Back to the tutorial path · Continue: images

View the PDF result

This file was generated with the code in this documentation. All pages appear below; open the PDF to use its links and bookmarks.

PDF result links: page 1 of 2
Page 1 of 2
PDF result links: page 2 of 2
Page 2 of 2
Use the browser PDF viewer

This viewer depends on your browser. If the document does not appear, use “Open the PDF” or “Download”.

Complete source code

Code from roukmoute/pdf · View the source at the documented commit ↗

Read the commented script: links.php
<?php

declare(strict_types=1);

use PDF\Document\Destinations\Fit;
use PDF\Document\Fonts\StandardFont;
use PDF\Document\Fonts\Type1Font;
use PDF\Document\ResourceDictionary;
use PDF\Document\Resources\Font;
use PDF\FileStructure;
use PDF\Specification\Dictionary\AnnotationDictionaryTable164\BorderEntry;
use PDF\Specification\Dictionary\AnnotationDictionaryTable164\ContentsEntry;
use PDF\Specification\Dictionary\AnnotationDictionaryTable164\RectEntry;
use PDF\Specification\Dictionary\AnnotationDictionaryTable164\TypeEntry;
use PDF\Specification\Dictionary\LinkAnnotationTable173;
use PDF\Specification\Dictionary\LinkAnnotationTable173\AEntry;
use PDF\Specification\Dictionary\LinkAnnotationTable173\DestEntry;
use PDF\Specification\Dictionary\LinkAnnotationTable173\SubtypeEntry;
use PDF\Specification\Dictionary\PageObjectTable30\AnnotsEntry;
use PDF\Specification\Dictionary\URIActionTable206;
use PDF\Specification\Dictionary\URIActionTable206\SEntry;
use PDF\Specification\Dictionary\URIActionTable206\URIEntry;
use PDF\Syntax\ContentStreamsResources\ContentStreams;
use PDF\Syntax\ContentStreamsResources\Operators\BeginTextObject;
use PDF\Syntax\ContentStreamsResources\Operators\EndTextObject;
use PDF\Syntax\ContentStreamsResources\Operators\MoveTextPosition;
use PDF\Syntax\ContentStreamsResources\Operators\SetNonstrokingRGBColor;
use PDF\Syntax\ContentStreamsResources\Operators\SetTextFontAndSize;
use PDF\Syntax\ContentStreamsResources\Operators\ShowText;
use PDF\Syntax\DataStructure\Rectangle;
use PDF\Syntax\DataStructure\TextString;
use PDF\Syntax\DocumentStructure\DocumentCatalog;
use PDF\Syntax\DocumentStructure\PageTree\PageObject;
use PDF\Syntax\DocumentStructure\PageTree\PageTreeNode;
use PDF\Syntax\FileStructure\FileBody;
use PDF\Syntax\FileStructure\FileHeader;
use PDF\Syntax\Object\ArrayType;
use PDF\Syntax\Object\DictionaryObject\Entry;
use PDF\Syntax\Object\DictionaryType;
use PDF\Syntax\Object\IndirectReference;
use PDF\Syntax\Object\NameType;
use PDF\Syntax\Object\NumericType;
use PDF\Syntax\Object\StreamObject;
use PDF\Syntax\Object\StringObject\LiteralString;

require dirname(__DIR__) . '/vendor/autoload.php';

// 1. Blue text is only drawing. Making it clickable requires
// a /Link annotation in /Annots with its rectangle and target.
$font = new Type1Font(StandardFont::Helvetica);
$resources = new ResourceDictionary(new Font(new DictionaryType(
    new Entry(new NameType('F1'), new IndirectReference($font)),
)));
$mediaBox = new Rectangle(new NumericType(0), new NumericType(0), new NumericType(595), new NumericType(842));

// 2. Building the target page first lets the internal link target this
// instance. Its position is determined later by addKid() order.
$targetPage = new PageObject($mediaBox, $resources, new StreamObject(new ContentStreams(
    new BeginTextObject(),
    new SetTextFontAndSize(new NameType('F1'), new NumericType(22)),
    new MoveTextPosition(new NumericType(54), new NumericType(770)),
    new ShowText(new LiteralString('Vous avez suivi le lien interne.')),
    new SetTextFontAndSize(new NameType('F1'), new NumericType(12)),
    new MoveTextPosition(new NumericType(0), new NumericType(-40)),
    new ShowText(new LiteralString('La destination /Fit ajuste cette seconde page dans la fenetre.')),
    new EndTextObject(),
)));

// 3. A URI action describes external navigation: /S /URI selects its
// type; /URI contains the ASCII address. Table206 connects the class
// to its ISO definition. It is not a convenience API.
$externalAction = new URIActionTable206(
    new SEntry(new NameType('URI')),
    new URIEntry(new LiteralString('https://github.com/roukmoute/pdf')),
);
$externalLink = new LinkAnnotationTable173(
    new TypeEntry(new NameType('Annot')),
    new SubtypeEntry(new NameType('Link')),
    new RectEntry(new Rectangle(new NumericType(54), new NumericType(671), new NumericType(380), new NumericType(693))),
    new BorderEntry(new ArrayType(new NumericType(0), new NumericType(0), new NumericType(0))),
    new ContentsEntry(new TextString('Ouvrir le dépôt GitHub du projet')),
    new AEntry($externalAction),
);

// 4. The internal link has /Dest instead of /A. Do not supply both
// for one annotation. Fit produces [page-reference /Fit].
$internalLink = new LinkAnnotationTable173(
    new TypeEntry(new NameType('Annot')),
    new SubtypeEntry(new NameType('Link')),
    new RectEntry(new Rectangle(new NumericType(54), new NumericType(611), new NumericType(380), new NumericType(633))),
    new BorderEntry(new ArrayType(new NumericType(0), new NumericType(0), new NumericType(0))),
    new ContentsEntry(new TextString('Aller à la deuxième page')),
    new DestEntry(new Fit($targetPage)),
);

// 5. /Rect uses page coordinates with the origin at the lower left.
// Text is positioned at y=678 and y=618 inside these rectangles.
// /Border [0 0 0] suppresses the annotation's automatic border.
$contents = new StreamObject(new ContentStreams(
    new BeginTextObject(),
    new SetTextFontAndSize(new NameType('F1'), new NumericType(22)),
    new MoveTextPosition(new NumericType(54), new NumericType(770)),
    new ShowText(new LiteralString('Deux liens, deux mecanismes PDF')),
    new SetTextFontAndSize(new NameType('F1'), new NumericType(12)),
    new MoveTextPosition(new NumericType(0), new NumericType(-40)),
    new ShowText(new LiteralString('Cliquez sur les textes bleus dans un lecteur PDF interactif.')),
    new SetNonstrokingRGBColor(new NumericType(0.1), new NumericType(0.3), new NumericType(0.75)),
    new SetTextFontAndSize(new NameType('F1'), new NumericType(15)),
    new MoveTextPosition(new NumericType(0), new NumericType(-52)),
    new ShowText(new LiteralString('Ouvrir le depot GitHub du projet')),
    new MoveTextPosition(new NumericType(0), new NumericType(-60)),
    new ShowText(new LiteralString('Aller a la deuxieme page')),
    new EndTextObject(),
));

// 6. Each annotation is referenced indirectly by the /Annots array.
// The LinkAnnotation schema also composes common Table164 entries.
// The page must receive its entries before attachment to the tree.
$firstPage = new PageObject($mediaBox, $resources, $contents, new AnnotsEntry(new ArrayType(
    new IndirectReference($externalLink),
    new IndirectReference($internalLink),
)));
$pages = (new PageTreeNode())->addKid($firstPage)->addKid($targetPage);
$catalog = new DocumentCatalog($pages);
$file = new FileStructure(new FileHeader('1.7'), new FileBody($catalog), new IndirectReference($catalog));
$path = $argv[1] ?? __DIR__ . '/links.pdf';
$file->writeTo($path);
echo $path . PHP_EOL;

Guide source at the documented commit ↗