Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ The release run heads these entries with the version and opens a fresh
`elementFromPoint` takes, for a host hit-testing while a zoom is applied.
- A view whose zoom does not follow the viewport β€” `viewport_width`,
`initial_zoom`, a sheet β€” keeps the reader's place across a width change.
- New `HtmlViewportMode::fit_width_by_view`: the view measures the fit and keeps
it current, so a rotation refits instead of holding what it opened at. For a
host whose web view does not fit a top-level document itself.

## v6.10.1 - 2026-08-21

Expand Down
6 changes: 4 additions & 2 deletions apple/include/OdrCoreObjC/ODRHtml.h
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,17 @@ typedef NS_ENUM(NSInteger, ODRHtmlColorScheme) {
ODRHtmlColorSchemeSystem,
} NS_SWIFT_NAME(HtmlColorScheme);

/// Initial zoom of the emitted HTML on mobile (the viewport meta tag). Desktop
/// browsers ignore it entirely.
/// The zoom a view opens at, and who fits it: the browser through the viewport
/// meta tag, which desktop browsers ignore, or the view itself.
typedef NS_ENUM(NSInteger, ODRHtmlViewportMode) {
/// Fixed-size paged content fits the width; reflowing content is actual size.
ODRHtmlViewportModeAutomatic = 0,
ODRHtmlViewportModeFitWidth,
ODRHtmlViewportModeActualSize,
/// No viewport meta tag at all.
ODRHtmlViewportModeNone,
/// The view measures and applies the fit itself, and keeps it current.
ODRHtmlViewportModeFitWidthByView,
} NS_SWIFT_NAME(HtmlViewportMode);

/// How text is emitted in PDF→HTML output.
Expand Down
2 changes: 2 additions & 0 deletions apple/src/ODRHtml.mm
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
ODR_SAME_ENUM(ODRHtmlViewportModeActualSize,
odr::HtmlViewportMode::actual_size);
ODR_SAME_ENUM(ODRHtmlViewportModeNone, odr::HtmlViewportMode::none);
ODR_SAME_ENUM(ODRHtmlViewportModeFitWidthByView,
odr::HtmlViewportMode::fit_width_by_view);

ODR_SAME_ENUM(ODRPdfTextModeDualLayer, odr::PdfTextMode::dual_layer);
ODR_SAME_ENUM(ODRPdfTextModeSingleLayer, odr::PdfTextMode::single_layer);
Expand Down
2 changes: 1 addition & 1 deletion jni/java/app/opendocument/core/HtmlViewportMode.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

