From 24b726039c9db7f660bc66ec97a0e284f43d9ba4 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Tue, 25 Aug 2026 22:33:30 +0200 Subject: [PATCH 1/2] fix(html): read a zoomed view's rects in the space its pointer events use `getBoundingClientRect` carries an applied `body{zoom}` on chromium since 128 and not on webkit, while `elementFromPoint` takes viewport coordinates on both. `anchor()` and `restore()` mixed the two, so a pinch's focus moved by the zoom's worth of its distance from the top of the screen on ios - measured at 301px of document for a focus 400px down at 0.5 -> 0.8. The unfocused anchor at `y = 1` was already right: the settling loop converges there whatever space it computes in, which is why nothing had shown up. `rectFactor()` detects how much of the zoom the engine reports, so the correction is `1` where the rects already carry it. `getViewportRect` puts the same conversion in reach of a host hit-testing its way back to an element. Separately, a view whose zoom does not follow the viewport dropped the reading position on a resize: `resized()` assigned `width` before delegating, so `remember()` took the assignment branch and overwrote the held anchor with one read after the browser had relaid out and scrolled. It now settles on the anchor it held, like the fitted branch does. Refs #726 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HZ7jMy9qsh5CofYxiAncUG --- CHANGELOG.md | 7 + src/odr/internal/html/frontend.cpp | 78 +++++++++-- src/odr/internal/html/frontend.hpp | 5 +- test/browser/viewport/.gitignore | 1 + test/browser/viewport/README.md | 28 ++++ test/browser/viewport/page.html | 122 +++++++++++++++++ test/browser/viewport/serve | 36 +++++ test/browser/viewport/tests.html | 209 +++++++++++++++++++++++++++++ 8 files changed, 475 insertions(+), 11 deletions(-) create mode 100644 test/browser/viewport/.gitignore create mode 100644 test/browser/viewport/README.md create mode 100644 test/browser/viewport/page.html create mode 100755 test/browser/viewport/serve create mode 100644 test/browser/viewport/tests.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 48a3fa860..09796f2b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,13 @@ The release run heads these entries with the version and opens a fresh - `psd`, `jp2`, `wmf` and `emf` no longer declare `translate_html` — no browser paints them, and `html::translate` throws `UnsupportedFileType` instead of writing a blank page. They are still detected and still open. +- `odr.setZoom(value, focus)` holds the point the pinch is centred on. Webkit + does not carry an applied `body{zoom}` in `getBoundingClientRect`, so the + focus moved with the zoom. +- New `odr.getViewportRect(element)`: the element's box in the coordinates + `elementFromPoint` takes, for a host hit-testing while a zoom is applied. +- A view whose zoom does not follow the viewport — `viewport_width`, + `initial_zoom`, a sheet — keeps the reader's place across a width change. ## v6.10.1 - 2026-08-21 diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index c411516b2..09fef10e8 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -392,6 +392,44 @@ constexpr std::string_view viewport_js = R"js( return content > available ? available / content : 1; } + // Whether `getBoundingClientRect` carries the zoom applied to the body; + // webkit does not. Only decidable while a zoom is applied. + var rectsZoomed = null; + + // Rect coordinates times this are viewport coordinates. + function rectFactor() { + var zoom = parseFloat(getComputedStyle(body).zoom); + if (!isFinite(zoom) || zoom <= 0 || zoom === 1) { + return 1; + } + if (rectsZoomed === null) { + var probe = document.createElement("div"); + probe.style.cssText = + "position:absolute;top:0;left:0;width:100px;height:100px;" + + "box-sizing:content-box;margin:0;padding:0;border:0"; + body.appendChild(probe); + var measured = probe.getBoundingClientRect().width; + body.removeChild(probe); + if (!measured) { + return 1; + } + rectsZoomed = Math.abs(measured - 100 * zoom) < Math.abs(measured - 100); + } + return rectsZoomed ? 1 : zoom; + } + + // @p element's box in viewport coordinates. + function boxOf(element) { + var box = element.getBoundingClientRect(); + var factor = rectFactor(); + return { + left: box.left * factor, + top: box.top * factor, + width: box.width * factor, + height: box.height * factor, + }; + } + // The element under @p point, and how far into it that point sits - a // fraction of the scroll height cannot stand in, the height scales too. Only // a given point pins x; the page column centres itself. @@ -402,7 +440,7 @@ constexpr std::string_view viewport_js = R"js( if (!element) { return null; } - var box = element.getBoundingClientRect(); + var box = boxOf(element); return { element: element, x: point ? x : null, @@ -439,7 +477,7 @@ constexpr std::string_view viewport_js = R"js( if (!target || !target.element.isConnected) { return; } - var box = target.element.getBoundingClientRect(); + var box = boxOf(target.element); var deltaY = box.top + target.intoY * box.height - target.y; var deltaX = target.x === null ? 0 : box.left + target.intoX * box.width - target.x; @@ -456,11 +494,7 @@ constexpr std::string_view viewport_js = R"js( // The browser applies a scroll offset of its own a few frames later, so // @p target is re-asserted until it settles. - function apply(target) { - var zoom = applied(); - body.style.zoom = zoom; - root.style.setProperty("--odr-zoom", zoom); - + function settle(target) { restoring = true; restore(target); @@ -475,7 +509,14 @@ constexpr std::string_view viewport_js = R"js( restore(target); requestAnimationFrame(again); })(); + } + + function apply(target) { + var zoom = applied(); + body.style.zoom = zoom; + root.style.setProperty("--odr-zoom", zoom); + settle(target); notify(); } @@ -490,8 +531,8 @@ constexpr std::string_view viewport_js = R"js( width = root.clientWidth; if (pinned !== null || !measures) { - // the scale does not follow the viewport - remember(); + // The scale does not follow the viewport; the reader's place still does. + settle(target); return; } @@ -513,6 +554,25 @@ constexpr std::string_view viewport_js = R"js( return pinned === null; }; + // @p element's box in the coordinates `elementFromPoint` takes, for a host + // hit-testing while a zoom is applied. + odr.getViewportRect = function (element) { + if (!element || typeof element.getBoundingClientRect !== "function") { + return null; + } + var box = boxOf(element); + return { + x: box.left, + y: box.top, + left: box.left, + top: box.top, + right: box.left + box.width, + bottom: box.top + box.height, + width: box.width, + height: box.height, + }; + }; + // @p focus, a pinch's midpoint, is the point that stays put across the // change; the top of the viewport where none is given. odr.setZoom = function (value, focus) { diff --git a/src/odr/internal/html/frontend.hpp b/src/odr/internal/html/frontend.hpp index 681bd49b3..b22754de2 100644 --- a/src/odr/internal/html/frontend.hpp +++ b/src/odr/internal/html/frontend.hpp @@ -46,8 +46,9 @@ void write_text_script(const WritingState &state); void write_search_script(const WritingState &state); /// `odr.getZoom()`, `setZoom(value, focus)`, `adjustZoom(factor, focus)`, -/// `resetZoom(focus)`, `isZoomFitted()`, `onZoomChange`, plus the fit @ref -/// write_zoom_style left to be measured. Holds the reading position. +/// `resetZoom(focus)`, `isZoomFitted()`, `getViewportRect(element)`, +/// `onZoomChange`, plus the fit @ref write_zoom_style left to be measured. +/// Holds the reading position. void write_viewport_script(const WritingState &state); /// What the corresponding `write_*` calls would link, without writing anything: diff --git a/test/browser/viewport/.gitignore b/test/browser/viewport/.gitignore new file mode 100644 index 000000000..1d91a74bf --- /dev/null +++ b/test/browser/viewport/.gitignore @@ -0,0 +1 @@ +viewport.js diff --git a/test/browser/viewport/README.md b/test/browser/viewport/README.md new file mode 100644 index 000000000..d99d75a3f --- /dev/null +++ b/test/browser/viewport/README.md @@ -0,0 +1,28 @@ +# `viewport.js` checks + +What the emitted zoom script does can only be seen in a browser, so these are +run by hand rather than by `odr_test`. + +```bash +test/browser/viewport/serve # extracts the script, serves on :8731 +open http://localhost:8731/tests.html +``` + +`serve` lifts `viewport_js` out of `src/odr/internal/html/frontend.cpp`, so what +runs is what ships. `page.html` stands in for a rendered view: it writes the +`:root{--odr-fit;--odr-zoom}` and `body{zoom}` that `write_zoom_style` would. + +Why the harness is shaped this way: + +- **`?webkit=1`** divides an applied zoom back out of chromium's rects, which is + what webkit returns — so one browser covers both. +- **A pinch focus, not the top of the viewport.** `restore()` is re-asserted for + 30 frames, and that loop converges at `y = 1` whatever coordinate space it + computes in; a focus 400px down does not. +- **`overflow-anchor: none`**, or chromium's own scroll anchoring covers for the + script. Webkit has none. +- **Positions are read as `(scrollY + y) / zoom`**, never through the script's + helpers, so a wrong answer cannot agree with itself. + +Keep the tab on screen: the browser throttles `resize` and +`requestAnimationFrame` in a window that is not. diff --git a/test/browser/viewport/page.html b/test/browser/viewport/page.html new file mode 100644 index 000000000..fe3a649a1 --- /dev/null +++ b/test/browser/viewport/page.html @@ -0,0 +1,122 @@ + + + + + + + + + + + + diff --git a/test/browser/viewport/serve b/test/browser/viewport/serve new file mode 100755 index 000000000..76d59d962 --- /dev/null +++ b/test/browser/viewport/serve @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Extracts the emitted zoom script and serves the checks beside it.""" + +import functools +import http.server +import pathlib +import socketserver + +PORT = 8731 + +HERE = pathlib.Path(__file__).resolve().parent +SOURCE = HERE.parents[2] / "src" / "odr" / "internal" / "html" / "frontend.cpp" +BEGIN = 'constexpr std::string_view viewport_js = R"js(' +END = ')js";' + + +def extract() -> str: + source = SOURCE.read_text() + begin = source.index(BEGIN) + return source[begin + len(BEGIN) : source.index(END, begin)] + + +def main() -> None: + target = HERE / "viewport.js" + target.write_text(extract()) + print(f"{SOURCE.name} -> {target.name}") + + handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(HERE)) + socketserver.TCPServer.allow_reuse_address = True + with socketserver.TCPServer(("127.0.0.1", PORT), handler) as server: + print(f"http://localhost:{PORT}/tests.html") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/test/browser/viewport/tests.html b/test/browser/viewport/tests.html new file mode 100644 index 000000000..c4db700fb --- /dev/null +++ b/test/browser/viewport/tests.html @@ -0,0 +1,209 @@ + + + + + viewport.js checks + + + +
running…
+ + + + From 0f981bbca0e8e99caeface7c0cced717b8609188 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Wed, 26 Aug 2026 07:54:20 +0200 Subject: [PATCH 2/2] fix(html): let only the owning settle run clear its guard `settle()`'s loop treated both exit reasons alike, so a superseded run - one whose token a newer `settle()` or `taken()` had bumped - cleared `restoring` and re-anchored `held` on its successor's behalf. Resize is dispatched before a frame's animation-frame callbacks, so the stale callback runs first and leaves the guard off for the newer run's whole correction: its own `scrollBy` is then remembered as the reader's, and the next resize settles on that polluted anchor. It is a drag-resize or an orientation change, not a same-tick race. Only the run that times out cleans up now, as `taken()` already did. `boxOf` returns the `DOMRect` shape `getViewportRect` was unpacking it into, so the rect is shaped once, where it is scaled. The browser checks pin what they were assuming: that the zoom under test reached the body, so a dead `setZoom` cannot pass as a held point; that `getViewportRect` answers `null` off an element; and that a raw rect hits exactly where the engine carries the zoom, read as `!webkit` instead of a third parameter to keep in step. `AGENTS.md` points at the harness, and the `viewport_js` declaration says `serve` reads it verbatim. The README no longer claims `?webkit=1` covers both spaces: it reproduces webkit's rects, while the scroll convention stays chromium's and is unprobed. Refs #726 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BLwtnPpkG4Ma35gUXUHjZD --- AGENTS.md | 1 + src/odr/internal/html/frontend.cpp | 43 ++++++++++++++++-------------- test/browser/viewport/README.md | 7 ++++- test/browser/viewport/tests.html | 25 +++++++++++++---- 4 files changed, 50 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b9c61a9c1..9d5fd89bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,7 @@ bytes ─▶ magic/open_strategy ─▶ DecodedFile ─▶ Document ─▶ Eleme | `wasm/` | WebAssembly bindings (embind), packaged as the npm package `@opendocument/odr-core`; see [`wasm/AGENTS.md`](wasm/AGENTS.md). | | `tools/pdf/` | Dev tooling (not built): PDF encoding-data generators, see `tools/pdf/README.md`. | | `test/src/` | GoogleTest suites; data fetched into `test/data` (see `cmake/test_data.cmake`). | +| `test/browser/` | Checks for the emitted scripts, run by hand in a browser — what they do is not visible to `odr_test`; see [`viewport/README.md`](test/browser/viewport/README.md). | | `offline/documentation/MS-*/` | Vendored Microsoft spec text (see [Specs](#specs)). | | `docs/design/README.md` | High-level design rationale. | diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index 09fef10e8..7112cc668 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -323,6 +323,8 @@ constexpr std::string_view document_js = R"js( )js"; /// The zoom api, and the fit where the css could not state it. +/// `test/browser/viewport/serve` lifts the script out by this declaration read +/// verbatim, so renaming it breaks the browser checks. constexpr std::string_view viewport_js = R"js( (function () { "use strict"; @@ -418,15 +420,23 @@ constexpr std::string_view viewport_js = R"js( return rectsZoomed ? 1 : zoom; } - // @p element's box in viewport coordinates. + // @p element's box in viewport coordinates, shaped like a `DOMRect`. function boxOf(element) { var box = element.getBoundingClientRect(); var factor = rectFactor(); + var left = box.left * factor; + var top = box.top * factor; + var width = box.width * factor; + var height = box.height * factor; return { - left: box.left * factor, - top: box.top * factor, - width: box.width * factor, - height: box.height * factor, + x: left, + y: top, + left: left, + top: top, + right: left + width, + bottom: top + height, + width: width, + height: height, }; } @@ -501,7 +511,11 @@ constexpr std::string_view viewport_js = R"js( var token = ++settling; var frames = 30; (function again() { - if (token !== settling || frames-- <= 0) { + if (token !== settling) { + // A newer run - or the reader - owns the state below now. + return; + } + if (frames-- <= 0) { restoring = false; remember(); return; @@ -557,20 +571,9 @@ constexpr std::string_view viewport_js = R"js( // @p element's box in the coordinates `elementFromPoint` takes, for a host // hit-testing while a zoom is applied. odr.getViewportRect = function (element) { - if (!element || typeof element.getBoundingClientRect !== "function") { - return null; - } - var box = boxOf(element); - return { - x: box.left, - y: box.top, - left: box.left, - top: box.top, - right: box.left + box.width, - bottom: box.top + box.height, - width: box.width, - height: box.height, - }; + return element && typeof element.getBoundingClientRect === "function" + ? boxOf(element) + : null; }; // @p focus, a pinch's midpoint, is the point that stays put across the diff --git a/test/browser/viewport/README.md b/test/browser/viewport/README.md index d99d75a3f..2827f24c5 100644 --- a/test/browser/viewport/README.md +++ b/test/browser/viewport/README.md @@ -15,7 +15,12 @@ runs is what ships. `page.html` stands in for a rendered view: it writes the Why the harness is shaped this way: - **`?webkit=1`** divides an applied zoom back out of chromium's rects, which is - what webkit returns — so one browser covers both. + what webkit returns — so one browser covers the rect space. It does not cover + the scroll space: `restore()` reads deltas in viewport coordinates and hands + them to `window.scrollBy`, which is only right if webkit's `scrollBy`/`scrollY` + are in that same zoomed space. `rectFactor()` probes for the rect convention at + runtime; nothing probes the scroll one, and here it is chromium's. So the pinch + check is worth one run in real safari. - **A pinch focus, not the top of the viewport.** `restore()` is re-asserted for 30 frames, and that loop converges at `y = 1` whatever coordinate space it computes in; a focus 400px down does not. diff --git a/test/browser/viewport/tests.html b/test/browser/viewport/tests.html index c4db700fb..6f1d97066 100644 --- a/test/browser/viewport/tests.html +++ b/test/browser/viewport/tests.html @@ -106,7 +106,7 @@ // Webkit does not carry an applied `body{zoom}` in a rect, so what a host // reads off one is not what `elementFromPoint` takes. - async function rectTest(label, webkit, expectRawHit) { + async function rectTest(label, webkit) { var win = await load( "content=page&zoom=0.472155&webkit=" + (webkit ? 1 : 0), ); @@ -131,8 +131,8 @@ check( label + ": a raw rect centre hits the element", - (rawHit === element) === expectRawHit, - "expected " + (expectRawHit ? "a hit" : "a miss") + ", got " + + (rawHit === element) === !webkit, + "expected " + (webkit ? "a miss" : "a hit") + ", got " + (rawHit ? rawHit.id || rawHit.tagName : "none"), ); check( @@ -146,6 +146,11 @@ near(view.width / raw.width, webkit ? 0.472155 : 1, 1e-6), "view/raw=" + (view.width / raw.width).toFixed(6), ); + check( + label + ": a non-element gives null", + win.odr.getViewportRect(null) === null && + win.odr.getViewportRect({}) === null, + ); } // A pinch hands `setZoom` its midpoint, which must stay put. The top of @@ -161,6 +166,16 @@ await wait(200); var after = docAt(win, focusY); + // Read off the body, not `odr.getZoom()`: that answers with what was + // asked for, whether or not it was ever applied - and a zoom that + // never lands moves nothing, which the check below cannot tell from a + // held point. + var applied = parseFloat(getComputedStyle(win.document.body).zoom); + check( + label + ": the zoom applied", + near(applied, 0.8, 1e-6), + "body zoom=" + applied, + ); check( label + ": the point under the pinch stays put (y=" + focusY + ")", near(after, before, 24), @@ -189,8 +204,8 @@ (async function () { say("--- rect space against viewport space ---"); - await rectTest("chromium", false, true); - await rectTest("webkit-simulated", true, false); + await rectTest("chromium", false); + await rectTest("webkit-simulated", true); say(""); say("--- the pinch focus holds ---");