A PDF link is geometry before it is markup
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));
});