From 2bddc2451f88c3460ee81df1dd515d502e31420c Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 20 Aug 2026 11:44:44 +0200 Subject: [PATCH 1/7] feat(html): fit paged output to the viewport, and hold the reading position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The output stated `width=device-width` and left fitting to whoever showed it. A browser honours a viewport meta tag for the top-level document only, so an embedder rendering into an iframe got no fit at all — the visitor could not even pinch the document, only the page around it — and both apps reimplemented the same judgement, each with its own bugs. `fit_width` now scales the page column itself, which works in a frame and out of it. Two ways in, depending on what the caller knows: - `HtmlConfig::viewport_width` says how wide the output will be shown. The factor is then computed at render time and emitted as css, so the page fits with no script at all — what an embedder under a strict Content-Security-Policy needs. - Otherwise the view measures itself at load and on every resize. The second half is #706's, and it is the half that actually bit: a browser answers a resize by keeping whatever was against the top of the screen, and gets it wrong, because the scale changes with the width. The anchor cannot be read when the resize arrives — by then the browser has already relaid out and moved the scroll — so it is kept current on scroll, and re-asserted for half a second afterwards, since the browser adjusts the offset a few frames late. Anything the reader does ends that. Both renderers share the geometry (page plus a 16px gutter each side), so both get the fit through the same two helpers. Closes #706 Closes #708 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XDs5aK3ZGSZsEvqUUwBBXU --- CHANGELOG.md | 15 ++ apple/include/OdrCoreObjC/ODRHtml.h | 5 + apple/src/ODRHtml.mm | 9 ++ .../app/opendocument/core/HtmlConfig.java | 6 + 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 | 8 ++ src/odr/internal/html/common.cpp | 65 +++++++++ src/odr/internal/html/common.hpp | 24 ++++ src/odr/internal/html/document.cpp | 76 +++++++++- src/odr/internal/html/frontend.cpp | 136 ++++++++++++++++++ src/odr/internal/html/frontend.hpp | 8 ++ src/odr/internal/html/pdf_file.cpp | 48 ++++++- test/src/html_test.cpp | 45 ++++++ test/src/internal/html/common_test.cpp | 70 +++++++++ wasm/js/index.d.ts | 7 + wasm/src/wasm_html.cpp | 4 + 19 files changed, 541 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46ad93a2b..29cb07f76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,21 @@ The release run heads these entries with the version and opens a fresh string, renders that text instead of dropping it. - An embedded font that will not re-encode says so in the log rather than being swapped for a substitute in silence. +- Paged output fits the viewport itself rather than leaving it to the host. A + viewport meta tag is honoured for the top-level document only, so anything + rendering into an iframe got no fit at all and every app reimplemented the + same judgement. `fit_width` now scales the page column to the screen, in the + top-level case and the embedded one alike. Only ever down — a page narrower + than the viewport is shown at its size. +- **New** `HtmlConfig::viewport_width`: the width the output will be shown at, + in css pixels. When set, the fit is a factor in the emitted css, so it needs + no script — which is what lets a page under a strict Content-Security-Policy + embed a fitted document. Bound in the python, wasm, jni and apple bindings as + `viewportWidth`. +- Output that fits itself keeps the reader's place when the viewport changes. + A browser answers a resize by holding what was against the top of the screen + and gets it wrong, because the scale changes with the width — a long document + came back a page or more from where it was. ## v6.9.0 - 2026-08-18 diff --git a/apple/include/OdrCoreObjC/ODRHtml.h b/apple/include/OdrCoreObjC/ODRHtml.h index 816eef9c3..0fe3fc230 100644 --- a/apple/include/OdrCoreObjC/ODRHtml.h +++ b/apple/include/OdrCoreObjC/ODRHtml.h @@ -94,6 +94,11 @@ NS_SWIFT_NAME(HtmlConfig) @property(nonatomic, strong, nullable) NSNumber *spreadsheetViewportMode; /// Raw `content` for the viewport meta tag; overrides the modes above. @property(nonatomic, copy, nullable) NSString *viewportContent; +/// The width the output will be shown at, in css pixels. When set, paged +/// content wider than it is is scaled to fit at render time, needing neither +/// the viewport meta tag nor a script; `nil` makes the output measure itself +/// at load and on every resize instead. +@property(nonatomic, strong, nullable) NSNumber *viewportWidth; @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 bb8fb3201..b85e99197 100644 --- a/apple/src/ODRHtml.mm +++ b/apple/src/ODRHtml.mm @@ -106,6 +106,9 @@ - (instancetype)initWithNativeConfig:(const odr::HtmlConfig &)config { _viewportContent = config.viewport_content.has_value() ? to_nsstring(*config.viewport_content) : nil; + _viewportWidth = config.viewport_width.has_value() + ? @(static_cast(*config.viewport_width)) + : nil; _formatHtml = config.format_html ? YES : NO; _htmlIndent = config.html_indent; _htmlIndentString = to_nsstring(config.html_indent_string); @@ -169,6 +172,12 @@ - (instancetype)initWithNativeConfig:(const odr::HtmlConfig &)config { } else { config.viewport_content.reset(); } + if (_viewportWidth != nil) { + config.viewport_width = + static_cast(_viewportWidth.unsignedIntValue); + } else { + config.viewport_width.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 fd1cc301a..362e54870 100644 --- a/jni/java/app/opendocument/core/HtmlConfig.java +++ b/jni/java/app/opendocument/core/HtmlConfig.java @@ -37,6 +37,12 @@ public final class HtmlConfig { public HtmlViewportMode spreadsheetViewportMode; /** Raw {@code content} for the viewport meta tag; overrides the modes above when set. */ public String viewportContent; + /** + * The width the output will be shown at, in css pixels. When set, paged content wider than it is + * scaled to fit at render time, needing neither the viewport meta tag nor a script; {@code null} + * makes the output measure itself at load and on every resize instead. + */ + public Integer viewportWidth; public boolean formatHtml = false; public int htmlIndent = 1; diff --git a/jni/src/jni_style.cpp b/jni/src/jni_style.cpp index cddad91c3..2c8c78663 100644 --- a/jni/src/jni_style.cpp +++ b/jni/src/jni_style.cpp @@ -368,6 +368,8 @@ jobject html_config_to_java(JNIEnv *env, const odr::HtmlConfig &config) { : -1)); set_object("viewportContent", "Ljava/lang/String;", make_string_opt(env, config.viewport_content)); + set_object("viewportWidth", "Ljava/lang/Integer;", + box_integer(env, config.viewport_width)); set_boolean("formatHtml", config.format_html); set_int("htmlIndent", config.html_indent); set_string("htmlIndentString", config.html_indent_string); @@ -508,6 +510,19 @@ odr::HtmlConfig html_config_from_java(JNIEnv *env, jobject config) { : std::make_optional(static_cast(code)); } result.viewport_content = get_string_opt("viewportContent"); + { + jobject width = get_object("viewportWidth", "Ljava/lang/Integer;"); + if (width == nullptr) { + result.viewport_width = std::nullopt; + } else { + jclass integer_cls = env->GetObjectClass(width); + jmethodID int_value = env->GetMethodID(integer_cls, "intValue", "()I"); + result.viewport_width = + static_cast(env->CallIntMethod(width, int_value)); + env->DeleteLocalRef(integer_cls); + } + env->DeleteLocalRef(width); + } 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 747f79f8c..7a7f1319f 100644 --- a/jni/tests/app/opendocument/core/HtmlTest.java +++ b/jni/tests/app/opendocument/core/HtmlTest.java @@ -38,6 +38,7 @@ void htmlConfigDefaults() { assertEquals(HtmlViewportMode.AUTOMATIC, config.viewportMode); assertNull(config.spreadsheetViewportMode); assertNull(config.viewportContent); + assertNull(config.viewportWidth); } @Test @@ -46,6 +47,7 @@ void viewportConfigRoundTrips() throws IOException { config.viewportMode = HtmlViewportMode.FIT_WIDTH; config.spreadsheetViewportMode = HtmlViewportMode.ACTUAL_SIZE; config.viewportContent = "width=420"; + config.viewportWidth = 420; Path cache = Files.createDirectories(tempDir.resolve("cache")); DecodedFile file = Odr.open(TestFiles.odtFile(tempDir).toString()); @@ -54,6 +56,7 @@ void viewportConfigRoundTrips() throws IOException { assertEquals(HtmlViewportMode.FIT_WIDTH, readBack.viewportMode); assertEquals(HtmlViewportMode.ACTUAL_SIZE, readBack.spreadsheetViewportMode); assertEquals("width=420", readBack.viewportContent); + assertEquals(Integer.valueOf(420), readBack.viewportWidth); } /** 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 ad0b8fa6f..3b2de0696 100644 --- a/python/src/bind_html.cpp +++ b/python/src/bind_html.cpp @@ -90,6 +90,7 @@ void odr_python::bind_html(py::module_ &m) { .def_readwrite("spreadsheet_viewport_mode", &odr::HtmlConfig::spreadsheet_viewport_mode) .def_readwrite("viewport_content", &odr::HtmlConfig::viewport_content) + .def_readwrite("viewport_width", &odr::HtmlConfig::viewport_width) .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 e55c14346..3b346ff0b 100644 --- a/python/tests/test_html.py +++ b/python/tests/test_html.py @@ -39,13 +39,16 @@ def test_html_config_viewport_defaults(): assert config.viewport_mode == pyodr.HtmlViewportMode.automatic assert config.spreadsheet_viewport_mode is None assert config.viewport_content is None + assert config.viewport_width 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 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 def test_viewport_mode_reaches_the_html(odt_path, tmp_path): diff --git a/src/odr/html.hpp b/src/odr/html.hpp index 3a9085643..78d679b2b 100644 --- a/src/odr/html.hpp +++ b/src/odr/html.hpp @@ -147,6 +147,14 @@ struct HtmlConfig { std::optional spreadsheet_viewport_mode; // raw `content` for the viewport meta tag; overrides the modes above std::optional viewport_content; + /// The width the output will be shown at, in css pixels. When set, paged + /// content wider than it is scaled to fit at render time — a plain factor in + /// the emitted css, needing neither the viewport meta tag (which a browser + /// honours for the top-level document only, so a frame's is inert) nor a + /// script. Leave it unset where the width can change — a phone that rotates, + /// a resizable frame: the output then measures itself at load and on every + /// resize, and keeps the reader's place across the change. + std::optional viewport_width; // formatting bool format_html{false}; diff --git a/src/odr/internal/html/common.cpp b/src/odr/internal/html/common.cpp index 4688a7a69..49c0d3fce 100644 --- a/src/odr/internal/html/common.cpp +++ b/src/odr/internal/html/common.cpp @@ -7,11 +7,14 @@ #include #include +#include #include #include #include #include +#include +#include namespace odr::internal { @@ -44,6 +47,68 @@ void html::write_viewport_meta( } } +bool html::fits_width(const HtmlConfig &config, const bool fit_width_by_default, + const std::optional mode_override) { + // A raw `viewport_content` is the caller taking the question over. + if (config.viewport_content.has_value()) { + return false; + } + + const HtmlViewportMode mode = mode_override.value_or(config.viewport_mode); + if (mode == HtmlViewportMode::automatic) { + return fit_width_by_default; + } + return mode == HtmlViewportMode::fit_width; +} + +std::optional html::css_pixels(const std::optional &measure) { + if (!measure.has_value()) { + return {}; + } + + // css absolute lengths, all defined against the inch (css values 3, 5.2). + static const std::unordered_map per_unit{ + {"px", 1.0}, {"in", 96.0}, {"pt", 96.0 / 72.0}, + {"pc", 96.0 / 6}, {"cm", 96.0 / 2.54}, {"mm", 96.0 / 25.4}, + }; + + const auto it = per_unit.find(std::string(measure->unit().name())); + if (it == std::end(per_unit)) { + return {}; + } + const double pixels = measure->magnitude() * it->second; + 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; + } + + 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, + // which is what every reader expects of "fit width". + if (factor >= 1) { + return true; + } + + out.write_header_style_begin(); + // `zoom` rather than `transform: scale()`: it scales the layout, so the page + // column ends up exactly as wide as the viewport and the document scrolls + // and reflows against the scaled size rather than overflowing beside it. + // `Measure` with no unit renders the bare number, positional and never in + // exponent form, which is what css takes. + out.out() << "body{zoom:" << Measure(factor, DynamicUnit()).to_string() + << "}"; + out.write_header_style_end(); + + return true; +} + std::string html::escape_text(std::string text) { if (text.empty()) { return text; diff --git a/src/odr/internal/html/common.hpp b/src/odr/internal/html/common.hpp index f079b0064..352b63e73 100644 --- a/src/odr/internal/html/common.hpp +++ b/src/odr/internal/html/common.hpp @@ -7,6 +7,7 @@ #include #include +#include namespace odr { struct Color; @@ -45,6 +46,29 @@ void write_viewport_meta(HtmlWriter &out, const HtmlConfig &config, bool fit_width_by_default, std::optional mode_override = {}); +/// Whether the output is meant to fit its width to the viewport — the same +/// question @ref write_viewport_meta answers with a meta tag, which a browser +/// honours for the top-level document only. +[[nodiscard]] bool +fits_width(const HtmlConfig &config, bool fit_width_by_default, + std::optional mode_override = {}); + +/// @p measure in css pixels (96 per inch), or nothing where it carries no +/// absolute unit. +[[nodiscard]] std::optional +css_pixels(const std::optional &measure); + +/// The side gutters the page column puts around its widest page, in css +/// pixels — part of the width to fit, and the same in both renderers. +constexpr double page_column_gutter_pixels = 32; + +/// Scales the body so that @p content_pixels of content fits +/// `config.viewport_width`. Writes nothing, and returns false, unless the +/// output fits its width and both widths are known — the load-time script is +/// what covers the rest. +bool write_viewport_fit_style(HtmlWriter &out, const HtmlConfig &config, + bool fits, std::optional content_pixels); + std::string escape_text(std::string text); /// Escape a string for use as an HTML double-quoted attribute value (`&`, `"`, diff --git a/src/odr/internal/html/document.cpp b/src/odr/internal/html/document.cpp index a73530af0..fa9bfe0c4 100644 --- a/src/odr/internal/html/document.cpp +++ b/src/odr/internal/html/document.cpp @@ -32,6 +32,63 @@ bool is_paged_content(const Document &document, const HtmlConfig &config) { document.document_type() == DocumentType::drawing; } +/// The widest page the document lays out, in css pixels, gutters included — +/// what fitting to the viewport has to scale down. Nothing for content that +/// reflows, or whose page carries no absolute width. +std::optional content_pixels(const Document &document) { + const Element root = document.root_element(); + + const auto page_width = [](const auto &page_like) { + return css_pixels(page_like.page_layout().width); + }; + const auto widest = [](const std::optional lhs, + const std::optional rhs) { + if (!lhs.has_value()) { + return rhs; + } + return rhs.has_value() ? std::optional(std::max(*lhs, *rhs)) : lhs; + }; + + std::optional result; + switch (document.document_type()) { + case DocumentType::text: + result = page_width(root.as_text_root()); + break; + case DocumentType::presentation: + for (const Element child : root.children()) { + result = widest(result, page_width(child.as_slide())); + } + break; + case DocumentType::drawing: + for (const Element child : root.children()) { + result = widest(result, page_width(child.as_page())); + } + break; + default: + break; + } + + if (!result.has_value()) { + return {}; + } + return *result + page_column_gutter_pixels; +} + +/// Whether the view has to measure itself at load time to fit the viewport — +/// true where it should fit but the css could not be given the factor. +bool fits_at_load_time(const Document &document, const HtmlConfig &config, + const bool paged_content) { + if (!paged_content || + !fits_width(config, paged_content, + document.document_type() == DocumentType::spreadsheet + ? config.spreadsheet_viewport_mode + : std::nullopt)) { + return false; + } + return !config.viewport_width.has_value() || + !content_pixels(document).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, @@ -48,10 +105,17 @@ void front(const Document &document, const WritingState &state, document.document_type() == DocumentType::spreadsheet && !name.empty() ? escape_text(name) : "odr"); - write_viewport_meta(out, state.config(), paged_content, - document.document_type() == DocumentType::spreadsheet - ? state.config().spreadsheet_viewport_mode - : std::nullopt); + const std::optional mode_override = + document.document_type() == DocumentType::spreadsheet + ? state.config().spreadsheet_viewport_mode + : std::nullopt; + 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(document)); + } write_document_style(state); write_document_dark_style(state); @@ -102,6 +166,10 @@ 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()))) { + write_viewport_script(state); + } out.write_body_end(); out.write_end(); diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index 733406993..d9baaa463 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -322,6 +322,131 @@ constexpr std::string_view document_js = R"js( })(); )js"; +/// The load-time half of fitting the page column to the viewport, for output +/// whose viewport width was not known when it was written. A viewport meta tag +/// cannot do this job: a browser honours only the top-level document's, so a +/// document in a frame is left overflowing. +constexpr std::string_view viewport_js = R"js( +(function () { + "use strict"; + + var root = document.documentElement; + var body = document.body; + + // The width the anchor below was taken at. A scroll event that arrives after + // the viewport has already changed is the browser's own doing, not the + // reader's, and must not be taken for the reading position. + var width = 0; + // Where the reader is, kept current rather than read when a resize arrives: + // by then the browser has already relaid out and moved the scroll, and what + // was against the top of the screen is gone. + var held = null; + // Our own scrolling, which must not be mistaken for the reader's. + var restoring = false; + // Identifies the settling run below, so a newer one - or the reader - ends it. + var settling = 0; + + // The natural width of what the body holds, measured unscaled. + function contentWidth() { + var zoom = body.style.zoom; + body.style.zoom = ""; + var natural = body.scrollWidth; + body.style.zoom = zoom; + return natural; + } + + function fit() { + var available = root.clientWidth; + var content = contentWidth(); + if (!available || !content) { + return; + } + // Only ever down: a page narrower than the viewport is shown at its size. + body.style.zoom = content > available ? available / content : ""; + width = available; + } + + // What the reader is looking at: 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 for this - the height changes with the scale, which is exactly + // what the browser's own guess gets wrong. + function anchor() { + var element = document.elementFromPoint(Math.floor(root.clientWidth / 2), 1); + if (!element) { + return null; + } + var box = element.getBoundingClientRect(); + return { element: element, into: box.height ? -box.top / box.height : 0 }; + } + + function remember() { + if (restoring) { + return; + } + if (root.clientWidth !== width) { + // The viewport changed and the resize event has not arrived - or never + // will, which happens. What is on screen now is the browser's guess, not + // the reader's position, so take neither and fit from here. + resized(); + return; + } + held = anchor(); + } + + function restore(target) { + if (!target || !target.element.isConnected) { + return; + } + var box = target.element.getBoundingClientRect(); + var delta = box.top + target.into * box.height; + if (delta) { + window.scrollBy(0, delta); + } + } + + function resized() { + var target = held; + + fit(); + restoring = true; + restore(target); + + // The browser answers a resize with a scroll offset of its own, a few + // frames later, so the position is re-asserted until it stops moving - and + // dropped the moment the reader takes over. + var token = ++settling; + var frames = 30; + (function again() { + if (token !== settling || frames-- <= 0) { + restoring = false; + remember(); + return; + } + restore(target); + requestAnimationFrame(again); + })(); + } + + function taken() { + ++settling; + restoring = false; + } + + fit(); + remember(); + + window.addEventListener("scroll", remember, { passive: true }); + window.addEventListener("resize", resized); + if (window.visualViewport) { + window.visualViewport.addEventListener("resize", resized); + } + // Anything the reader does ends the re-assertion above. + window.addEventListener("wheel", taken, { passive: true }); + window.addEventListener("touchstart", taken, { passive: true }); + window.addEventListener("keydown", taken); +})(); +)js"; + /// Text search over the rendered page, format-agnostic: it walks text nodes. constexpr std::string_view search_js = R"js( (function () { @@ -1216,6 +1341,8 @@ constexpr Asset spreadsheet_js_asset{HtmlResourceType::js, "text/javascript", "spreadsheet.js", spreadsheet_js}; constexpr Asset text_js_asset{HtmlResourceType::js, "text/javascript", "text.js", text_js}; +constexpr Asset viewport_js_asset{HtmlResourceType::js, "text/javascript", + "viewport.js", viewport_js}; /// Appends @p asset to @p resources; `nullopt` to embed it. HtmlResourceLocation locate(const Asset &asset, const HtmlConfig &config, @@ -1369,6 +1496,10 @@ void html::write_text_script(const WritingState &state) { write_script(text_js_asset, state); } +void html::write_viewport_script(const WritingState &state) { + write_script(viewport_js_asset, 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}; @@ -1388,6 +1519,11 @@ HtmlResources html::locate_search_resources(const HtmlConfig &config) { return locate_all(assets, config); } +HtmlResources html::locate_viewport_resources(const HtmlConfig &config) { + static constexpr std::array assets{viewport_js_asset}; + return locate_all(assets, config); +} + HtmlResources html::locate_media_resources(const HtmlConfig &config) { static constexpr std::array assets{media_css_asset}; return locate_all(assets, config); diff --git a/src/odr/internal/html/frontend.hpp b/src/odr/internal/html/frontend.hpp index f0047335f..8a1cfd596 100644 --- a/src/odr/internal/html/frontend.hpp +++ b/src/odr/internal/html/frontend.hpp @@ -45,6 +45,13 @@ 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 time and on every resize, +/// holding the reading position across the change. For output whose viewport +/// width was not known when it was written — @ref +/// odr::HtmlConfig::viewport_width writes the fit into the css instead, and +/// then this is not needed. +void write_viewport_script(const WritingState &state); + /// What the corresponding `write_*` calls would link, without writing anything: /// a service has to answer for these paths as well as for its views. Every /// entry is located `nullopt` when the config embeds them. @@ -52,5 +59,6 @@ HtmlResources locate_text_resources(const HtmlConfig &config); HtmlResources locate_xml_resources(const HtmlConfig &config); HtmlResources locate_media_resources(const HtmlConfig &config); HtmlResources locate_search_resources(const HtmlConfig &config); +HtmlResources locate_viewport_resources(const HtmlConfig &config); } // namespace odr::internal::html diff --git a/src/odr/internal/html/pdf_file.cpp b/src/odr/internal/html/pdf_file.cpp index 7bfcc83e6..df5b75445 100644 --- a/src/odr/internal/html/pdf_file.cpp +++ b/src/odr/internal/html/pdf_file.cpp @@ -1132,7 +1132,16 @@ class HtmlServiceImpl final : public HtmlService { public: HtmlServiceImpl(PdfFile pdf_file, HtmlConfig config, const Logger &logger) : HtmlService(std::move(config), logger), m_pdf_file{std::move(pdf_file)}, - m_resources{locate_search_resources(this->config())} {} + m_resources{locate_search_resources(this->config())} { + // Declared whether or not a given view ends up writing it — a page is + // parsed long after this, and answering for a path nothing links costs + // nothing. + if (fits_width(this->config(), true)) { + for (auto &&resource : locate_viewport_resources(this->config())) { + m_resources.push_back(std::move(resource)); + } + } + } /// Parses once, applies the `[page_range_begin, page_range_end)` range and /// builds the views: the combined document plus one per rendered page. The @@ -1713,7 +1722,8 @@ class HtmlServiceImpl final : public HtmlService { } substitute_faces.append_faces(font_faces); - write_header_common(state, font_faces, font_styles, styles, [&] { + const std::optional content = content_pixels(pages_out); + write_header_common(state, font_faces, font_styles, styles, content, [&] { // Visual layer glyph spans: not selectable (selection rides the `.sel` // layer). out.out() << ".g{user-select:none}"; @@ -1824,6 +1834,9 @@ 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); + } out.write_body_end(); out.write_end(); @@ -2187,7 +2200,8 @@ class HtmlServiceImpl final : public HtmlService { substitute_faces.append_faces(font_faces); // ---- Pass 2: write HTML --------------------------------------------- - write_header_common(state, font_faces, font_styles, styles, [&] { + const std::optional content = content_pixels(pages_out); + write_header_common(state, font_faces, font_styles, styles, content, [&] { // Invisible text render modes (Tr 3/7). out.out() << ".i{color:transparent}"; // Unclean glyphs via generated content, out of the DOM text stream. @@ -2298,6 +2312,9 @@ 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); + } out.write_body_end(); out.write_end(); @@ -2548,11 +2565,34 @@ class HtmlServiceImpl final : public HtmlService { /// The document/head prologue shared by both modes, with `write_mode_css()` /// slotted between the constant rules. Leaves the writer after ``. + /// The widest page a view holds, in css pixels and with `.d`'s side gutters + /// included — what fitting to the viewport has to scale down. + template + static std::optional + content_pixels(const std::vector &pages) { + double widest = 0; + for (const PageOut &page : pages) { + widest = std::max(widest, page.width); + } + if (widest <= 0) { + return {}; + } + return widest * pt_to_in * 96.0 + page_column_gutter_pixels; + } + + /// Whether the view has to measure itself at load time to fit the viewport — + /// true where it should fit but the css could not be given the factor. + bool fits_at_load_time(const std::optional content) const { + return fits_width(config(), true) && + (!config().viewport_width.has_value() || !content.has_value()); + } + template void write_header_common(const WritingState &state, const std::string &font_faces, const std::string &font_styles, const AtomicStyles &styles, + const std::optional content, WriteModeCss &&write_mode_css) const { HtmlWriter &out = state.out(); @@ -2562,6 +2602,8 @@ 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); 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/test/src/html_test.cpp b/test/src/html_test.cpp index 5cddf632d..6d566539e 100644 --- a/test/src/html_test.cpp +++ b/test/src/html_test.cpp @@ -261,3 +261,48 @@ TEST(html, views) { EXPECT_EQ(views.at(1).name(), "Foglio1"); EXPECT_EQ(views.at(2).name(), "Foglio2"); } + +// #708: a viewport meta tag is honoured for the top-level document only, so an +// embedder rendering into a frame got no fit at all. #706: and whatever fits +// has to hold the reader's place when the viewport changes under it. +TEST(html, paged_output_fits_the_viewport) { + const auto logger = Logger::create_stdio("odr-test", LogLevel::verbose); + + const DecodedFile file( + TestData::test_file_path("odr-public/odp/style-various-1.odp"), logger); + + const auto render = [&](const HtmlConfig &config) { + const std::string cache = + (std::filesystem::current_path() / "fit").string(); + std::ostringstream out; + html::translate(file, cache, config).list_views().at(0).write_html(out); + return std::move(out).str(); + }; + + { + // 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); + } + + { + // A slide is 28cm wide here, well over the 400 css pixels configured. + HtmlConfig config; + config.viewport_width = 400; + const std::string html = render(config); + 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); + } + + { + // Told not to fit, neither half applies. + HtmlConfig config; + 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); + } +} diff --git a/test/src/internal/html/common_test.cpp b/test/src/internal/html/common_test.cpp index befb99a8b..1c1b81ea6 100644 --- a/test/src/internal/html/common_test.cpp +++ b/test/src/internal/html/common_test.cpp @@ -68,3 +68,73 @@ TEST(html_common, viewport_content_beats_modes_and_is_escaped) { emit_viewport(config, true), R"()"); } + +namespace { + +std::string emit_fit(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); + return out.str(); +} + +} // namespace + +TEST(html_common, fits_width_follows_the_resolved_mode) { + HtmlConfig config; + + EXPECT_TRUE(ihtml::fits_width(config, true)); + EXPECT_FALSE(ihtml::fits_width(config, false)); + + config.viewport_mode = HtmlViewportMode::fit_width; + EXPECT_TRUE(ihtml::fits_width(config, false)); + + config.viewport_mode = HtmlViewportMode::actual_size; + EXPECT_FALSE(ihtml::fits_width(config, true)); + + config.viewport_mode = HtmlViewportMode::automatic; + EXPECT_TRUE(ihtml::fits_width(config, true, HtmlViewportMode::fit_width)); + EXPECT_FALSE(ihtml::fits_width(config, true, HtmlViewportMode::none)); + + // the caller took the question over + config.viewport_content = "width=420"; + EXPECT_FALSE(ihtml::fits_width(config, true)); +} + +TEST(html_common, css_pixels_converts_the_absolute_units) { + EXPECT_EQ(ihtml::css_pixels(Measure(1, DynamicUnit("in"))), 96.0); + EXPECT_EQ(ihtml::css_pixels(Measure(72, DynamicUnit("pt"))), 96.0); + EXPECT_EQ(ihtml::css_pixels(Measure(2.54, DynamicUnit("cm"))), 96.0); + EXPECT_EQ(ihtml::css_pixels(Measure(25.4, DynamicUnit("mm"))), 96.0); + EXPECT_EQ(ihtml::css_pixels(Measure(96, DynamicUnit("px"))), 96.0); + + EXPECT_FALSE(ihtml::css_pixels(std::nullopt).has_value()); + EXPECT_FALSE(ihtml::css_pixels(Measure(50, DynamicUnit("%"))).has_value()); + EXPECT_FALSE(ihtml::css_pixels(Measure(0, DynamicUnit("in"))).has_value()); +} + +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), ""); +} + +TEST(html_common, the_fit_never_scales_up) { + HtmlConfig config; + config.viewport_width = 1200; + + EXPECT_EQ(emit_fit(config, true, 800), ""); +} + +TEST(html_common, the_fit_needs_both_widths_and_a_reason_to_fit) { + HtmlConfig config; + + // no viewport width configured — the load-time script covers it instead + EXPECT_EQ(emit_fit(config, true, 800), ""); + + config.viewport_width = 400; + EXPECT_EQ(emit_fit(config, true, std::nullopt), ""); + EXPECT_EQ(emit_fit(config, false, 800), ""); +} diff --git a/wasm/js/index.d.ts b/wasm/js/index.d.ts index 66b1e5696..59889764e 100644 --- a/wasm/js/index.d.ts +++ b/wasm/js/index.d.ts @@ -87,6 +87,13 @@ export interface HtmlConfig { colorScheme?: number; spreadsheetGridlines?: number; viewportMode?: number; + /** + * The width the output will be shown at, in css pixels. When set, paged + * content wider than it is scaled to fit at render time — no viewport meta + * tag (inert in a frame) and no script needed. Leave it out where the width + * can change: the output then measures itself at load and on every resize. + */ + viewportWidth?: number; pdfTextMode?: number; } diff --git a/wasm/src/wasm_html.cpp b/wasm/src/wasm_html.cpp index 416553483..caa2dec81 100644 --- a/wasm/src/wasm_html.cpp +++ b/wasm/src/wasm_html.cpp @@ -141,6 +141,10 @@ HtmlConfig to_html_config(const emscripten::val &value) { read_enum(value, "colorScheme", config.color_scheme); read_enum(value, "spreadsheetGridlines", config.spreadsheet_gridlines); read_enum(value, "viewportMode", config.viewport_mode); + if (const emscripten::val width = value["viewportWidth"]; + !width.isUndefined() && !width.isNull()) { + config.viewport_width = width.as(); + } read_enum(value, "pdfTextMode", config.pdf_text_mode); return config; From 228edaa91c3539887c4290f29952eda51229ebae Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 20 Aug 2026 12:01:56 +0200 Subject: [PATCH 2/7] fix(html): fit each view to its own page, and leave a pinch alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings: - A per-slide or per-page view passed the whole document's widest page, so a deck whose master pages differ in width scaled its narrow slides down further than they needed. Each fragment answers for the page it renders now; the widest is kept for `document.html`, which writes them all into one file. No fixture in the corpus mixes page widths, so this is not covered by a test. - A `visualViewport` resize fires throughout a pinch gesture, and each one restarted the settling loop — undoing the cancellation `taken()` had just made and letting `scrollBy` fight the reader's pan. A resize that does not change the layout width changes no scale either, so it is now ignored: pinch and height-only changes both fall through. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XDs5aK3ZGSZsEvqUUwBBXU --- src/odr/internal/html/document.cpp | 103 ++++++++++++++++++++--------- src/odr/internal/html/frontend.cpp | 7 ++ 2 files changed, 77 insertions(+), 33 deletions(-) diff --git a/src/odr/internal/html/document.cpp b/src/odr/internal/html/document.cpp index fa9bfe0c4..ab15db740 100644 --- a/src/odr/internal/html/document.cpp +++ b/src/odr/internal/html/document.cpp @@ -32,15 +32,34 @@ bool is_paged_content(const Document &document, const HtmlConfig &config) { document.document_type() == DocumentType::drawing; } -/// The widest page the document lays out, in css pixels, gutters included — -/// what fitting to the viewport has to scale down. Nothing for content that -/// reflows, or whose page carries no absolute width. -std::optional content_pixels(const Document &document) { +/// A page box plus the gutters the column puts around it, in css pixels — +/// what fitting to the viewport has to scale down. +std::optional page_content_pixels(const PageLayout &page_layout) { + const std::optional width = css_pixels(page_layout.width); + if (!width.has_value()) { + return {}; + } + return *width + page_column_gutter_pixels; +} + +/// Per view, so a deck whose master pages differ in width fits each slide to +/// the viewport rather than all of them to the widest. +std::optional fragment_content_pixels(const TextRoot &element) { + return page_content_pixels(element.page_layout()); +} +std::optional fragment_content_pixels(const Slide &element) { + return page_content_pixels(element.page_layout()); +} +std::optional fragment_content_pixels(const Page &element) { + return page_content_pixels(element.page_layout()); +} +/// A sheet reflows; there is no page box to fit. +std::optional fragment_content_pixels(const Sheet &) { return {}; } + +/// The widest of them, for the view that writes every page into one file. +std::optional document_content_pixels(const Document &document) { const Element root = document.root_element(); - const auto page_width = [](const auto &page_like) { - return css_pixels(page_like.page_layout().width); - }; const auto widest = [](const std::optional lhs, const std::optional rhs) { if (!lhs.has_value()) { @@ -52,47 +71,50 @@ std::optional content_pixels(const Document &document) { std::optional result; switch (document.document_type()) { case DocumentType::text: - result = page_width(root.as_text_root()); + result = fragment_content_pixels(root.as_text_root()); break; case DocumentType::presentation: for (const Element child : root.children()) { - result = widest(result, page_width(child.as_slide())); + result = widest(result, fragment_content_pixels(child.as_slide())); } break; case DocumentType::drawing: for (const Element child : root.children()) { - result = widest(result, page_width(child.as_page())); + result = widest(result, fragment_content_pixels(child.as_page())); } break; default: break; } - if (!result.has_value()) { - return {}; - } - return *result + page_column_gutter_pixels; + return result; +} + +/// A spreadsheet answers the viewport question with its own mode. +std::optional +viewport_mode_override(const Document &document, const HtmlConfig &config) { + return document.document_type() == DocumentType::spreadsheet + ? config.spreadsheet_viewport_mode + : std::nullopt; } /// Whether the view has to measure itself at load time to fit the viewport — /// true where it should fit but the css could not be given the factor. bool fits_at_load_time(const Document &document, const HtmlConfig &config, - const bool paged_content) { - if (!paged_content || - !fits_width(config, paged_content, - document.document_type() == DocumentType::spreadsheet - ? config.spreadsheet_viewport_mode - : std::nullopt)) { + 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(document).has_value(); + 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, - const std::string &name) { + const std::string &name, + const std::optional content_pixels) { HtmlWriter &out = state.out(); const bool paged_content = is_paged_content(document, state.config()); @@ -106,15 +128,13 @@ void front(const Document &document, const WritingState &state, ? escape_text(name) : "odr"); const std::optional mode_override = - document.document_type() == DocumentType::spreadsheet - ? state.config().spreadsheet_viewport_mode - : std::nullopt; + 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(document)); + content_pixels); } write_document_style(state); @@ -154,7 +174,8 @@ void front(const Document &document, const WritingState &state, } } -void back(const Document &document, const WritingState &state) { +void back(const Document &document, const WritingState &state, + const std::optional content_pixels) { HtmlWriter &out = state.out(); if (is_paged_content(document, state.config())) { @@ -167,7 +188,8 @@ void back(const Document &document, const WritingState &state) { write_spreadsheet_script(state); } if (fits_at_load_time(document, state.config(), - is_paged_content(document, state.config()))) { + is_paged_content(document, state.config()), + content_pixels)) { write_viewport_script(state); } @@ -190,10 +212,14 @@ class HtmlFragmentBase { virtual void write_fragment(HtmlWriter &out, WritingState &state) const = 0; + /// The width this one view lays out, which is what it is fitted against. + [[nodiscard]] virtual std::optional content_pixels() const = 0; + void write_document(HtmlWriter &out, WritingState &state) const { - front(m_document, state, m_name); + const std::optional content = content_pixels(); + front(m_document, state, m_name, content); write_fragment(out, state); - back(m_document, state); + back(m_document, state, content); } protected: @@ -339,11 +365,14 @@ class HtmlServiceImpl final : public HtmlService { WritingState state(out, config(), resources); - front(m_document, state, ""); + // every page in one file, so the column is as wide as the widest of them + const std::optional content = document_content_pixels(m_document); + + front(m_document, state, "", content); for (const auto &fragment : m_fragments) { fragment->write_fragment(out, state); } - back(m_document, state); + back(m_document, state, content); return resources; } @@ -366,6 +395,10 @@ class TextHtmlFragment final : public HtmlFragmentBase { : HtmlFragmentBase(std::move(name), index, std::move(path), std::move(document)) {} + [[nodiscard]] std::optional content_pixels() const override { + return fragment_content_pixels(m_document.root_element().as_text_root()); + } + void write_fragment(HtmlWriter &out, WritingState &state) const override { const Element root = m_document.root_element(); const TextRoot element = root.as_text_root(); @@ -409,6 +442,10 @@ class ElementHtmlFragment final : public HtmlFragmentBase { std::move(document)), m_element{element} {} + [[nodiscard]] std::optional content_pixels() const override { + return fragment_content_pixels(m_element); + } + void write_fragment(HtmlWriter &, WritingState &state) const override { Translate(m_element, state); } diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index d9baaa463..dae0ca381 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -405,6 +405,13 @@ constexpr std::string_view viewport_js = R"js( } function resized() { + if (root.clientWidth === width) { + // Nothing that changes the scale: a height-only change, or a pinch, + // which moves the visual viewport and fires here without touching the + // layout width. Restoring through a gesture would fight the reader. + return; + } + var target = held; fit(); From f48e9ec570025bf2ae5149855c57c09cfaf98fe8 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 20 Aug 2026 12:04:58 +0200 Subject: [PATCH 3/7] test(html): pin per-view fitting with a mixed-rotation pdf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/Rotate` makes a page as wide as the reader sees it, so one document holds pages of different widths — which is how mixed widths actually turn up, no odf deck in the corpus mixes them. A two-page mini-pdf, one page turned a quarter, now covers the contract: each page view is fitted to its own page and the view holding both to the wider one. It fails on the "use the widest everywhere" behaviour this replaced. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XDs5aK3ZGSZsEvqUUwBBXU --- test/src/html_test.cpp | 51 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/test/src/html_test.cpp b/test/src/html_test.cpp index 6d566539e..ca3e40afd 100644 --- a/test/src/html_test.cpp +++ b/test/src/html_test.cpp @@ -7,6 +7,8 @@ #include +#include + #include #include @@ -306,3 +308,52 @@ TEST(html, paged_output_fits_the_viewport) { EXPECT_EQ(html.find("body.style.zoom"), std::string::npos); } } + +// A page turned by `/Rotate` is as wide as the reader sees it, so one document +// can hold pages of different widths — which is how mixed widths turn up in the +// wild. Each view is then fitted to the page it renders, not to the widest. +TEST(html, each_view_fits_the_page_it_renders) { + test::pdf::PdfFileBuilder builder; + builder.object("<< /Type /Catalog /Pages 2 0 R >>") + .object("<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>") + // 612pt wide as it stands + .object("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>") + // 792pt tall on paper, but a quarter turn makes it 1224pt wide on screen + .object("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 792 1224] " + "/Rotate 90 >>"); + + const std::string path = + (std::filesystem::current_path() / "mixed_rotation.pdf").string(); + { + std::ofstream out(path, std::ios::binary); + out << builder.trailer("/Root 1 0 R").build_classic(); + } + + HtmlConfig config; + config.viewport_width = 400; + + const DecodedFile file{path}; + const HtmlService service = html::translate( + file, (std::filesystem::current_path() / "rotate").string(), config); + + const auto factor_of = [&](const std::size_t view) { + std::ostringstream out; + service.list_views().at(view).write_html(out); + const std::string html = std::move(out).str(); + const std::size_t at = html.find("body{zoom:"); + EXPECT_NE(at, std::string::npos); + return std::stod(html.substr(at + 10)); + }; + + const double document_view = factor_of(0); + const double narrow_page = factor_of(1); + const double wide_page = factor_of(2); + + // the narrow page is scaled down less than the wide one + EXPECT_GT(narrow_page, wide_page); + // and the view holding both is fitted to the wide one + EXPECT_DOUBLE_EQ(document_view, wide_page); + // 400 / (612pt + 32px) and 400 / (1224pt + 32px), in css pixels + EXPECT_NEAR(narrow_page, 400.0 / (612 * 96.0 / 72 + 32), 1e-6); + EXPECT_NEAR(wide_page, 400.0 / (1224 * 96.0 / 72 + 32), 1e-6); +} From 4f98069e7f00d8d4c6380b0302791e01ffcccecc Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 20 Aug 2026 12:29:50 +0200 Subject: [PATCH 4/7] fix(html): scale at load only where a viewport meta tag cannot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fitting every `fit_width` view at load changed 86 of 377 public reference renders — every slide deck and every `.doc` — because a desktop window is narrower than a 28cm slide. Shrinking a wide page in a desktop browser is not what either issue asked for, and it is a behaviour change every consumer would have to absorb. The load-time half is now for the case that has no other answer: a document in a frame, where the meta tag is inert. A top-level document is left to the tag, as before, so the reference output does not move. A host that does want the fit says so with `viewport_width` and gets it as a css factor, framed or not — which is the better answer for the apps anyway, since they know their web view's width. The reading position is held in both contexts; it moves no pixel at load. Verified in Chrome: framed at 500px, `zoom 0.589623`, no overflow; top-level at 1512px, no zoom and markup unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XDs5aK3ZGSZsEvqUUwBBXU --- CHANGELOG.md | 20 ++++++++++---------- src/odr/html.hpp | 10 +++++++--- src/odr/internal/html/frontend.cpp | 20 ++++++++++++++++++-- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29cb07f76..fa241e816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,17 +47,17 @@ The release run heads these entries with the version and opens a fresh string, renders that text instead of dropping it. - An embedded font that will not re-encode says so in the log rather than being swapped for a substitute in silence. -- Paged output fits the viewport itself rather than leaving it to the host. A - viewport meta tag is honoured for the top-level document only, so anything - rendering into an iframe got no fit at all and every app reimplemented the - same judgement. `fit_width` now scales the page column to the screen, in the - top-level case and the embedded one alike. Only ever down — a page narrower - than the viewport is shown at its size. +- Paged output rendered **into a frame** fits the viewport itself. A viewport + meta tag is honoured for the top-level document only, so an embedder got no + fit at all and a visitor on a phone could not even pinch the document. A + framed `fit_width` view now scales its page column to the frame; only ever + down, and a top-level document is left to the meta tag exactly as before. - **New** `HtmlConfig::viewport_width`: the width the output will be shown at, - in css pixels. When set, the fit is a factor in the emitted css, so it needs - no script — which is what lets a page under a strict Content-Security-Policy - embed a fitted document. Bound in the python, wasm, jni and apple bindings as - `viewportWidth`. + in css pixels. When set, the fit is a factor in the emitted css — no script, + and it applies framed or not, so a host that knows its width (a web view, a + fixed-size frame) can stop reimplementing the fit itself. It is also what + lets a page under a strict Content-Security-Policy embed a fitted document. + Bound in the python, wasm, jni and apple bindings as `viewportWidth`. - Output that fits itself keeps the reader's place when the viewport changes. A browser answers a resize by holding what was against the top of the screen and gets it wrong, because the scale changes with the width — a long document diff --git a/src/odr/html.hpp b/src/odr/html.hpp index 78d679b2b..532e2f69f 100644 --- a/src/odr/html.hpp +++ b/src/odr/html.hpp @@ -151,9 +151,13 @@ struct HtmlConfig { /// content wider than it is scaled to fit at render time — a plain factor in /// the emitted css, needing neither the viewport meta tag (which a browser /// honours for the top-level document only, so a frame's is inert) nor a - /// script. Leave it unset where the width can change — a phone that rotates, - /// a resizable frame: the output then measures itself at load and on every - /// resize, and keeps the reader's place across the change. + /// script. This is how a host that knows its width — a web view, a + /// fixed-size frame — gets the fit whatever the context. + /// + /// Left unset, a document *in a frame* measures itself at load and on every + /// resize, since nothing else can fit it there; a top-level document is left + /// to the viewport meta tag, as it always was. Either way the output keeps + /// the reader's place when the viewport changes under it. std::optional viewport_width; // formatting diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index dae0ca381..162787a9a 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -333,6 +333,15 @@ constexpr std::string_view viewport_js = R"js( var root = document.documentElement; var body = document.body; + // A viewport meta tag is honoured for the top-level document only, so that is + // exactly where it is not this script's job to scale anything: doing it there + // too would shrink a wide page in a desktop window, which no reader asked + // for. In a frame the tag is inert and there is nothing else to fit the page. + // Output whose width is known when it is written says so with + // `HtmlConfig::viewport_width` and gets the factor in its css instead, framed + // or not. + var framed = window.top !== window.self; + // The width the anchor below was taken at. A scroll event that arrives after // the viewport has already changed is the browser's own doing, not the // reader's, and must not be taken for the reading position. @@ -357,13 +366,20 @@ constexpr std::string_view viewport_js = R"js( function fit() { var available = root.clientWidth; + if (!available) { + return; + } + width = available; + + if (!framed) { + return; + } var content = contentWidth(); - if (!available || !content) { + if (!content) { return; } // Only ever down: a page narrower than the viewport is shown at its size. body.style.zoom = content > available ? available / content : ""; - width = available; } // What the reader is looking at: the element against the top of the viewport, From db04363d9fadd81464fdb2670646b72cfcb0e7ae Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 20 Aug 2026 21:58:52 +0200 Subject: [PATCH 5/7] docs(html): trim the viewport comments to what is not in the code Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015d5RcmsA777vwXiuafjx6k --- CHANGELOG.md | 16 +++---- apple/include/OdrCoreObjC/ODRHtml.h | 7 ++- .../app/opendocument/core/HtmlConfig.java | 5 +-- src/odr/html.hpp | 15 ++----- src/odr/internal/html/common.cpp | 9 ++-- src/odr/internal/html/common.hpp | 15 +++---- src/odr/internal/html/document.cpp | 10 ++--- src/odr/internal/html/frontend.cpp | 43 +++++++------------ src/odr/internal/html/frontend.hpp | 8 ++-- src/odr/internal/html/pdf_file.cpp | 16 +++---- test/src/html_test.cpp | 11 ++--- wasm/js/index.d.ts | 7 ++- 12 files changed, 59 insertions(+), 103 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa241e816..3d43b7072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,19 +49,13 @@ The release run heads these entries with the version and opens a fresh swapped for a substitute in silence. - Paged output rendered **into a frame** fits the viewport itself. A viewport meta tag is honoured for the top-level document only, so an embedder got no - fit at all and a visitor on a phone could not even pinch the document. A - framed `fit_width` view now scales its page column to the frame; only ever - down, and a top-level document is left to the meta tag exactly as before. + fit at all. Only ever down, and a top-level document is left to the meta tag. - **New** `HtmlConfig::viewport_width`: the width the output will be shown at, - in css pixels. When set, the fit is a factor in the emitted css — no script, - and it applies framed or not, so a host that knows its width (a web view, a - fixed-size frame) can stop reimplementing the fit itself. It is also what - lets a page under a strict Content-Security-Policy embed a fitted document. - Bound in the python, wasm, jni and apple bindings as `viewportWidth`. + in css pixels. The fit is then a factor in the emitted css — no script, framed + or not. Bound in the python, wasm, jni and apple bindings as `viewportWidth`. - Output that fits itself keeps the reader's place when the viewport changes. - A browser answers a resize by holding what was against the top of the screen - and gets it wrong, because the scale changes with the width — a long document - came back a page or more from where it was. + The browser's own guess is wrong here because the scale changes with the + width, and a long document came back a page or more from where it was. ## v6.9.0 - 2026-08-18 diff --git a/apple/include/OdrCoreObjC/ODRHtml.h b/apple/include/OdrCoreObjC/ODRHtml.h index 0fe3fc230..9df4214f7 100644 --- a/apple/include/OdrCoreObjC/ODRHtml.h +++ b/apple/include/OdrCoreObjC/ODRHtml.h @@ -94,10 +94,9 @@ NS_SWIFT_NAME(HtmlConfig) @property(nonatomic, strong, nullable) NSNumber *spreadsheetViewportMode; /// Raw `content` for the viewport meta tag; overrides the modes above. @property(nonatomic, copy, nullable) NSString *viewportContent; -/// The width the output will be shown at, in css pixels. When set, paged -/// content wider than it is is scaled to fit at render time, needing neither -/// the viewport meta tag nor a script; `nil` makes the output measure itself -/// at load and on every resize instead. +/// The width the output will be shown at, in css pixels. Paged content wider +/// than it is scaled down at render time; `nil` makes the output measure itself +/// at load instead. @property(nonatomic, strong, nullable) NSNumber *viewportWidth; @property(nonatomic) BOOL formatHtml; diff --git a/jni/java/app/opendocument/core/HtmlConfig.java b/jni/java/app/opendocument/core/HtmlConfig.java index 362e54870..e4b17a47a 100644 --- a/jni/java/app/opendocument/core/HtmlConfig.java +++ b/jni/java/app/opendocument/core/HtmlConfig.java @@ -38,9 +38,8 @@ public final class HtmlConfig { /** Raw {@code content} for the viewport meta tag; overrides the modes above when set. */ public String viewportContent; /** - * The width the output will be shown at, in css pixels. When set, paged content wider than it is - * scaled to fit at render time, needing neither the viewport meta tag nor a script; {@code null} - * makes the output measure itself at load and on every resize instead. + * The width the output will be shown at, in css pixels. Paged content wider than it is scaled + * down at render time; {@code null} makes the output measure itself at load instead. */ public Integer viewportWidth; diff --git a/src/odr/html.hpp b/src/odr/html.hpp index 532e2f69f..fa9d7dbbf 100644 --- a/src/odr/html.hpp +++ b/src/odr/html.hpp @@ -147,17 +147,10 @@ struct HtmlConfig { std::optional spreadsheet_viewport_mode; // raw `content` for the viewport meta tag; overrides the modes above std::optional viewport_content; - /// The width the output will be shown at, in css pixels. When set, paged - /// content wider than it is scaled to fit at render time — a plain factor in - /// the emitted css, needing neither the viewport meta tag (which a browser - /// honours for the top-level document only, so a frame's is inert) nor a - /// script. This is how a host that knows its width — a web view, a - /// fixed-size frame — gets the fit whatever the context. - /// - /// Left unset, a document *in a frame* measures itself at load and on every - /// resize, since nothing else can fit it there; a top-level document is left - /// to the viewport meta tag, as it always was. Either way the output keeps - /// the reader's place when the viewport changes under it. + /// The width the output will be shown at, in css pixels. Paged content wider + /// than it is scaled down at render time, as a factor in the emitted css — + /// no meta tag, which a browser honours for the top-level document only, and + /// no script. Unset, a framed document measures itself at load instead. std::optional viewport_width; // formatting diff --git a/src/odr/internal/html/common.cpp b/src/odr/internal/html/common.cpp index 49c0d3fce..5840b5eed 100644 --- a/src/odr/internal/html/common.cpp +++ b/src/odr/internal/html/common.cpp @@ -90,18 +90,15 @@ bool html::write_viewport_fit_style( 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, - // which is what every reader expects of "fit width". + // only ever down: a page narrower than the viewport is shown at its size if (factor >= 1) { return true; } out.write_header_style_begin(); // `zoom` rather than `transform: scale()`: it scales the layout, so the page - // column ends up exactly as wide as the viewport and the document scrolls - // and reflows against the scaled size rather than overflowing beside it. - // `Measure` with no unit renders the bare number, positional and never in - // exponent form, which is what css takes. + // scrolls against the scaled size instead of overflowing beside it. + // `Measure` with no unit renders the bare number, never in exponent form. out.out() << "body{zoom:" << Measure(factor, DynamicUnit()).to_string() << "}"; out.write_header_style_end(); diff --git a/src/odr/internal/html/common.hpp b/src/odr/internal/html/common.hpp index 352b63e73..1c864409d 100644 --- a/src/odr/internal/html/common.hpp +++ b/src/odr/internal/html/common.hpp @@ -46,9 +46,8 @@ void write_viewport_meta(HtmlWriter &out, const HtmlConfig &config, bool fit_width_by_default, std::optional mode_override = {}); -/// Whether the output is meant to fit its width to the viewport — the same -/// question @ref write_viewport_meta answers with a meta tag, which a browser -/// honours for the top-level document only. +/// Whether the output is meant to fit its width to the viewport — the question +/// @ref write_viewport_meta answers with a meta tag. [[nodiscard]] bool fits_width(const HtmlConfig &config, bool fit_width_by_default, std::optional mode_override = {}); @@ -58,14 +57,12 @@ fits_width(const HtmlConfig &config, bool fit_width_by_default, [[nodiscard]] std::optional css_pixels(const std::optional &measure); -/// The side gutters the page column puts around its widest page, in css -/// pixels — part of the width to fit, and the same in both renderers. +/// The side gutters the page column puts around its pages, in css pixels. constexpr double page_column_gutter_pixels = 32; -/// Scales the body so that @p content_pixels of content fits -/// `config.viewport_width`. Writes nothing, and returns false, unless the -/// output fits its width and both widths are known — the load-time script is -/// what covers the rest. +/// Scales the body so @p content_pixels of content fits +/// `config.viewport_width`. Writes nothing and returns false unless @p fits and +/// both widths are known; the load-time script covers the rest. bool write_viewport_fit_style(HtmlWriter &out, const HtmlConfig &config, bool fits, std::optional content_pixels); diff --git a/src/odr/internal/html/document.cpp b/src/odr/internal/html/document.cpp index ab15db740..ac6ce98e3 100644 --- a/src/odr/internal/html/document.cpp +++ b/src/odr/internal/html/document.cpp @@ -32,8 +32,7 @@ bool is_paged_content(const Document &document, const HtmlConfig &config) { document.document_type() == DocumentType::drawing; } -/// A page box plus the gutters the column puts around it, in css pixels — -/// what fitting to the viewport has to scale down. +/// A page box plus the gutters the column puts around it, in css pixels. std::optional page_content_pixels(const PageLayout &page_layout) { const std::optional width = css_pixels(page_layout.width); if (!width.has_value()) { @@ -42,8 +41,7 @@ std::optional page_content_pixels(const PageLayout &page_layout) { return *width + page_column_gutter_pixels; } -/// Per view, so a deck whose master pages differ in width fits each slide to -/// the viewport rather than all of them to the widest. +/// Per view, so slides of differing width are each fitted to their own page. std::optional fragment_content_pixels(const TextRoot &element) { return page_content_pixels(element.page_layout()); } @@ -98,8 +96,8 @@ viewport_mode_override(const Document &document, const HtmlConfig &config) { : std::nullopt; } -/// Whether the view has to measure itself at load time to fit the viewport — -/// true where it should fit but the css could not be given the factor. +/// Whether the view has to measure itself at load: it should fit, but no css +/// factor could be written for it. bool fits_at_load_time(const Document &document, const HtmlConfig &config, const bool paged_content, const std::optional content_pixels) { diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index 162787a9a..b1e395f62 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -323,9 +323,7 @@ constexpr std::string_view document_js = R"js( )js"; /// The load-time half of fitting the page column to the viewport, for output -/// whose viewport width was not known when it was written. A viewport meta tag -/// cannot do this job: a browser honours only the top-level document's, so a -/// document in a frame is left overflowing. +/// whose width was not known when it was written. constexpr std::string_view viewport_js = R"js( (function () { "use strict"; @@ -333,22 +331,15 @@ constexpr std::string_view viewport_js = R"js( var root = document.documentElement; var body = document.body; - // A viewport meta tag is honoured for the top-level document only, so that is - // exactly where it is not this script's job to scale anything: doing it there - // too would shrink a wide page in a desktop window, which no reader asked - // for. In a frame the tag is inert and there is nothing else to fit the page. - // Output whose width is known when it is written says so with - // `HtmlConfig::viewport_width` and gets the factor in its css instead, framed - // or not. + // Only a frame is scaled here: the viewport meta tag covers the top-level + // document but is inert in a frame. var framed = window.top !== window.self; - // The width the anchor below was taken at. A scroll event that arrives after - // the viewport has already changed is the browser's own doing, not the - // reader's, and must not be taken for the reading position. + // 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; - // Where the reader is, kept current rather than read when a resize arrives: - // by then the browser has already relaid out and moved the scroll, and what - // was against the top of the screen is gone. + // Where the reader is, kept current: by the time a resize arrives the browser + // has relaid out and moved the scroll. var held = null; // Our own scrolling, which must not be mistaken for the reader's. var restoring = false; @@ -382,10 +373,9 @@ constexpr std::string_view viewport_js = R"js( body.style.zoom = content > available ? available / content : ""; } - // What the reader is looking at: 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 for this - the height changes with the scale, which is exactly - // what the browser's own guess gets wrong. + // 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); if (!element) { @@ -400,9 +390,8 @@ constexpr std::string_view viewport_js = R"js( return; } if (root.clientWidth !== width) { - // The viewport changed and the resize event has not arrived - or never - // will, which happens. What is on screen now is the browser's guess, not - // the reader's position, so take neither and fit from here. + // The viewport changed without a resize event. What is on screen is the + // browser's guess, not the reader's position, so fit from here instead. resized(); return; } @@ -423,8 +412,7 @@ constexpr std::string_view viewport_js = R"js( function resized() { if (root.clientWidth === width) { // Nothing that changes the scale: a height-only change, or a pinch, - // which moves the visual viewport and fires here without touching the - // layout width. Restoring through a gesture would fight the reader. + // where restoring would fight the reader. return; } @@ -434,9 +422,8 @@ constexpr std::string_view viewport_js = R"js( restoring = true; restore(target); - // The browser answers a resize with a scroll offset of its own, a few - // frames later, so the position is re-asserted until it stops moving - and - // dropped the moment the reader takes over. + // 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() { diff --git a/src/odr/internal/html/frontend.hpp b/src/odr/internal/html/frontend.hpp index 8a1cfd596..d33ea64c8 100644 --- a/src/odr/internal/html/frontend.hpp +++ b/src/odr/internal/html/frontend.hpp @@ -45,11 +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 time and on every resize, -/// holding the reading position across the change. For output whose viewport -/// width was not known when it was written — @ref -/// odr::HtmlConfig::viewport_width writes the fit into the css instead, and -/// then this is not needed. +/// 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. void write_viewport_script(const WritingState &state); /// What the corresponding `write_*` calls would link, without writing anything: diff --git a/src/odr/internal/html/pdf_file.cpp b/src/odr/internal/html/pdf_file.cpp index df5b75445..8db653487 100644 --- a/src/odr/internal/html/pdf_file.cpp +++ b/src/odr/internal/html/pdf_file.cpp @@ -1133,9 +1133,8 @@ class HtmlServiceImpl final : public HtmlService { HtmlServiceImpl(PdfFile pdf_file, HtmlConfig config, const Logger &logger) : HtmlService(std::move(config), logger), m_pdf_file{std::move(pdf_file)}, m_resources{locate_search_resources(this->config())} { - // Declared whether or not a given view ends up writing it — a page is - // parsed long after this, and answering for a path nothing links costs - // nothing. + // declared before any page is parsed, so before it is known which views + // write it if (fits_width(this->config(), true)) { for (auto &&resource : locate_viewport_resources(this->config())) { m_resources.push_back(std::move(resource)); @@ -2563,10 +2562,7 @@ class HtmlServiceImpl final : public HtmlService { close_svg(); } - /// The document/head prologue shared by both modes, with `write_mode_css()` - /// slotted between the constant rules. Leaves the writer after ``. - /// The widest page a view holds, in css pixels and with `.d`'s side gutters - /// included — what fitting to the viewport has to scale down. + /// The widest page a view holds, in css pixels, with `.d`'s side gutters. template static std::optional content_pixels(const std::vector &pages) { @@ -2580,13 +2576,15 @@ class HtmlServiceImpl final : public HtmlService { return widest * pt_to_in * 96.0 + page_column_gutter_pixels; } - /// Whether the view has to measure itself at load time to fit the viewport — - /// true where it should fit but the css could not be given the factor. + /// Whether the view has to measure itself at load: it should fit, but no css + /// factor could be written for it. 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 void write_header_common(const WritingState &state, const std::string &font_faces, diff --git a/test/src/html_test.cpp b/test/src/html_test.cpp index ca3e40afd..1a37ec910 100644 --- a/test/src/html_test.cpp +++ b/test/src/html_test.cpp @@ -264,9 +264,8 @@ TEST(html, views) { EXPECT_EQ(views.at(2).name(), "Foglio2"); } -// #708: a viewport meta tag is honoured for the top-level document only, so an -// embedder rendering into a frame got no fit at all. #706: and whatever fits -// has to hold the reader's place when the viewport changes under it. +// #708 (a meta tag does not fit a framed document) and #706 (the fit has to +// hold the reading position). TEST(html, paged_output_fits_the_viewport) { const auto logger = Logger::create_stdio("odr-test", LogLevel::verbose); @@ -309,16 +308,14 @@ TEST(html, paged_output_fits_the_viewport) { } } -// A page turned by `/Rotate` is as wide as the reader sees it, so one document -// can hold pages of different widths — which is how mixed widths turn up in the -// wild. Each view is then fitted to the page it renders, not to the widest. +// `/Rotate` is how one document comes to hold pages of differing width. TEST(html, each_view_fits_the_page_it_renders) { test::pdf::PdfFileBuilder builder; builder.object("<< /Type /Catalog /Pages 2 0 R >>") .object("<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>") // 612pt wide as it stands .object("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>") - // 792pt tall on paper, but a quarter turn makes it 1224pt wide on screen + // a quarter turn makes this 1224pt wide on screen .object("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 792 1224] " "/Rotate 90 >>"); diff --git a/wasm/js/index.d.ts b/wasm/js/index.d.ts index 59889764e..dab45f3d3 100644 --- a/wasm/js/index.d.ts +++ b/wasm/js/index.d.ts @@ -88,10 +88,9 @@ export interface HtmlConfig { spreadsheetGridlines?: number; viewportMode?: number; /** - * The width the output will be shown at, in css pixels. When set, paged - * content wider than it is scaled to fit at render time — no viewport meta - * tag (inert in a frame) and no script needed. Leave it out where the width - * can change: the output then measures itself at load and on every resize. + * The width the output will be shown at, in css pixels. Paged content wider + * than it is scaled down at render time; leave it out where the width can + * change and the output measures itself at load instead. */ viewportWidth?: number; pdfTextMode?: number; From c4cc7ed0d7fa5a210e19d2d6f62779a5c4a34a1a Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 20 Aug 2026 22:06:06 +0200 Subject: [PATCH 6/7] docs(html): one-line the config docstrings, and document the rest Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015d5RcmsA777vwXiuafjx6k --- apple/include/OdrCoreObjC/ODRHtml.h | 4 +- .../app/opendocument/core/HtmlConfig.java | 5 +- src/odr/html.hpp | 52 ++++++++----------- src/odr/internal/html/common.cpp | 5 +- src/odr/internal/html/common.hpp | 11 ++-- src/odr/internal/html/document.cpp | 3 +- src/odr/internal/html/pdf_file.cpp | 6 +-- wasm/js/index.d.ts | 6 +-- 8 files changed, 35 insertions(+), 57 deletions(-) diff --git a/apple/include/OdrCoreObjC/ODRHtml.h b/apple/include/OdrCoreObjC/ODRHtml.h index 9df4214f7..268934bd0 100644 --- a/apple/include/OdrCoreObjC/ODRHtml.h +++ b/apple/include/OdrCoreObjC/ODRHtml.h @@ -94,9 +94,7 @@ NS_SWIFT_NAME(HtmlConfig) @property(nonatomic, strong, nullable) NSNumber *spreadsheetViewportMode; /// Raw `content` for the viewport meta tag; overrides the modes above. @property(nonatomic, copy, nullable) NSString *viewportContent; -/// The width the output will be shown at, in css pixels. Paged content wider -/// than it is scaled down at render time; `nil` makes the output measure itself -/// at load instead. +/// The width the output is shown at, in css pixels; fits paged content to it. @property(nonatomic, strong, nullable) NSNumber *viewportWidth; @property(nonatomic) BOOL formatHtml; diff --git a/jni/java/app/opendocument/core/HtmlConfig.java b/jni/java/app/opendocument/core/HtmlConfig.java index e4b17a47a..fbab8cd0c 100644 --- a/jni/java/app/opendocument/core/HtmlConfig.java +++ b/jni/java/app/opendocument/core/HtmlConfig.java @@ -37,10 +37,7 @@ public final class HtmlConfig { public HtmlViewportMode spreadsheetViewportMode; /** Raw {@code content} for the viewport meta tag; overrides the modes above when set. */ public String viewportContent; - /** - * The width the output will be shown at, in css pixels. Paged content wider than it is scaled - * down at render time; {@code null} makes the output measure itself at load instead. - */ + /** The width the output is shown at, in css pixels; fits paged content to it. */ public Integer viewportWidth; public boolean formatHtml = false; diff --git a/src/odr/html.hpp b/src/odr/html.hpp index fa9d7dbbf..774b233f6 100644 --- a/src/odr/html.hpp +++ b/src/odr/html.hpp @@ -104,21 +104,20 @@ enum class PdfTextMode { /// @brief HTML configuration. struct HtmlConfig { - // document output file names + /// File name for the view that writes the whole document. std::string document_output_file_name{"document.html"}; - // document element output file names + // per-element view file names; `{index}` is the element's 0-based number std::string slide_output_file_name{"slide{index}.html"}; std::string sheet_output_file_name{"sheet{index}.html"}; std::string page_output_file_name{"page{index}.html"}; - // embedding + /// Embed images as data urls rather than writing them beside the document. bool embed_images{true}; /// Write the renderer's own css and js into every document rather than beside /// it as one shared file the documents link. bool embed_shipped_resources{true}; - // resources /// Where linked shipped resources go, relative to the output path unless /// named absolutely. Empty puts them beside the document. std::string resource_path; @@ -126,37 +125,34 @@ struct HtmlConfig { /// output stays movable. bool relative_resource_paths{true}; - // create editable output + /// Write `contenteditable` output, which back-translation reads edits from. bool editable{false}; - // text document margin + /// Render a text document as fixed-size pages rather than reflowing text. bool text_document_margin{false}; - // colors the output renders against + /// The colors the output renders against. HtmlColorScheme color_scheme{HtmlColorScheme::light}; - // spreadsheet table limit + /// Largest sheet region written; cells past it are dropped. std::optional spreadsheet_limit{TableDimensions(10000, 500)}; + /// Trim a sheet to the cells it uses before @ref spreadsheet_limit applies. bool spreadsheet_limit_by_content{true}; - // spreadsheet gridlines + /// Which gridlines a sheet paints. HtmlTableGridlines spreadsheet_gridlines{HtmlTableGridlines::soft}; - // initial zoom on mobile + /// Initial zoom on mobile; see @ref HtmlViewportMode. HtmlViewportMode viewport_mode{HtmlViewportMode::automatic}; - // overrides `viewport_mode` for spreadsheet content when set + /// Overrides @ref viewport_mode for spreadsheet content when set. std::optional spreadsheet_viewport_mode; - // raw `content` for the viewport meta tag; overrides the modes above + /// Raw `content` for the viewport meta tag; overrides the modes above. std::optional viewport_content; - /// The width the output will be shown at, in css pixels. Paged content wider - /// than it is scaled down at render time, as a factor in the emitted css — - /// no meta tag, which a browser honours for the top-level document only, and - /// no script. Unset, a framed document measures itself at load instead. + /// The width the output is shown at, in css pixels; fits paged content to it. std::optional viewport_width; - // formatting + /// Indent and break the output into lines rather than writing one stream. bool format_html{false}; - // Indentation when `format_html` is set: `html_indent_string` is repeated - // `html_indent` times per nesting level (0 disables indentation entirely). + /// Repeated @ref html_indent_string per nesting level; 0 disables indenting. std::uint8_t html_indent{1}; std::string html_indent_string{"\t"}; @@ -165,22 +161,20 @@ struct HtmlConfig { /// @deprecated See @ref background_image_format. double background_image_dpi{144.0}; - // Paged-document page range (currently honored by the PDF pipeline): render - // only pages with 0-based index in `[page_range_begin, page_range_end)`. - // Page views and `#pN` anchors keep their document-global page numbers. + /// Renders only the pages with 0-based index in `[page_range_begin, + /// page_range_end)`; page views and `#pN` anchors keep their document-global + /// numbers. Honored by the pdf pipeline. std::uint32_t page_range_begin{0}; std::optional page_range_end; - // PDF text mode + /// How pdf text is written; see @ref PdfTextMode. PdfTextMode pdf_text_mode{PdfTextMode::dual_layer}; - // `dual_layer` renders its invisible selection layer in a local system font - // (first of these that resolves), whose natural width rarely matches the - // PDF-derived box CSS justify has to fill — and justify can only add spacing. - // The size-adjust (0-1, written as the @font-face percent) shrinks the - // fallback's metrics toward the PDF's to close that gap. Safe to - // underestimate, not to overestimate: the excess is clipped, not shrunk. + /// System fonts `dual_layer` sets its selection layer in, first that + /// resolves. std::vector pdf_dual_layer_fallback_fonts{ "Arial", "Helvetica", "Liberation Sans", "DejaVu Sans", "Nimbus Sans"}; + /// Shrinks the fallback's metrics toward the pdf's (0-1) so css justify can + /// fill the box. Safe to underestimate: the excess is clipped, not shrunk. double pdf_dual_layer_fallback_font_size_adjust{0.5}; /// @deprecated Inert: no output carries a restriction to lift. diff --git a/src/odr/internal/html/common.cpp b/src/odr/internal/html/common.cpp index 5840b5eed..f1f452622 100644 --- a/src/odr/internal/html/common.cpp +++ b/src/odr/internal/html/common.cpp @@ -96,9 +96,8 @@ bool html::write_viewport_fit_style( } out.write_header_style_begin(); - // `zoom` rather than `transform: scale()`: it scales the layout, so the page - // scrolls against the scaled size instead of overflowing beside it. - // `Measure` with no unit renders the bare number, never in exponent form. + // `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(); diff --git a/src/odr/internal/html/common.hpp b/src/odr/internal/html/common.hpp index 1c864409d..3e29b42be 100644 --- a/src/odr/internal/html/common.hpp +++ b/src/odr/internal/html/common.hpp @@ -46,23 +46,20 @@ void write_viewport_meta(HtmlWriter &out, const HtmlConfig &config, bool fit_width_by_default, std::optional mode_override = {}); -/// Whether the output is meant to fit its width to the viewport — the question -/// @ref write_viewport_meta answers with a meta tag. +/// Whether the output is meant to fit its width to the viewport. [[nodiscard]] bool fits_width(const HtmlConfig &config, bool fit_width_by_default, std::optional mode_override = {}); -/// @p measure in css pixels (96 per inch), or nothing where it carries no -/// absolute unit. +/// @p measure in css pixels, or nothing without an absolute unit. [[nodiscard]] std::optional 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 of content fits -/// `config.viewport_width`. Writes nothing and returns false unless @p fits and -/// both widths are known; the load-time script covers the rest. +/// 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); diff --git a/src/odr/internal/html/document.cpp b/src/odr/internal/html/document.cpp index ac6ce98e3..7e1226714 100644 --- a/src/odr/internal/html/document.cpp +++ b/src/odr/internal/html/document.cpp @@ -96,8 +96,7 @@ viewport_mode_override(const Document &document, const HtmlConfig &config) { : std::nullopt; } -/// Whether the view has to measure itself at load: it should fit, but no css -/// factor could be written for it. +/// 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) { diff --git a/src/odr/internal/html/pdf_file.cpp b/src/odr/internal/html/pdf_file.cpp index 8db653487..ae5e8f93c 100644 --- a/src/odr/internal/html/pdf_file.cpp +++ b/src/odr/internal/html/pdf_file.cpp @@ -1133,8 +1133,7 @@ class HtmlServiceImpl final : public HtmlService { HtmlServiceImpl(PdfFile pdf_file, HtmlConfig config, const Logger &logger) : 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 it is known which views - // write it + // 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)); @@ -2576,8 +2575,7 @@ class HtmlServiceImpl final : public HtmlService { return widest * pt_to_in * 96.0 + page_column_gutter_pixels; } - /// Whether the view has to measure itself at load: it should fit, but no css - /// factor could be written for it. + /// 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()); diff --git a/wasm/js/index.d.ts b/wasm/js/index.d.ts index dab45f3d3..4df8e190e 100644 --- a/wasm/js/index.d.ts +++ b/wasm/js/index.d.ts @@ -87,11 +87,7 @@ export interface HtmlConfig { colorScheme?: number; spreadsheetGridlines?: number; viewportMode?: number; - /** - * The width the output will be shown at, in css pixels. Paged content wider - * than it is scaled down at render time; leave it out where the width can - * change and the output measures itself at load instead. - */ + /** The width the output is shown at, in css pixels; fits paged content to it. */ viewportWidth?: number; pdfTextMode?: number; } From 2de09a9a9a14532f97c35b608f2d68af22a4f1ca Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 20 Aug 2026 22:06:06 +0200 Subject: [PATCH 7/7] feat(html): fit an image view to the viewport Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015d5RcmsA777vwXiuafjx6k --- CHANGELOG.md | 2 ++ src/odr/internal/html/image_file.cpp | 5 +++++ test/src/html_test.cpp | 24 ++++++++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d43b7072..ec1620e14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,8 @@ The release run heads these entries with the version and opens a fresh - **New** `HtmlConfig::viewport_width`: the width the output will be shown at, in css pixels. The fit is then a factor in the emitted css — no script, framed or not. Bound in the python, wasm, jni and apple bindings as `viewportWidth`. +- An image view fits the viewport too: `img{max-width:100%}`, so a scan wider + than the frame stops overflowing. `actual_size` still shows it 1:1. - Output that fits itself keeps the reader's place when the viewport changes. The browser's own guess is wrong here because the scale changes with the width, and a long document came back a page or more from where it was. diff --git a/src/odr/internal/html/image_file.cpp b/src/odr/internal/html/image_file.cpp index 6b2ad7f7b..84a997849 100644 --- a/src/odr/internal/html/image_file.cpp +++ b/src/odr/internal/html/image_file.cpp @@ -107,6 +107,11 @@ class HtmlServiceImpl final : public HtmlService { 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`. + if (fits_width(config(), true)) { + out.out() << "img{max-width:100%;height:auto}"; + } out.write_header_style_end(); if (writes_dark_style(config())) { out.write_header_style_begin(dark_style_media(config())); diff --git a/test/src/html_test.cpp b/test/src/html_test.cpp index 1a37ec910..4bf27b026 100644 --- a/test/src/html_test.cpp +++ b/test/src/html_test.cpp @@ -354,3 +354,27 @@ TEST(html, each_view_fits_the_page_it_renders) { EXPECT_NEAR(narrow_page, 400.0 / (612 * 96.0 / 72 + 32), 1e-6); 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. +TEST(html, an_image_fits_the_viewport) { + const auto logger = Logger::create_stdio("odr-test", LogLevel::verbose); + + const DecodedFile file( + TestData::test_file_path("odr-public/png/tango-example-icons.png"), + logger); + + const auto render = [&](const HtmlConfig &config) { + const std::string cache = + (std::filesystem::current_path() / "image_fit").string(); + std::ostringstream out; + html::translate(file, cache, config).list_views().at(0).write_html(out); + return std::move(out).str(); + }; + + EXPECT_NE(render(HtmlConfig()).find("img{max-width: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); +}