A named destination is not a page number
TLDR
PDF internal links point at destinations, and a destination is rarely a page number. It is a name to look up, or an array whose first element is a page reference that must itself be resolved against the document. A resolver that handled only numbers returned null for the two common forms, and every such link was dropped. The fix is to accept all three forms and recurse through the name lookup.Bug class: treating an identifier as its value
The resolver was written against the simplest possible input: a page number, already resolved. The code checked for a number, returned it, and otherwise returned null. That is a classic mismatch between an identifier and the thing it names, and it fails on every document where the identifier is not the value.PDF destinations come in three forms. A number is a page index, ready to use. A named destination is a string keyed into the document's name tree. An array starts with a page reference, a raw object that the document must translate into a page index. Only the first form was handled.
// Before: only the already-resolved form worked
if (typeof dest === 'number') return dest;
return null; // named and array destinations silently dropped
// After: resolve the identifier, then recurse on the value if (typeof dest === 'string') { const resolved = await doc.getDestination(dest); return resolved ? resolveDest(doc, resolved) : null; }
Note the handle: doc, not page. The name tree belongs to the document, so getDestination and getPageIndex live on the document object and a page object has neither. Passing a page makes every lookup throw into the catch and return null, which looks exactly like a document with no internal links. That is the second half of this bug class: resolving an identifier against the wrong scope fails the same silent way as not resolving it at all.
Why the language and API produce it
The extraction library exposesgetDestination(name) but the annotations surface gives you the raw destination value before any resolution. So the code sees a string, a number, or an array, and each requires a different path. The easy path was the number. The other two require an async lookup and a recursion, which is exactly the shape of code that gets stubbed with a null return and called done.
The fix
Accept the string form by calling the destination lookup and recursing on whatever comes back. Accept the array form by resolving its first element, which may be a page reference carrying a page number directly, or a raw reference that needs the document's page index. Check both, and only then give up. Unresolvable destinations are still dropped, but only ones that are genuinely unresolvable.if (typeof dest === 'object') {
if (dest.pageNumber) return dest.pageNumber;
const ref = dest[0];
if (typeof ref === 'number') return ref;
if (ref.pageNumber) return ref.pageNumber;
if (ref.num != null) return (await pageIndex(ref)) + 1;
return null;
}
How to prevent the class
Every resolver worth keeping has a test that feeds all of its input forms, including the forms that were never handled. But write the stub against the real object, not against the call you wish existed. A stub shaped{ getDestination } passes whether you hand the resolver a document or a page, so it proves the recursion and proves nothing about the handle. The test that catches this is the one whose fake mirrors which object actually owns the method. The pattern generalizes: whenever an API hands you a value that might be a reference to a reference, enumerate the forms and test each one. The unhandled form is always the one the fixture data happened not to use.