The coordinate flip that ate a document's links
TLDR
PDF link annotations report rectangles in bottom-left user space, where y grows upward. The extraction pipeline's layout model is top-left, where y grows downward. Skipping the flip is not a cosmetic mistake, it is a silent data loss: every link box lands on the wrong band of the page and covers no text, so the links are dropped one by one. The fix was to transform opposite corners through the same matrix the text used, and to pin the mapping with a test that would fail the day the flip regressed.The assumption that seemed reasonable
A link annotation looked like a plain box with x, y, width and height. The pipeline already had the page dimensions. Converting a bottom-left box to top-left looked like a one-line subtraction, and the first version did exactly that: measure the box, subtract the y from the page height, done.The assumption underneath was that the box was axis-aligned in both spaces. On an unrotated page it is, and the subtraction works. The pipeline's own text geometry had been mapped with a proper matrix transform, corner by corner, precisely because pages rotate. The link boxes were mapped with arithmetic instead, and the two systems drifted.
When it failed
The failure was invisible at first. Links simply did not appear in the extracted document, and a document with no links is not obviously wrong. Nobody reports a missing hyperlink until they need one, and by then the extraction is in a saved file.When a test was added that fed a synthetic page with a known link rectangle and asserted the anchor appeared in the markup, it passed. The rectangle was carefully placed in the middle of the page, where the subtraction happened to be correct. The same test with the rectangle near the bottom edge failed, because the subtraction assumed the box's top edge was the page top. On a rotated page the error was worse than inverted: the box ended up on its side.
What was actually wrong
The real defect was treating the mapping as an arithmetic detail instead of a geometric transform. A rectangle in one space is only meaningful after it has been moved through the same transformation that every other piece of geometry on the page went through. The pipeline had a helper for exactly that, built for the text and figure boxes, and the link code did not use it.There was a second, quieter error buried in the same code. A link that overlaps a figure's caption can cover text items, but the figure region does not render those items as text, it renders them as an image label layer. The coverage logic looked at the link's item list, saw that every item was covered, and decided the link was handled. It was not, because nothing on the rendered page was clickable. The logic was checking whether the link was covered by the region, not whether the region actually rendered the covered items.
What got deleted
The manual y subtraction went away, replaced by a call into the shared corner-transform helper. The second defect was fixed by changing the rule, not the math: text-rendering regions skip links whose items are all inside their own scope, while image and table regions carry every link that touches them, because they never render inline anchors.What replaced it
Link rectangles are now transformed as two opposite corners through the page's viewport matrix, then renormalized. The same helper maps text glyphs, figure boxes and link boxes, so all three live in one space and can be compared directly. Coverage is computed by rectangle intersection against the text item boxes, with a small slack so a box that barely grazes a word still catches it.// Both corners through the real matrix, then renormalized
const [x1, y1] = toViewport(transform, x, y);
const [x2, y2] = toViewport(transform, x + w, y + h);
return {
x: Math.min(x1, x2),
y: Math.min(y1, y2),
w: Math.abs(x2 - x1),
h: Math.abs(y2 - y1),
};
The generalizable lesson
Any two coordinate spaces meet in code, and every value that crosses the boundary must cross it the same way. The moment one piece of geometry is converted with arithmetic and another with a transform, they stop being comparable and the failure is silent, because nothing compares them until the user tries to click something. Pin the boundary with a test that computes an exact expected box for a known rectangle. A coordinate flip is a one-line mistake that survives code review, and it is the cheapest thing to test.The second lesson is about coverage checks. Asking "does this structure cover this content" is not the same as asking "does this structure render this content". The check must be against the render path, not the data model.