From 866e7ddffa558914adb23ce631e77c2ae94d0491 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 20 Aug 2026 22:53:00 +0200 Subject: [PATCH 1/4] feat(html): expose the zoom every view opens at to the host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_015d5RcmsA777vwXiuafjx6k --- CHANGELOG.md | 7 + apple/include/OdrCoreObjC/ODRHtml.h | 2 + apple/src/ODRHtml.mm | 7 + .../app/opendocument/core/HtmlConfig.java | 2 + jni/src/jni_style.cpp | 15 ++ jni/tests/app/opendocument/core/HtmlTest.java | 3 + python/src/bind_html.cpp | 1 + python/tests/test_html.py | 3 + src/odr/html.hpp | 3 + src/odr/internal/html/common.cpp | 75 +++++-- src/odr/internal/html/common.hpp | 12 +- src/odr/internal/html/document.cpp | 34 +--- src/odr/internal/html/filesystem.cpp | 2 + src/odr/internal/html/frontend.cpp | 187 ++++++++++++++---- src/odr/internal/html/frontend.hpp | 9 +- src/odr/internal/html/image_file.cpp | 32 ++- src/odr/internal/html/pdf_file.cpp | 23 +-- src/odr/internal/html/text_file.cpp | 2 + src/odr/internal/html/xml_file.cpp | 2 + test/src/html_test.cpp | 36 +++- test/src/internal/html/common_test.cpp | 48 ++++- wasm/js/index.d.ts | 2 + wasm/src/wasm_html.cpp | 4 + 23 files changed, 382 insertions(+), 129 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec1620e1..36d88c14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- Every html view exposes a zoom the host drives: `odr.getZoom()`, + `setZoom(value, focus)`, `adjustZoom(factor, focus)`, `resetZoom(focus)`, + `isZoomFitted()` and an `odr.onZoomChange` hook. `1` is actual size, `focus` + is the point the zoom is centred on — a pinch's midpoint — and the reading + position is held across the change. `HtmlConfig::initial_zoom` sets what the + view opens at, script or no script. A view that fits keeps following the + viewport until a zoom is set, and `resetZoom()` puts it back on the fit. - A pdf that nests parentheses inside a string opens, and keeps its document metadata — `cairo` and `pdfTeX` write their `/Producer` that way. - A jni build without a JDK fails instead of shipping a package missing diff --git a/apple/include/OdrCoreObjC/ODRHtml.h b/apple/include/OdrCoreObjC/ODRHtml.h index 268934bd..0ab944d3 100644 --- a/apple/include/OdrCoreObjC/ODRHtml.h +++ b/apple/include/OdrCoreObjC/ODRHtml.h @@ -96,6 +96,8 @@ NS_SWIFT_NAME(HtmlConfig) @property(nonatomic, copy, nullable) NSString *viewportContent; /// The width the output is shown at, in css pixels; fits paged content to it. @property(nonatomic, strong, nullable) NSNumber *viewportWidth; +/// The zoom the view opens at, 1 being actual size; `nil` follows the fit. +@property(nonatomic, strong, nullable) NSNumber *initialZoom; @property(nonatomic) BOOL formatHtml; /// Repeated `htmlIndentString` per nesting level; 0 disables indentation. diff --git a/apple/src/ODRHtml.mm b/apple/src/ODRHtml.mm index b85e9919..f09988d6 100644 --- a/apple/src/ODRHtml.mm +++ b/apple/src/ODRHtml.mm @@ -109,6 +109,8 @@ - (instancetype)initWithNativeConfig:(const odr::HtmlConfig &)config { _viewportWidth = config.viewport_width.has_value() ? @(static_cast(*config.viewport_width)) : nil; + _initialZoom = + config.initial_zoom.has_value() ? @(*config.initial_zoom) : nil; _formatHtml = config.format_html ? YES : NO; _htmlIndent = config.html_indent; _htmlIndentString = to_nsstring(config.html_indent_string); @@ -178,6 +180,11 @@ - (instancetype)initWithNativeConfig:(const odr::HtmlConfig &)config { } else { config.viewport_width.reset(); } + if (_initialZoom != nil) { + config.initial_zoom = _initialZoom.doubleValue; + } else { + config.initial_zoom.reset(); + } config.format_html = _formatHtml == YES; config.html_indent = _htmlIndent; config.html_indent_string = to_string(_htmlIndentString); diff --git a/jni/java/app/opendocument/core/HtmlConfig.java b/jni/java/app/opendocument/core/HtmlConfig.java index fbab8cd0..116a0d17 100644 --- a/jni/java/app/opendocument/core/HtmlConfig.java +++ b/jni/java/app/opendocument/core/HtmlConfig.java @@ -39,6 +39,8 @@ public final class HtmlConfig { public String viewportContent; /** The width the output is shown at, in css pixels; fits paged content to it. */ public Integer viewportWidth; + /** The zoom the view opens at, 1 being actual size; {@code null} follows the fit. */ + public Double initialZoom; public boolean formatHtml = false; public int htmlIndent = 1; diff --git a/jni/src/jni_style.cpp b/jni/src/jni_style.cpp index 2c8c7866..436475b0 100644 --- a/jni/src/jni_style.cpp +++ b/jni/src/jni_style.cpp @@ -370,6 +370,8 @@ jobject html_config_to_java(JNIEnv *env, const odr::HtmlConfig &config) { make_string_opt(env, config.viewport_content)); set_object("viewportWidth", "Ljava/lang/Integer;", box_integer(env, config.viewport_width)); + set_object("initialZoom", "Ljava/lang/Double;", + box_double(env, config.initial_zoom)); set_boolean("formatHtml", config.format_html); set_int("htmlIndent", config.html_indent); set_string("htmlIndentString", config.html_indent_string); @@ -523,6 +525,19 @@ odr::HtmlConfig html_config_from_java(JNIEnv *env, jobject config) { } env->DeleteLocalRef(width); } + { + jobject zoom = get_object("initialZoom", "Ljava/lang/Double;"); + if (zoom == nullptr) { + result.initial_zoom = std::nullopt; + } else { + jclass double_cls = env->GetObjectClass(zoom); + jmethodID double_value = + env->GetMethodID(double_cls, "doubleValue", "()D"); + result.initial_zoom = env->CallDoubleMethod(zoom, double_value); + env->DeleteLocalRef(double_cls); + } + env->DeleteLocalRef(zoom); + } result.format_html = get_boolean("formatHtml"); result.html_indent = static_cast(get_int("htmlIndent")); result.html_indent_string = get_string("htmlIndentString"); diff --git a/jni/tests/app/opendocument/core/HtmlTest.java b/jni/tests/app/opendocument/core/HtmlTest.java index 7a7f1319..4d769310 100644 --- a/jni/tests/app/opendocument/core/HtmlTest.java +++ b/jni/tests/app/opendocument/core/HtmlTest.java @@ -39,6 +39,7 @@ void htmlConfigDefaults() { assertNull(config.spreadsheetViewportMode); assertNull(config.viewportContent); assertNull(config.viewportWidth); + assertNull(config.initialZoom); } @Test @@ -48,6 +49,7 @@ void viewportConfigRoundTrips() throws IOException { config.spreadsheetViewportMode = HtmlViewportMode.ACTUAL_SIZE; config.viewportContent = "width=420"; config.viewportWidth = 420; + config.initialZoom = 1.5; Path cache = Files.createDirectories(tempDir.resolve("cache")); DecodedFile file = Odr.open(TestFiles.odtFile(tempDir).toString()); @@ -57,6 +59,7 @@ void viewportConfigRoundTrips() throws IOException { assertEquals(HtmlViewportMode.ACTUAL_SIZE, readBack.spreadsheetViewportMode); assertEquals("width=420", readBack.viewportContent); assertEquals(Integer.valueOf(420), readBack.viewportWidth); + assertEquals(Double.valueOf(1.5), readBack.initialZoom); } /** The C++ suite covers the mode matrix; this only proves the config crosses JNI. */ diff --git a/python/src/bind_html.cpp b/python/src/bind_html.cpp index 3b2de069..2ebd1ca4 100644 --- a/python/src/bind_html.cpp +++ b/python/src/bind_html.cpp @@ -91,6 +91,7 @@ void odr_python::bind_html(py::module_ &m) { &odr::HtmlConfig::spreadsheet_viewport_mode) .def_readwrite("viewport_content", &odr::HtmlConfig::viewport_content) .def_readwrite("viewport_width", &odr::HtmlConfig::viewport_width) + .def_readwrite("initial_zoom", &odr::HtmlConfig::initial_zoom) .def_readwrite("format_html", &odr::HtmlConfig::format_html) .def_readwrite("html_indent", &odr::HtmlConfig::html_indent) .def_readwrite("html_indent_string", &odr::HtmlConfig::html_indent_string) diff --git a/python/tests/test_html.py b/python/tests/test_html.py index 3b346ff0..72a9e384 100644 --- a/python/tests/test_html.py +++ b/python/tests/test_html.py @@ -40,15 +40,18 @@ def test_html_config_viewport_defaults(): assert config.spreadsheet_viewport_mode is None assert config.viewport_content is None assert config.viewport_width is None + assert config.initial_zoom is None config.viewport_mode = pyodr.HtmlViewportMode.fit_width config.spreadsheet_viewport_mode = pyodr.HtmlViewportMode.actual_size config.viewport_content = "width=420" config.viewport_width = 420 + config.initial_zoom = 1.5 assert config.viewport_mode == pyodr.HtmlViewportMode.fit_width assert config.spreadsheet_viewport_mode == pyodr.HtmlViewportMode.actual_size assert config.viewport_content == "width=420" assert config.viewport_width == 420 + assert config.initial_zoom == 1.5 def test_viewport_mode_reaches_the_html(odt_path, tmp_path): diff --git a/src/odr/html.hpp b/src/odr/html.hpp index 774b233f..5631a686 100644 --- a/src/odr/html.hpp +++ b/src/odr/html.hpp @@ -149,6 +149,9 @@ struct HtmlConfig { std::optional viewport_content; /// The width the output is shown at, in css pixels; fits paged content to it. std::optional viewport_width; + /// The zoom the view opens at, 1 being actual size; unset follows the fit. + /// `odr.setZoom` moves it afterwards. + std::optional initial_zoom; /// Indent and break the output into lines rather than writing one stream. bool format_html{false}; diff --git a/src/odr/internal/html/common.cpp b/src/odr/internal/html/common.cpp index f1f45262..b78efad9 100644 --- a/src/odr/internal/html/common.cpp +++ b/src/odr/internal/html/common.cpp @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -80,29 +81,67 @@ std::optional html::css_pixels(const std::optional &measure) { return pixels > 0 ? std::optional(pixels) : std::nullopt; } -bool html::write_viewport_fit_style( - HtmlWriter &out, const HtmlConfig &config, const bool fits, - const std::optional content_pixels) { - if (!fits || !config.viewport_width.has_value() || - !content_pixels.has_value()) { - return false; +void html::write_zoom_style(HtmlWriter &out, const HtmlConfig &config, + const bool fits, + const std::optional content_pixels) { + // Nothing shown here, so the factor is 1 unless the fit says otherwise. + std::optional fit = 1; + if (fits) { + if (config.viewport_width.has_value() && content_pixels.has_value()) { + // only ever down: a page narrower than the viewport is shown at its size + fit = std::min(1.0, static_cast(config.viewport_width.value()) / + *content_pixels); + } else { + // only the view can measure this one + fit.reset(); + } } - const double factor = - static_cast(config.viewport_width.value()) / *content_pixels; - // only ever down: a page narrower than the viewport is shown at its size - if (factor >= 1) { - return true; - } + const std::optional zoom = + config.initial_zoom.has_value() ? config.initial_zoom : fit; + + const auto number = [](const double value) { + // `Measure` renders no exponent form + return Measure(value, DynamicUnit()).to_string(); + }; + + // Both default to 1, so only a factor that is not 1 has anything to say. + const bool writes_fit = !fit.has_value() || *fit != 1; + const bool writes_zoom = zoom.has_value() && *zoom != 1; out.write_header_style_begin(); - // `zoom` scales the layout, so the page scrolls against the scaled size - // instead of overflowing beside it; `Measure` renders no exponent form - out.out() << "body{zoom:" << Measure(factor, DynamicUnit()).to_string() - << "}"; - out.write_header_style_end(); - return true; + if (writes_fit || writes_zoom) { + out.out() << ":root{"; + if (writes_fit) { + out.out() << "--odr-fit:"; + if (fit.has_value()) { + out.out() << number(*fit); + } else { + out.out() << "auto"; + } + if (writes_zoom) { + out.out() << ";"; + } + } + if (writes_zoom) { + out.out() << "--odr-zoom:" << number(*zoom); + } + out.out() << "}"; + } + + if (writes_zoom) { + // `zoom` scales the layout, so the page scrolls against the scaled size + // instead of overflowing beside it + out.out() << "body{zoom:" << number(*zoom) << "}"; + } + + // Paper has its own geometry: whatever the reader is zoomed to, print the + // view at its size. Beats the inline zoom the view script writes. + out.out() << "@media print{:root{--odr-zoom:1!important}" + "body{zoom:1!important}}"; + + out.write_header_style_end(); } std::string html::escape_text(std::string text) { diff --git a/src/odr/internal/html/common.hpp b/src/odr/internal/html/common.hpp index 3e29b42b..a725a2b4 100644 --- a/src/odr/internal/html/common.hpp +++ b/src/odr/internal/html/common.hpp @@ -58,10 +58,14 @@ css_pixels(const std::optional &measure); /// The side gutters the page column puts around its pages, in css pixels. constexpr double page_column_gutter_pixels = 32; -/// Scales the body so @p content_pixels fits `config.viewport_width`. Writes -/// nothing unless @p fits and both widths are known. -bool write_viewport_fit_style(HtmlWriter &out, const HtmlConfig &config, - bool fits, std::optional content_pixels); +/// Writes the zoom state the view opens at, as css the view script then reads +/// and moves: `--odr-fit` is the factor fitting @p content_pixels into +/// `config.viewport_width`, `auto` where only the view can measure it, and 1 +/// where @p fits is false; `--odr-zoom` and `body{zoom}` carry what is applied, +/// which is @ref odr::HtmlConfig::initial_zoom where it is set and the fit +/// otherwise. +void write_zoom_style(HtmlWriter &out, const HtmlConfig &config, bool fits, + std::optional content_pixels); std::string escape_text(std::string text); diff --git a/src/odr/internal/html/document.cpp b/src/odr/internal/html/document.cpp index 7e122671..bb88e71a 100644 --- a/src/odr/internal/html/document.cpp +++ b/src/odr/internal/html/document.cpp @@ -96,17 +96,6 @@ viewport_mode_override(const Document &document, const HtmlConfig &config) { : std::nullopt; } -/// True where the view should fit but no css factor could be written. -bool fits_at_load_time(const Document &document, const HtmlConfig &config, - const bool paged_content, - const std::optional content_pixels) { - if (!paged_content || !fits_width(config, paged_content, - viewport_mode_override(document, config))) { - return false; - } - return !config.viewport_width.has_value() || !content_pixels.has_value(); -} - /// @p name titles the view; empty when the whole document is written as one /// file, which no one view names. void front(const Document &document, const WritingState &state, @@ -127,12 +116,10 @@ void front(const Document &document, const WritingState &state, const std::optional mode_override = viewport_mode_override(document, state.config()); write_viewport_meta(out, state.config(), paged_content, mode_override); - if (paged_content) { - write_viewport_fit_style( - out, state.config(), - fits_width(state.config(), paged_content, mode_override), - content_pixels); - } + write_zoom_style(out, state.config(), + paged_content && + fits_width(state.config(), paged_content, mode_override), + content_pixels); write_document_style(state); write_document_dark_style(state); @@ -171,8 +158,7 @@ void front(const Document &document, const WritingState &state, } } -void back(const Document &document, const WritingState &state, - const std::optional content_pixels) { +void back(const Document &document, const WritingState &state) { HtmlWriter &out = state.out(); if (is_paged_content(document, state.config())) { @@ -184,11 +170,7 @@ void back(const Document &document, const WritingState &state, if (document.document_type() == DocumentType::spreadsheet) { write_spreadsheet_script(state); } - if (fits_at_load_time(document, state.config(), - is_paged_content(document, state.config()), - content_pixels)) { - write_viewport_script(state); - } + write_viewport_script(state); out.write_body_end(); out.write_end(); @@ -216,7 +198,7 @@ class HtmlFragmentBase { const std::optional content = content_pixels(); front(m_document, state, m_name, content); write_fragment(out, state); - back(m_document, state, content); + back(m_document, state); } protected: @@ -369,7 +351,7 @@ class HtmlServiceImpl final : public HtmlService { for (const auto &fragment : m_fragments) { fragment->write_fragment(out, state); } - back(m_document, state, content); + back(m_document, state); return resources; } diff --git a/src/odr/internal/html/filesystem.cpp b/src/odr/internal/html/filesystem.cpp index ad8b14c8..20587913 100644 --- a/src/odr/internal/html/filesystem.cpp +++ b/src/odr/internal/html/filesystem.cpp @@ -188,6 +188,7 @@ class HtmlServiceImpl final : public HtmlService { out.write_header_target("_blank"); out.write_header_title("odr"); write_viewport_meta(out, config(), false); + write_zoom_style(out, config(), false, {}); write_filesystem_style(state); write_filesystem_dark_style(state); write_search_style(state); @@ -267,6 +268,7 @@ class HtmlServiceImpl final : public HtmlService { out.write_element_end("table"); write_search_script(state); + write_viewport_script(state); out.write_body_end(); diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index b1e395f6..3f33fc7c 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -322,19 +322,40 @@ constexpr std::string_view document_js = R"js( })(); )js"; -/// The load-time half of fitting the page column to the viewport, for output -/// whose width was not known when it was written. +/// Owns the zoom every view opens at: the fit the css could not state, and +/// `odr.setZoom()` and friends on top of it. constexpr std::string_view viewport_js = R"js( (function () { "use strict"; + var odr = (window.odr = window.odr || {}); + var root = document.documentElement; var body = document.body; - // Only a frame is scaled here: the viewport meta tag covers the top-level + var minZoom = 0.1; + var maxZoom = 10; + + // Only a frame is fitted here: the viewport meta tag covers the top-level // document but is inert in a frame. var framed = window.top !== window.self; + function declared(name) { + return getComputedStyle(root).getPropertyValue(name).trim(); + } + + // `auto` where the renderer could not state the fit, so only we can measure + // it; a number, defaulting to 1, where it could. + var measures = declared("--odr-fit") === "auto"; + var fit = measures ? 1 : parseFloat(declared("--odr-fit")) || 1; + + // What the reader - or the config - pinned the zoom to, `null` while the view + // follows the fit. A written zoom that is just the fit again is not a pin. + var pinned = parseFloat(declared("--odr-zoom")); + if (!isFinite(pinned) || (!measures && pinned === fit)) { + pinned = null; + } + // The width the anchor below was taken at: a scroll arriving after the // viewport changed is the browser's doing, not the reader's. var width = 0; @@ -346,43 +367,66 @@ constexpr std::string_view viewport_js = R"js( // Identifies the settling run below, so a newer one - or the reader - ends it. var settling = 0; + function applied() { + return pinned !== null ? pinned : fit; + } + // The natural width of what the body holds, measured unscaled. function contentWidth() { var zoom = body.style.zoom; - body.style.zoom = ""; + // `1`, not empty: a stylesheet may carry a zoom of its own to fall back to. + body.style.zoom = "1"; var natural = body.scrollWidth; body.style.zoom = zoom; return natural; } - function fit() { + function measureFit() { var available = root.clientWidth; - if (!available) { - return; - } - width = available; - - if (!framed) { - return; + if (!available || !framed) { + // Out of a frame the viewport meta tag has already fitted the document. + return available ? 1 : fit; } var content = contentWidth(); if (!content) { - return; + return fit; } // Only ever down: a page narrower than the viewport is shown at its size. - body.style.zoom = content > available ? available / content : ""; + return content > available ? available / content : 1; } - // The element against the top of the viewport, and how far into it that top - // sits. A fraction of the scroll height cannot stand in: the height itself - // changes with the scale. - function anchor() { - var element = document.elementFromPoint(Math.floor(root.clientWidth / 2), 1); + // The element under @p point - the top of the viewport where none is given - + // and how far into it the point sits. A fraction of the scroll height cannot + // stand in: the height itself changes with the scale. + // + // Only a point given by the caller pins the horizontal too: without one the + // page column centres itself, and holding x would fight that. + function anchor(point) { + var x = point ? point.x : Math.floor(root.clientWidth / 2); + var y = point ? point.y : 1; + var element = document.elementFromPoint(x, y); if (!element) { return null; } var box = element.getBoundingClientRect(); - return { element: element, into: box.height ? -box.top / box.height : 0 }; + return { + element: element, + x: point ? x : null, + y: y, + intoX: box.width ? (x - box.left) / box.width : 0, + intoY: box.height ? (y - box.top) / box.height : 0, + }; + } + + // `{x, y}` in viewport coordinates, which a mouse or touch event carries as + // `clientX`/`clientY`, so either can be handed straight to the zoom calls. + function point(value) { + if (!value) { + return null; + } + var x = value.x !== undefined ? value.x : value.clientX; + var y = value.y !== undefined ? value.y : value.clientY; + return isFinite(x) && isFinite(y) ? { x: x, y: y } : null; } function remember() { @@ -403,27 +447,31 @@ constexpr std::string_view viewport_js = R"js( return; } var box = target.element.getBoundingClientRect(); - var delta = box.top + target.into * box.height; - if (delta) { - window.scrollBy(0, delta); + var deltaY = box.top + target.intoY * box.height - target.y; + var deltaX = + target.x === null ? 0 : box.left + target.intoX * box.width - target.x; + if (deltaX || deltaY) { + window.scrollBy(deltaX, deltaY); } } - function resized() { - if (root.clientWidth === width) { - // Nothing that changes the scale: a height-only change, or a pinch, - // where restoring would fight the reader. - return; + function notify() { + if (typeof odr.onZoomChange === "function") { + odr.onZoomChange(applied(), pinned === null); } + } - var target = held; + // Writes the zoom out and holds @p target under the top of the viewport. The + // browser applies a scroll offset of its own a few frames later, so the + // position is re-asserted until it settles. + function apply(target) { + var zoom = applied(); + body.style.zoom = zoom; + root.style.setProperty("--odr-zoom", zoom); - fit(); restoring = true; restore(target); - // The browser applies a scroll offset of its own a few frames later, so - // the position is re-asserted until it settles. var token = ++settling; var frames = 30; (function again() { @@ -435,6 +483,28 @@ constexpr std::string_view viewport_js = R"js( restore(target); requestAnimationFrame(again); })(); + + notify(); + } + + function resized() { + if (root.clientWidth === width) { + // Nothing that changes the scale: a height-only change, or a pinch, + // where restoring would fight the reader. + return; + } + + var target = held; + width = root.clientWidth; + + if (pinned !== null || !measures) { + // The scale does not follow the viewport, so nothing to re-apply. + remember(); + return; + } + + fit = measureFit(); + apply(target); } function taken() { @@ -442,7 +512,53 @@ constexpr std::string_view viewport_js = R"js( restoring = false; } - fit(); + // `1` is actual size, whatever the fit made of it. The value excludes the + // browser's own page and pinch zoom, which no page can read or set. + odr.getZoom = function () { + return applied(); + }; + + // Whether the view still follows the fit rather than a pinned zoom. + odr.isZoomFitted = function () { + return pinned === null; + }; + + // @p focus, where given, is the point the zoom is centred on - a pinch's + // midpoint - and stays put across the change. Without one the top of the + // viewport does. + odr.setZoom = function (value, focus) { + var next = Number(value); + if (!isFinite(next)) { + return applied(); + } + pinned = Math.min(maxZoom, Math.max(minZoom, next)); + // Read the anchor now rather than trusting the held one: a call is a + // moment we are told about, unlike a resize, which arrives relaid out. + apply(anchor(point(focus))); + return applied(); + }; + + odr.adjustZoom = function (factor, focus) { + return odr.setZoom(applied() * Number(factor), focus); + }; + + // Back to following the fit, which a resize then keeps up to date again. + odr.resetZoom = function (focus) { + pinned = null; + var target = anchor(point(focus)); + if (measures) { + fit = measureFit(); + } + apply(target); + return applied(); + }; + + width = root.clientWidth; + if (measures && pinned === null) { + fit = measureFit(); + body.style.zoom = fit; + root.style.setProperty("--odr-zoom", fit); + } remember(); window.addEventListener("scroll", remember, { passive: true }); @@ -1512,14 +1628,15 @@ void html::write_viewport_script(const WritingState &state) { HtmlResources html::locate_text_resources(const HtmlConfig &config) { static constexpr std::array assets{text_css_asset, search_css_asset, - search_js_asset, text_js_asset}; + search_js_asset, text_js_asset, + viewport_js_asset}; static constexpr std::array dark{text_dark_css_asset, search_dark_css_asset}; return locate_all(assets, dark, config); } HtmlResources html::locate_xml_resources(const HtmlConfig &config) { static constexpr std::array assets{xml_css_asset, search_css_asset, - search_js_asset}; + search_js_asset, viewport_js_asset}; static constexpr std::array dark{xml_dark_css_asset, search_dark_css_asset}; return locate_all(assets, dark, config); } diff --git a/src/odr/internal/html/frontend.hpp b/src/odr/internal/html/frontend.hpp index d33ea64c..09b50156 100644 --- a/src/odr/internal/html/frontend.hpp +++ b/src/odr/internal/html/frontend.hpp @@ -45,9 +45,12 @@ void write_text_script(const WritingState &state); /// rest of that object, for every view rendering text, whatever the format. void write_search_script(const WritingState &state); -/// Fits the page column to the viewport at load and on every resize, holding -/// the reading position across the change. For output whose width was not known -/// when it was written; @ref odr::HtmlConfig::viewport_width covers the rest. +/// The zoom half of the `odr` object — `getZoom()`, `setZoom(value, focus)`, +/// `adjustZoom(factor, focus)`, `resetZoom(focus)`, `isZoomFitted()`, +/// `onZoomChange` — and, where +/// @ref odr::HtmlConfig::viewport_width left the fit to be measured, the fit +/// itself: at load and on every resize, holding the reading position across the +/// change. A view writing it writes @ref write_zoom_style with it. void write_viewport_script(const WritingState &state); /// What the corresponding `write_*` calls would link, without writing anything: diff --git a/src/odr/internal/html/image_file.cpp b/src/odr/internal/html/image_file.cpp index 84a99784..3947541d 100644 --- a/src/odr/internal/html/image_file.cpp +++ b/src/odr/internal/html/image_file.cpp @@ -56,7 +56,8 @@ class HtmlServiceImpl final : public HtmlService { public: HtmlServiceImpl(ImageFile image_file, HtmlConfig config, const Logger &logger) : HtmlService(std::move(config), logger), - m_image_file{std::move(image_file)} { + m_image_file{std::move(image_file)}, + m_resources{locate_viewport_resources(this->config())} { m_views.emplace_back( std::make_shared(*this, "image", 0, "image.html")); } @@ -66,7 +67,7 @@ class HtmlServiceImpl final : public HtmlService { [[nodiscard]] const HtmlViews &list_views() const override { return m_views; } [[nodiscard]] bool exists(const std::string &path) const override { - return path == "image.html"; + return path == "image.html" || resource_at(m_resources, path) != nullptr; } [[nodiscard]] std::string mimetype(const std::string &path) const override { @@ -74,6 +75,11 @@ class HtmlServiceImpl final : public HtmlService { return "text/html"; } + if (const odr::HtmlResource *resource = resource_at(m_resources, path); + resource != nullptr) { + return resource->mime_type(); + } + throw FileNotFound("Unknown path: " + path); } @@ -84,6 +90,12 @@ class HtmlServiceImpl final : public HtmlService { return; } + if (const odr::HtmlResource *resource = resource_at(m_resources, path); + resource != nullptr) { + resource->write_resource(out); + return; + } + throw FileNotFound("Unknown path: " + path); } @@ -98,6 +110,7 @@ class HtmlServiceImpl final : public HtmlService { HtmlResources write_image(HtmlWriter &out) const { HtmlResources resources; + const WritingState state(out, config(), resources); out.write_begin(); out.write_header_begin(); @@ -105,12 +118,17 @@ class HtmlServiceImpl final : public HtmlService { out.write_header_target("_blank"); out.write_header_title("odr"); write_viewport_meta(out, config(), true); + // An image has no layout width to preserve, so css alone fits it, framed + // or not - no measuring and no `viewport_width`, and the zoom the reader + // sets rides on top of that fit rather than replacing it. + write_zoom_style(out, config(), false, {}); out.write_header_style_begin(); out.out() << "body{margin:0;background:#fff}"; - // An image has no layout width to preserve, so css alone fits it, framed - // or not - no measuring and no `viewport_width`. if (fits_width(config(), true)) { - out.out() << "img{max-width:100%;height:auto}"; + // `100%` of a zoomed body is the viewport again, whatever the zoom, so + // the factor has to be put back for the image to grow with it. + out.out() << "img{max-width:calc(100% * var(--odr-zoom, 1));" + "height:auto}"; } out.write_header_style_end(); if (writes_dark_style(config())) { @@ -133,6 +151,8 @@ class HtmlServiceImpl final : public HtmlService { out.out() << "\">"; } + write_viewport_script(state); + out.write_body_end(); out.write_end(); @@ -141,6 +161,8 @@ class HtmlServiceImpl final : public HtmlService { protected: ImageFile m_image_file; + /// The script this view links; empty of locations when the config embeds it. + HtmlResources m_resources; HtmlViews m_views; }; diff --git a/src/odr/internal/html/pdf_file.cpp b/src/odr/internal/html/pdf_file.cpp index ae5e8f93..8b06c760 100644 --- a/src/odr/internal/html/pdf_file.cpp +++ b/src/odr/internal/html/pdf_file.cpp @@ -1134,10 +1134,8 @@ class HtmlServiceImpl final : public HtmlService { : HtmlService(std::move(config), logger), m_pdf_file{std::move(pdf_file)}, m_resources{locate_search_resources(this->config())} { // declared before any page is parsed, so before the views are known - if (fits_width(this->config(), true)) { - for (auto &&resource : locate_viewport_resources(this->config())) { - m_resources.push_back(std::move(resource)); - } + for (auto &&resource : locate_viewport_resources(this->config())) { + m_resources.push_back(std::move(resource)); } } @@ -1832,9 +1830,7 @@ class HtmlServiceImpl final : public HtmlService { } out.write_element_end("div"); // .d write_search_script(state); - if (fits_at_load_time(content)) { - write_viewport_script(state); - } + write_viewport_script(state); out.write_body_end(); out.write_end(); @@ -2310,9 +2306,7 @@ class HtmlServiceImpl final : public HtmlService { } out.write_element_end("div"); // .d write_search_script(state); - if (fits_at_load_time(content)) { - write_viewport_script(state); - } + write_viewport_script(state); out.write_body_end(); out.write_end(); @@ -2575,12 +2569,6 @@ class HtmlServiceImpl final : public HtmlService { return widest * pt_to_in * 96.0 + page_column_gutter_pixels; } - /// True where the view should fit but no css factor could be written. - bool fits_at_load_time(const std::optional content) const { - return fits_width(config(), true) && - (!config().viewport_width.has_value() || !content.has_value()); - } - /// The document/head prologue shared by both modes, with `write_mode_css()` /// slotted between the constant rules. Leaves the writer after ``. template @@ -2598,8 +2586,7 @@ class HtmlServiceImpl final : public HtmlService { out.write_header_target("_blank"); out.write_header_title("odr"); write_viewport_meta(out, config(), true); - write_viewport_fit_style(out, config(), fits_width(config(), true), - content); + write_zoom_style(out, config(), fits_width(config(), true), content); out.write_header_style_begin(); out.out() << "body{margin:0;background:#525659}"; // `.d`: the page column, sized to the widest page so pages of differing diff --git a/src/odr/internal/html/text_file.cpp b/src/odr/internal/html/text_file.cpp index 4f1ceacb..f7092008 100644 --- a/src/odr/internal/html/text_file.cpp +++ b/src/odr/internal/html/text_file.cpp @@ -99,6 +99,7 @@ class HtmlServiceImpl final : public HtmlService { out.write_header_target("_blank"); out.write_header_title("odr"); write_viewport_meta(out, config(), false); + write_zoom_style(out, config(), false, {}); write_text_style(state); write_text_dark_style(state); @@ -156,6 +157,7 @@ class HtmlServiceImpl final : public HtmlService { write_search_script(state); write_text_script(state); + write_viewport_script(state); out.write_body_end(); diff --git a/src/odr/internal/html/xml_file.cpp b/src/odr/internal/html/xml_file.cpp index 6cc6a9eb..e9389d0f 100644 --- a/src/odr/internal/html/xml_file.cpp +++ b/src/odr/internal/html/xml_file.cpp @@ -259,6 +259,7 @@ class HtmlServiceImpl final : public HtmlService { out.write_header_target("_blank"); out.write_header_title("odr"); write_viewport_meta(out, config(), false); + write_zoom_style(out, config(), false, {}); write_xml_style(state); write_xml_dark_style(state); @@ -276,6 +277,7 @@ class HtmlServiceImpl final : public HtmlService { out.write_element_end("div"); write_search_script(state); + write_viewport_script(state); out.write_body_end(); diff --git a/test/src/html_test.cpp b/test/src/html_test.cpp index 4bf27b02..43ec1d72 100644 --- a/test/src/html_test.cpp +++ b/test/src/html_test.cpp @@ -283,8 +283,9 @@ TEST(html, paged_output_fits_the_viewport) { { // Nothing said how wide the output will be shown, so it measures itself. const std::string html = render(HtmlConfig()); - EXPECT_NE(html.find("body.style.zoom"), std::string::npos); - EXPECT_EQ(html.find("body{zoom:"), std::string::npos); + EXPECT_NE(html.find("--odr-fit:auto"), std::string::npos); + // nothing to apply yet: the view measures the fit and applies it itself + EXPECT_EQ(html.find("--odr-zoom:0"), std::string::npos); } { @@ -292,9 +293,11 @@ TEST(html, paged_output_fits_the_viewport) { HtmlConfig config; config.viewport_width = 400; const std::string html = render(config); + EXPECT_NE(html.find("--odr-fit:0."), std::string::npos); EXPECT_NE(html.find("body{zoom:0."), std::string::npos); - // no script: the factor is in the css, which is the point of configuring it - EXPECT_EQ(html.find("body.style.zoom"), std::string::npos); + // the factor is in the css, which is the point of configuring the width; + // the script is written for the zoom api, not to measure anything + EXPECT_EQ(html.find("--odr-fit:auto"), std::string::npos); } { @@ -303,8 +306,20 @@ TEST(html, paged_output_fits_the_viewport) { config.viewport_mode = HtmlViewportMode::actual_size; config.viewport_width = 400; const std::string html = render(config); - EXPECT_EQ(html.find("body{zoom:"), std::string::npos); - EXPECT_EQ(html.find("body.style.zoom"), std::string::npos); + // no fit stated at all, and so nothing but the print rule to apply + EXPECT_EQ(html.find("--odr-fit:"), std::string::npos); + EXPECT_EQ(html.find("body{zoom:0."), std::string::npos); + } + + { + // A pinned zoom is what the view opens at, fit or no fit. + HtmlConfig config; + config.initial_zoom = 2; + const std::string html = render(config); + EXPECT_NE(html.find("--odr-zoom:2"), std::string::npos); + EXPECT_NE(html.find("body{zoom:2}"), std::string::npos); + // still measured, so `resetZoom()` has a fit to go back to + EXPECT_NE(html.find("--odr-fit:auto"), std::string::npos); } } @@ -355,8 +370,8 @@ TEST(html, each_view_fits_the_page_it_renders) { EXPECT_NEAR(wide_page, 400.0 / (1224 * 96.0 / 72 + 32), 1e-6); } -// An image overflowed its frame the same way a page did, and needs no script: -// it has no layout width to preserve. +// An image overflowed its frame the same way a page did. Css alone fits it — +// it has no layout width to preserve — and the reader's zoom rides on top. TEST(html, an_image_fits_the_viewport) { const auto logger = Logger::create_stdio("odr-test", LogLevel::verbose); @@ -372,9 +387,10 @@ TEST(html, an_image_fits_the_viewport) { return std::move(out).str(); }; - EXPECT_NE(render(HtmlConfig()).find("img{max-width:100%"), std::string::npos); + EXPECT_NE(render(HtmlConfig()).find("img{max-width:calc(100% *"), + std::string::npos); HtmlConfig actual_size; actual_size.viewport_mode = HtmlViewportMode::actual_size; - EXPECT_EQ(render(actual_size).find("img{max-width:100%"), std::string::npos); + EXPECT_EQ(render(actual_size).find("img{max-width:"), std::string::npos); } diff --git a/test/src/internal/html/common_test.cpp b/test/src/internal/html/common_test.cpp index 1c1b81ea..72cd6245 100644 --- a/test/src/internal/html/common_test.cpp +++ b/test/src/internal/html/common_test.cpp @@ -71,14 +71,22 @@ TEST(html_common, viewport_content_beats_modes_and_is_escaped) { namespace { -std::string emit_fit(const HtmlConfig &config, const bool fits, - const std::optional content_pixels) { +std::string emit_zoom(const HtmlConfig &config, const bool fits, + const std::optional content_pixels) { std::ostringstream out; ihtml::HtmlWriter writer(out, false, ""); - ihtml::write_viewport_fit_style(writer, config, fits, content_pixels); + ihtml::write_zoom_style(writer, config, fits, content_pixels); return out.str(); } +/// Written whatever the zoom is, so every case states it. +constexpr const char *print = + "@media print{:root{--odr-zoom:1!important}body{zoom:1!important}}"; + +std::string styled(const std::string &css) { + return ""; +} + } // namespace TEST(html_common, fits_width_follows_the_resolved_mode) { @@ -118,23 +126,43 @@ TEST(html_common, the_fit_scales_the_body_to_the_configured_viewport) { HtmlConfig config; config.viewport_width = 400; - EXPECT_EQ(emit_fit(config, true, 800), ""); + EXPECT_EQ(emit_zoom(config, true, 800), + styled(":root{--odr-fit:0.5;--odr-zoom:0.5}body{zoom:0.5}")); } TEST(html_common, the_fit_never_scales_up) { HtmlConfig config; config.viewport_width = 1200; - EXPECT_EQ(emit_fit(config, true, 800), ""); + EXPECT_EQ(emit_zoom(config, true, 800), styled("")); +} + +TEST(html_common, the_fit_is_left_to_the_view_where_a_width_is_missing) { + HtmlConfig config; + + // no viewport width configured — the view measures itself instead + EXPECT_EQ(emit_zoom(config, true, 800), styled(":root{--odr-fit:auto}")); + + config.viewport_width = 400; + EXPECT_EQ(emit_zoom(config, true, std::nullopt), + styled(":root{--odr-fit:auto}")); + + // nothing to fit: the view opens at actual size + EXPECT_EQ(emit_zoom(config, false, 800), styled("")); } -TEST(html_common, the_fit_needs_both_widths_and_a_reason_to_fit) { +TEST(html_common, a_pinned_zoom_replaces_the_fit_it_opens_at) { HtmlConfig config; + config.initial_zoom = 2; - // no viewport width configured — the load-time script covers it instead - EXPECT_EQ(emit_fit(config, true, 800), ""); + // nothing is fitted, so the pinned zoom stands alone + EXPECT_EQ(emit_zoom(config, false, 800), + styled(":root{--odr-zoom:2}body{zoom:2}")); + // the fit is still stated, so `resetZoom()` has one to go back to config.viewport_width = 400; - EXPECT_EQ(emit_fit(config, true, std::nullopt), ""); - EXPECT_EQ(emit_fit(config, false, 800), ""); + EXPECT_EQ(emit_zoom(config, true, 800), + styled(":root{--odr-fit:0.5;--odr-zoom:2}body{zoom:2}")); + EXPECT_EQ(emit_zoom(config, true, std::nullopt), + styled(":root{--odr-fit:auto;--odr-zoom:2}body{zoom:2}")); } diff --git a/wasm/js/index.d.ts b/wasm/js/index.d.ts index 4df8e190..8fe9cf37 100644 --- a/wasm/js/index.d.ts +++ b/wasm/js/index.d.ts @@ -89,6 +89,8 @@ export interface HtmlConfig { viewportMode?: number; /** The width the output is shown at, in css pixels; fits paged content to it. */ viewportWidth?: number; + /** The zoom the view opens at, 1 being actual size; unset follows the fit. */ + initialZoom?: number; pdfTextMode?: number; } diff --git a/wasm/src/wasm_html.cpp b/wasm/src/wasm_html.cpp index caa2dec8..f63b4565 100644 --- a/wasm/src/wasm_html.cpp +++ b/wasm/src/wasm_html.cpp @@ -145,6 +145,10 @@ HtmlConfig to_html_config(const emscripten::val &value) { !width.isUndefined() && !width.isNull()) { config.viewport_width = width.as(); } + if (const emscripten::val zoom = value["initialZoom"]; + !zoom.isUndefined() && !zoom.isNull()) { + config.initial_zoom = zoom.as(); + } read_enum(value, "pdfTextMode", config.pdf_text_mode); return config; From b2af951ada142a077232ff35d323a9e8f02eef45 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 20 Aug 2026 23:05:08 +0200 Subject: [PATCH 2/4] fix(html): tell a zoom pinned to actual size from no pin at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--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) Claude-Session: https://claude.ai/code/session_015d5RcmsA777vwXiuafjx6k --- src/odr/internal/html/common.cpp | 17 ++++++++++------- src/odr/internal/html/common.hpp | 7 ++++--- src/odr/internal/html/frontend.cpp | 6 +++--- test/src/internal/html/common_test.cpp | 21 ++++++++++++++++++++- 4 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/odr/internal/html/common.cpp b/src/odr/internal/html/common.cpp index b78efad9..4ccbbb1f 100644 --- a/src/odr/internal/html/common.cpp +++ b/src/odr/internal/html/common.cpp @@ -105,13 +105,16 @@ void html::write_zoom_style(HtmlWriter &out, const HtmlConfig &config, return Measure(value, DynamicUnit()).to_string(); }; - // Both default to 1, so only a factor that is not 1 has anything to say. + // The fit defaults to 1, so only a factor that is not 1 has anything to say. const bool writes_fit = !fit.has_value() || *fit != 1; - const bool writes_zoom = zoom.has_value() && *zoom != 1; + // A pin is stated because it was set, not because of what it is: `1` is the + // caller asking for actual size, which is not the same as asking for the fit. + const bool writes_pin = config.initial_zoom.has_value(); + const bool writes_body_zoom = zoom.has_value() && *zoom != 1; out.write_header_style_begin(); - if (writes_fit || writes_zoom) { + if (writes_fit || writes_pin) { out.out() << ":root{"; if (writes_fit) { out.out() << "--odr-fit:"; @@ -120,17 +123,17 @@ void html::write_zoom_style(HtmlWriter &out, const HtmlConfig &config, } else { out.out() << "auto"; } - if (writes_zoom) { + if (writes_pin) { out.out() << ";"; } } - if (writes_zoom) { - out.out() << "--odr-zoom:" << number(*zoom); + if (writes_pin) { + out.out() << "--odr-zoom:" << number(*config.initial_zoom); } out.out() << "}"; } - if (writes_zoom) { + if (writes_body_zoom) { // `zoom` scales the layout, so the page scrolls against the scaled size // instead of overflowing beside it out.out() << "body{zoom:" << number(*zoom) << "}"; diff --git a/src/odr/internal/html/common.hpp b/src/odr/internal/html/common.hpp index a725a2b4..2bae5658 100644 --- a/src/odr/internal/html/common.hpp +++ b/src/odr/internal/html/common.hpp @@ -61,9 +61,10 @@ constexpr double page_column_gutter_pixels = 32; /// Writes the zoom state the view opens at, as css the view script then reads /// and moves: `--odr-fit` is the factor fitting @p content_pixels into /// `config.viewport_width`, `auto` where only the view can measure it, and 1 -/// where @p fits is false; `--odr-zoom` and `body{zoom}` carry what is applied, -/// which is @ref odr::HtmlConfig::initial_zoom where it is set and the fit -/// otherwise. +/// where @p fits is false; `--odr-zoom` states @ref +/// odr::HtmlConfig::initial_zoom, and only that, so a pin is told apart from +/// the fit whatever its value; `body{zoom}` applies the pin where there is one +/// and the fit otherwise. void write_zoom_style(HtmlWriter &out, const HtmlConfig &config, bool fits, std::optional content_pixels); diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index 3f33fc7c..08297983 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -349,10 +349,10 @@ constexpr std::string_view viewport_js = R"js( var measures = declared("--odr-fit") === "auto"; var fit = measures ? 1 : parseFloat(declared("--odr-fit")) || 1; - // What the reader - or the config - pinned the zoom to, `null` while the view - // follows the fit. A written zoom that is just the fit again is not a pin. + // What the config pinned the zoom to, `null` while the view follows the fit - + // which is what the css states, whatever the fit turns out to be. var pinned = parseFloat(declared("--odr-zoom")); - if (!isFinite(pinned) || (!measures && pinned === fit)) { + if (!isFinite(pinned)) { pinned = null; } diff --git a/test/src/internal/html/common_test.cpp b/test/src/internal/html/common_test.cpp index 72cd6245..b40fed0f 100644 --- a/test/src/internal/html/common_test.cpp +++ b/test/src/internal/html/common_test.cpp @@ -126,8 +126,9 @@ TEST(html_common, the_fit_scales_the_body_to_the_configured_viewport) { HtmlConfig config; config.viewport_width = 400; + // `--odr-zoom` states a pin, and nothing pinned this one EXPECT_EQ(emit_zoom(config, true, 800), - styled(":root{--odr-fit:0.5;--odr-zoom:0.5}body{zoom:0.5}")); + styled(":root{--odr-fit:0.5}body{zoom:0.5}")); } TEST(html_common, the_fit_never_scales_up) { @@ -166,3 +167,21 @@ TEST(html_common, a_pinned_zoom_replaces_the_fit_it_opens_at) { EXPECT_EQ(emit_zoom(config, true, std::nullopt), styled(":root{--odr-fit:auto;--odr-zoom:2}body{zoom:2}")); } + +TEST(html_common, a_zoom_pinned_to_actual_size_is_still_a_pin) { + HtmlConfig config; + config.initial_zoom = 1; + + // nothing to apply, but the view has to know the fit was turned down + EXPECT_EQ(emit_zoom(config, true, std::nullopt), + styled(":root{--odr-fit:auto;--odr-zoom:1}")); + + config.viewport_width = 400; + EXPECT_EQ(emit_zoom(config, true, 800), + styled(":root{--odr-fit:0.5;--odr-zoom:1}")); + + // and a pin that lands on the fit is a pin too: a resize leaves it alone + config.initial_zoom = 0.5; + EXPECT_EQ(emit_zoom(config, true, 800), + styled(":root{--odr-fit:0.5;--odr-zoom:0.5}body{zoom:0.5}")); +} From 92819bf7ebc3dac7fad3d831397f75fe242b1813 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 20 Aug 2026 23:11:22 +0200 Subject: [PATCH 3/4] docs(html): trim the zoom comments and changelog entry Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015d5RcmsA777vwXiuafjx6k --- CHANGELOG.md | 11 +++----- src/odr/html.hpp | 1 - src/odr/internal/html/common.cpp | 8 ++---- src/odr/internal/html/common.hpp | 12 ++++----- src/odr/internal/html/frontend.cpp | 38 ++++++++++------------------ src/odr/internal/html/frontend.hpp | 10 +++----- src/odr/internal/html/image_file.cpp | 7 +++-- 7 files changed, 31 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36d88c14..da15156e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,13 +16,10 @@ The release run heads these entries with the version and opens a fresh ## Unreleased -- Every html view exposes a zoom the host drives: `odr.getZoom()`, - `setZoom(value, focus)`, `adjustZoom(factor, focus)`, `resetZoom(focus)`, - `isZoomFitted()` and an `odr.onZoomChange` hook. `1` is actual size, `focus` - is the point the zoom is centred on — a pinch's midpoint — and the reading - position is held across the change. `HtmlConfig::initial_zoom` sets what the - view opens at, script or no script. A view that fits keeps following the - viewport until a zoom is set, and `resetZoom()` puts it back on the fit. +- Html views take a zoom from their host: `odr.getZoom()`, `setZoom(value, + focus)`, `adjustZoom(factor, focus)`, `resetZoom(focus)`, `isZoomFitted()` + and an `onZoomChange` hook, holding the reading position across the change. + `HtmlConfig::initial_zoom` sets what the view opens at, script or no script. - A pdf that nests parentheses inside a string opens, and keeps its document metadata — `cairo` and `pdfTeX` write their `/Producer` that way. - A jni build without a JDK fails instead of shipping a package missing diff --git a/src/odr/html.hpp b/src/odr/html.hpp index 5631a686..6b2c0268 100644 --- a/src/odr/html.hpp +++ b/src/odr/html.hpp @@ -150,7 +150,6 @@ struct HtmlConfig { /// The width the output is shown at, in css pixels; fits paged content to it. std::optional viewport_width; /// The zoom the view opens at, 1 being actual size; unset follows the fit. - /// `odr.setZoom` moves it afterwards. std::optional initial_zoom; /// Indent and break the output into lines rather than writing one stream. diff --git a/src/odr/internal/html/common.cpp b/src/odr/internal/html/common.cpp index 4ccbbb1f..830d0be6 100644 --- a/src/odr/internal/html/common.cpp +++ b/src/odr/internal/html/common.cpp @@ -84,7 +84,6 @@ std::optional html::css_pixels(const std::optional &measure) { void html::write_zoom_style(HtmlWriter &out, const HtmlConfig &config, const bool fits, const std::optional content_pixels) { - // Nothing shown here, so the factor is 1 unless the fit says otherwise. std::optional fit = 1; if (fits) { if (config.viewport_width.has_value() && content_pixels.has_value()) { @@ -105,10 +104,8 @@ void html::write_zoom_style(HtmlWriter &out, const HtmlConfig &config, return Measure(value, DynamicUnit()).to_string(); }; - // The fit defaults to 1, so only a factor that is not 1 has anything to say. const bool writes_fit = !fit.has_value() || *fit != 1; - // A pin is stated because it was set, not because of what it is: `1` is the - // caller asking for actual size, which is not the same as asking for the fit. + // stated because it was set: `1` is actual size asked for, not the fit const bool writes_pin = config.initial_zoom.has_value(); const bool writes_body_zoom = zoom.has_value() && *zoom != 1; @@ -139,8 +136,7 @@ void html::write_zoom_style(HtmlWriter &out, const HtmlConfig &config, out.out() << "body{zoom:" << number(*zoom) << "}"; } - // Paper has its own geometry: whatever the reader is zoomed to, print the - // view at its size. Beats the inline zoom the view script writes. + // paper has its own geometry; beats the script's inline zoom out.out() << "@media print{:root{--odr-zoom:1!important}" "body{zoom:1!important}}"; diff --git a/src/odr/internal/html/common.hpp b/src/odr/internal/html/common.hpp index 2bae5658..fdbb4401 100644 --- a/src/odr/internal/html/common.hpp +++ b/src/odr/internal/html/common.hpp @@ -58,13 +58,11 @@ css_pixels(const std::optional &measure); /// The side gutters the page column puts around its pages, in css pixels. constexpr double page_column_gutter_pixels = 32; -/// Writes the zoom state the view opens at, as css the view script then reads -/// and moves: `--odr-fit` is the factor fitting @p content_pixels into -/// `config.viewport_width`, `auto` where only the view can measure it, and 1 -/// where @p fits is false; `--odr-zoom` states @ref -/// odr::HtmlConfig::initial_zoom, and only that, so a pin is told apart from -/// the fit whatever its value; `body{zoom}` applies the pin where there is one -/// and the fit otherwise. +/// Writes the zoom the view opens at, which the view script reads and moves: +/// `--odr-fit` the factor fitting @p content_pixels into +/// `config.viewport_width`, `auto` where only the view can measure it; +/// `--odr-zoom` @ref odr::HtmlConfig::initial_zoom; `body{zoom}` the one that +/// wins. void write_zoom_style(HtmlWriter &out, const HtmlConfig &config, bool fits, std::optional content_pixels); diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index 08297983..e24679b8 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -322,8 +322,7 @@ constexpr std::string_view document_js = R"js( })(); )js"; -/// Owns the zoom every view opens at: the fit the css could not state, and -/// `odr.setZoom()` and friends on top of it. +/// The zoom api, and the fit where the css could not state it. constexpr std::string_view viewport_js = R"js( (function () { "use strict"; @@ -344,13 +343,11 @@ constexpr std::string_view viewport_js = R"js( return getComputedStyle(root).getPropertyValue(name).trim(); } - // `auto` where the renderer could not state the fit, so only we can measure - // it; a number, defaulting to 1, where it could. + // `auto` where only we can measure the fit; a number where the css states it. var measures = declared("--odr-fit") === "auto"; var fit = measures ? 1 : parseFloat(declared("--odr-fit")) || 1; - // What the config pinned the zoom to, `null` while the view follows the fit - - // which is what the css states, whatever the fit turns out to be. + // `null` while the view follows the fit. var pinned = parseFloat(declared("--odr-zoom")); if (!isFinite(pinned)) { pinned = null; @@ -397,10 +394,8 @@ constexpr std::string_view viewport_js = R"js( // The element under @p point - the top of the viewport where none is given - // and how far into it the point sits. A fraction of the scroll height cannot - // stand in: the height itself changes with the scale. - // - // Only a point given by the caller pins the horizontal too: without one the - // page column centres itself, and holding x would fight that. + // stand in: the height itself changes with the scale. Only a given point + // pins x too, where the page column would otherwise centre itself. function anchor(point) { var x = point ? point.x : Math.floor(root.clientWidth / 2); var y = point ? point.y : 1; @@ -418,8 +413,7 @@ constexpr std::string_view viewport_js = R"js( }; } - // `{x, y}` in viewport coordinates, which a mouse or touch event carries as - // `clientX`/`clientY`, so either can be handed straight to the zoom calls. + // `{x, y}`, or the `clientX`/`clientY` of a mouse or touch event. function point(value) { if (!value) { return null; @@ -461,9 +455,8 @@ constexpr std::string_view viewport_js = R"js( } } - // Writes the zoom out and holds @p target under the top of the viewport. The - // browser applies a scroll offset of its own a few frames later, so the - // position is re-asserted until it settles. + // 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; @@ -498,7 +491,7 @@ constexpr std::string_view viewport_js = R"js( width = root.clientWidth; if (pinned !== null || !measures) { - // The scale does not follow the viewport, so nothing to re-apply. + // the scale does not follow the viewport remember(); return; } @@ -512,28 +505,24 @@ constexpr std::string_view viewport_js = R"js( restoring = false; } - // `1` is actual size, whatever the fit made of it. The value excludes the - // browser's own page and pinch zoom, which no page can read or set. + // `1` is actual size. Excludes the browser's own page and pinch zoom. odr.getZoom = function () { return applied(); }; - // Whether the view still follows the fit rather than a pinned zoom. odr.isZoomFitted = function () { return pinned === null; }; - // @p focus, where given, is the point the zoom is centred on - a pinch's - // midpoint - and stays put across the change. Without one the top of the - // viewport does. + // @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) { var next = Number(value); if (!isFinite(next)) { return applied(); } pinned = Math.min(maxZoom, Math.max(minZoom, next)); - // Read the anchor now rather than trusting the held one: a call is a - // moment we are told about, unlike a resize, which arrives relaid out. + // read now, unlike a resize, which arrives relaid out apply(anchor(point(focus))); return applied(); }; @@ -542,7 +531,6 @@ constexpr std::string_view viewport_js = R"js( return odr.setZoom(applied() * Number(factor), focus); }; - // Back to following the fit, which a resize then keeps up to date again. odr.resetZoom = function (focus) { pinned = null; var target = anchor(point(focus)); diff --git a/src/odr/internal/html/frontend.hpp b/src/odr/internal/html/frontend.hpp index 09b50156..ec8780b4 100644 --- a/src/odr/internal/html/frontend.hpp +++ b/src/odr/internal/html/frontend.hpp @@ -45,12 +45,10 @@ void write_text_script(const WritingState &state); /// rest of that object, for every view rendering text, whatever the format. void write_search_script(const WritingState &state); -/// The zoom half of the `odr` object — `getZoom()`, `setZoom(value, focus)`, -/// `adjustZoom(factor, focus)`, `resetZoom(focus)`, `isZoomFitted()`, -/// `onZoomChange` — and, where -/// @ref odr::HtmlConfig::viewport_width left the fit to be measured, the fit -/// itself: at load and on every resize, holding the reading position across the -/// change. A view writing it writes @ref write_zoom_style with it. +/// `odr.getZoom()`, `setZoom(value, focus)`, `adjustZoom(factor, focus)`, +/// `resetZoom(focus)`, `isZoomFitted()`, `onZoomChange` — and the fit itself +/// where @ref write_zoom_style left it to be measured. Holds the reading +/// position across every change. Written by every view writing that style. void write_viewport_script(const WritingState &state); /// What the corresponding `write_*` calls would link, without writing anything: diff --git a/src/odr/internal/html/image_file.cpp b/src/odr/internal/html/image_file.cpp index 3947541d..93cb539d 100644 --- a/src/odr/internal/html/image_file.cpp +++ b/src/odr/internal/html/image_file.cpp @@ -119,14 +119,13 @@ class HtmlServiceImpl final : public HtmlService { out.write_header_title("odr"); write_viewport_meta(out, config(), true); // An image has no layout width to preserve, so css alone fits it, framed - // or not - no measuring and no `viewport_width`, and the zoom the reader - // sets rides on top of that fit rather than replacing it. + // or not - no measuring and no `viewport_width`. write_zoom_style(out, config(), false, {}); out.write_header_style_begin(); out.out() << "body{margin:0;background:#fff}"; if (fits_width(config(), true)) { - // `100%` of a zoomed body is the viewport again, whatever the zoom, so - // the factor has to be put back for the image to grow with it. + // `100%` of a zoomed body is the viewport again, so the factor has to + // be put back for the image to grow with it out.out() << "img{max-width:calc(100% * var(--odr-zoom, 1));" "height:auto}"; } From 488f67dc9dc59721e79e4cf32ab9244beb67467c Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 20 Aug 2026 23:13:19 +0200 Subject: [PATCH 4/4] docs(html): shorten the zoom doc comments further Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015d5RcmsA777vwXiuafjx6k --- CHANGELOG.md | 5 ++--- src/odr/internal/html/common.hpp | 8 +++----- src/odr/internal/html/frontend.cpp | 7 +++---- src/odr/internal/html/frontend.hpp | 5 ++--- 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da15156e..e277a9dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,9 +17,8 @@ The release run heads these entries with the version and opens a fresh ## Unreleased - Html views take a zoom from their host: `odr.getZoom()`, `setZoom(value, - focus)`, `adjustZoom(factor, focus)`, `resetZoom(focus)`, `isZoomFitted()` - and an `onZoomChange` hook, holding the reading position across the change. - `HtmlConfig::initial_zoom` sets what the view opens at, script or no script. + focus)`, `adjustZoom()`, `resetZoom()`, `isZoomFitted()`, `onZoomChange`. + `HtmlConfig::initial_zoom` sets what they open at, script or no script. - A pdf that nests parentheses inside a string opens, and keeps its document metadata — `cairo` and `pdfTeX` write their `/Producer` that way. - A jni build without a JDK fails instead of shipping a package missing diff --git a/src/odr/internal/html/common.hpp b/src/odr/internal/html/common.hpp index fdbb4401..86365626 100644 --- a/src/odr/internal/html/common.hpp +++ b/src/odr/internal/html/common.hpp @@ -58,11 +58,9 @@ css_pixels(const std::optional &measure); /// The side gutters the page column puts around its pages, in css pixels. constexpr double page_column_gutter_pixels = 32; -/// Writes the zoom the view opens at, which the view script reads and moves: -/// `--odr-fit` the factor fitting @p content_pixels into -/// `config.viewport_width`, `auto` where only the view can measure it; -/// `--odr-zoom` @ref odr::HtmlConfig::initial_zoom; `body{zoom}` the one that -/// wins. +/// The zoom the view opens at: `--odr-fit` fits @p content_pixels into +/// `config.viewport_width` (`auto` where only the view can measure it), +/// `--odr-zoom` pins it, `body{zoom}` applies the winner. void write_zoom_style(HtmlWriter &out, const HtmlConfig &config, bool fits, std::optional content_pixels); diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index e24679b8..c411516b 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -392,10 +392,9 @@ constexpr std::string_view viewport_js = R"js( return content > available ? available / content : 1; } - // The element under @p point - the top of the viewport where none is given - - // and how far into it the point sits. A fraction of the scroll height cannot - // stand in: the height itself changes with the scale. Only a given point - // pins x too, where the page column would otherwise centre itself. + // 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. function anchor(point) { var x = point ? point.x : Math.floor(root.clientWidth / 2); var y = point ? point.y : 1; diff --git a/src/odr/internal/html/frontend.hpp b/src/odr/internal/html/frontend.hpp index ec8780b4..681bd49b 100644 --- a/src/odr/internal/html/frontend.hpp +++ b/src/odr/internal/html/frontend.hpp @@ -46,9 +46,8 @@ 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` — and the fit itself -/// where @ref write_zoom_style left it to be measured. Holds the reading -/// position across every change. Written by every view writing that style. +/// `resetZoom(focus)`, `isZoomFitted()`, `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: