From 2c08c53ef20984929dd16b7f6b1478fd7a5162f0 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Wed, 26 Aug 2026 20:58:59 +0200 Subject: [PATCH 1/2] feat(html): let a host put a floor under the margin a view keeps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `HtmlConfig::min_content_margin` states, per side, the least distance the generated content keeps from the view's border. Every view already insets its content by some amount it names itself — 3mm for a reflowed text document, 16px for the page column and the source views, 8px for a font specimen, nothing for an image or a file listing — and no single number could stand in for all of them, so the config is a floor rather than a value: an unset side leaves the view exactly where it was, a set one only ever raises it. The arithmetic is css's. Each inset becomes `max(own, var(--odr-min-margin-*, 0px))`, so the view's own measure stays in the stylesheet and no unit has to be converted to state it. `write_content_margin_style` declares the variables per view and writes nothing at all when no side is set, which is what keeps the shipped stylesheets independent of the config — they are shared under one name when the config links rather than embeds them. One place still needs the number: the gutter around the page column is part of the width the view is fitted to, so `page_column_gutter_pixels` reads the config and a side css can read but `css_pixels` cannot convert keeps the built-in gutter — the margin still applies, only the fit cannot count it. A unit css could not read at all is dropped rather than written into the style element it would otherwise close. A sheet and the media view are not inset: the one meets the edge by design, the other is full-bleed. Bound in python, jni, wasm (`minContentMargin`, css lengths as strings) and apple, where `ODRMeasure` and `ODRDirectionalMeasure` gain the initialisers a caller needs to state one. Rendering with the default config is unchanged. Where the stylesheet is linked the document is byte-identical; where it is embedded — the image, pdf and font views — the reference output restates the same insets as `max(own, var(...))`, which with the variables unset is the number it always was, and the shipped stylesheets render pixel-identically under `compare-html`. The reference-output pins are advanced to match. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UoWywNazPio5vphzB59U5g --- CHANGELOG.md | 7 ++ apple/include/OdrCoreObjC/ODRHtml.h | 6 ++ apple/include/OdrCoreObjC/ODRStyle.h | 12 ++++ apple/src/ODRHtml.mm | 7 ++ apple/src/ODRPrivate.h | 4 ++ apple/src/ODRStyle.mm | 44 ++++++++++++ apple/tests/OdrCoreTests.swift | 20 ++++++ .../app/opendocument/core/HtmlConfig.java | 7 ++ jni/src/jni_style.cpp | 46 ++++++++++++ jni/tests/app/opendocument/core/HtmlTest.java | 20 ++++++ python/src/bind_html.cpp | 1 + python/tests/test_html.py | 23 ++++++ src/odr/html.hpp | 9 +++ src/odr/internal/html/common.cpp | 67 +++++++++++++++++ src/odr/internal/html/common.hpp | 15 +++- src/odr/internal/html/document.cpp | 55 ++++++++------ src/odr/internal/html/filesystem.cpp | 1 + src/odr/internal/html/font_file.cpp | 6 +- src/odr/internal/html/frontend.cpp | 17 ++--- src/odr/internal/html/image_file.cpp | 7 +- src/odr/internal/html/pdf_file.cpp | 19 +++-- src/odr/internal/html/text_file.cpp | 1 + src/odr/internal/html/xml_file.cpp | 1 + test/data.cmake | 4 +- test/src/html_test.cpp | 72 +++++++++++++++++++ wasm/js/index.d.ts | 11 +++ wasm/src/wasm_html.cpp | 19 +++++ wasm/tests/render.test.mjs | 20 ++++++ 28 files changed, 480 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7847b6190..4eeedd26c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- `HtmlConfig::min_content_margin` puts a floor under the distance the + generated content keeps from the view's border, per side. Unset, every view + keeps the inset it has today; a set side raises it - in a reflowed text + document, the page column of a text, presentation, drawing or pdf view, the + text and xml source views, the file listing, the image view and the font + specimen. A sheet and the media view are not inset. Bound in python, jni, + wasm (`minContentMargin`, css lengths as strings) and apple. - **Breaking** Bytes that do not read as text no longer come back as `text_file` and render as nonsense - `decode` throws `UnknownFileType` and `list_file_types` comes back empty. A file is text when it is empty, or its diff --git a/apple/include/OdrCoreObjC/ODRHtml.h b/apple/include/OdrCoreObjC/ODRHtml.h index 5f0bcf59e..ab6227ba5 100644 --- a/apple/include/OdrCoreObjC/ODRHtml.h +++ b/apple/include/OdrCoreObjC/ODRHtml.h @@ -1,6 +1,7 @@ #import #import +#import #import NS_ASSUME_NONNULL_BEGIN @@ -101,6 +102,11 @@ NS_SWIFT_NAME(HtmlConfig) /// The zoom the view opens at, 1 being actual size; `nil` follows the fit. @property(nonatomic, strong, nullable) NSNumber *initialZoom; +/// The least distance the generated content keeps from the view's border. A +/// `nil` side keeps the inset the view already has; a set side raises it, +/// never lowers it. +@property(nonatomic, strong) ODRDirectionalMeasure *minContentMargin; + @property(nonatomic) BOOL formatHtml; /// Repeated `htmlIndentString` per nesting level; 0 disables indentation. @property(nonatomic) uint8_t htmlIndent; diff --git a/apple/include/OdrCoreObjC/ODRStyle.h b/apple/include/OdrCoreObjC/ODRStyle.h index 99f8c7a8c..668b75d0e 100644 --- a/apple/include/OdrCoreObjC/ODRStyle.h +++ b/apple/include/OdrCoreObjC/ODRStyle.h @@ -79,6 +79,11 @@ NS_SWIFT_NAME(Measure) /// Magnitude and unit as odrcore writes them, e.g. `12pt`. @property(nonatomic, readonly, copy) NSString *stringValue; +/// A css length as odrcore writes one, e.g. `3mm`. A magnitude with no unit +/// reads as unitless. +- (instancetype)initWithString:(NSString *)string; +- (instancetype)initWithMagnitude:(double)magnitude unit:(NSString *)unit; + - (instancetype)init NS_UNAVAILABLE; + (instancetype)new NS_UNAVAILABLE; @end @@ -92,6 +97,13 @@ NS_SWIFT_NAME(DirectionalMeasure) @property(nonatomic, readonly, nullable) ODRMeasure *left; @property(nonatomic, readonly, nullable) ODRMeasure *bottom; +/// For the sides a caller states itself, e.g. +/// @ref ODRHtmlConfig.minContentMargin. +- (instancetype)initWithRight:(nullable ODRMeasure *)right + top:(nullable ODRMeasure *)top + left:(nullable ODRMeasure *)left + bottom:(nullable ODRMeasure *)bottom; + - (instancetype)init NS_UNAVAILABLE; + (instancetype)new NS_UNAVAILABLE; @end diff --git a/apple/src/ODRHtml.mm b/apple/src/ODRHtml.mm index 32c512f96..7f6a06d8e 100644 --- a/apple/src/ODRHtml.mm +++ b/apple/src/ODRHtml.mm @@ -108,6 +108,8 @@ - (instancetype)initWithNativeConfig:(const odr::HtmlConfig &)config { _viewportContent = config.viewport_content.has_value() ? to_nsstring(*config.viewport_content) : nil; + _minContentMargin = + [ODRDirectionalMeasure directionalWithHandle:config.min_content_margin]; _viewportWidth = config.viewport_width.has_value() ? @(static_cast(*config.viewport_width)) : nil; @@ -187,6 +189,11 @@ - (instancetype)initWithNativeConfig:(const odr::HtmlConfig &)config { } else { config.initial_zoom.reset(); } + if (_minContentMargin != nil) { + config.min_content_margin = _minContentMargin.handle; + } else { + config.min_content_margin = odr::DirectionalStyle(); + } config.format_html = _formatHtml == YES; config.html_indent = _htmlIndent; config.html_indent_string = to_string(_htmlIndentString); diff --git a/apple/src/ODRPrivate.h b/apple/src/ODRPrivate.h index 85affbc68..295aaaeca 100644 --- a/apple/src/ODRPrivate.h +++ b/apple/src/ODRPrivate.h @@ -17,6 +17,8 @@ #include #include +#include + /// Cross-translation-unit access to the C++ value each wrapper owns. /// /// Each `@implementation` holds its handle as an ivar, destroyed by ARC's @@ -85,11 +87,13 @@ NS_ASSUME_NONNULL_BEGIN @interface ODRMeasure (Private) + (instancetype)measureWithHandle:(const odr::Measure &)handle; +- (const std::optional &)handle; @end @interface ODRDirectionalMeasure (Private) + (instancetype)directionalWithHandle: (const odr::DirectionalStyle &)handle; +- (odr::DirectionalStyle)handle; @end @interface ODRDirectionalString (Private) diff --git a/apple/src/ODRStyle.mm b/apple/src/ODRStyle.mm index b233407ad..fca09ff4b 100644 --- a/apple/src/ODRStyle.mm +++ b/apple/src/ODRStyle.mm @@ -9,6 +9,7 @@ using odr::apple::guarded_value; using odr::apple::to_nsstring; +using odr::apple::to_string; ODR_SAME_ENUM(ODRFontWeightNormal, odr::FontWeight::normal); ODR_SAME_ENUM(ODRFontWeightBold, odr::FontWeight::bold); @@ -107,6 +108,26 @@ - (NSString *)stringValue { return guarded_value([&] { return to_nsstring(_handle->to_string()); }, @""); } +- (instancetype)initWithString:(NSString *)string { + if ((self = [super init]) == nil) { + return nil; + } + _handle = odr::Measure(to_string(string)); + return self; +} + +- (instancetype)initWithMagnitude:(double)magnitude unit:(NSString *)unit { + if ((self = [super init]) == nil) { + return nil; + } + _handle = odr::Measure(magnitude, odr::DynamicUnit(to_string(unit))); + return self; +} + +- (const std::optional &)handle { + return _handle; +} + - (NSString *)description { return self.stringValue; } @@ -127,6 +148,29 @@ + (instancetype)directionalWithHandle: return result; } +- (instancetype)initWithRight:(ODRMeasure *)right + top:(ODRMeasure *)top + left:(ODRMeasure *)left + bottom:(ODRMeasure *)bottom { + if ((self = [super init]) == nil) { + return nil; + } + _right = right; + _top = top; + _left = left; + _bottom = bottom; + return self; +} + +- (odr::DirectionalStyle)handle { + odr::DirectionalStyle result; + result.right = _right != nil ? _right.handle : std::nullopt; + result.top = _top != nil ? _top.handle : std::nullopt; + result.left = _left != nil ? _left.handle : std::nullopt; + result.bottom = _bottom != nil ? _bottom.handle : std::nullopt; + return result; +} + @end @implementation ODRDirectionalString diff --git a/apple/tests/OdrCoreTests.swift b/apple/tests/OdrCoreTests.swift index 5facde83e..eccdee02f 100644 --- a/apple/tests/OdrCoreTests.swift +++ b/apple/tests/OdrCoreTests.swift @@ -119,6 +119,26 @@ final class HtmlTests: XCTestCase { XCTAssertTrue(html.contains(" measure_from_java(JNIEnv *env, jobject value) { + if (value == nullptr) { + return std::nullopt; + } + jclass cls = env->GetObjectClass(value); + const double magnitude = + env->GetDoubleField(value, env->GetFieldID(cls, "magnitude", "D")); + auto unit = static_cast(env->GetObjectField( + value, env->GetFieldID(cls, "unit", "Ljava/lang/String;"))); + const odr::Measure result(magnitude, odr::DynamicUnit(to_string(env, unit))); + env->DeleteLocalRef(unit); + env->DeleteLocalRef(cls); + return result; +} + +odr::DirectionalStyle +directional_measure_from_java(JNIEnv *env, jobject value) { + odr::DirectionalStyle result; + if (value == nullptr) { + return result; + } + + jclass cls = env->GetObjectClass(value); + const auto side = [&](const char *name) { + jobject measure = env->GetObjectField( + value, env->GetFieldID(cls, name, "Lapp/opendocument/core/Measure;")); + std::optional parsed = measure_from_java(env, measure); + env->DeleteLocalRef(measure); + return parsed; + }; + result.right = side("right"); + result.top = side("top"); + result.left = side("left"); + result.bottom = side("bottom"); + env->DeleteLocalRef(cls); + return result; +} + jobject make_directional_string(JNIEnv *env, const odr::DirectionalStyle &style) { @@ -374,6 +412,8 @@ jobject html_config_to_java(JNIEnv *env, const odr::HtmlConfig &config) { box_integer(env, config.viewport_width)); set_object("initialZoom", "Ljava/lang/Double;", box_double(env, config.initial_zoom)); + set_object("minContentMargin", "Lapp/opendocument/core/DirectionalMeasure;", + make_directional_measure(env, config.min_content_margin)); set_boolean("formatHtml", config.format_html); set_int("htmlIndent", config.html_indent); set_string("htmlIndentString", config.html_indent_string); @@ -540,6 +580,12 @@ odr::HtmlConfig html_config_from_java(JNIEnv *env, jobject config) { } env->DeleteLocalRef(zoom); } + { + jobject margin = get_object("minContentMargin", + "Lapp/opendocument/core/DirectionalMeasure;"); + result.min_content_margin = directional_measure_from_java(env, margin); + env->DeleteLocalRef(margin); + } 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 71ffaa9d9..fd33ff5ae 100644 --- a/jni/tests/app/opendocument/core/HtmlTest.java +++ b/jni/tests/app/opendocument/core/HtmlTest.java @@ -62,6 +62,26 @@ void viewportConfigRoundTrips() throws IOException { assertEquals(Double.valueOf(1.5), readBack.initialZoom); } + /** The C++ suite covers where the floor lands; this only proves it crosses JNI. */ + @Test + void minContentMarginReachesTheHtml() throws IOException { + assertNull(new HtmlConfig().minContentMargin.top); + assertTrue(!renderOdt(new HtmlConfig()).contains(":root{--odr-min-margin")); + + HtmlConfig config = new HtmlConfig(); + config.minContentMargin = + new DirectionalMeasure(null, new Measure(12, "px"), new Measure(1, "cm"), null); + assertTrue( + renderOdt(config).contains(":root{--odr-min-margin-top:12px;--odr-min-margin-left:1cm;}")); + + Path cache = Files.createDirectories(tempDir.resolve("margin")); + DecodedFile file = Odr.open(TestFiles.odtFile(tempDir).toString()); + HtmlConfig readBack = Html.translate(file, cache.toString(), config).config(); + assertEquals(new Measure(12, "px"), readBack.minContentMargin.top); + assertEquals(new Measure(1, "cm"), readBack.minContentMargin.left); + assertNull(readBack.minContentMargin.right); + } + /** The C++ suite covers the mode matrix; this only proves the config crosses JNI. */ @Test void viewportModeReachesTheHtml() throws IOException { diff --git a/python/src/bind_html.cpp b/python/src/bind_html.cpp index 76c058524..21887c2b5 100644 --- a/python/src/bind_html.cpp +++ b/python/src/bind_html.cpp @@ -93,6 +93,7 @@ void odr_python::bind_html(py::module_ &m) { .def_readwrite("viewport_content", &odr::HtmlConfig::viewport_content) .def_readwrite("viewport_width", &odr::HtmlConfig::viewport_width) .def_readwrite("initial_zoom", &odr::HtmlConfig::initial_zoom) + .def_readwrite("min_content_margin", &odr::HtmlConfig::min_content_margin) .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 4f99564c6..475d41db2 100644 --- a/python/tests/test_html.py +++ b/python/tests/test_html.py @@ -90,6 +90,29 @@ def render(name, config): assert '' in render("raw", raw) +def test_min_content_margin_reaches_the_html(odt_path, tmp_path): + # The C++ suite covers where the floor lands; this only proves it crosses + # the binding, unset sides and all. + def render(name, config): + cache = tmp_path / name + cache.mkdir() + file = pyodr.open(str(odt_path)) + service = pyodr.html.translate(file, str(cache), config) + content, _ = service.list_views()[0].write_html() + return content + + default = pyodr.HtmlConfig() + assert default.min_content_margin.top is None + assert ":root{--odr-min-margin" not in render("default", default) + + config = pyodr.HtmlConfig() + config.min_content_margin.top = pyodr.Measure("12px") + config.min_content_margin.left = pyodr.Measure("1cm") + html = render("margin", config) + assert ":root{--odr-min-margin-top:12px;--odr-min-margin-left:1cm;}" in html + assert "--odr-min-margin-right:" not in html + + def test_translate_text(txt_path, tmp_path): html = translate_offline(txt_path, tmp_path) pages = html.pages() diff --git a/src/odr/html.hpp b/src/odr/html.hpp index fd3f12d3b..96a841b35 100644 --- a/src/odr/html.hpp +++ b/src/odr/html.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -156,6 +157,14 @@ struct HtmlConfig { /// The zoom the view opens at, 1 being actual size; unset follows the fit. std::optional initial_zoom; + /// The least distance the generated content keeps from the view's border. A + /// side left unset keeps the inset the view already has — `3mm` for a + /// reflowed text document, `16px` for the page column and the source views, + /// `8px` for a font specimen, none for an image or a file listing. A set + /// side raises that inset, never lowers it. A sheet and the media view are + /// not inset: the one meets the edge by design, the other is full-bleed. + DirectionalStyle min_content_margin; + /// Indent and break the output into lines rather than writing one stream. bool format_html{false}; /// Repeated @ref html_indent_string per nesting level; 0 disables indenting. diff --git a/src/odr/internal/html/common.cpp b/src/odr/internal/html/common.cpp index 85e9516ce..6ed386929 100644 --- a/src/odr/internal/html/common.cpp +++ b/src/odr/internal/html/common.cpp @@ -11,6 +11,8 @@ #include #include +#include +#include #include #include #include @@ -18,6 +20,7 @@ #include #include #include +#include namespace odr::internal { @@ -92,6 +95,70 @@ std::optional html::css_pixels(const std::optional &measure) { return pixels > 0 ? std::optional(pixels) : std::nullopt; } +namespace { + +/// Whether @p name is a unit css could read: letters, or a percent sign. Also +/// what keeps a hosted value from closing the `"); + + const std::string page = render_odt(config); + + EXPECT_EQ(page.find(":root{--odr-min-margin"), std::string::npos); + EXPECT_EQ(page.find(""); + const auto every_view = [&] { + return std::array{ + // a document, and the page column it stacks + render_odt(config), + render("odr-public/txt/lorem ipsum.txt", config), + xml(), + // a file listing: the archive view of a zip + render_as("odr-public/odt/about.odt", FileType::zip), + render_as("odr-public/png/tango-example-icons.png", + FileType::portable_network_graphics), + render_as("odr-public/pdf/empty.pdf", + FileType::portable_document_format), + render_as("odr-private/otf/OpenSans-Regular.otf", + FileType::opentype_font), + }; + }; - const std::string page = render_odt(config); + for (const std::string &page : every_view()) { + EXPECT_EQ(page.find("--odr-min-margin-left:"), std::string::npos); + } - EXPECT_EQ(page.find(":root{--odr-min-margin"), std::string::npos); - EXPECT_EQ(page.find("")), ""); + EXPECT_EQ(emit(Measure(1, DynamicUnit())), ""); + + // a magnitude that renders no length + EXPECT_EQ(emit(Measure(-1, DynamicUnit("px"))), ""); + EXPECT_EQ(emit(Measure(0, DynamicUnit("px"))), ""); + EXPECT_EQ( + emit(Measure(std::numeric_limits::infinity(), DynamicUnit("px"))), + ""); + EXPECT_EQ(emit(Measure(std::numeric_limits::quiet_NaN(), + DynamicUnit("px"))), + ""); + + // what css does read is written verbatim, unit case and all + EXPECT_NE(emit(Measure("2EM")).find("--odr-min-margin-top:2EM;"), + std::string::npos); + EXPECT_NE(emit(Measure("50%")).find("--odr-min-margin-top:50%;"), + std::string::npos); +} + +TEST(html_common, the_page_column_gutter_is_raised_but_never_lowered) { + HtmlConfig config; + + // the 16px per side the stylesheets state + EXPECT_EQ(ihtml::page_column_gutter_pixels(config), 32); + + config.min_content_margin.left = Measure("100px"); + EXPECT_EQ(ihtml::page_column_gutter_pixels(config), 116); + + config.min_content_margin.right = Measure("100PX"); + EXPECT_EQ(ihtml::page_column_gutter_pixels(config), 200); + + // a smaller floor changes nothing, and the top and bottom are no gutter + config.min_content_margin.left = Measure("1px"); + config.min_content_margin.right = Measure("1px"); + config.min_content_margin.top = Measure("100px"); + EXPECT_EQ(ihtml::page_column_gutter_pixels(config), 32); + + // css applies these, the fit cannot count them + config.min_content_margin.left = Measure("100em"); + config.min_content_margin.right = Measure("50%"); + EXPECT_EQ(ihtml::page_column_gutter_pixels(config), 32); +} + TEST(html_common, an_opaque_color_is_a_hex_triplet) { EXPECT_EQ(ihtml::color(Color(1, 2, 3)), "#010203"); EXPECT_EQ(ihtml::color(Color(1, 2, 3, 255)), "#010203"); diff --git a/wasm/js/index.d.ts b/wasm/js/index.d.ts index 6ce29ecf4..db6a20833 100644 --- a/wasm/js/index.d.ts +++ b/wasm/js/index.d.ts @@ -93,8 +93,8 @@ export interface HtmlConfig { initialZoom?: number; /** * The least distance the generated content keeps from the view's border, per - * side, as a css length (e.g. `"3mm"`). An omitted side keeps the inset the - * view already has; a given side raises it, never lowers it. + * side, as a css length (e.g. `"3mm"`). A given side raises the inset the + * view already has, never lowers it. */ minContentMargin?: { top?: string;