Semantic and Spatial Layouts Can't Share a Ruler: Page Snap with IntersectionObserver
TLDR: Our visual diff syncs two panes: PDF on the left, extracted HTML on the right. The first version used within-page ratio math: "if you're 50% down the PDF page, scroll the HTML pane to 50% down the matching page." It didn't work. PDF pages have fixed physical height. HTML pages have content-dependent semantic height. The two are not proportional. Fix: use IntersectionObserver and page-snap via the shared data-page anchor. Thirty lines, correct at every zoom level. This is part 2 of a series on silent math bugs in our PDF editor.
Repo: tools/pdf-processor
The setup
Our visual diff places two scrollable panes side by side. The left pane renders the original PDF using pdfjs-dist. Each page becomes a <div class="page-wrapper"> containing a <canvas>. The right pane contains the extracted HTML, where each PDF page maps to a <section data-page="N"> card.
The brief: when you scroll one pane, the other should follow.
The doomed first version
My first implementation used the obvious approach. Find which page the source pane is currently anchored to. Compute the proportional offset within that page. Apply the same proportion to the matching page in the other pane.
function readActivePage(container, selector) {
const items = container.querySelectorAll(selector);
const top = container.scrollTop;
for (const el of items) {
if (top < el.offsetTop + el.offsetHeight) {
return {
page: el.getAttribute('data-page'),
ratio: (top - el.offsetTop) / el.offsetHeight,
};
}
}
}
function applyToTarget(container, info, selector) { const el = container.querySelector(${selector}[data-page="${info.page}"]); container.scrollTop = el.offsetTop + info.ratio * el.offsetHeight; }
This passed the smoke test. I shipped it. The user reported it didn't work.
Two things were wrong, layered on top of each other.
Problem 1: The CSS zoom trap
The PDF zoom feature uses the CSS zoom property. We picked zoom over transform: scale() specifically because zoom causes real layout reflow. Scrollbars know about it. Container heights track it. That's the whole point.
But zoom interacts oddly with the JavaScript geometry properties. In Chromium, element.offsetTop and element.offsetHeight report unzoomed values for elements with zoom: 1.5. The element's layout box is still the original size from JavaScript's perspective. Meanwhile, container.scrollTop is in zoomed pixels, because that's what the scrollbar actually moves.
At zoom 1.5, if the user has scrolled 600 zoomed pixels into the container, the math says:
top = 600
el.offsetTop = 400 (page 2's unzoomed start)
el.offsetHeight = 1100 (page 2's unzoomed height)
ratio = (600 - 400) / 1100 = 0.18
The math claims we are 18 percent into page 2. The actual visual position is somewhere on page 1. The bracketing math points at the wrong page entirely.
You can fix this by switching the CSS to transform: scale() and writing your own scrollbar logic. We didn't want to. We chose zoom because it gave us correct reflow for free. The price is that direct DOM measurements become unreliable.
Problem 2: The semantic vs spatial gap
But the deeper problem was unrelated to zoom.
A PDF page is spatial. It is a physical artifact: 8.5 by 11 inches, every page roughly the same height. If page 1 of a PDF is 1100 pixels tall, every page 1 of every PDF is roughly 1100 pixels tall.
An HTML page produced by extraction is semantic. It is whatever the content of that page happens to be. A page with one heading and a paragraph is 200 pixels. A page with three tables and dense text is 2500 pixels. There is no relationship between the height of the HTML section and the height of the PDF page it came from.
The within-page ratio is fiction. "50 percent down the PDF page" does not correspond to "50 percent down the HTML page." A user scrolling through a paragraph in the PDF is not scrolling through the same paragraph at the same proportional rate in the HTML, because the HTML version of that paragraph is a wildly different size.
The fix: page-snap with IntersectionObserver
The fix throws out within-page math entirely.
function setupScrollSync() {
const left = document.getElementById('visual-diff-pdf');
const right = document.getElementById('visual-diff-html');
for (const o of _observers) o.observer.disconnect(); _observers = [];
const leftPages = left.querySelectorAll('.page-wrapper'); const rightPages = right.querySelectorAll('.pdf-page-content'); if (!leftPages.length || !rightPages.length) return;
let suppress = 0;
const scrollOtherTo = (page, targetPane, targetSelector) => { if (suppress > 0) { suppress--; return; } const el = targetPane.querySelector(${targetSelector}[data-page="${page}"]); if (!el) return; suppress = 1; el.scrollIntoView({ block: 'start', behavior: 'auto' }); };
_observers.push(createPaneObserver(left, leftPages, page => scrollOtherTo(page, right, '.pdf-page-content'))); _observers.push(createPaneObserver(right, rightPages, page => scrollOtherTo(page, left, '.page-wrapper'))); }
function createPaneObserver(pane, pages, onActivePageChange) { const ratios = new Map(); let activePage = null;
const observer = new IntersectionObserver(entries => { for (const e of entries) ratios.set(e.target, e.intersectionRatio);
let topEl = null, topRatio = -1; for (const [el, r] of ratios) { if (r > topRatio) { topRatio = r; topEl = el; } } if (!topEl) return;
const page = topEl.getAttribute('data-page'); if (page === activePage) return; activePage = page; onActivePageChange(page); }, { root: pane, threshold: [0, 0.25, 0.5, 0.75, 1] });
pages.forEach(p => observer.observe(p)); return { observer }; }
Why this works
Each pane gets an IntersectionObserver with root: pane. The observer's intersection logic is computed natively by the browser, in actual rendered coordinates. It does not care about CSS zoom. It does not care about offsetTop. It cares about whether the element is currently visible inside the scroll viewport, which is exactly the question we want answered.
When the most-visible page changes on one pane, the matching page on the other pane gets scrollIntoView({ block: 'start' }). There is no within-page math. Within-page scrolling is independent on both sides.
The suppress counter is a re-entrancy guard. When we programmatically scroll pane B, that scroll triggers pane B's observer, which would call scrollOtherTo on pane A and create a feedback loop. The counter blocks one cycle of bounce-back.
The result is correct at every zoom level. It works on every PDF. It is thirty lines.
The lesson
When two layouts represent the same logical content but use different physical metrics, sync them via the structural anchors they actually share, not the pixel positions they happen to have. In our case the shared anchor was data-page. In another tool it might be a heading ID, a section landmark, or an element role.
Next in the series: Three editable surfaces, one source of truth. The third bug, same shape.