Keeping links alive when a pipeline rebuilds your markup
TLDR
Extraction pipelines rebuild a PDF's markup from glyph geometry, so the links a document carries are not attached to any text element: they are rectangles on the page. Retaining them means treating link retention as a geometry problem first, resolving every link to the text it covers, and only then emitting markup. The rendered HTML is the carrier: anchors in the markup, a link index in the structured document, and exporters read both.Problem class: links arrive as rectangles, not tags
A PDF link is an annotation with a bounding box and an action. It says: this region of the page, when clicked, goes to a URL or to another page in the document. It says nothing about which words are inside the box. A paragraph renderer that walks glyphs and emits text has no notion of an annotation layer, so its output simply drops the links. The document reads fine, the structure is preserved, and every hyperlink silently disappears.The naive approach is to look for links after the markup exists, scanning the DOM for an element whose box happens to overlap the annotation. That couples markup shape to geometry and fails on the first real document, because the markup is a reflowed approximation of the geometry, never a 1:1 copy.
Better model: resolve links to text items, then render
The extraction pipeline has one authoritative space where links and text can meet: the page coordinate system, transformed into the same viewport space the text glyphs were classified in. Resolve every annotation into that space, hit-test it against the text items, and record exactly which items each link covers. That mapping is the whole feature. The markup step then just reads it.// Each link carries the text items it covers, by index
function associateLinks(links, textMeta) {
return links.map(link => {
const indices = [];
for (let i = 0; i < textMeta.length; i++) {
const item = textMeta[i];
if (!item.str || !item.str.trim()) continue;
const box = { x: item.vx, y: item.vy, w: item.vWidth, h: item.vFont };
if (intersects(link.rect, box, 2)) indices.push(i);
}
return { ...link, itemIndices: indices };
});
}
Two coordinate systems collide here and both must be handled or every link lands in the wrong place. PDF user space is bottom-left with y increasing upward. Viewport space is top-left. The mapping must transform opposite corners through the same matrix used for the text, not scale width and height, because rotated pages make a scale-only mapping rotate every link box onto its side.
Three kinds of coverage, three render paths
A link that covers a paragraph, heading or list item becomes an anchor tag around the covered text. A link that covers a figure or a table, which render from pixel crops and grids rather than text runs, cannot wrap any text, so the link rides as a data attribute on the region wrapper. A link that covers both a figure and its caption does both. The distinction is made by asking whether the covered region renders its text through the run renderer, not by guessing from the link's shape.// Textless links surface on the region wrapper instead
const regionLinkAttr = (region, links) => {
const hrefs = links
.filter(l => intersects(l.rect, region.bbox))
.filter(l => !regionRendersText(region.type) || !coveredHere(l, region))
.map(l => l.href);
return hrefs.length ? data-link="${hrefs.join(',')}" : '';
};
Internal links point at other pages. A destination is not a page number, it is a name or an array that has to be resolved against the document, and the resolution is asynchronous. The resolver accepts a page number, a named destination, or an already-resolved array, and the page anchor the link points at is emitted as an id on the target page's section. A destination that cannot resolve is dropped, because a link that points nowhere is worse than no link.
Sanitize at the source
Every URL from a PDF is attacker-controlled input. A document can carry a link that readsjavascript:alert(1). The sanitizer runs at extraction time, before the value is ever written into markup, and it drops dangerous schemes and overlong URLs. The rendered HTML goes through the markup sanitizer as well, but defense at the source is what keeps a crafted PDF from ever putting an executable scheme in front of the sanitizer.
function sanitizeLinkUrl(url) {
if (typeof url !== 'string' || !url.trim()) return null;
const trimmed = url.trim();
if (trimmed.length > 2048) return null;
if (/^(javascript|vbscript|data|file):/i.test(trimmed)) return null;
return trimmed;
}
The HTML is the structured document's source of truth
Rather than threading the link list through every exporter, the rebuilt HTML carries the links as anchor tags, and the structured document is built by walking that HTML. One walk captures every anchor, its href, the page it lives on and its text, and produces the document's link index. The exporters never need geometry again: markdown getstext, JSON gets the index, the HTML export gets the anchors for free. A link index in the structured document and a list in the nav panel both read the same walk.
Implementation evidence
A regression suite pins the whole path with deterministic synthetic pages. It asserts that a covered text item becomes an anchor with provenance attributes, that a textless figure link lands on the region wrapper, that two different hrefs on one line never merge into one tag, and that ajavascript: URL never reaches the markup. It also pins the coordinate mapping: a bottom-left rectangle on a known page maps to an exact top-left box, so a future flip is caught the day it is introduced. A second suite covers the secondary path where the document was extracted by a server-side model, asserting the same anchors appear from its link metadata.
Tradeoffs
The resolution-to-text approach assumes the page has a text layer. On a scanned page with no text, there is nothing to attach an anchor to, and the link can only ride as a data attribute on whatever region it covers. That is accepted: the alternative is inventing text positions that do not exist.Links inside table cells and links over display math that renders through its own renderer are the two known gaps. Both are recorded in the link index and on the region wrapper, but neither gets an inline anchor. If real documents make either case common, each is a local fix in one render path.