From 548f80d40a930b907d16dc36dd07e2808428c71a Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 20 Sep 2026 11:40:25 +0200 Subject: [PATCH 1/2] fix(html): fitting the width states a floor a wide page can reach A browser floors the page scale at 0.25, so `width=device-width` alone cannot fit content more than four screens wide: an A0 sheet on a phone opens at its own size, scrolls sideways, and the pinch stops before the page fits. Measured in a WebView at 3232 css px on a 412 px screen, the scale sat at 0.25 with less than half the page visible. Fitting the width now states the floor the content needs - what fits the widest page on a 320 px screen, the narrowest there is. Where the browser's own 0.25 already reaches, nothing is added and the meta is unchanged, so no existing output moves. `write_viewport_meta` takes the `content_pixels` that `write_zoom_style` beside it already takes. The call sites without one state no floor. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FJJdfqpnVCKBNHXjAVxSou --- CHANGELOG.md | 6 ++++++ src/odr/internal/html/common.cpp | 34 ++++++++++++++++++++++++++++-- src/odr/internal/html/common.hpp | 8 ++++++- src/odr/internal/html/document.cpp | 3 ++- src/odr/internal/html/pdf_file.cpp | 2 +- test/src/html_test.cpp | 31 +++++++++++++++++++++++++++ 6 files changed, 79 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98f259e2e..a24ce2adf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- Fitting the width now states a `minimum-scale` where the content needs one. + A browser floors the page scale at 0.25, which cannot fit a page more than + four screens wide, so an A0 sheet on a phone scrolled sideways and would not + zoom out. The floor stated is what fits the widest page on a 320px screen, + and nothing is added where 0.25 already reaches. + - **Fix**: selecting text in a pdf view showed the invisible layer that carries it, as a second set of glyphs over the drawn ones. That layer now stays transparent under `::selection`, and states the highlight background, which diff --git a/src/odr/internal/html/common.cpp b/src/odr/internal/html/common.cpp index 26192001d..c7cec3f02 100644 --- a/src/odr/internal/html/common.cpp +++ b/src/odr/internal/html/common.cpp @@ -27,9 +27,37 @@ namespace odr::internal { +namespace { + +/// The narrowest screen worth fitting a page to, in css px. A floor below what +/// reaches here buys nothing: no phone is narrower. +constexpr double narrowest_viewport_pixels = 320.0; + +/// What the browser floors the page scale at on its own. +constexpr double browser_minimum_scale = 0.25; + +/// `minimum-scale=,` for content the browser's own floor cannot fit, or +/// empty where it can. Fitting the width is pointless where the reader cannot +/// zoom out far enough to see it. +std::string minimum_scale_for(const std::optional content_pixels) { + if (!content_pixels.has_value() || *content_pixels <= 0) { + return {}; + } + const double fit = narrowest_viewport_pixels / *content_pixels; + if (fit >= browser_minimum_scale) { + return {}; + } + // three decimals reach a page 320000px wide, and the string stays short + const double floored = std::max(std::floor(fit * 1000.0) / 1000.0, 0.001); + return "minimum-scale=" + Measure(floored, DynamicUnit()).to_string() + ","; +} + +} // namespace + void html::write_viewport_meta( HtmlWriter &out, const HtmlConfig &config, const bool fit_width_by_default, - const std::optional mode_override) { + const std::optional mode_override, + const std::optional content_pixels) { if (config.viewport_content.has_value()) { out.write_header_viewport( xml::escape_attribute(config.viewport_content.value())); @@ -44,7 +72,9 @@ void html::write_viewport_meta( switch (mode) { case HtmlViewportMode::fit_width: - out.write_header_viewport("width=device-width,user-scalable=yes"); + out.write_header_viewport("width=device-width," + + minimum_scale_for(content_pixels) + + "user-scalable=yes"); break; case HtmlViewportMode::actual_size: // A stated scale is what turns the browser's own fitting off. diff --git a/src/odr/internal/html/common.hpp b/src/odr/internal/html/common.hpp index 6606639cc..56efb9233 100644 --- a/src/odr/internal/html/common.hpp +++ b/src/odr/internal/html/common.hpp @@ -86,9 +86,15 @@ struct WritingState { /// `config.viewport_mode`. `fit_width_by_default` resolves /// `HtmlViewportMode::automatic`: true for fixed-size paged content, false for /// content that reflows to the screen width. +/// +/// `content_pixels` is the width the content wants, as `write_zoom_style` +/// takes it. Fitting the width states a `minimum-scale` that can reach it: the +/// browser's own floor is 0.25, which cannot fit a page more than four screens +/// wide - an A0 sheet on a phone scrolls sideways and will not zoom out. void write_viewport_meta(HtmlWriter &out, const HtmlConfig &config, bool fit_width_by_default, - std::optional mode_override = {}); + std::optional mode_override = {}, + std::optional content_pixels = {}); /// Who fits the output's width to the viewport. enum class WidthFit { diff --git a/src/odr/internal/html/document.cpp b/src/odr/internal/html/document.cpp index c1a99470c..1bc28c5e2 100644 --- a/src/odr/internal/html/document.cpp +++ b/src/odr/internal/html/document.cpp @@ -172,7 +172,8 @@ void write_head(const Document &document, const WritingState &state, : "odr"); const std::optional mode_override = viewport_mode_override(document, state.config()); - write_viewport_meta(out, state.config(), paged_content, mode_override); + write_viewport_meta(out, state.config(), paged_content, mode_override, + content_pixels); write_zoom_style(out, state.config(), paged_content ? width_fit(state.config(), paged_content, mode_override) diff --git a/src/odr/internal/html/pdf_file.cpp b/src/odr/internal/html/pdf_file.cpp index 13d358f95..0c7ca87ca 100644 --- a/src/odr/internal/html/pdf_file.cpp +++ b/src/odr/internal/html/pdf_file.cpp @@ -2685,7 +2685,7 @@ class HtmlServiceImpl final : public HtmlService { out.write_header_begin(); out.write_header_charset("UTF-8"); out.write_header_title("odr"); - write_viewport_meta(out, config(), true); + write_viewport_meta(out, config(), true, {}, content); write_zoom_style(out, config(), width_fit(config(), true), content); write_content_margin_style(out, config()); out.write_header_style_begin(); diff --git a/test/src/html_test.cpp b/test/src/html_test.cpp index f57e5e9c9..1abba3bba 100644 --- a/test/src/html_test.cpp +++ b/test/src/html_test.cpp @@ -309,6 +309,37 @@ std::optional fit_of(const std::string &page) { } // namespace +/// The `content` of the page's viewport meta, or "" where it states none. +std::string viewport_of(const std::string &page) { + static const std::string key = "name=\"viewport\" content=\""; + const std::size_t at = page.find(key); + if (at == std::string::npos) { + return {}; + } + const std::size_t begin = at + key.length(); + return page.substr(begin, page.find('"', begin) - begin); +} + +// A browser floors the page scale at 0.25, so a page more than four screens +// wide cannot be zoomed out to. Fitting the width states a floor that reaches +// it, and states none where the browser's own already does. +TEST(html, fitting_the_width_states_a_floor_a_wide_page_needs) { + HtmlConfig config; + config.text_document_margin = true; + + EXPECT_EQ(viewport_of(render_odt(config)), + "width=device-width,user-scalable=yes"); + + // the gutter is part of the width, so this is a page column four screens + // and more wide + config.min_content_margin.left = Measure("800px"); + config.min_content_margin.right = Measure("800px"); + + const std::string wide = viewport_of(render_odt(config)); + EXPECT_NE(wide.find("minimum-scale=0."), std::string::npos) << wide; + EXPECT_NE(wide.find("width=device-width"), std::string::npos) << wide; +} + // The gutter around the page column is part of the width the view is fitted // to, so raising it fits the pages smaller. TEST(html, min_content_margin_widens_the_page_column_fit) { From 4828e6fba4a32935711e2a35f6b642aa0560934b Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 20 Sep 2026 11:49:33 +0200 Subject: [PATCH 2/2] test(html): keep the viewport helper file-local, and cut the comments `viewport_of` sat outside the anonymous namespace, so it had external linkage where `fit_of` beside it does not. The assertions now state the whole meta, which catches the order of the clauses and the separators. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0149gFxhkvKTBQidz6brU8Vt --- CHANGELOG.md | 8 +++----- src/odr/internal/html/common.cpp | 8 +++----- src/odr/internal/html/common.hpp | 4 +--- test/src/html_test.cpp | 13 ++++++------- 4 files changed, 13 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a24ce2adf..eca30cd19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,9 @@ The release run heads these entries with the version and opens a fresh ## Unreleased -- Fitting the width now states a `minimum-scale` where the content needs one. - A browser floors the page scale at 0.25, which cannot fit a page more than - four screens wide, so an A0 sheet on a phone scrolled sideways and would not - zoom out. The floor stated is what fits the widest page on a 320px screen, - and nothing is added where 0.25 already reaches. +- Fitting the width states a `minimum-scale` where the content is more than + four screens wide, which the browser's own floor of 0.25 cannot reach. An A0 + pdf page now zooms out to fit; narrower content is unchanged. - **Fix**: selecting text in a pdf view showed the invisible layer that carries it, as a second set of glyphs over the drawn ones. That layer now stays diff --git a/src/odr/internal/html/common.cpp b/src/odr/internal/html/common.cpp index c7cec3f02..9d2c10a81 100644 --- a/src/odr/internal/html/common.cpp +++ b/src/odr/internal/html/common.cpp @@ -29,16 +29,14 @@ namespace odr::internal { namespace { -/// The narrowest screen worth fitting a page to, in css px. A floor below what -/// reaches here buys nothing: no phone is narrower. +/// The narrowest screen a floor is computed for, in css px. constexpr double narrowest_viewport_pixels = 320.0; /// What the browser floors the page scale at on its own. constexpr double browser_minimum_scale = 0.25; -/// `minimum-scale=,` for content the browser's own floor cannot fit, or -/// empty where it can. Fitting the width is pointless where the reader cannot -/// zoom out far enough to see it. +/// `minimum-scale=,` where the browser's own floor cannot fit the content, +/// empty where it can. std::string minimum_scale_for(const std::optional content_pixels) { if (!content_pixels.has_value() || *content_pixels <= 0) { return {}; diff --git a/src/odr/internal/html/common.hpp b/src/odr/internal/html/common.hpp index 56efb9233..a1411346f 100644 --- a/src/odr/internal/html/common.hpp +++ b/src/odr/internal/html/common.hpp @@ -88,9 +88,7 @@ struct WritingState { /// content that reflows to the screen width. /// /// `content_pixels` is the width the content wants, as `write_zoom_style` -/// takes it. Fitting the width states a `minimum-scale` that can reach it: the -/// browser's own floor is 0.25, which cannot fit a page more than four screens -/// wide - an A0 sheet on a phone scrolls sideways and will not zoom out. +/// takes it: fitting the width states a `minimum-scale` that reaches it. void write_viewport_meta(HtmlWriter &out, const HtmlConfig &config, bool fit_width_by_default, std::optional mode_override = {}, diff --git a/test/src/html_test.cpp b/test/src/html_test.cpp index 1abba3bba..3d9f6fd8d 100644 --- a/test/src/html_test.cpp +++ b/test/src/html_test.cpp @@ -307,11 +307,9 @@ std::optional fit_of(const std::string &page) { return std::stod(page.substr(begin)); } -} // namespace - /// The `content` of the page's viewport meta, or "" where it states none. std::string viewport_of(const std::string &page) { - static const std::string key = "name=\"viewport\" content=\""; + constexpr std::string_view key = "name=\"viewport\" content=\""; const std::size_t at = page.find(key); if (at == std::string::npos) { return {}; @@ -320,9 +318,10 @@ std::string viewport_of(const std::string &page) { return page.substr(begin, page.find('"', begin) - begin); } +} // namespace + // A browser floors the page scale at 0.25, so a page more than four screens -// wide cannot be zoomed out to. Fitting the width states a floor that reaches -// it, and states none where the browser's own already does. +// wide cannot be zoomed out to. A wider page states a floor of its own. TEST(html, fitting_the_width_states_a_floor_a_wide_page_needs) { HtmlConfig config; config.text_document_margin = true; @@ -336,8 +335,8 @@ TEST(html, fitting_the_width_states_a_floor_a_wide_page_needs) { config.min_content_margin.right = Measure("800px"); const std::string wide = viewport_of(render_odt(config)); - EXPECT_NE(wide.find("minimum-scale=0."), std::string::npos) << wide; - EXPECT_NE(wide.find("width=device-width"), std::string::npos) << wide; + EXPECT_TRUE(wide.starts_with("width=device-width,minimum-scale=0.")) << wide; + EXPECT_TRUE(wide.ends_with(",user-scalable=yes")) << wide; } // The gutter around the page column is part of the width the view is fitted