diff --git a/CHANGELOG.md b/CHANGELOG.md index 7847b6190..636f818a6 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 +- `HtmlConfig::min_content_margin` puts a floor under the distance the + generated content keeps from the view's border, per side. A set side raises + the inset a view already has, never lowers it; a sheet is never inset. Bound + in python, jni, wasm and apple as `minContentMargin`. - **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..33899c11a 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,10 @@ 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 +/// set side raises the inset the view already has, 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..a0c0dc760 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,12 @@ 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. `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..a18e85621 100644 --- a/jni/tests/app/opendocument/core/HtmlTest.java +++ b/jni/tests/app/opendocument/core/HtmlTest.java @@ -62,6 +62,31 @@ 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")); + + // the field is the host's to null, and reading one is not a crash + HtmlConfig cleared = new HtmlConfig(); + cleared.minContentMargin = null; + assertTrue(!renderOdt(cleared).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..445be5a93 100644 --- a/src/odr/html.hpp +++ b/src/odr/html.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -156,6 +157,11 @@ 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 + /// set side raises the inset the view already has, never lowers it; a unit + /// css cannot read as a length is ignored. A sheet is never inset. + 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..56f5503e4 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,8 @@ #include #include #include +#include +#include namespace odr::internal { @@ -73,23 +77,105 @@ html::width_fit(const HtmlConfig &config, const bool fit_width_by_default, } } -std::optional html::css_pixels(const std::optional &measure) { +namespace { + +/// css absolute lengths, all defined against the inch (css values 3, 5.2). +const std::unordered_map &css_absolute_units() { + static const std::unordered_map units{ + {"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}, + {"q", 96.0 / 101.6}, + }; + return units; +} + +/// Whether css reads @p name as a length: an absolute one, one it resolves +/// against the font, the line or the viewport, or `%` against the containing +/// block. +bool is_css_length_unit(const std::string &name) { + static const std::unordered_set relative{ + "em", "rem", "ex", "ch", "ic", "cap", "lh", "rlh", + "vw", "vh", "vi", "vb", "vmin", "vmax", "%", + }; + return css_absolute_units().contains(name) || relative.contains(name); +} + +/// Unit identifiers are case-insensitive in css, and a host writes the name. +std::string unit_name(const Measure &measure) { + return util::string::to_lower(measure.unit().name()); +} + +/// One side gutter the page column puts around its pages, in css pixels — what +/// `.odr-page-outer` and the pdf view's `.p` state as their side margin. +constexpr double page_column_side_gutter_pixels = 16; + +/// The margin a side states, where css can read it as a length. Anything else +/// keeps the built-in inset: every rule floors its own against a `max()`, and a +/// `var()` css cannot resolve voids the whole shorthand rather than the side. +std::optional css_margin(const std::optional &measure) { if (!measure.has_value()) { return {}; } + const double magnitude = measure->magnitude(); + if (!std::isfinite(magnitude) || magnitude <= 0) { + return {}; + } + return is_css_length_unit(unit_name(*measure)) ? measure : std::nullopt; +} - // 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}, - }; +} // namespace - const auto it = per_unit.find(std::string(measure->unit().name())); - if (it == std::end(per_unit)) { +std::optional html::css_pixels(const std::optional &measure) { + if (!measure.has_value()) { + return {}; + } + + const auto &units = css_absolute_units(); + const auto it = units.find(unit_name(*measure)); + if (it == std::end(units)) { return {}; } const double pixels = measure->magnitude() * it->second; - return pixels > 0 ? std::optional(pixels) : std::nullopt; + return std::isfinite(pixels) && pixels > 0 ? std::optional(pixels) + : std::nullopt; +} + +double html::page_column_gutter_pixels(const HtmlConfig &config) { + // Whatever `css_margin` drops, `css_pixels` drops too, so it need not run. + const auto side = [](const std::optional &measure) { + return std::max(page_column_side_gutter_pixels, + css_pixels(measure).value_or(0.0)); + }; + return side(config.min_content_margin.left) + + side(config.min_content_margin.right); +} + +void html::write_content_margin_style(HtmlWriter &out, + const HtmlConfig &config) { + const DirectionalStyle &margin = config.min_content_margin; + + const std::array sides{ + std::pair{std::string_view("top"), css_margin(margin.top)}, + std::pair{std::string_view("right"), css_margin(margin.right)}, + std::pair{std::string_view("bottom"), css_margin(margin.bottom)}, + std::pair{std::string_view("left"), css_margin(margin.left)}, + }; + + if (std::ranges::none_of( + sides, [](const auto &side) { return side.second.has_value(); })) { + return; + } + + out.write_header_style_begin(); + out.out() << ":root{"; + for (const auto &[name, measure] : sides) { + if (measure.has_value()) { + out.out() << "--odr-min-margin-" << name << ":" << measure->to_string() + << ";"; + } + } + out.out() << "}"; + out.write_header_style_end(); } void html::write_zoom_style(HtmlWriter &out, const HtmlConfig &config, diff --git a/src/odr/internal/html/common.hpp b/src/odr/internal/html/common.hpp index 50c401a9b..6eab71fe5 100644 --- a/src/odr/internal/html/common.hpp +++ b/src/odr/internal/html/common.hpp @@ -61,8 +61,10 @@ width_fit(const HtmlConfig &config, bool fit_width_by_default, [[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; +/// Both side gutters the page column puts around its pages, in css pixels, +/// raised where `config.min_content_margin` asks for more. A side the css +/// resolves but this cannot — `em`, `%` — keeps the built-in gutter here. +[[nodiscard]] double page_column_gutter_pixels(const HtmlConfig &config); /// The zoom the view opens at: `--odr-fit` fits @p content_pixels into /// `config.viewport_width`, or names who measures it instead, `--odr-zoom` pins @@ -70,6 +72,11 @@ constexpr double page_column_gutter_pixels = 32; void write_zoom_style(HtmlWriter &out, const HtmlConfig &config, WidthFit fits, std::optional content_pixels); +/// `config.min_content_margin` as the `--odr-min-margin-*` the stylesheets +/// floor their insets against; nothing at all where no side states a css +/// length, which leaves those insets as shipped. +void write_content_margin_style(HtmlWriter &out, const HtmlConfig &config); + 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 e8a47f68f..fd0939606 100644 --- a/src/odr/internal/html/document.cpp +++ b/src/odr/internal/html/document.cpp @@ -33,29 +33,37 @@ bool is_paged_content(const Document &document, const HtmlConfig &config) { } /// A page box plus the gutters the column puts around it, in css pixels. -std::optional page_content_pixels(const PageLayout &page_layout) { +std::optional page_content_pixels(const PageLayout &page_layout, + const HtmlConfig &config) { const std::optional width = css_pixels(page_layout.width); if (!width.has_value()) { return {}; } - return *width + page_column_gutter_pixels; + return *width + page_column_gutter_pixels(config); } /// 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 TextRoot &element, + const HtmlConfig &config) { + return page_content_pixels(element.page_layout(), config); } -std::optional fragment_content_pixels(const Slide &element) { - return page_content_pixels(element.page_layout()); +std::optional fragment_content_pixels(const Slide &element, + const HtmlConfig &config) { + return page_content_pixels(element.page_layout(), config); } -std::optional fragment_content_pixels(const Page &element) { - return page_content_pixels(element.page_layout()); +std::optional fragment_content_pixels(const Page &element, + const HtmlConfig &config) { + return page_content_pixels(element.page_layout(), config); } /// A sheet reflows; there is no page box to fit. -std::optional fragment_content_pixels(const Sheet &) { return {}; } +std::optional fragment_content_pixels(const Sheet &, + const HtmlConfig &) { + return {}; +} /// The widest of them, for the view that writes every page into one file. -std::optional document_content_pixels(const Document &document) { +std::optional document_content_pixels(const Document &document, + const HtmlConfig &config) { const Element root = document.root_element(); const auto widest = [](const std::optional lhs, @@ -69,16 +77,17 @@ std::optional document_content_pixels(const Document &document) { std::optional result; switch (document.document_type()) { case DocumentType::text: - result = fragment_content_pixels(root.as_text_root()); + result = fragment_content_pixels(root.as_text_root(), config); break; case DocumentType::presentation: for (const Element child : root.children()) { - result = widest(result, fragment_content_pixels(child.as_slide())); + result = + widest(result, fragment_content_pixels(child.as_slide(), config)); } break; case DocumentType::drawing: for (const Element child : root.children()) { - result = widest(result, fragment_content_pixels(child.as_page())); + result = widest(result, fragment_content_pixels(child.as_page(), config)); } break; default: @@ -121,6 +130,7 @@ void front(const Document &document, const WritingState &state, ? width_fit(state.config(), paged_content, mode_override) : WidthFit::none, content_pixels); + write_content_margin_style(out, state.config()); write_document_style(state); write_document_dark_style(state); @@ -193,10 +203,11 @@ 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; + [[nodiscard]] virtual std::optional + content_pixels(const HtmlConfig &config) const = 0; void write_document(HtmlWriter &out, WritingState &state) const { - const std::optional content = content_pixels(); + const std::optional content = content_pixels(state.config()); front(m_document, state, m_name, content); write_fragment(out, state); back(m_document, state); @@ -346,7 +357,8 @@ class HtmlServiceImpl final : public HtmlService { WritingState state(out, config(), resources); // 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); + const std::optional content = + document_content_pixels(m_document, config()); front(m_document, state, "", content); for (const auto &fragment : m_fragments) { @@ -375,8 +387,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()); + [[nodiscard]] std::optional + content_pixels(const HtmlConfig &config) const override { + return fragment_content_pixels(m_document.root_element().as_text_root(), + config); } void write_fragment(HtmlWriter &out, WritingState &state) const override { @@ -422,8 +436,9 @@ class ElementHtmlFragment final : public HtmlFragmentBase { std::move(document)), m_element{element} {} - [[nodiscard]] std::optional content_pixels() const override { - return fragment_content_pixels(m_element); + [[nodiscard]] std::optional + content_pixels(const HtmlConfig &config) const override { + return fragment_content_pixels(m_element, config); } void write_fragment(HtmlWriter &, WritingState &state) const override { diff --git a/src/odr/internal/html/filesystem.cpp b/src/odr/internal/html/filesystem.cpp index e1bf97af9..913bef0fd 100644 --- a/src/odr/internal/html/filesystem.cpp +++ b/src/odr/internal/html/filesystem.cpp @@ -189,6 +189,7 @@ class HtmlServiceImpl final : public HtmlService { out.write_header_title("odr"); write_viewport_meta(out, config(), false); write_zoom_style(out, config(), WidthFit::none, {}); + write_content_margin_style(out, config()); write_filesystem_style(state); write_filesystem_dark_style(state); write_search_style(state); diff --git a/src/odr/internal/html/font_file.cpp b/src/odr/internal/html/font_file.cpp index b1555e455..c2f92c68b 100644 --- a/src/odr/internal/html/font_file.cpp +++ b/src/odr/internal/html/font_file.cpp @@ -77,6 +77,7 @@ class HtmlServiceImpl final : public HtmlService { out.write_header_target("_blank"); out.write_header_title("odr"); write_viewport_meta(out, config(), false); + write_content_margin_style(out, config()); out.write_header_end(); out.write_body_begin(); @@ -112,7 +113,10 @@ class HtmlServiceImpl final : public HtmlService { out.out() << "@font-face{font-family:'odr-specimen';src:url(" << url << ");}"; // The page's own margin, so it does not depend on the browser's. - out.out() << "body{margin:0;padding:8px;background:#fff;" + out.out() << "body{margin:0;padding:max(8px,var(--odr-min-margin-top,0px)) " + "max(8px,var(--odr-min-margin-right,0px)) " + "max(8px,var(--odr-min-margin-bottom,0px)) " + "max(8px,var(--odr-min-margin-left,0px));background:#fff;" "font-family:sans-serif;}"; out.out() << ".grid{display:flex;flex-wrap:wrap;}"; out.out() << ".cell{width:4em;height:4em;border:1px solid #ddd;margin:2px;" diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index 1f1be61da..1f328f16d 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -30,14 +30,15 @@ x-s{display:inline} against each other, not against the viewport. The page's side margin is part of that width, so fitting the document to a phone screen leaves a gutter instead of going edge to edge. */ -.odr-pages{display:flex;flex-direction:column;align-items:center;gap:16px;padding:16px 0;width:max-content;min-width:100%} +.odr-pages{display:flex;flex-direction:column;align-items:center;gap:16px;padding:max(16px,var(--odr-min-margin-top,0px)) 0 max(16px,var(--odr-min-margin-bottom,0px));width:max-content;min-width:100%} /* A stacking context, for the shape backgrounds a page holds at `z-index:-1`. Not a negative `z-index`, which is one too but takes the page out of reach of hit testing. */ -.odr-page-outer{display:flex;margin:0 16px;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.5);isolation:isolate} +.odr-page-outer{display:flex;margin:0 max(16px,var(--odr-min-margin-right,0px)) 0 max(16px,var(--odr-min-margin-left,0px));background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.5);isolation:isolate} /* Reflowed to the viewport there is no page box to inset the text. A physical - measure, like the page margin it stands in for. */ -.odr-text-flow{padding:3mm} + measure, like the page margin it stands in for; `--odr-min-margin-*` is the + floor the config puts under it, and is unset by default. */ +.odr-text-flow{padding:max(3mm,var(--odr-min-margin-top,0px)) max(3mm,var(--odr-min-margin-right,0px)) max(3mm,var(--odr-min-margin-bottom,0px)) max(3mm,var(--odr-min-margin-left,0px))} /* The label is text rather than a `::marker`, which no selection would copy. It hangs into the item's padding so wrapped lines align under the text. */ .odr-list-item{padding-left:2em} @@ -140,8 +141,8 @@ body{margin:0;background:#fff} .odr-text{display:flex;align-items:stretch;min-height:100vh;color:var(--odr-text-fg);font:13px/20px var(--odr-text-mono);tab-size:4} /* The numbers are ours, not the file's, so the gutter stays out of a selection of the page. */ -.odr-text-nr{display:flex;flex-direction:column;flex:none;padding:16px 12px 16px 16px;text-align:right;color:var(--odr-text-muted);background:var(--odr-text-gutter);border-right:1px solid var(--odr-text-line);font-variant-numeric:tabular-nums;user-select:none;-webkit-user-select:none} -.odr-text-body{display:flex;flex-direction:column;flex:1;min-width:0;padding:16px;white-space:pre} +.odr-text-nr{display:flex;flex-direction:column;flex:none;padding:max(16px,var(--odr-min-margin-top,0px)) 12px max(16px,var(--odr-min-margin-bottom,0px)) max(16px,var(--odr-min-margin-left,0px));text-align:right;color:var(--odr-text-muted);background:var(--odr-text-gutter);border-right:1px solid var(--odr-text-line);font-variant-numeric:tabular-nums;user-select:none;-webkit-user-select:none} +.odr-text-body{display:flex;flex-direction:column;flex:1;min-width:0;padding:max(16px,var(--odr-min-margin-top,0px)) max(16px,var(--odr-min-margin-right,0px)) max(16px,var(--odr-min-margin-bottom,0px)) 16px;white-space:pre} .odr-text-wrap{white-space:break-spaces;word-break:break-word;overflow-wrap:anywhere} /* A hovered line reaches its number, which is in the other column, by beginning a viewport to the left of it; the padding puts the text back where it was. @@ -178,7 +179,7 @@ constexpr std::string_view xml_css = R"css( --odr-xml-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; } body{margin:0;background:#fff} -.odr-xml{padding:16px;color:var(--odr-xml-text);font:13px/1.6 var(--odr-xml-mono);word-break:break-word;overflow-wrap:anywhere} +.odr-xml{padding:max(16px,var(--odr-min-margin-top,0px)) max(16px,var(--odr-min-margin-right,0px)) max(16px,var(--odr-min-margin-bottom,0px)) max(16px,var(--odr-min-margin-left,0px));color:var(--odr-xml-text);font:13px/1.6 var(--odr-xml-mono);word-break:break-word;overflow-wrap:anywhere} .odr-xml-line,.odr-xml summary{padding-left:1.5em} .odr-xml summary{display:block;position:relative;list-style:none;cursor:pointer} .odr-xml summary::-webkit-details-marker{display:none} @@ -222,7 +223,7 @@ constexpr std::string_view filesystem_css = R"css( --odr-files-font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; --odr-files-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; } -body{margin:0;background:#fff;color:#1f2328;font:13px/1.5 var(--odr-files-font)} +body{margin:0;padding:var(--odr-min-margin-top,0px) var(--odr-min-margin-right,0px) var(--odr-min-margin-bottom,0px) var(--odr-min-margin-left,0px);background:#fff;color:#1f2328;font:13px/1.5 var(--odr-files-font)} .odr-files{border-collapse:collapse;width:100%} .odr-files td{padding:5px 12px;border-top:1px solid var(--odr-files-line)} .odr-files tbody tr:hover>*{background-image:linear-gradient(rgba(0,0,0,.04),rgba(0,0,0,.04))} diff --git a/src/odr/internal/html/image_file.cpp b/src/odr/internal/html/image_file.cpp index 7dae30b35..1d558364a 100644 --- a/src/odr/internal/html/image_file.cpp +++ b/src/odr/internal/html/image_file.cpp @@ -121,8 +121,13 @@ class HtmlServiceImpl final : public HtmlService { // 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(), WidthFit::none, {}); + write_content_margin_style(out, config()); out.write_header_style_begin(); - out.out() << "body{margin:0;background:#fff}"; + // The picture runs to the border unless the config puts a floor under it. + out.out() << "body{margin:0;padding:var(--odr-min-margin-top,0px) " + "var(--odr-min-margin-right,0px) " + "var(--odr-min-margin-bottom,0px) " + "var(--odr-min-margin-left,0px);background:#fff}"; 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 diff --git a/src/odr/internal/html/pdf_file.cpp b/src/odr/internal/html/pdf_file.cpp index c646f30ab..e79be122b 100644 --- a/src/odr/internal/html/pdf_file.cpp +++ b/src/odr/internal/html/pdf_file.cpp @@ -1718,7 +1718,7 @@ class HtmlServiceImpl final : public HtmlService { } substitute_faces.append_faces(font_faces); - const std::optional content = content_pixels(pages_out); + const std::optional content = content_pixels(pages_out, config()); write_header_common(state, font_faces, font_styles, styles, content, [&] { // Visual layer glyph spans: not selectable (selection rides the `.sel` // layer). @@ -2194,7 +2194,7 @@ class HtmlServiceImpl final : public HtmlService { substitute_faces.append_faces(font_faces); // ---- Pass 2: write HTML --------------------------------------------- - const std::optional content = content_pixels(pages_out); + const std::optional content = content_pixels(pages_out, config()); write_header_common(state, font_faces, font_styles, styles, content, [&] { // Invisible text render modes (Tr 3/7). out.out() << ".i{color:transparent}"; @@ -2557,8 +2557,8 @@ class HtmlServiceImpl final : public HtmlService { /// The widest page a view holds, in css pixels, with `.d`'s side gutters. template - static std::optional - content_pixels(const std::vector &pages) { + static std::optional content_pixels(const std::vector &pages, + const HtmlConfig &config) { double widest = 0; for (const PageOut &page : pages) { widest = std::max(widest, page.width); @@ -2566,7 +2566,7 @@ class HtmlServiceImpl final : public HtmlService { if (widest <= 0) { return {}; } - return widest * pt_to_in * 96.0 + page_column_gutter_pixels; + return widest * pt_to_in * 96.0 + page_column_gutter_pixels(config); } /// The document/head prologue shared by both modes, with `write_mode_css()` @@ -2587,16 +2587,21 @@ class HtmlServiceImpl final : public HtmlService { out.write_header_title("odr"); write_viewport_meta(out, config(), true); write_zoom_style(out, config(), width_fit(config(), true), content); + write_content_margin_style(out, config()); 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 // width centre against each other rather than against the viewport. Their // side margin is part of that width, so a phone screen keeps a gutter. out.out() << ".d{display:flex;flex-direction:column;align-items:center;" - "gap:16px;padding:16px 0;width:max-content;min-width:100%}"; + "gap:16px;padding:max(16px,var(--odr-min-margin-top,0px)) 0 " + "max(16px,var(--odr-min-margin-bottom,0px));" + "width:max-content;min-width:100%}"; // `overflow:hidden` clips to the crop box, as a viewer does: content may // sit outside it (a bleed, or an InDesign spread's other page). - out.out() << ".p{position:relative;margin:0 16px;background:#fff;" + out.out() << ".p{position:relative;" + "margin:0 max(16px,var(--odr-min-margin-right,0px)) 0 " + "max(16px,var(--odr-min-margin-left,0px));background:#fff;" "overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.5)}"; // `.t`: shared base for all absolutely-positioned line blocks. // `font-size:0` collapses its strut, which outranks the run it holds and diff --git a/src/odr/internal/html/text_file.cpp b/src/odr/internal/html/text_file.cpp index 4016ec7d9..38df832c8 100644 --- a/src/odr/internal/html/text_file.cpp +++ b/src/odr/internal/html/text_file.cpp @@ -100,6 +100,7 @@ class HtmlServiceImpl final : public HtmlService { out.write_header_title("odr"); write_viewport_meta(out, config(), false); write_zoom_style(out, config(), WidthFit::none, {}); + write_content_margin_style(out, config()); write_text_style(state); write_text_dark_style(state); diff --git a/src/odr/internal/html/xml_file.cpp b/src/odr/internal/html/xml_file.cpp index 14064f39f..bcd52cb1d 100644 --- a/src/odr/internal/html/xml_file.cpp +++ b/src/odr/internal/html/xml_file.cpp @@ -260,6 +260,7 @@ class HtmlServiceImpl final : public HtmlService { out.write_header_title("odr"); write_viewport_meta(out, config(), false); write_zoom_style(out, config(), WidthFit::none, {}); + write_content_margin_style(out, config()); write_xml_style(state); write_xml_dark_style(state); diff --git a/test/data.cmake b/test/data.cmake index 9bcaea825..5742c25d7 100644 --- a/test/data.cmake +++ b/test/data.cmake @@ -17,9 +17,9 @@ odr_test_data( odr_test_data( PATH "reference-output/odr-public" URL "https://github.com/opendocument-app/OpenDocument.test.output.git" - REVISION "f211f7544b400cb69b13e277a6c280e131a05c65") + REVISION "280cafc1747882ea600cb5ff96a68ced3af81712") odr_test_data( PATH "reference-output/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git" - REVISION "eacdb50e9cd6791538708c6be6b288e077cc4a0b") + REVISION "e813a20df7b0623768e8eb2afb83b80f83d43f42") diff --git a/test/src/html_test.cpp b/test/src/html_test.cpp index 3b3365b54..2f936b372 100644 --- a/test/src/html_test.cpp +++ b/test/src/html_test.cpp @@ -1,7 +1,12 @@ #include +#include +#include #include #include +#include #include +#include +#include #include #include @@ -220,6 +225,98 @@ TEST(html, flowing_text_is_inset_from_the_screen_edge) { EXPECT_NE(page.find(".odr-text-flow{padding:"), std::string::npos); } +// Every view that insets its content declares the floor it was given; unset it +// declares nothing, which leaves the shipped stylesheets alone. +TEST(html, min_content_margin_reaches_every_view) { + HtmlConfig config; + + const auto render_as = [&](const std::string &path, const FileType as) { + const DecodedFile file(TestData::test_file_path(path), as, Logger::null()); + std::ostringstream out; + html::translate(file, config).list_views().at(0).write_html(out); + return std::move(out).str(); + }; + + const auto xml = [&] { + const DecodedFile file(File::from_memory("c"), FileType::xml); + std::ostringstream out; + html::translate(file, config).list_views().at(0).write_html(out); + return std::move(out).str(); + }; + + 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), + }; + }; + + for (const std::string &page : every_view()) { + EXPECT_EQ(page.find("--odr-min-margin-left:"), std::string::npos); + } + + config.min_content_margin.left = Measure("7px"); + for (const std::string &page : every_view()) { + EXPECT_NE(page.find(":root{--odr-min-margin-left:7px;}"), + std::string::npos); + } +} + +namespace { + +/// The `--odr-fit` @p page states as a factor, or nothing where it names who +/// measures it instead. +std::optional fit_of(const std::string &page) { + constexpr std::string_view key = "--odr-fit:"; + const std::size_t at = page.find(key); + if (at == std::string::npos) { + return {}; + } + // `auto` and `view` name a measurer rather than stating a factor + const std::size_t begin = at + key.length(); + if (begin >= page.length() || + std::isdigit(static_cast(page[begin])) == 0) { + return {}; + } + return std::stod(page.substr(begin)); +} + +} // namespace + +// 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) { + HtmlConfig config; + config.text_document_margin = true; + config.viewport_width = 400; + + const std::optional narrow = fit_of(render_odt(config)); + ASSERT_TRUE(narrow.has_value()); + + config.min_content_margin.left = Measure("100px"); + config.min_content_margin.right = Measure("100px"); + const std::optional wide = fit_of(render_odt(config)); + ASSERT_TRUE(wide.has_value()); + + EXPECT_LT(*wide, *narrow); + + // A unit the fit cannot convert leaves it where it was; the css still + // applies the margin. + config.min_content_margin.left = Measure("100em"); + config.min_content_margin.right = Measure("100em"); + EXPECT_EQ(fit_of(render_odt(config)), narrow); +} + // For `system` it is the element carrying the dark style that is gated, not a // rule inside it. TEST(html, color_scheme_writes_the_dark_style) { diff --git a/test/src/internal/html/common_test.cpp b/test/src/internal/html/common_test.cpp index 5975eda2f..ca128728a 100644 --- a/test/src/internal/html/common_test.cpp +++ b/test/src/internal/html/common_test.cpp @@ -6,8 +6,10 @@ #include +#include #include #include +#include using namespace odr; namespace ihtml = odr::internal::html; @@ -217,6 +219,87 @@ TEST(html_common, a_zoom_pinned_to_actual_size_is_still_a_pin) { styled(":root{--odr-fit:0.5;--odr-zoom:0.5}body{zoom:0.5}")); } +namespace { + +std::string emit_margin(const HtmlConfig &config) { + std::ostringstream out; + ihtml::HtmlWriter writer(out, false, ""); + ihtml::write_content_margin_style(writer, config); + return out.str(); +} + +} // namespace + +TEST(html_common, the_content_margin_states_the_sides_that_are_set) { + HtmlConfig config; + + // unset it declares nothing, which leaves the shipped insets as they are + EXPECT_EQ(emit_margin(config), ""); + + config.min_content_margin.top = Measure("12px"); + config.min_content_margin.left = Measure("1cm"); + EXPECT_EQ(emit_margin(config), ""); +} + +// The stylesheets floor their insets with `max(16px,var(--odr-min-margin-*))`, +// so a value css cannot resolve voids the whole shorthand rather than one side +// — it has to be dropped here instead. +TEST(html_common, the_content_margin_drops_what_css_cannot_read_as_a_length) { + HtmlConfig config; + + const auto emit = [&](const Measure &measure) { + config.min_content_margin = {}; + config.min_content_margin.top = measure; + return emit_margin(config); + }; + + // a unit that is neither a css length nor closes the style element + EXPECT_EQ(emit(Measure("1foo")), ""); + EXPECT_EQ(emit(Measure("1px;}")), ""); + 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 8fe9cf376..db6a20833 100644 --- a/wasm/js/index.d.ts +++ b/wasm/js/index.d.ts @@ -91,6 +91,17 @@ export interface HtmlConfig { viewportWidth?: number; /** The zoom the view opens at, 1 being actual size; unset follows the fit. */ initialZoom?: number; + /** + * The least distance the generated content keeps from the view's border, per + * side, as a css length (e.g. `"3mm"`). A given side raises the inset the + * view already has, never lowers it. + */ + minContentMargin?: { + top?: string; + right?: string; + bottom?: string; + left?: string; + }; pdfTextMode?: number; } diff --git a/wasm/src/wasm_html.cpp b/wasm/src/wasm_html.cpp index f63b4565e..26c885d4f 100644 --- a/wasm/src/wasm_html.cpp +++ b/wasm/src/wasm_html.cpp @@ -6,6 +6,7 @@ #include +#include #include #include @@ -34,6 +35,16 @@ void read_enum(const emscripten::val &value, const char *key, T &target) { target = static_cast(field.as()); } +/// A css length a caller states as a string, e.g. `"3mm"`. +void read_measure(const emscripten::val &value, const char *key, + std::optional &target) { + const emscripten::val field = value[key]; + if (field.isUndefined() || field.isNull()) { + return; + } + target = Measure(field.as()); +} + /// Translates on first use, so a caller that only wants metadata does not pay /// for a render at open. Session &warm(const Handle handle) { @@ -151,6 +162,14 @@ HtmlConfig to_html_config(const emscripten::val &value) { } read_enum(value, "pdfTextMode", config.pdf_text_mode); + if (const emscripten::val margin = value["minContentMargin"]; + !margin.isUndefined() && !margin.isNull()) { + read_measure(margin, "top", config.min_content_margin.top); + read_measure(margin, "right", config.min_content_margin.right); + read_measure(margin, "bottom", config.min_content_margin.bottom); + read_measure(margin, "left", config.min_content_margin.left); + } + return config; } diff --git a/wasm/tests/render.test.mjs b/wasm/tests/render.test.mjs index 51b66ddf3..978ab2031 100644 --- a/wasm/tests/render.test.mjs +++ b/wasm/tests/render.test.mjs @@ -76,6 +76,26 @@ describe('render', () => { } }); + // The C++ suite covers where the floor lands; this only proves the sides + // cross the binding as css lengths, omitted ones staying omitted. + it('honours a minimum content margin', () => { + const plain = odr.open(fixture('mixed-layout.odt')); + const inset = odr.open(fixture('mixed-layout.odt'), { + minContentMargin: { top: '12px', left: '1cm' }, + }); + try { + assert.ok(!plain.render(0).html.includes(':root{--odr-min-margin')); + const html = inset.render(0).html; + assert.ok( + html.includes(':root{--odr-min-margin-top:12px;--odr-min-margin-left:1cm;}'), + ); + assert.ok(!html.includes('--odr-min-margin-right:')); + } finally { + plain.close(); + inset.close(); + } + }); + it('honours a config passed at open', () => { const plain = odr.open(fixture('mixed-layout.odt')); const editable = odr.open(fixture('mixed-layout.odt'), { editable: true });