From 89f6798449e676e750d6f8b50abeacfc7697381e Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Wed, 2 Sep 2026 22:20:05 +0200 Subject: [PATCH] fix(html): scale the text with the fit, where webkit holds it back Webkit reads a css `zoom` as a scale the reader asked for and holds text at the size it would have had without one, so `fit_width_by_view` fitted the page box and left the type at its natural measure. It states that compensation as `text-size-adjust` divided by the zoom, so the script now states the zoom back as the percentage. Blink reads the same percentage as a plain multiplier, so a probe decides whether to state it at all. Closes #761. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018cMYRXLJdiCkH65Jm2W9B5 --- CHANGELOG.md | 4 ++ src/odr/internal/html/common.cpp | 6 ++- src/odr/internal/html/frontend.cpp | 71 ++++++++++++++++++++++++-- test/browser/viewport/README.md | 6 +++ test/data.cmake | 2 +- test/src/internal/html/common_test.cpp | 4 +- 6 files changed, 84 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea4219ecc..a1463e270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- A fitted or zoomed view scales its text with the page in WebKit, where the + type used to stay at its unscaled size. `HtmlViewportMode::fit_width_by_view` + is usable on iOS. Closes #761. + - A PDF `gs` applies the `/ExtGState` stroke parameters `/LW`, `/LC`, `/LJ`, `/ML` and `/D`. A producer that sets the line width only there — Canva does — used to have every stroke drawn at the initial width of 1. diff --git a/src/odr/internal/html/common.cpp b/src/odr/internal/html/common.cpp index 10b5a1c7d..46736a960 100644 --- a/src/odr/internal/html/common.cpp +++ b/src/odr/internal/html/common.cpp @@ -245,8 +245,10 @@ void html::write_zoom_style(HtmlWriter &out, const HtmlConfig &config, out.out() << "body{zoom:" << number(*zoom) << "}"; } - // paper has its own geometry; beats the script's inline zoom - out.out() << "@media print{:root{--odr-zoom:1!important}" + // paper has its own geometry; beats the script's inline zoom and adjustment + out.out() << "@media print{:root{--odr-zoom:1!important;" + "-webkit-text-size-adjust:100%!important;" + "text-size-adjust:100%!important}" "body{zoom:1!important}}"; out.write_header_style_end(); diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index b9a5f2d2a..0fee0a501 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -372,13 +372,72 @@ constexpr std::string_view viewport_js = R"js( return pinned !== null ? pinned : fit; } + // Webkit divides a stated `text-size-adjust` by the css zoom, so restating + // the zoom as the percentage holds the type where its box is. Blink reads the + // same percentage as a plain multiplier, hence the probe below. + var adjustsText = false; + var adjusted = ""; + + function textAdjust(value) { + adjusted = value; + root.style.setProperty("-webkit-text-size-adjust", value); + root.style.setProperty("text-size-adjust", value); + } + + // A run against a stated length, so no rect convention enters. True in webkit + // and in the engines that ignore the property; false where it scales the text + // a second time. + function textAdjustHolds() { + var ruler = document.createElement("div"); + ruler.style.cssText = + "position:absolute;top:0;left:0;width:400px;height:0;overflow:hidden"; + var run = document.createElement("span"); + run.style.cssText = "font:100px/1 monospace;white-space:pre"; + run.textContent = "MMMMMMMMMM"; + ruler.appendChild(run); + body.appendChild(ruler); + + function ratio() { + var length = ruler.getBoundingClientRect().width; + return length ? run.getBoundingClientRect().width / length : 0; + } + + var zoom = body.style.zoom; + body.style.zoom = "1"; + var unzoomed = ratio(); + body.style.zoom = "0.5"; + textAdjust("50%"); + var zoomed = ratio(); + textAdjust(""); + body.style.zoom = zoom; + body.removeChild(ruler); + + return unzoomed > 0 && Math.abs(zoomed - unzoomed) < unzoomed / 50; + } + + function zoomBody(zoom) { + body.style.zoom = zoom; + root.style.setProperty("--odr-zoom", zoom); + if (adjustsText) { + textAdjust(zoom * 100 + "%"); + } + } + // The natural width of what the body holds, measured unscaled. function contentWidth() { var zoom = body.style.zoom; + var adjust = adjusted; // `1`, not empty: a stylesheet may carry a zoom of its own to fall back to. body.style.zoom = "1"; + // the percentage compensates a zoom this measurement removes + if (adjust) { + textAdjust("100%"); + } var natural = body.scrollWidth; body.style.zoom = zoom; + if (adjust) { + textAdjust(adjust); + } return natural; } @@ -527,9 +586,7 @@ constexpr std::string_view viewport_js = R"js( } function apply(target) { - var zoom = applied(); - body.style.zoom = zoom; - root.style.setProperty("--odr-zoom", zoom); + zoomBody(applied()); settle(target); notify(); @@ -604,11 +661,15 @@ constexpr std::string_view viewport_js = R"js( return applied(); }; + // Before the first measurement, so every one of them reads the same state. + adjustsText = textAdjustHolds(); width = root.clientWidth; if (measures && pinned === null) { fit = measureFit(); - body.style.zoom = fit; - root.style.setProperty("--odr-zoom", fit); + } + // Restated inline so a css-stated zoom carries the adjustment with it. + if (applied() !== 1) { + zoomBody(applied()); } remember(); diff --git a/test/browser/viewport/README.md b/test/browser/viewport/README.md index ecb5a55ec..73b11fa50 100644 --- a/test/browser/viewport/README.md +++ b/test/browser/viewport/README.md @@ -36,3 +36,9 @@ each printing its own verdict. Keep the tab on screen: the browser throttles `requestAnimationFrame` in a window that is not. The harness dispatches the scroll and resize events itself, but not the settling frames that follow them. + +Two webkit rules nothing here reproduces, both about type under an applied +`zoom` (#761): it holds the text at its unscaled size unless the zoom is stated +back as `text-size-adjust`, by a factor its cluster heuristics decide, and it +draws no text below 9px. Neither shows on 400 identical lines — the factor needs +the shape of a real render. The oracle is a document served to a real webkit. diff --git a/test/data.cmake b/test/data.cmake index 1cbde600f..00bac1b24 100644 --- a/test/data.cmake +++ b/test/data.cmake @@ -22,4 +22,4 @@ odr_test_data( odr_test_data( PATH "reference-output/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git" - REVISION "2f5964b9b8a5172aac2e4f0d893c6ccf625f8857") + REVISION "bcea37b997a045387b7069412ba668fa44a7e26b") diff --git a/test/src/internal/html/common_test.cpp b/test/src/internal/html/common_test.cpp index dd267fcda..9caedee8c 100644 --- a/test/src/internal/html/common_test.cpp +++ b/test/src/internal/html/common_test.cpp @@ -92,7 +92,9 @@ std::string emit_zoom(const HtmlConfig &config, const ihtml::WidthFit fits, /// Written whatever the zoom is, so every case states it. constexpr const char *print = - "@media print{:root{--odr-zoom:1!important}body{zoom:1!important}}"; + "@media print{:root{--odr-zoom:1!important;" + "-webkit-text-size-adjust:100%!important;text-size-adjust:100%!important}" + "body{zoom:1!important}}"; std::string styled(const std::string &css) { return "";