Skip to content

feat(html): expose the zoom every view opens at to the host - #724

Merged
andiwand merged 4 commits into
mainfrom
feat/html-zoom-api
Aug 20, 2026
Merged

feat(html): expose the zoom every view opens at to the host#724
andiwand merged 4 commits into
mainfrom
feat/html-zoom-api

Conversation

@andiwand

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

The fit from #719 landed a scale on the body and nothing could move it afterwards. An app wanting a zoom control had to reach for the platform's own — WKWebView.pageZoom, WebView.zoomBy — which differ per platform, are inert for an embedder rendering us in a frame, and know nothing of the fit. That is the thing #719 just climbed out of.

The api

Written into the odr object every view already carries:

odr.getZoom()                    // effective, 1 is actual size
odr.setZoom(value, focus)        // pins it, returns what was applied
odr.adjustZoom(factor, focus)    // multiplicative, pins it
odr.resetZoom(focus)             // back to following the fit
odr.isZoomFitted()
odr.onZoomChange = function (zoom, fitted) {}

Clamped to [0.1, 10]; a value that is not a number leaves the zoom alone. onZoomChange fires on a resize too, since the effective zoom moves while fitted without anyone calling a setter.

The state behind it

One nullable number: a pinned zoom, or null while the view follows the fit — not a multiplier over the fit. A phone rotating with a 2× multiplier compounds it against the wider fit and overshoots, where a pinned zoom keeps the text the size the reader set. While pinned, the fit is not measured at all, so a resize costs nothing until resetZoom().

isZoomFitted() is the one bit a toolbar cannot derive from the number: "fitted at 0.72" and "pinned at 0.72" read the same otherwise.

Focal point

focus is the point the zoom is centred on — a pinch's midpoint — given as {x, y} or any object carrying clientX/clientY, so a touch or mouse event can be handed straight over. The anchor that holds the reading position now pins the horizontal too, but only when a point asked for it: otherwise the page column centres itself and holding x would fight that.

Css says what the view opens at

So a page under a strict Content-Security-Policy opens right with no script: --odr-fit is the factor, or auto where only the view can measure it, and --odr-zoom plus body{zoom} carry what is applied. HtmlConfig::initial_zoom writes that and reaches through all four bindings (python, jni, apple, wasm). Printing resets both — paper has its own geometry, and !important beats the inline zoom the script writes.

Coverage

Documents, pdf, text, xml, the archive listing and images. An image is fitted by css rather than by a body zoom (100% of a zoomed body is the viewport again), so its max-width puts the factor back to grow with it. The media view opts out — zooming a <video> is not meaningful — and the font preview ships no resources to hang a script on.

Verified

html_common and html suites pass, plus the 306 html-output tests. In Chrome, framed and top-level:

  • the fit tracks resizes while fitted (0.385 → 0.771 at double width) and holds at 1.5 while pinned
  • resetZoom() re-measures against the current width
  • the static css factor is read back by the script instead of being re-measured
  • the reading position drifts by ~0 across zoom steps, including back-to-back changes
  • a focal point stays within half a pixel over zoom in, further in, and out
  • an image doubles its rendered width at 2×

Not in here

  • Pinch gestures. This is the lever, not the gesture: in a frame a two-finger gesture still goes to the top-level page. A built-in handler (touch-action plus touchmove, and WebKit's non-standard gesturechange) needs real-device testing on both platforms and can break panning if it is wrong.
  • Reference output. Every touched view gains a small <style> in its head, so the pinned output drifts — same as feat(html): fit paged output to the viewport, and hold the reading position #719, which did not advance the pointer either.

The fit landed a scale on the body and nothing could move it afterwards.
An app wanting a zoom control had to reach for the platform's own —
`WKWebView.pageZoom`, `WebView.zoomBy` — which differ per platform, are
inert for an embedder rendering us in a frame, and know nothing of the
fit, so each app would again reimplement the same judgement with its own
bugs. That is the thing #719 just climbed out of.

The `odr` object every view already carries now answers for the zoom:

    odr.getZoom()                    // effective, 1 is actual size
    odr.setZoom(value, focus)        // pins it
    odr.adjustZoom(factor, focus)    // multiplicative, pins it
    odr.resetZoom(focus)             // back to following the fit
    odr.isZoomFitted()
    odr.onZoomChange = function (zoom, fitted) {}

The state behind it is one nullable number: a pinned zoom, or `null`
while the view follows the fit. Not a multiplier over the fit — a phone
rotating with a 2x multiplier compounds it against the wider fit and
overshoots, where a pinned zoom keeps the text the size the reader set.
Which one is live is what `isZoomFitted()` answers, and the only bit a
toolbar cannot derive from the number alone.

`focus` is the point the zoom is centred on, a pinch's midpoint, given
as `{x, y}` or any object carrying `clientX`/`clientY`. It stays put
across the change; the anchor that holds the reading position now pins
the horizontal too, but only when a point asked for it — otherwise the
page column centres itself and holding x would fight that.

The css says what the view opens at, so a page under a strict
Content-Security-Policy opens right with no script at all: `--odr-fit`
is the factor, or `auto` where only the view can measure it, and
`--odr-zoom` plus `body{zoom}` carry what is applied.
`HtmlConfig::initial_zoom` writes that, and reaches through all four
bindings. Printing resets both: paper has its own geometry.

Every view that renders something to read gets it — documents, pdf,
text, xml, the archive listing, images. An image is fitted by css rather
than by a body zoom, so its `max-width` puts the factor back to grow
with it. The media view opts out, and the font preview ships no
resources to hang a script on.

Verified in Chrome, framed and top-level: the fit tracks resizes while
fitted and holds while pinned, clamping and a NaN guard hold, the
reading position drifts by ~0 across zoom steps, and a focal point stays
within half a pixel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d5RcmsA777vwXiuafjx6k

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 866e7ddffa

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/odr/internal/html/common.cpp Outdated
andiwand and others added 3 commits August 20, 2026 23:05
`--odr-zoom` was written for what it was worth, so `initial_zoom = 1`
wrote nothing at all and the view read it back as unpinned: it went on
to measure the fit and apply that, and where the fit was known it
reported that factor while the page stood at actual size.

A pin is stated because it was set, whatever its value, and it states
only itself — the fit already has `--odr-fit`, and `body{zoom}` applies
whichever of the two wins. That also removes the guess the script made
of a pin that happened to equal the fit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d5RcmsA777vwXiuafjx6k
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d5RcmsA777vwXiuafjx6k
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d5RcmsA777vwXiuafjx6k
@andiwand
andiwand merged commit 1767ba5 into main Aug 20, 2026
32 checks passed
@andiwand
andiwand deleted the feat/html-zoom-api branch August 20, 2026 21:19
andiwand added a commit that referenced this pull request Aug 20, 2026
Both changed the head of every page — a declared fit, a print-time zoom
pin and `viewport.js` — without regenerating the reference set, so the
comparison rendered new output against a tree missing the script. 265
public and 1175 private files, and the script itself; nothing else moved.


Claude-Session: https://claude.ai/code/session_015d5RcmsA777vwXiuafjx6k

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant