diff --git a/AGENTS.md b/AGENTS.md index b9c61a9c..9d5fd89b 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/CHANGELOG.md b/CHANGELOG.md index 48a3fa86..09796f2b 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 c411516b..7112cc66 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"; @@ -392,6 +394,52 @@ 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, 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 { + x: left, + y: top, + left: left, + top: top, + right: left + width, + bottom: top + height, + width: width, + height: height, + }; + } + // 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 +450,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 +487,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,18 +504,18 @@ 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); 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; @@ -475,7 +523,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 +545,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 +568,14 @@ 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) { + return element && typeof element.getBoundingClientRect === "function" + ? boxOf(element) + : null; + }; + // @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 681bd49b..b22754de 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 00000000..1d91a74b --- /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 00000000..2827f24c --- /dev/null +++ b/test/browser/viewport/README.md @@ -0,0 +1,33 @@ +# `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 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. +- **`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 00000000..fe3a649a --- /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 00000000..76d59d96 --- /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 00000000..6f1d9706 --- /dev/null +++ b/test/browser/viewport/tests.html @@ -0,0 +1,224 @@ + + + + +running…+ + + +