Engineering Journal
Pdf Processor
Pdf Processor

A PDF link is geometry before it is markup

2026-08-20

TLDR

A PDF link is not stored as a text range, it is stored as a rectangle over the page. Any pipeline that rebuilds markup from glyph geometry will lose links unless it resolves them in the source coordinate space, against the text items, before any HTML exists. Doing the work after markup is rendered couples retention to a specific layout and breaks as soon as the layout changes.

The position

Link retention belongs in the geometry layer, not the markup layer. The extraction pipeline should produce anchors, not search for them.

What the industry does and why

Markup-based extractors handle links the easy way: the source format already has them. HTML, Markdown and DOCX all store a hyperlink as an element with a target attached to a text range. The parser reads the element, keeps the target, done. That works because the source format and the output format share the concept of an element. A text range maps to a text range.

Why it fails for this problem class

A PDF has no text ranges. The closest thing is a text layer, glyph positions that a renderer may or may not expose, and link annotations that reference rectangles. The PDF's own viewer draws the text and draws the link box over it, and the association between the two lives in the click handler, not in the file.

So the source and the output do not share an element model. An extraction pipeline rebuilds paragraphs from glyphs, reflows them, breaks lines where the original did not, and the resulting HTML has boxes that only roughly correspond to the source geometry. Searching the rendered output for "the element at this rectangle" is searching for a layout that is already a lossy approximation. On a reflowed line, a link that covered two words on the source page now covers one word on the rendered line, and the other word moved to the next line. The link follows neither.

The better approach

Resolve links to text items before rendering. In the source coordinate space, every text glyph has a position and every link has a rectangle. Hit-test the rectangles against the glyph boxes and record the exact items each link covers. That resolution is independent of how the text is later reflowed, because it is done in the space where both the text and the links actually live.

The rendered HTML then becomes a carrier, not a target. Covered text items render as anchors. Textless regions, figures and tables, carry the link on their wrapper. The structured document is built by walking the HTML and collects the link index from the anchors. Markup, index and exports all read one source of truth.

// Resolve in source space, before any markup exists
links.forEach(link => {
  link.itemIndices = textMeta
    .map((item, i) => i)
    .filter(i => intersects(link.rect, boxOf(textMeta[i]), 2));
});

What you give up

Resolution in source space costs a real text layer. On a scanned page with no text, there is nothing to attach an anchor to, and the link can only ride on the region wrapper as an attribute. That is a real limitation and it is honest: inventing text positions to hang anchors on would be fabricating structure.

When the common pattern is right

If your source format stores links as elements over text ranges, read them as elements. The element model is shared, the mapping is trivial, and doing geometry work there would be wasted effort. The geometry-first approach is only right when the source format hides its structure behind coordinates, which is the entire point of a layout format like PDF.
Read this post in the full Engineering Journal →