diff --git a/CHANGELOG.md b/CHANGELOG.md index ec1620e1..e277a9dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ 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()`, `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/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..6b2c0268 100644 --- a/src/odr/html.hpp +++ b/src/odr/html.hpp @@ -149,6 +149,8 @@ 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. + 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..830d0be6 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,66 @@ 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) { + 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(); + }; + + const bool writes_fit = !fit.has_value() || *fit != 1; + // 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; 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_pin) { + out.out() << ":root{"; + if (writes_fit) { + out.out() << "--odr-fit:"; + if (fit.has_value()) { + out.out() << number(*fit); + } else { + out.out() << "auto"; + } + if (writes_pin) { + out.out() << ";"; + } + } + if (writes_pin) { + out.out() << "--odr-zoom:" << number(*config.initial_zoom); + } + out.out() << "}"; + } + + 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) << "}"; + } + + // paper has its own geometry; beats the script's inline zoom + 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..86365626 100644 --- a/src/odr/internal/html/common.hpp +++ b/src/odr/internal/html/common.hpp @@ -58,10 +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; -/// 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); +/// 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); 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..c411516b 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -322,19 +322,37 @@ 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. +/// The zoom api, and the fit where the css could not state 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 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; + + // `null` while the view follows the fit. + var pinned = parseFloat(declared("--odr-zoom")); + if (!isFinite(pinned)) { + 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 +364,62 @@ 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, 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; + 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}`, or the `clientX`/`clientY` of a mouse or touch event. + 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 +440,30 @@ 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; + // 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); - 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 +475,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 + remember(); + return; + } + + fit = measureFit(); + apply(target); } function taken() { @@ -442,7 +504,48 @@ constexpr std::string_view viewport_js = R"js( restoring = false; } - fit(); + // `1` is actual size. Excludes the browser's own page and pinch zoom. + odr.getZoom = function () { + return applied(); + }; + + odr.isZoomFitted = function () { + return pinned === 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) { + var next = Number(value); + if (!isFinite(next)) { + return applied(); + } + pinned = Math.min(maxZoom, Math.max(minZoom, next)); + // read now, 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); + }; + + 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 +1615,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..681bd49b 100644 --- a/src/odr/internal/html/frontend.hpp +++ b/src/odr/internal/html/frontend.hpp @@ -45,9 +45,9 @@ 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. +/// `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. 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..93cb539d 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,16 @@ class HtmlServiceImpl final : public HtmlService { out.write_header_target("_blank"); out.write_header_title("odr"); write_viewport_meta(out, config(), true); - 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`. + write_zoom_style(out, config(), false, {}); + out.write_header_style_begin(); + out.out() << "body{margin:0;background:#fff}"; if (fits_width(config(), true)) { - out.out() << "img{max-width:100%;height:auto}"; + // `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}"; } out.write_header_style_end(); if (writes_dark_style(config())) { @@ -133,6 +150,8 @@ class HtmlServiceImpl final : public HtmlService { out.out() << "\">"; } + write_viewport_script(state); + out.write_body_end(); out.write_end(); @@ -141,6 +160,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..b40fed0f 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,62 @@ 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), ""); + // `--odr-zoom` states a pin, and nothing pinned this one + EXPECT_EQ(emit_zoom(config, true, 800), + styled(":root{--odr-fit: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_needs_both_widths_and_a_reason_to_fit) { +TEST(html_common, the_fit_is_left_to_the_view_where_a_width_is_missing) { HtmlConfig config; - // no viewport width configured — the load-time script covers it instead - EXPECT_EQ(emit_fit(config, true, 800), ""); + // 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_fit(config, true, std::nullopt), ""); - EXPECT_EQ(emit_fit(config, false, 800), ""); + 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, a_pinned_zoom_replaces_the_fit_it_opens_at) { + HtmlConfig config; + config.initial_zoom = 2; + + // 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_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}")); +} + +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}")); } 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;