/** Mirrors {@code odr::HtmlViewportMode}; constant order must match the C++ declaration. */
public enum HtmlViewportMode {
AUTOMATIC, FIT_WIDTH, ACTUAL_SIZE, NONE;
AUTOMATIC, FIT_WIDTH, ACTUAL_SIZE, NONE, FIT_WIDTH_BY_VIEW;

static HtmlViewportMode fromNative(int code) {
return code < 0 ? null : values()[code];
Expand Down
6 changes: 6 additions & 0 deletions jni/tests/app/opendocument/core/HtmlTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ void viewportModeReachesTheHtml() throws IOException {
.contains(
"<meta name=\"viewport\" content=\"width=device-width,user-scalable=yes\"/>"));

// only paged content has a width to fit, hence the margins
HtmlConfig byView = new HtmlConfig();
byView.viewportMode = HtmlViewportMode.FIT_WIDTH_BY_VIEW;
byView.textDocumentMargin = true;
assertTrue(renderOdt(byView).contains("--odr-fit:view"));

HtmlConfig raw = new HtmlConfig();
raw.viewportContent = "width=420";
assertTrue(renderOdt(raw).contains("<meta name=\"viewport\" content=\"width=420\"/>"));
Expand Down
3 changes: 2 additions & 1 deletion python/src/bind_html.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ void odr_python::bind_html(py::module_ &m) {
.value("automatic", odr::HtmlViewportMode::automatic)
.value("fit_width", odr::HtmlViewportMode::fit_width)
.value("actual_size", odr::HtmlViewportMode::actual_size)
.value("none", odr::HtmlViewportMode::none);
.value("none", odr::HtmlViewportMode::none)
.value("fit_width_by_view", odr::HtmlViewportMode::fit_width_by_view);

py::enum_<odr::PdfTextMode>(m, "PdfTextMode")
.value("dual_layer", odr::PdfTextMode::dual_layer)
Expand Down
6 changes: 6 additions & 0 deletions python/tests/test_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ def render(name, config):
in render("fit_width", fit_width)
)

# only paged content has a width to fit, hence the margins
by_view = pyodr.HtmlConfig()
by_view.viewport_mode = pyodr.HtmlViewportMode.fit_width_by_view
by_view.text_document_margin = True
assert "--odr-fit:view" in render("by_view", by_view)

raw = pyodr.HtmlConfig()
raw.viewport_content = "width=420"
assert '<meta name="viewport" content="width=420"/>' in render("raw", raw)
Expand Down
10 changes: 7 additions & 3 deletions src/odr/html.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,19 @@ enum class HtmlColorScheme {
system, ///< `light` or `dark`, by the reader's `prefers-color-scheme`
};

/// @brief Initial zoom of the emitted HTML on mobile (viewport meta tag).
/// Desktop browsers ignore the tag entirely.
/// @brief The zoom a view opens at, and who fits it: the browser through the
/// viewport meta tag, which desktop browsers ignore, or the view itself.
enum class HtmlViewportMode {
automatic, ///< `fit_width` for fixed-size paged content (PDF pages, slides,
///< drawings, images, text documents with page margins),
///< `actual_size` for reflowing content (spreadsheets, text)
fit_width, ///< initial zoom fits the content's full width on screen
actual_size, ///< initial zoom locked to 100% (`initial-scale=1.0`)
none, ///< no viewport meta tag at all
/// Like @ref fit_width, but measured by the view and kept current, so a
/// rotation refits. Needs the script. Last because the bindings map this
/// enum by ordinal.
fit_width_by_view,
};

/// @brief How text is emitted in PDF→HTML output. Neither mode needs
Expand Down Expand Up @@ -141,7 +145,7 @@ struct HtmlConfig {
/// Which gridlines a sheet paints.
HtmlTableGridlines spreadsheet_gridlines{HtmlTableGridlines::soft};

/// Initial zoom on mobile; see @ref HtmlViewportMode.
/// The zoom the view opens at; see @ref HtmlViewportMode.
HtmlViewportMode viewport_mode{HtmlViewportMode::automatic};
/// Overrides @ref viewport_mode for spreadsheet content when set.
std::optional<HtmlViewportMode> spreadsheet_viewport_mode;
Expand Down
40 changes: 30 additions & 10 deletions src/odr/internal/html/common.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
#include <iomanip>
#include <sstream>
#include <string>
#include <string_view>
#include <unordered_map>

namespace odr::internal {
Expand All @@ -39,6 +40,8 @@ void html::write_viewport_meta(
out.write_header_viewport("width=device-width,user-scalable=yes");
break;
case HtmlViewportMode::actual_size:
// A stated scale is what turns the browser's own fitting off.
case HtmlViewportMode::fit_width_by_view:
out.write_header_viewport(
"width=device-width,initial-scale=1.0,user-scalable=yes");
break;
Expand All @@ -48,18 +51,25 @@ void html::write_viewport_meta(
}
}

bool html::fits_width(const HtmlConfig &config, const bool fit_width_by_default,
const std::optional<HtmlViewportMode> mode_override) {
html::WidthFit
html::width_fit(const HtmlConfig &config, const bool fit_width_by_default,
const std::optional<HtmlViewportMode> mode_override) {
// A raw `viewport_content` is the caller taking the question over.
if (config.viewport_content.has_value()) {
return false;
return WidthFit::none;
}

const HtmlViewportMode mode = mode_override.value_or(config.viewport_mode);
if (mode == HtmlViewportMode::automatic) {
return fit_width_by_default;
switch (mode) {
case HtmlViewportMode::automatic:
return fit_width_by_default ? WidthFit::browser : WidthFit::none;
case HtmlViewportMode::fit_width:
return WidthFit::browser;
case HtmlViewportMode::fit_width_by_view:
return WidthFit::view;
default:
return WidthFit::none;
}
return mode == HtmlViewportMode::fit_width;
}

std::optional<double> html::css_pixels(const std::optional<Measure> &measure) {
Expand All @@ -82,18 +92,28 @@ std::optional<double> html::css_pixels(const std::optional<Measure> &measure) {
}

void html::write_zoom_style(HtmlWriter &out, const HtmlConfig &config,
const bool fits,
const WidthFit fits,
const std::optional<double> content_pixels) {
// The factor, or who measures it where the css cannot state it.
std::optional<double> fit = 1;
if (fits) {
std::string_view measures;
switch (fits) {
case WidthFit::none:
break;
case WidthFit::browser:
if (config.viewport_width.has_value() && content_pixels.has_value()) {
// only ever down: a page narrower than the viewport is shown at its size
fit = std::min(1.0, static_cast<double>(config.viewport_width.value()) /
*content_pixels);
} else {
// only the view can measure this one
fit.reset();
measures = "auto";
}
break;
case WidthFit::view:
fit.reset();
measures = "view";
break;
}

const std::optional<double> zoom =
Expand All @@ -118,7 +138,7 @@ void html::write_zoom_style(HtmlWriter &out, const HtmlConfig &config,
if (fit.has_value()) {
out.out() << number(*fit);
} else {
out.out() << "auto";
out.out() << measures;
}
if (writes_pin) {
out.out() << ";";
Expand Down
20 changes: 13 additions & 7 deletions src/odr/internal/html/common.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,16 @@ void write_viewport_meta(HtmlWriter &out, const HtmlConfig &config,
bool fit_width_by_default,
std::optional<HtmlViewportMode> 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<HtmlViewportMode> mode_override = {});
/// Who fits the output's width to the viewport.
enum class WidthFit {
none, ///< nobody: it is shown at its size
browser, ///< the browser, steered by the viewport meta tag
view, ///< the view itself, measured and kept current
};

[[nodiscard]] WidthFit
width_fit(const HtmlConfig &config, bool fit_width_by_default,
std::optional<HtmlViewportMode> mode_override = {});

/// @p measure in css pixels, or nothing without an absolute unit.
[[nodiscard]] std::optional<double>
Expand All @@ -59,9 +65,9 @@ css_pixels(const std::optional<Measure> &measure);
constexpr double page_column_gutter_pixels = 32;

/// The zoom the view opens at: `--odr-fit` fits @p content_pixels into
/// `config.viewport_width` (`auto` where only the view can measure it),
/// `--odr-zoom` pins it, `body{zoom}` applies the winner.
void write_zoom_style(HtmlWriter &out, const HtmlConfig &config, bool fits,
/// `config.viewport_width`, or names who measures it instead, `--odr-zoom` pins
/// it, `body{zoom}` applies the winner.
void write_zoom_style(HtmlWriter &out, const HtmlConfig &config, WidthFit fits,
std::optional<double> content_pixels);

std::string escape_text(std::string text);
Expand Down
5 changes: 3 additions & 2 deletions src/odr/internal/html/document.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,9 @@ void front(const Document &document, const WritingState &state,
viewport_mode_override(document, state.config());
write_viewport_meta(out, state.config(), paged_content, mode_override);
write_zoom_style(out, state.config(),
paged_content &&
fits_width(state.config(), paged_content, mode_override),
paged_content
? width_fit(state.config(), paged_content, mode_override)
: WidthFit::none,
content_pixels);

write_document_style(state);
Expand Down
2 changes: 1 addition & 1 deletion src/odr/internal/html/filesystem.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ class HtmlServiceImpl final : public HtmlService {
out.write_header_target("_blank");
out.write_header_title("odr");
write_viewport_meta(out, config(), false);
write_zoom_style(out, config(), false, {});
write_zoom_style(out, config(), WidthFit::none, {});
write_filesystem_style(state);
write_filesystem_dark_style(state);
write_search_style(state);
Expand Down
15 changes: 7 additions & 8 deletions src/odr/internal/html/frontend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -337,17 +337,17 @@ constexpr std::string_view viewport_js = R"js(
var minZoom = 0.1;
var maxZoom = 10;

// Only a frame is fitted here: the viewport meta tag covers the top-level
// document but is inert in a frame.
var framed = window.top !== window.self;

function declared(name) {
return getComputedStyle(root).getPropertyValue(name).trim();
}

// `auto` where only we can measure the fit; a number where the css states it.
var measures = declared("--odr-fit") === "auto";
var fit = measures ? 1 : parseFloat(declared("--odr-fit")) || 1;
// A number is the css stating the fit; `view` and `auto` ask us to measure
// it. `auto` only in a frame, where the viewport meta tag is inert.
var stated = declared("--odr-fit");
var measures = stated === "view" || (stated === "auto" && framed);
var fit = measures ? 1 : parseFloat(stated) || 1;

// `null` while the view follows the fit.
var pinned = parseFloat(declared("--odr-zoom"));
Expand Down Expand Up @@ -382,9 +382,8 @@ constexpr std::string_view viewport_js = R"js(

function measureFit() {
var available = root.clientWidth;
if (!available || !framed) {
// Out of a frame the viewport meta tag has already fitted the document.
return available ? 1 : fit;
if (!available) {
return fit;
}
var content = contentWidth();
if (!content) {
Expand Down
4 changes: 2 additions & 2 deletions src/odr/internal/html/image_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,10 @@ class HtmlServiceImpl final : public HtmlService {
write_viewport_meta(out, config(), true);
// An image has no layout width to preserve, so css alone fits it, framed
// or not - no measuring and no `viewport_width`.
write_zoom_style(out, config(), false, {});
write_zoom_style(out, config(), WidthFit::none, {});
out.write_header_style_begin();
out.out() << "body{margin:0;background:#fff}";
if (fits_width(config(), true)) {
if (width_fit(config(), true) != WidthFit::none) {
// `100%` of a zoomed body is the viewport again, so the factor has to
// be put back for the image to grow with it
out.out() << "img{max-width:calc(100% * var(--odr-zoom, 1));"
Expand Down
2 changes: 1 addition & 1 deletion src/odr/internal/html/pdf_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2586,7 +2586,7 @@ class HtmlServiceImpl final : public HtmlService {
out.write_header_target("_blank");
out.write_header_title("odr");
write_viewport_meta(out, config(), true);
write_zoom_style(out, config(), fits_width(config(), true), content);
write_zoom_style(out, config(), width_fit(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
Expand Down
2 changes: 1 addition & 1 deletion src/odr/internal/html/text_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ class HtmlServiceImpl final : public HtmlService {
out.write_header_target("_blank");
out.write_header_title("odr");
write_viewport_meta(out, config(), false);
write_zoom_style(out, config(), false, {});
write_zoom_style(out, config(), WidthFit::none, {});

write_text_style(state);
write_text_dark_style(state);
Expand Down
2 changes: 1 addition & 1 deletion src/odr/internal/html/xml_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ class HtmlServiceImpl final : public HtmlService {
out.write_header_target("_blank");
out.write_header_title("odr");
write_viewport_meta(out, config(), false);
write_zoom_style(out, config(), false, {});
write_zoom_style(out, config(), WidthFit::none, {});

write_xml_style(state);
write_xml_dark_style(state);
Expand Down
9 changes: 7 additions & 2 deletions test/browser/viewport/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,10 @@ Why the harness is shaped this way:
- **Positions are read as `(scrollY + y) / zoom`**, never through the script's
helpers, so a wrong answer cannot agree with itself.

Keep the tab on screen: the browser throttles `resize` and
`requestAnimationFrame` in a window that is not.
Who fits the width is the one thing a frame cannot check: `--odr-fit` `auto` and
`view` both measure in one. `tests.html` links the two top-level pages that can,
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.
Loading
Loading