diff --git a/CHANGELOG.md b/CHANGELOG.md index 46ad93a2..ec1620e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +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 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. 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. 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. ## v6.9.0 - 2026-08-18 diff --git a/apple/include/OdrCoreObjC/ODRHtml.h b/apple/include/OdrCoreObjC/ODRHtml.h index 816eef9c..268934bd 100644 --- a/apple/include/OdrCoreObjC/ODRHtml.h +++ b/apple/include/OdrCoreObjC/ODRHtml.h @@ -94,6 +94,8 @@ 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 is shown at, in css pixels; fits paged content to it. +@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 bb8fb320..b85e9919 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 fd1cc301..fbab8cd0 100644 --- a/jni/java/app/opendocument/core/HtmlConfig.java +++ b/jni/java/app/opendocument/core/HtmlConfig.java @@ -37,6 +37,8 @@ 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 is shown at, in css pixels; fits paged content to it. */ + 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 cddad91c..2c8c7866 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 747f79f8..7a7f1319 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 ad0b8fa6..3b2de069 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 e55c1434..3b346ff0 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 3a908564..774b233f 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,32 +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 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"}; @@ -160,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 4688a7a6..f1f45262 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,64 @@ 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 + if (factor >= 1) { + return true; + } + + out.write_header_style_begin(); + // `zoom` scales the layout, so the page scrolls against the scaled size + // instead of overflowing beside it; `Measure` renders no exponent form + out.out() << "body{zoom:" << Measure(factor, DynamicUnit()).to_string() + << "}"; + out.write_header_style_end(); + + return true; +} + 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 f079b006..3e29b42b 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,23 @@ 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. +[[nodiscard]] bool +fits_width(const HtmlConfig &config, bool fit_width_by_default, + std::optional mode_override = {}); + +/// @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 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); + 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 a73530af..7e122671 100644 --- a/src/odr/internal/html/document.cpp +++ b/src/odr/internal/html/document.cpp @@ -32,10 +32,86 @@ 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. +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 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()); +} +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 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 = fragment_content_pixels(root.as_text_root()); + break; + case DocumentType::presentation: + for (const Element child : root.children()) { + result = widest(result, fragment_content_pixels(child.as_slide())); + } + break; + case DocumentType::drawing: + for (const Element child : root.children()) { + result = widest(result, fragment_content_pixels(child.as_page())); + } + break; + default: + break; + } + + 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; +} + +/// True where the view should fit but no css factor could be written. +bool fits_at_load_time(const Document &document, const HtmlConfig &config, + const bool paged_content, + const std::optional content_pixels) { + if (!paged_content || !fits_width(config, paged_content, + viewport_mode_override(document, config))) { + return false; + } + return !config.viewport_width.has_value() || !content_pixels.has_value(); +} + /// @p name titles the view; empty when the whole document is written as one /// file, which no one view names. void front(const Document &document, const WritingState &state, - 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()); @@ -48,10 +124,15 @@ 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 = + viewport_mode_override(document, state.config()); + write_viewport_meta(out, state.config(), paged_content, mode_override); + if (paged_content) { + write_viewport_fit_style( + out, state.config(), + fits_width(state.config(), paged_content, mode_override), + content_pixels); + } write_document_style(state); write_document_dark_style(state); @@ -90,7 +171,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())) { @@ -102,6 +184,11 @@ void back(const Document &document, const WritingState &state) { if (document.document_type() == DocumentType::spreadsheet) { write_spreadsheet_script(state); } + if (fits_at_load_time(document, state.config(), + is_paged_content(document, state.config()), + content_pixels)) { + write_viewport_script(state); + } out.write_body_end(); out.write_end(); @@ -122,10 +209,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: @@ -271,11 +362,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; } @@ -298,6 +392,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(); @@ -341,6 +439,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 73340699..b1e395f6 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -322,6 +322,141 @@ constexpr std::string_view document_js = R"js( })(); )js"; +/// The load-time half of fitting the page column to the viewport, for output +/// whose width was not known when it was written. +constexpr std::string_view viewport_js = R"js( +(function () { + "use strict"; + + var root = document.documentElement; + var body = document.body; + + // 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 arriving after the + // viewport changed is the browser's doing, not the reader's. + var width = 0; + // 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; + // 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; + if (!available) { + return; + } + width = available; + + if (!framed) { + return; + } + var content = contentWidth(); + if (!content) { + return; + } + // Only ever down: a page narrower than the viewport is shown at its size. + body.style.zoom = content > available ? available / content : ""; + } + + // 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) { + 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 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; + } + 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() { + if (root.clientWidth === width) { + // Nothing that changes the scale: a height-only change, or a pinch, + // where restoring would fight the reader. + return; + } + + var target = held; + + fit(); + restoring = true; + restore(target); + + // The browser applies a scroll offset of its own a few frames later, so + // the position is re-asserted until it settles. + var token = ++settling; + var frames = 30; + (function again() { + 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 +1351,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 +1506,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 +1529,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 f0047335..d33ea64c 100644 --- a/src/odr/internal/html/frontend.hpp +++ b/src/odr/internal/html/frontend.hpp @@ -45,6 +45,11 @@ void write_text_script(const WritingState &state); /// rest of that object, for every view rendering text, whatever the format. void write_search_script(const WritingState &state); +/// Fits the page column to the viewport at load and on every resize, holding +/// the reading position across the change. For output whose width was not known +/// when it was written; @ref odr::HtmlConfig::viewport_width covers the rest. +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 +57,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/image_file.cpp b/src/odr/internal/html/image_file.cpp index 6b2ad7f7..84a99784 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/src/odr/internal/html/pdf_file.cpp b/src/odr/internal/html/pdf_file.cpp index 7bfcc83e..ae5e8f93 100644 --- a/src/odr/internal/html/pdf_file.cpp +++ b/src/odr/internal/html/pdf_file.cpp @@ -1132,7 +1132,14 @@ 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 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)); + } + } + } /// 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 +1720,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 +1832,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 +2198,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 +2310,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(); @@ -2546,6 +2561,26 @@ class HtmlServiceImpl final : public HtmlService { close_svg(); } + /// The widest page a view holds, in css pixels, with `.d`'s side gutters. + 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; + } + + /// True where the view should fit but no css factor could be written. + bool fits_at_load_time(const std::optional content) const { + return fits_width(config(), true) && + (!config().viewport_width.has_value() || !content.has_value()); + } + /// The document/head prologue shared by both modes, with `write_mode_css()` /// slotted between the constant rules. Leaves the writer after ``. template @@ -2553,6 +2588,7 @@ class HtmlServiceImpl final : public HtmlService { 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 +2598,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 5cddf632..4bf27b02 100644 --- a/test/src/html_test.cpp +++ b/test/src/html_test.cpp @@ -7,6 +7,8 @@ #include +#include + #include #include @@ -261,3 +263,118 @@ TEST(html, views) { EXPECT_EQ(views.at(1).name(), "Foglio1"); EXPECT_EQ(views.at(2).name(), "Foglio2"); } + +// #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); + + 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); + } +} + +// `/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] >>") + // a quarter turn makes this 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); +} + +// 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); +} diff --git a/test/src/internal/html/common_test.cpp b/test/src/internal/html/common_test.cpp index befb99a8..1c1b81ea 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 66b1e569..4df8e190 100644 --- a/wasm/js/index.d.ts +++ b/wasm/js/index.d.ts @@ -87,6 +87,8 @@ export interface HtmlConfig { colorScheme?: number; spreadsheetGridlines?: number; viewportMode?: number; + /** The width the output is shown at, in css pixels; fits paged content to it. */ + viewportWidth?: number; pdfTextMode?: number; } diff --git a/wasm/src/wasm_html.cpp b/wasm/src/wasm_html.cpp index 41655348..caa2dec8 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;