From 6f2dadd82237b272cfe4625276f265812d2ab89d Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 6 Sep 2026 11:35:26 +0200 Subject: [PATCH] perf(html): stop a sheet repeating itself, and keep a cell's text on one line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rendered sheet spent more than half its bytes on `style` attributes it wrote over and over — 26 distinct blocks across 259,957 attributes in `Supervised_Business_Register_300425.ods` — and four nodes per cell before any content. It also broke every cell's text into lines, which no spreadsheet does unless the file says to, and the second line then painted over the row below. - A style block becomes a class defined once in ``, named the first time it is written. The class names itself three times, for the specificity an inline attribute had. `` has to name the classes before the cells that use them and the renderer streams, so a spreadsheet's body goes into a buffer and the head out in front of it — one walk, where a second would cost as much as the first. The buffer is capped at 8 MB, past which a block first seen stays inline. - A cell holding one plain string drops the run around it, and where the file states no row height the block too. A stated row height keeps the block: nothing else holds the row to it, since `contain:size`, `max-height`, `overflow` and `content-visibility` are all ignored on a table cell. - `style:wrap-option`, `alignment@wrapText` and the XF `fWrap` bit are read into a new `TableCellStyle::wrap_text`, off by default in every format. A line too long for its cell runs over the empty cells beside it and is cut where the next one has content — bounded with `clip-path`, so it never paints over it — and out onto the canvas where nothing follows. `max-width:0` keeps such a line from widening the column, which `table-layout:fixed` alone does not. - Clicking a cell that is cut raises it: the block goes out of flow, sized to the string and over its neighbours, so no row moves. Clicking inside it is for the text; clicking elsewhere, or Escape, puts it back. Read-only, the reference corpus's sheets fall from 374 MB to 202 MB, and the 500,000 rendered cells of the register from 121 MB to 38 MB — 1.5 M inline styles down to none. 254 of 314 spreadsheet views render pixel-identically; the 60 that move are the ones that used to wrap. Rendering takes as long as it did, and peaks about 4% higher. Closes #822. Closes #238. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FUh7UU43PbVPEBq2LVtoVN --- CHANGELOG.md | 9 + CMakeLists.txt | 1 + apple/include/OdrCoreObjC/ODRHtml.h | 3 + apple/include/OdrCoreObjC/ODRStyle.h | 2 + apple/src/ODRHtml.mm | 4 + apple/src/ODRStyle.mm | 1 + .../app/opendocument/core/HtmlConfig.java | 2 + .../app/opendocument/core/TableCellStyle.java | 5 +- jni/src/jni_style.cpp | 15 +- python/src/bind_html.cpp | 2 + python/src/bind_style.cpp | 3 +- src/odr/html.hpp | 4 + src/odr/internal/html/common.hpp | 10 +- src/odr/internal/html/document.cpp | 121 ++++-- src/odr/internal/html/document_element.cpp | 385 ++++++++++++++---- src/odr/internal/html/frontend.cpp | 116 +++++- src/odr/internal/html/html_writer.cpp | 28 +- src/odr/internal/html/html_writer.hpp | 8 + src/odr/internal/html/style_registry.cpp | 55 +++ src/odr/internal/html/style_registry.hpp | 34 ++ src/odr/internal/odf/odf_style.cpp | 4 + .../internal/oldms/spreadsheet/xls_style.cpp | 2 + .../spreadsheet/ooxml_spreadsheet_style.cpp | 2 + src/odr/internal/util/stream_util.cpp | 37 ++ src/odr/internal/util/stream_util.hpp | 26 ++ src/odr/style.cpp | 1 + src/odr/style.hpp | 3 + test/browser/sheet/.gitignore | 3 + test/browser/sheet/README.md | 20 + test/browser/sheet/serve | 42 ++ test/browser/sheet/tests.html | 148 +++++++ test/src/html_output_test.cpp | 5 +- test/src/html_test.cpp | 185 ++++++++- test/src/internal/util/stream_util_test.cpp | 44 ++ wasm/src/wasm_html.cpp | 5 + 35 files changed, 1215 insertions(+), 120 deletions(-) create mode 100644 src/odr/internal/html/style_registry.cpp create mode 100644 src/odr/internal/html/style_registry.hpp create mode 100644 test/browser/sheet/.gitignore create mode 100644 test/browser/sheet/README.md create mode 100755 test/browser/sheet/serve create mode 100644 test/browser/sheet/tests.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f94fc779..b9b542f7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,15 @@ The release run heads these entries with the version and opens a fresh had set a global locale with a comma decimal separator. Adds a `fmt` dependency. +- A sheet cell keeps its text on one line unless the file says to wrap it, read + into a new `TableCellStyle::wrap_text`. A line too long for its cell spills + over the empty cells beside it and is cut where the next has content. #238 + +- A spreadsheet view writes far less html for the same rendering: repeated style + blocks become classes, and a plain cell drops the run around it. The register + file's 500,000 cells fall from 121 MB to 38 MB. New + `HtmlConfig::spreadsheet_style_buffer`. #822 + - New `Sheet::page_layout()`: the paper an ods states for a sheet, read from the master page its table style names. Mirrored in the Python, JNI and Apple bindings. Empty for xlsx, xls, numbers and csv. diff --git a/CMakeLists.txt b/CMakeLists.txt index e15011c27..750555b87 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -156,6 +156,7 @@ set(ODR_SOURCE_FILES "src/odr/internal/html/image_file.cpp" "src/odr/internal/html/media_file.cpp" "src/odr/internal/html/pdf_file.cpp" + "src/odr/internal/html/style_registry.cpp" "src/odr/internal/html/text_file.cpp" "src/odr/internal/html/xml_file.cpp" diff --git a/apple/include/OdrCoreObjC/ODRHtml.h b/apple/include/OdrCoreObjC/ODRHtml.h index 166ceac3d..cf89c19da 100644 --- a/apple/include/OdrCoreObjC/ODRHtml.h +++ b/apple/include/OdrCoreObjC/ODRHtml.h @@ -96,6 +96,9 @@ NS_SWIFT_NAME(HtmlConfig) NSNumber *spreadsheetCellLimit NS_REFINED_FOR_SWIFT; @property(nonatomic) BOOL spreadsheetLimitByContent; @property(nonatomic) ODRHtmlTableGridlines spreadsheetGridlines; +/// How much of a sheet's body is held back while `` collects the classes +/// its cells name. +@property(nonatomic) unsigned long long spreadsheetStyleBuffer; @property(nonatomic) ODRHtmlViewportMode viewportMode; /// Overrides `viewportMode` for spreadsheets when set. diff --git a/apple/include/OdrCoreObjC/ODRStyle.h b/apple/include/OdrCoreObjC/ODRStyle.h index 73121bdac..3835f1ace 100644 --- a/apple/include/OdrCoreObjC/ODRStyle.h +++ b/apple/include/OdrCoreObjC/ODRStyle.h @@ -227,6 +227,8 @@ NS_SWIFT_NAME(TableCellStyle) @property(nonatomic, readonly) ODRDirectionalString *border; /// `double`, boxed. @property(nonatomic, readonly, nullable) NSNumber *textRotation; +/// `BOOL`, boxed. +@property(nonatomic, readonly, nullable) NSNumber *wrapText; - (instancetype)init NS_UNAVAILABLE; + (instancetype)new NS_UNAVAILABLE; diff --git a/apple/src/ODRHtml.mm b/apple/src/ODRHtml.mm index 36cda60ff..25e52f745 100644 --- a/apple/src/ODRHtml.mm +++ b/apple/src/ODRHtml.mm @@ -104,6 +104,8 @@ - (instancetype)initWithNativeConfig:(const odr::HtmlConfig &)config { _spreadsheetLimitByContent = config.spreadsheet_limit_by_content ? YES : NO; _spreadsheetGridlines = static_cast(config.spreadsheet_gridlines); + _spreadsheetStyleBuffer = + static_cast(config.spreadsheet_style_buffer); _viewportMode = static_cast(config.viewport_mode); _spreadsheetViewportMode = config.spreadsheet_viewport_mode.has_value() @@ -176,6 +178,8 @@ - (instancetype)initWithNativeConfig:(const odr::HtmlConfig &)config { config.spreadsheet_limit_by_content = _spreadsheetLimitByContent == YES; config.spreadsheet_gridlines = static_cast(_spreadsheetGridlines); + config.spreadsheet_style_buffer = + static_cast(_spreadsheetStyleBuffer); config.viewport_mode = static_cast(_viewportMode); if (_spreadsheetViewportMode != nil) { config.spreadsheet_viewport_mode = static_cast( diff --git a/apple/src/ODRStyle.mm b/apple/src/ODRStyle.mm index 788d59395..db958d6af 100644 --- a/apple/src/ODRStyle.mm +++ b/apple/src/ODRStyle.mm @@ -276,6 +276,7 @@ + (instancetype)styleWithHandle:(const odr::TableCellStyle &)handle { [ODRDirectionalMeasure directionalWithHandle:handle.padding]; result->_border = [ODRDirectionalString directionalWithHandle:handle.border]; result->_textRotation = box_number(handle.text_rotation); + result->_wrapText = box_number(handle.wrap_text); return result; } diff --git a/jni/java/app/opendocument/core/HtmlConfig.java b/jni/java/app/opendocument/core/HtmlConfig.java index fff935ca4..17e3d6a09 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 boolean spreadsheetLimitByContent = true; public HtmlTableGridlines spreadsheetGridlines = HtmlTableGridlines.SOFT; + /** How much of a sheet's body is held back while the head collects its classes. */ + public long spreadsheetStyleBuffer = 128L << 20; /** Initial zoom on mobile. */ public HtmlViewportMode viewportMode = HtmlViewportMode.AUTOMATIC; diff --git a/jni/java/app/opendocument/core/TableCellStyle.java b/jni/java/app/opendocument/core/TableCellStyle.java index 978fbdab7..f70b4c0fb 100644 --- a/jni/java/app/opendocument/core/TableCellStyle.java +++ b/jni/java/app/opendocument/core/TableCellStyle.java @@ -8,6 +8,7 @@ public final class TableCellStyle { public final DirectionalMeasure padding; public final DirectionalString border; public final Double textRotation; + public final Boolean wrapText; TableCellStyle( int horizontalAlign, @@ -15,12 +16,14 @@ public final class TableCellStyle { Color backgroundColor, DirectionalMeasure padding, DirectionalString border, - Double textRotation) { + Double textRotation, + Boolean wrapText) { this.horizontalAlign = HorizontalAlign.fromNative(horizontalAlign); this.verticalAlign = VerticalAlign.fromNative(verticalAlign); this.backgroundColor = backgroundColor; this.padding = padding; this.border = border; this.textRotation = textRotation; + this.wrapText = wrapText; } } diff --git a/jni/src/jni_style.cpp b/jni/src/jni_style.cpp index 121162d2b..49bba8d8c 100644 --- a/jni/src/jni_style.cpp +++ b/jni/src/jni_style.cpp @@ -310,12 +310,13 @@ jobject make_table_cell_style(JNIEnv *env, const odr::TableCellStyle &style) { env, "app/opendocument/core/TableCellStyle", "(IILapp/opendocument/core/Color;" "Lapp/opendocument/core/DirectionalMeasure;" - "Lapp/opendocument/core/DirectionalString;Ljava/lang/Double;)V", + "Lapp/opendocument/core/DirectionalString;Ljava/lang/Double;" + "Ljava/lang/Boolean;)V", enum_code(style.horizontal_align), enum_code(style.vertical_align), make_color(env, style.background_color), make_directional_measure(env, style.padding), make_directional_string(env, style.border), - box_double(env, style.text_rotation)); + box_double(env, style.text_rotation), box_boolean(env, style.wrap_text)); } jobject make_graphic_style(JNIEnv *env, const odr::GraphicStyle &style) { @@ -421,6 +422,9 @@ jobject html_config_to_java(JNIEnv *env, const odr::HtmlConfig &config) { const auto set_double = [&](const char *name, const double value) { env->SetDoubleField(result, env->GetFieldID(cls, name, "D"), value); }; + const auto set_long = [&](const char *name, const jlong value) { + env->SetLongField(result, env->GetFieldID(cls, name, "J"), value); + }; const auto set_object = [&](const char *name, const char *signature, jobject value) { env->SetObjectField(result, env->GetFieldID(cls, name, signature), value); @@ -446,6 +450,8 @@ jobject html_config_to_java(JNIEnv *env, const odr::HtmlConfig &config) { set_object("spreadsheetCellLimit", "Ljava/lang/Long;", box_long(env, config.spreadsheet_cell_limit)); set_boolean("spreadsheetLimitByContent", config.spreadsheet_limit_by_content); + set_long("spreadsheetStyleBuffer", + static_cast(config.spreadsheet_style_buffer)); set_object("spreadsheetGridlines", "Lapp/opendocument/core/HtmlTableGridlines;", enum_from_code(env, "app/opendocument/core/HtmlTableGridlines", @@ -538,6 +544,9 @@ odr::HtmlConfig html_config_from_java(JNIEnv *env, jobject config) { const auto get_double = [&](const char *name) { return env->GetDoubleField(config, env->GetFieldID(cls, name, "D")); }; + const auto get_long = [&](const char *name) { + return env->GetLongField(config, env->GetFieldID(cls, name, "J")); + }; const auto get_object = [&](const char *name, const char *signature) { return env->GetObjectField(config, env->GetFieldID(cls, name, signature)); }; @@ -592,6 +601,8 @@ odr::HtmlConfig html_config_from_java(JNIEnv *env, jobject config) { env->DeleteLocalRef(long_cls); } } + result.spreadsheet_style_buffer = + static_cast(get_long("spreadsheetStyleBuffer")); result.spreadsheet_limit_by_content = get_boolean("spreadsheetLimitByContent"); { diff --git a/python/src/bind_html.cpp b/python/src/bind_html.cpp index 87c7a8dae..c6da5f602 100644 --- a/python/src/bind_html.cpp +++ b/python/src/bind_html.cpp @@ -89,6 +89,8 @@ void odr_python::bind_html(py::module_ &m) { &odr::HtmlConfig::spreadsheet_limit_by_content) .def_readwrite("spreadsheet_gridlines", &odr::HtmlConfig::spreadsheet_gridlines) + .def_readwrite("spreadsheet_style_buffer", + &odr::HtmlConfig::spreadsheet_style_buffer) .def_readwrite("viewport_mode", &odr::HtmlConfig::viewport_mode) .def_readwrite("spreadsheet_viewport_mode", &odr::HtmlConfig::spreadsheet_viewport_mode) diff --git a/python/src/bind_style.cpp b/python/src/bind_style.cpp index 971b6ab46..5b1605816 100644 --- a/python/src/bind_style.cpp +++ b/python/src/bind_style.cpp @@ -186,7 +186,8 @@ void odr_python::bind_style(py::module_ &m) { .def_readwrite("background_color", &odr::TableCellStyle::background_color) .def_readwrite("padding", &odr::TableCellStyle::padding) .def_readwrite("border", &odr::TableCellStyle::border) - .def_readwrite("text_rotation", &odr::TableCellStyle::text_rotation); + .def_readwrite("text_rotation", &odr::TableCellStyle::text_rotation) + .def_readwrite("wrap_text", &odr::TableCellStyle::wrap_text); py::class_(m, "GraphicStyle") .def(py::init<>()) diff --git a/src/odr/html.hpp b/src/odr/html.hpp index a1ffc2b68..591c522d8 100644 --- a/src/odr/html.hpp +++ b/src/odr/html.hpp @@ -148,6 +148,10 @@ struct HtmlConfig { bool spreadsheet_limit_by_content{true}; /// Which gridlines a sheet paints. HtmlTableGridlines spreadsheet_gridlines{HtmlTableGridlines::soft}; + /// How much of a sheet's body is held back while `` collects the + /// classes its cells name. Past it the head goes out with what it has, and a + /// style block first seen later stays inline. + std::uint64_t spreadsheet_style_buffer{128u << 20}; /// The zoom the view opens at; see @ref HtmlViewportMode. HtmlViewportMode viewport_mode{HtmlViewportMode::automatic}; diff --git a/src/odr/internal/html/common.hpp b/src/odr/internal/html/common.hpp index 79ec37f4f..24a47c0bc 100644 --- a/src/odr/internal/html/common.hpp +++ b/src/odr/internal/html/common.hpp @@ -24,16 +24,21 @@ class File; namespace odr::internal::html { +class StyleRegistry; + struct WritingState { WritingState(HtmlWriter &out, const HtmlConfig &config, - HtmlResources &resources, const Logger &logger) + HtmlResources &resources, const Logger &logger, + StyleRegistry *styles = nullptr) : m_out{&out}, m_config{&config}, m_resources(&resources), - m_logger{&logger} {} + m_logger{&logger}, m_styles{styles} {} [[nodiscard]] HtmlWriter &out() const { return *m_out; } [[nodiscard]] const HtmlConfig &config() const { return *m_config; } [[nodiscard]] HtmlResources &resources() const { return *m_resources; } [[nodiscard]] const Logger &logger() const { return *m_logger; } + /// Where repeated style blocks are deduplicated, or null where they are not. + [[nodiscard]] StyleRegistry *styles() const { return m_styles; } /// The view's base direction, stated on its root. [[nodiscard]] TextDirection direction() const { return m_direction; } @@ -44,6 +49,7 @@ struct WritingState { const HtmlConfig *m_config; HtmlResources *m_resources; const Logger *m_logger; + StyleRegistry *m_styles; TextDirection m_direction{TextDirection::left_to_right}; }; diff --git a/src/odr/internal/html/document.cpp b/src/odr/internal/html/document.cpp index e0867e269..1850f7c77 100644 --- a/src/odr/internal/html/document.cpp +++ b/src/odr/internal/html/document.cpp @@ -15,6 +15,8 @@ #include #include #include +#include +#include #include #include @@ -151,17 +153,14 @@ viewport_mode_override(const Document &document, const HtmlConfig &config) { : std::nullopt; } -/// @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, WritingState &state, - const std::string &name, - const std::optional content_pixels) { +/// @p name titles the view; empty for the file that holds every view. +void write_head(const Document &document, const WritingState &state, + const std::string &name, + const std::optional content_pixels) { HtmlWriter &out = state.out(); const bool paged_content = is_paged_content(document, state.config()); - state.set_direction(document_direction(document)); - out.write_begin(HtmlElementOptions().set_attributes(HtmlAttributesVector{ {"dir", translate_text_direction(state.direction())}})); out.write_header_begin(); @@ -188,8 +187,21 @@ void front(const Document &document, WritingState &state, write_spreadsheet_style(state); write_spreadsheet_dark_style(state); } + // Last, after the sheets whose rules they stand in for. + if (StyleRegistry *styles = state.styles(); + styles != nullptr && styles->has_rules()) { + out.write_header_style_begin(); + styles->write_rules(out.out()); + out.write_header_style_end(); + } out.write_header_end(); +} + +void write_body_begin(const Document &document, const WritingState &state) { + HtmlWriter &out = state.out(); + + const bool paged_content = is_paged_content(document, state.config()); std::string body_clazz = "odr-body"; if (paged_content) { @@ -217,7 +229,7 @@ void front(const Document &document, WritingState &state, } } -void back(const Document &document, const WritingState &state) { +void write_body_end(const Document &document, const WritingState &state) { HtmlWriter &out = state.out(); if (is_paged_content(document, state.config())) { @@ -232,7 +244,53 @@ void back(const Document &document, const WritingState &state) { write_viewport_script(state); out.write_body_end(); +} + +/// Writes one view. A spreadsheet's body goes into a buffer so `` can +/// name the classes its cells use, which one walk cannot do in order. +template +HtmlResources +render(const Document &document, const HtmlConfig &config, const Logger &logger, + HtmlWriter &out, const std::string &name, + const std::optional content_pixels, Write &&write) { + HtmlResources resources; + + const auto body = [&](const WritingState &state) { + write_body_begin(document, state); + write(state); + write_body_end(document, state); + }; + + if (document.document_type() != DocumentType::spreadsheet) { + WritingState state(out, config, resources, logger); + state.set_direction(document_direction(document)); + write_head(document, state, name, content_pixels); + body(state); + out.write_end(); + return resources; + } + + StyleRegistry styles; + WritingState head_state(out, config, resources, logger, &styles); + head_state.set_direction(document_direction(document)); + + util::stream::DeferredBuffer buffer( + out.out(), static_cast(config.spreadsheet_style_buffer), + [&] { + styles.close(); + write_head(document, head_state, name, content_pixels); + }); + { + std::ostream deferred(&buffer); + HtmlWriter body_out(deferred, config); + WritingState state(body_out, config, resources, logger, &styles); + state.set_direction(head_state.direction()); + body(state); + } + buffer.release(); + out.write_end(); + return resources; } class HtmlFragmentBase { @@ -247,8 +305,10 @@ class HtmlFragmentBase { [[nodiscard]] const std::string &name() const { return m_name; } [[nodiscard]] std::size_t index() const { return m_index; } [[nodiscard]] const std::string &path() const { return m_path; } + [[nodiscard]] const Document &document() const { return m_document; } - virtual void write_fragment(HtmlWriter &out, WritingState &state) const = 0; + virtual void write_fragment(HtmlWriter &out, + const WritingState &state) const = 0; /// The width this one view lays out, which is what it is fitted against. [[nodiscard]] virtual std::optional @@ -265,13 +325,6 @@ class HtmlFragmentBase { return m_cut; } - void write_document(HtmlWriter &out, WritingState &state) const { - const std::optional content = content_pixels(state.config()); - front(m_document, state, m_name, content); - write_fragment(out, state); - back(m_document, state); - } - protected: [[nodiscard]] virtual std::optional measure_sheet_cut(const HtmlConfig &config) const = 0; @@ -313,10 +366,12 @@ class HtmlFragmentView final : public abstract::HtmlView { } HtmlResources write_html(HtmlWriter &out) const override { - HtmlResources resources; - WritingState state(out, service().config(), resources, service().logger()); - m_fragment->write_document(out, state); - return resources; + return render(m_fragment->document(), service().config(), + service().logger(), out, m_fragment->name(), + m_fragment->content_pixels(service().config()), + [this](const WritingState &state) { + m_fragment->write_fragment(state.out(), state); + }); } private: @@ -448,21 +503,14 @@ class HtmlServiceImpl final : public HtmlService { } HtmlResources write_document(HtmlWriter &out) const { - HtmlResources resources; - - WritingState state(out, config(), resources, logger()); - // 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, config()); - - front(m_document, state, "", content); - for (const auto &fragment : m_fragments) { - fragment->write_fragment(out, state); - } - back(m_document, state); - - return resources; + return render(m_document, config(), logger(), out, "", + document_content_pixels(m_document, config()), + [this](const WritingState &state) { + for (const auto &fragment : m_fragments) { + fragment->write_fragment(state.out(), state); + } + }); } protected: @@ -505,7 +553,8 @@ class TextHtmlFragment final : public HtmlFragmentBase { config); } - void write_fragment(HtmlWriter &out, WritingState &state) const override { + void write_fragment(HtmlWriter &out, + const WritingState &state) const override { const Element root = m_document.root_element(); const TextRoot element = root.as_text_root(); @@ -593,7 +642,7 @@ class ElementHtmlFragment final : public HtmlFragmentBase { return fragment_content_pixels(m_element, config); } - void write_fragment(HtmlWriter &, WritingState &state) const override { + void write_fragment(HtmlWriter &, const WritingState &state) const override { Translate(m_element, state); } diff --git a/src/odr/internal/html/document_element.cpp b/src/odr/internal/html/document_element.cpp index d7589d7a0..196fd1544 100644 --- a/src/odr/internal/html/document_element.cpp +++ b/src/odr/internal/html/document_element.cpp @@ -13,10 +13,12 @@ #include #include #include +#include #include #include #include +#include namespace odr::internal { @@ -185,6 +187,42 @@ TableDimensions html::sheet_rendered_extent(const Sheet &sheet, namespace { +/// Whether a reader sees anything. A bookmark marks a place rather than filling +/// one, and a span or a link is a style around what it holds, so a paragraph +/// holding only those is still an empty line. +bool has_content(const ElementRange &children) { + for (const Element child : children) { + switch (child.type()) { + case ElementType::bookmark: + break; + case ElementType::span: + case ElementType::link: + if (has_content(child.children())) { + return true; + } + break; + case ElementType::text: + if (!child.as_text().content().empty()) { + return true; + } + break; + default: + return true; + } + } + return false; +} + +/// A break where the paragraph holds nothing, so a blank line survives being +/// pasted elsewhere; otherwise a break opportunity, or content all out of flow +/// leaves no line box. +void write_paragraph_line_box(const bool empty, + const html::WritingState &state) { + state.out().write_element_begin( + empty ? "br" : "wbr", + html::HtmlElementOptions().set_close_type(html::HtmlCloseType::none)); +} + /// How far a sheet has to shrink to fit the paper the file states; nothing /// where it fits already, or where no width is stated. std::optional sheet_print_fit(const Sheet &sheet, @@ -215,6 +253,166 @@ std::optional sheet_print_fit(const Sheet &sheet, return printable / content; } +/// A run whose style the box around it can carry instead. Not a background, a +/// raised run or an editable one: each means something else on the box. +std::optional plain_text(const Element &element, + const html::WritingState &state) { + if (element.type() != ElementType::text) { + return {}; + } + if (state.config().editable && element.is_editable()) { + return {}; + } + + const Text text = element.as_text(); + if (text.content().empty()) { + return {}; + } + const TextStyle style = text.style(); + if (style.background_color.has_value() || style.font_position.has_value()) { + return {}; + } + return text; +} + +/// @ref plain_text where @p paragraph holds one and nothing else. +std::optional plain_run(const Paragraph ¶graph, + const html::WritingState &state) { + const ElementRange children = paragraph.children(); + ElementIterator child = children.begin(); + if (child == children.end()) { + return {}; + } + const Element element = *child; + if (++child != children.end()) { + return {}; + } + return plain_text(element, state); +} + +/// Nothing a reader would see, so the cell beside it may spill over it. +bool is_blank(const SheetCell &cell) { + for (const Element child : cell.children()) { + if (child.type() != ElementType::paragraph || + has_content(child.children())) { + return false; + } + } + return true; +} + +/// A shape or picture anchored in a cell reaches past it by design. +bool holds_only_text(const SheetCell &cell) { + for (const Element child : cell.children()) { + switch (child.type()) { + case ElementType::paragraph: + case ElementType::text: + case ElementType::span: + case ElementType::link: + case ElementType::bookmark: + case ElementType::line_break: + break; + default: + return false; + } + } + return true; +} + +bool is_zero(const std::optional> &margin) { + return !margin.has_value() || margin->magnitude() == 0; +} + +/// What the run computed to, under the two properties the paragraph's block +/// carries. +TextStyle run_style(const Paragraph ¶graph, const Text &run) { + TextStyle result; + result.font_name = paragraph.text_style().font_name; + result.font_size = paragraph.text_style().font_size; + result.override(run.style()); + return result; +} + +struct FoldedCell { + std::string style; + std::string text; +}; + +/// The `td` carries the styles of the boxes it stands in for. A stated row +/// height keeps the paragraph: `contain:size`, `max-height`, `overflow` and +/// `content-visibility` are all ignored on a table cell. +std::optional fold_cell(const SheetCell &cell, + const html::WritingState &state, + const bool wraps, const bool anchors_shapes, + const std::optional &row_height) { + if (wraps || anchors_shapes) { + return {}; + } + + const ElementRange children = cell.children(); + ElementIterator child = children.begin(); + if (child == children.end()) { + return {}; + } + const Element only = *child; + if (++child != children.end()) { + return {}; + } + + if (only.type() == ElementType::text) { + const std::optional run = plain_text(only, state); + if (!run.has_value()) { + return {}; + } + return FoldedCell{html::translate_text_style(run->style()), + html::escape_text(run->content())}; + } + + if (only.type() != ElementType::paragraph || row_height.has_value()) { + return {}; + } + const Paragraph paragraph = only.as_paragraph(); + const std::optional run = plain_run(paragraph, state); + if (!run.has_value()) { + return {}; + } + const ParagraphStyle style = paragraph.style(); + if (!is_zero(style.margin.left) || !is_zero(style.margin.right) || + !is_zero(style.margin.top) || !is_zero(style.margin.bottom)) { + return {}; + } + + return FoldedCell{html::translate_paragraph_style(style, state.direction()) + + html::translate_text_style(run_style(paragraph, *run)), + html::escape_text(run->content())}; +} + +/// A paragraph holding one plain string carries what the run's `x-s` carried. +void translate_cell_children(const SheetCell &cell, + const html::WritingState &state) { + for (const Element child : cell.children()) { + const std::optional run = child.type() == ElementType::paragraph + ? plain_run(child.as_paragraph(), state) + : std::nullopt; + if (!run.has_value()) { + html::translate_element(child, state); + continue; + } + const Paragraph paragraph = child.as_paragraph(); + + state.out().write_element_begin( + "x-p", html::HtmlElementOptions().set_inline(true).set_style( + "display:block;" + + html::translate_paragraph_style(paragraph.style(), + state.direction()) + + html::translate_text_style(run_style(paragraph, *run)), + state.styles())); + state.out().out() << html::escape_text(run->content()); + write_paragraph_line_box(false, state); + state.out().write_element_end("x-p"); + } +} + } // namespace std::optional html::sheet_cut(const Sheet &sheet, @@ -257,16 +455,22 @@ void html::translate_sheet(const Sheet &sheet, const WritingState &state) { .set_close_type(HtmlCloseType::none) .set_class("odr-sheet-gutter")); + // `table-layout:fixed` still sizes the table from its content, so an unbroken + // line would widen its column; `max-width:0` takes the cell out of that sum. + // Only where a width is stated: a column that states none is its content's. + std::vector> column_pixels(end_column); + for (std::uint32_t column_index = 0; column_index < end_column; ++column_index) { const TableColumnStyle table_column_style = sheet.column_style(column_index); + column_pixels[column_index] = css_pixels(table_column_style.width); state.out().write_element_begin( - "col", - HtmlElementOptions() - .set_close_type(HtmlCloseType::none) - .set_style(translate_table_column_style(table_column_style))); + "col", HtmlElementOptions() + .set_close_type(HtmlCloseType::none) + .set_style(translate_table_column_style(table_column_style), + state.styles())); } // No `scope`: the letters and numbers are a ruler, not headers of what they @@ -304,6 +508,9 @@ void html::translate_sheet(const Sheet &sheet, const WritingState &state) { state.out().write_element_begin("tbody"); + const ElementRange shapes = sheet.shapes(); + const bool has_shapes = shapes.begin() != shapes.end(); + TableCursor cursor; for (std::uint32_t row_index = cursor.row(); row_index < end_row; row_index = cursor.row()) { @@ -311,27 +518,33 @@ void html::translate_sheet(const Sheet &sheet, const WritingState &state) { state.out().write_element_begin( "tr", HtmlElementOptions().set_style( - translate_table_row_style(table_row_style))); + translate_table_row_style(table_row_style), state.styles())); state.out().write_element_begin( "th", HtmlElementOptions() .set_inline(true) .set_class("odr-sheet-row-header") - .set_style([&]() -> std::optional { - const std::optional height = - table_row_style.height; - if (!height.has_value()) { - return std::nullopt; - } - return "height:" + height->to_string() + - ";max-height:" + height->to_string() + ";"; - }())); + .set_style( + [&]() -> std::string { + const std::optional height = + table_row_style.height; + if (!height.has_value()) { + return {}; + } + return "height:" + height->to_string() + + ";max-height:" + height->to_string() + ";"; + }(), + state.styles())); state.out().write_raw(TablePosition::to_row_string(row_index)); state.out().write_element_end("th"); + // Carried forward, so no position is read twice. + std::optional pending; for (std::uint32_t column_index = cursor.column(); column_index < end_column; column_index = cursor.column()) { - const SheetCell cell = sheet.cell(column_index, row_index); + const SheetCell cell = + pending.has_value() ? *pending : sheet.cell(column_index, row_index); + pending.reset(); if (cell.is_covered()) { // normally unreachable: the cursor skips positions covered by an @@ -348,9 +561,66 @@ void html::translate_sheet(const Sheet &sheet, const WritingState &state) { const TableDimensions cell_span = cell.span(); const ValueType cell_value_type = cell.value_type(); + // `style:wrap-option` is `no-wrap` by default, and `wrapText` is off. + const bool wraps = cell_style.wrap_text.value_or(false); + const std::uint32_t next_column = column_index + cell_span.columns; + std::optional next; + if (next_column < end_column) { + next = sheet.cell(next_column, row_index); + } + + const bool anchors_shapes = + has_shapes && column_index == 0 && row_index == 0; + const bool cuts_its_text = !anchors_shapes && holds_only_text(cell); + + std::string cell_css; + if (!wraps && cuts_its_text) { + cell_css += "white-space:nowrap;"; + } + if (wraps && cuts_its_text) { + // Its block is held at the row's height, so a broken line runs past + // the bottom and would paint over the row below. + cell_css += "overflow:hidden;"; + } + // Over the empty cells beside it, cut where the next one has something + // to show, unbounded where nothing follows. Each blank cell is walked by + // the one cell that may spill over it, so the row costs one pass. + if (!wraps && cuts_its_text && column_pixels[column_index].has_value()) { + std::optional spill(0); + bool bounded = false; + for (std::uint32_t ahead = next_column; ahead < end_column; ++ahead) { + const SheetCell cell_ahead = ahead == next_column && next.has_value() + ? *next + : sheet.cell(ahead, row_index); + if (!is_blank(cell_ahead)) { + bounded = true; + break; + } + if (!column_pixels[ahead].has_value()) { + spill.reset(); + bounded = true; + break; + } + *spill += *column_pixels[ahead]; + } + if (bounded) { + if (!spill.has_value() || *spill == 0) { + cell_css += "overflow:hidden;"; + } else { + cell_css += "clip-path:inset(0 " + + util::number::to_string_significant(-*spill, 7) + + "px 0 0);"; + } + } + } + + const std::optional folded = + fold_cell(cell, state, wraps, anchors_shapes, table_row_style.height); + state.out().write_element_begin( "td", HtmlElementOptions() + .set_inline(folded.has_value()) .set_attributes([&](const HtmlAttributeWriterCallback &clb) { if (cell_span.columns > 1) { clb("colspan", std::to_string(cell_span.columns)); @@ -359,7 +629,13 @@ void html::translate_sheet(const Sheet &sheet, const WritingState &state) { clb("rowspan", std::to_string(cell_span.rows)); } }) - .set_style(translate_table_cell_style(cell_style)) + .set_style( + translate_table_cell_style(cell_style) + + (column_pixels[column_index].has_value() ? "max-width:0;" + : "") + + cell_css + + (folded.has_value() ? folded->style : std::string()), + state.styles()) .set_class([&]() -> std::optional { if (cell_value_type == ValueType::float_number) { return "odr-value-type-float"; @@ -371,10 +647,17 @@ void html::translate_sheet(const Sheet &sheet, const WritingState &state) { translate_element(shape, state); } } - translate_children(cell.children(), state); + if (folded.has_value()) { + state.out().out() << folded->text; + } else { + translate_cell_children(cell, state); + } state.out().write_element_end("td"); cursor.add_cell(cell_span.columns, cell_span.rows); + if (cursor.column() == next_column) { + pending = next; + } } state.out().write_element_end("tr"); @@ -428,15 +711,16 @@ void html::translate_text(const Element &element, const WritingState &state) { const Text text = element.as_text(); state.out().write_element_begin( - "x-s", HtmlElementOptions() - .set_inline(true) - .set_attributes([&](const HtmlAttributeWriterCallback &clb) { - if (state.config().editable && element.is_editable()) { - clb("contenteditable", "true"); - clb("data-odr-path", element.document_path().to_string()); - } - }) - .set_style(translate_text_style(text.style()))); + "x-s", + HtmlElementOptions() + .set_inline(true) + .set_attributes([&](const HtmlAttributeWriterCallback &clb) { + if (state.config().editable && element.is_editable()) { + clb("contenteditable", "true"); + clb("data-odr-path", element.document_path().to_string()); + } + }) + .set_style(translate_text_style(text.style()), state.styles())); state.out().out() << escape_text(text.content()); state.out().write_element_end("x-s"); } @@ -453,35 +737,7 @@ void html::translate_line_break(const Element &element, state.out().write_element_end("x-s"); } -namespace { - -/// Whether a reader sees anything. A bookmark marks a place rather than filling -/// one, and a span or a link is a style around what it holds, so a paragraph -/// holding only those is still an empty line. -bool has_content(const ElementRange &children) { - for (const Element child : children) { - switch (child.type()) { - case ElementType::bookmark: - break; - case ElementType::span: - case ElementType::link: - if (has_content(child.children())) { - return true; - } - break; - case ElementType::text: - if (!child.as_text().content().empty()) { - return true; - } - break; - default: - return true; - } - } - return false; -} - -} // namespace +namespace {} // namespace void html::translate_page_break(const Element & /*element*/, const WritingState &state) { @@ -501,8 +757,9 @@ void html::translate_paragraph(const Element &element, "x-p", HtmlElementOptions().set_inline(true).set_style( "display:block;" + - translate_paragraph_style(paragraph.style(), state.direction()) + - translate_block_font_style(paragraph.text_style()))); + translate_paragraph_style(paragraph.style(), state.direction()) + + translate_block_font_style(paragraph.text_style()), + state.styles())); if (!marker.empty()) { state.out().write_element_begin( "x-s", HtmlElementOptions() @@ -514,16 +771,8 @@ void html::translate_paragraph(const Element &element, state.out().write_element_end("x-s"); } translate_children(paragraph.children(), state); - if (marker.empty() && !has_content(paragraph.children())) { - // A line break, not a break opportunity: only a break is copied, so a blank - // line between two paragraphs survives being pasted somewhere else. - state.out().write_element_begin( - "br", HtmlElementOptions().set_close_type(HtmlCloseType::none)); - } else { - // A paragraph whose content is all out of flow has no line box of its own. - state.out().write_element_begin( - "wbr", HtmlElementOptions().set_close_type(HtmlCloseType::none)); - } + write_paragraph_line_box(marker.empty() && !has_content(paragraph.children()), + state); state.out().write_element_end("x-p"); } @@ -532,7 +781,7 @@ void html::translate_span(const Element &element, const WritingState &state) { state.out().write_element_begin( "x-s", HtmlElementOptions().set_inline(true).set_style( - translate_text_style(span.style()))); + translate_text_style(span.style()), state.styles())); translate_children(span.children(), state); state.out().write_element_end("x-s"); } diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index 59e620c00..d2443716e 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -80,17 +80,19 @@ constexpr std::string_view spreadsheet_css = R"css( --odr-sheet-wash-pinned:rgba(0,0,0,.09); --odr-sheet-wash-ruler:rgba(0,0,0,.10); --odr-sheet-focus:#3c78dc; +--odr-sheet-raised:#ffffff; --odr-sheet-font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; } /* A sheet is not a page: past the last row and column is canvas. */ body{margin:0;background:var(--odr-sheet-canvas)} .odr-sheet{background:#fff;border-collapse:collapse;table-layout:fixed} -/* The sheet's own cells, not a table the document itself drew inside one. */ -.odr-sheet>tbody>tr>td{vertical-align:bottom;height:inherit;padding:1px 6px} +/* The sheet's own cells, not a table the document itself drew inside one. The + font is what anything in a cell falls back to, a string written straight + into it included. */ +.odr-sheet>tbody>tr>td{vertical-align:bottom;height:inherit;padding:1px 6px;font-family:var(--odr-sheet-font);font-size:10pt} +/* Exactly the cell's height, which is what holds a row to the height the file + states. `translate_sheet` says per cell what a line too long for it does. */ .odr-sheet>tbody>tr>td>x-p{height:inherit} -/* The font anything in a cell falls back to, a shape's text included, where the - file names none of its own. */ -.odr-sheet>tbody>tr>td x-p{font-family:var(--odr-sheet-font);font-size:10pt} /* Sticky cells in a collapsed border model do not repaint their borders in Chrome or WebKit, so the ruler uses inset shadows. */ .odr-sheet th{position:sticky;background:var(--odr-sheet-ruler);color:var(--odr-sheet-ruler-text);font:500 12px/1.6 var(--odr-sheet-font);text-align:center;vertical-align:middle;padding:0 4px;white-space:nowrap;user-select:none} @@ -106,6 +108,13 @@ body{margin:0;background:var(--odr-sheet-canvas)} .odr-sheet tbody tr.odr-sheet-pinned>*{background-image:linear-gradient(var(--odr-sheet-wash-pinned),var(--odr-sheet-wash-pinned))} .odr-sheet tbody tr:hover>th,.odr-sheet tbody tr.odr-sheet-pinned>th{background-image:linear-gradient(var(--odr-sheet-wash-ruler),var(--odr-sheet-wash-ruler))} .odr-sheet .odr-sheet-pinned-cell{outline:2px solid var(--odr-sheet-focus);outline-offset:-2px} +/* The clipped cell a reader asked to see: out of flow so the row cannot move, + sized to the string, over its neighbours. `.odr-sheet-raised-box` is the + wrapper the script adds to a cell that writes its string without one. */ +.odr-sheet td.odr-sheet-raised{overflow:visible!important;clip-path:none!important;z-index:4} +.odr-sheet td.odr-sheet-raised.odr-sheet-pinned-cell{outline:none} +.odr-sheet td.odr-sheet-raised>x-p,.odr-sheet td.odr-sheet-raised>.odr-sheet-raised-box{position:absolute!important;left:0;top:0;z-index:4;height:auto!important;min-width:100%;width:max-content;max-width:60vw;padding:1px 6px;margin:-1px -6px;background:var(--odr-sheet-raised)!important;box-shadow:0 1px 4px rgba(0,0,0,.35);outline:2px solid var(--odr-sheet-focus);outline-offset:-2px;overflow:visible!important;white-space:normal!important} +.odr-sheet-raised-box{display:block} /* The header's `position:sticky` already makes it a containing block. */ .odr-sheet-sort{position:absolute;top:1px;right:1px;bottom:1px;width:17px;display:flex;align-items:center;justify-content:center;border-radius:2px;opacity:0;cursor:pointer} .odr-sheet-column-header:hover .odr-sheet-sort,.odr-sheet-sort-asc,.odr-sheet-sort-desc{opacity:1} @@ -136,6 +145,7 @@ constexpr std::string_view spreadsheet_dark_css = R"css( --odr-sheet-wash-pinned:rgba(255,255,255,.10); --odr-sheet-wash-ruler:rgba(255,255,255,.12); --odr-sheet-focus:#4c8dff; +--odr-sheet-raised:#1c2128; } .odr-sheet{background-color:#161b22!important} )css"; @@ -1001,7 +1011,90 @@ constexpr std::string_view spreadsheet_js = R"js( return cell !== null && !merged ? cell.cellIndex : -1; } + var raisedCell = null; + var raisedWrapper = null; + var raisedContent = null; + + // The block the cell writes, or the cell where it writes none. `null` for + // anything else — a shape, several blocks — which is not raised. + function boxOf(cell) { + if (cell.childElementCount === 0) { + return cell; + } + var only = cell.firstElementChild; + return cell.childElementCount === 1 && only.tagName === "X-P" ? only : null; + } + + // Past the cell's edge by the spill `translate_sheet` measured, at the edge + // where it clips, unbounded where it does neither. + function visibleRight(cell) { + var style = getComputedStyle(cell); + var right = cell.getBoundingClientRect().right; + var inset = /inset\(([^)]*)\)/.exec(style.clipPath || ""); + if (inset !== null) { + var sides = inset[1].trim().split(/\s+/); + return sides.length > 1 ? right - parseFloat(sides[1]) : right; + } + return style.overflow === "visible" ? Infinity : right; + } + + // On the text, not the box: what is cut off is the string running past where + // the cell still paints. + function cutOff(cell, box) { + var range = document.createRange(); + range.selectNodeContents(box); + var ink = range.getBoundingClientRect(); + var rect = cell.getBoundingClientRect(); + return ( + ink.width > 0 && + (ink.right > visibleRight(cell) + 1 || + (getComputedStyle(cell).overflow !== "visible" && + ink.bottom > rect.bottom + 1)) + ); + } + + function lower() { + if (raisedCell === null) { + return; + } + raisedCell.classList.remove("odr-sheet-raised"); + if (raisedWrapper !== null) { + while (raisedWrapper.firstChild) { + raisedCell.insertBefore(raisedWrapper.firstChild, raisedWrapper); + } + raisedWrapper.remove(); + raisedWrapper = null; + } + raisedContent = null; + raisedCell = null; + } + + // Over its neighbours rather than pushing them aside. + function raise(cell) { + lower(); + if (cell === null || cell.tagName !== "TD") { + return; + } + var box = boxOf(cell); + if (box === null || !cutOff(cell, box)) { + return; + } + if (box === cell) { + raisedWrapper = document.createElement("span"); + raisedWrapper.className = "odr-sheet-raised-box"; + while (cell.firstChild) { + raisedWrapper.appendChild(cell.firstChild); + } + cell.appendChild(raisedWrapper); + box = raisedWrapper; + } + cell.classList.add("odr-sheet-raised"); + raisedCell = cell; + raisedContent = box; + } + function pin(column, row, cell) { + lower(); if (pinnedRow !== null) { pinnedRow.classList.remove("odr-sheet-pinned"); } @@ -1018,6 +1111,7 @@ constexpr std::string_view spreadsheet_js = R"js( } if (pinnedCell !== null) { pinnedCell.classList.add("odr-sheet-pinned-cell"); + raise(pinnedCell); } paint(); } @@ -1036,6 +1130,11 @@ constexpr std::string_view spreadsheet_js = R"js( }); table.addEventListener("click", function (event) { + // Selecting inside what is raised must not put the cell back. + if (raisedContent !== null && raisedContent.contains(event.target)) { + return; + } + var cell = event.target.closest("td,th"); if (cell === null) { return; @@ -1058,6 +1157,13 @@ constexpr std::string_view spreadsheet_js = R"js( } }); + // The canvas around the sheet included. + document.addEventListener("click", function (event) { + if (event.target.closest(".odr-sheet") === null) { + pin(-1, null, null); + } + }); + document.addEventListener("keydown", function (event) { if (event.key === "Escape") { pin(-1, null, null); diff --git a/src/odr/internal/html/html_writer.cpp b/src/odr/internal/html/html_writer.cpp index b61cdcf30..79c821bb4 100644 --- a/src/odr/internal/html/html_writer.cpp +++ b/src/odr/internal/html/html_writer.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include @@ -64,9 +65,18 @@ void write_attributes(std::ostream &out, const HtmlAttributes &attributes) { void write_element_options(std::ostream &out, const HtmlElementOptions &options) { - if (options.clazz && !is_empty(*options.clazz)) { + const bool has_clazz = options.clazz && !is_empty(*options.clazz); + if (has_clazz || options.style_class) { out << " class=\""; - write_writable(out, *options.clazz); + if (has_clazz) { + write_writable(out, *options.clazz); + } + if (options.style_class) { + if (has_clazz) { + out << " "; + } + out << *options.style_class; + } out << "\""; } if (options.style && !is_empty(*options.style)) { @@ -108,6 +118,20 @@ HtmlElementOptions::set_style(std::optional _style) { return *this; } +HtmlElementOptions &HtmlElementOptions::set_style(std::string _style, + StyleRegistry *registry) { + if (registry != nullptr) { + if (const std::string *name = registry->use(_style); name != nullptr) { + style_class = *name; + return *this; + } + } + if (!_style.empty()) { + style = std::move(_style); + } + return *this; +} + HtmlElementOptions & HtmlElementOptions::set_class(std::optional _class) { clazz = std::move(_class); diff --git a/src/odr/internal/html/html_writer.hpp b/src/odr/internal/html/html_writer.hpp index 59a955520..399d059cd 100644 --- a/src/odr/internal/html/html_writer.hpp +++ b/src/odr/internal/html/html_writer.hpp @@ -11,6 +11,8 @@ namespace odr::internal::html { +class StyleRegistry; + enum class HtmlCloseType { standard, trailing, @@ -35,6 +37,8 @@ struct HtmlElementOptions { std::optional style{}; std::optional clazz{}; + /// The class a deduplicated style block resolved to, written after `clazz`. + std::optional style_class{}; std::optional extra{}; @@ -42,6 +46,10 @@ struct HtmlElementOptions { HtmlElementOptions &set_close_type(HtmlCloseType _close_type); HtmlElementOptions &set_attributes(std::optional _attributes); HtmlElementOptions &set_style(std::optional _style); + /// Either an inline `style` attribute or, where @p registry names the block, + /// a class beside whatever `set_class` says. A null @p registry — every view + /// but a spreadsheet's — keeps it inline. + HtmlElementOptions &set_style(std::string _style, StyleRegistry *registry); HtmlElementOptions &set_class(std::optional _class); HtmlElementOptions &set_extra(std::optional _extra); }; diff --git a/src/odr/internal/html/style_registry.cpp b/src/odr/internal/html/style_registry.cpp new file mode 100644 index 000000000..658822dd5 --- /dev/null +++ b/src/odr/internal/html/style_registry.cpp @@ -0,0 +1,55 @@ +#include + +#include +#include + +namespace odr::internal::html { + +namespace { + +/// Base 36, so the first 36 blocks fit in two characters. Each name is written +/// once per element carrying the block. +std::string name(std::size_t index) { + static constexpr std::string_view digits = + "0123456789abcdefghijklmnopqrstuvwxyz"; + std::string suffix; + do { + suffix.insert(suffix.begin(), digits[index % digits.size()]); + index /= digits.size(); + } while (index != 0); + return 'c' + suffix; +} + +} // namespace + +const std::string *StyleRegistry::use(const std::string &style) { + if (style.empty()) { + return nullptr; + } + + if (m_closed) { + const auto it = m_entries.find(style); + return it == m_entries.end() ? nullptr : &it->second; + } + + const auto [it, inserted] = m_entries.try_emplace(style); + if (inserted) { + it->second = name(m_order.size()); + m_order.push_back(&*it); + } + return &it->second; +} + +void StyleRegistry::write_rules(std::ostream &out) const { + for (const auto *entry : m_order) { + // Named three times for the specificity an inline `style` had. Still under + // `!important`, which the dark sheet needs. + const std::string &name = entry->second; + out << "\n." << name << '.' << name << '.' << name << '{'; + const std::string_view block = entry->first; + out << (block.back() == ';' ? block.substr(0, block.size() - 1) : block); + out << '}'; + } +} + +} // namespace odr::internal::html diff --git a/src/odr/internal/html/style_registry.hpp b/src/odr/internal/html/style_registry.hpp new file mode 100644 index 000000000..8d43c36ee --- /dev/null +++ b/src/odr/internal/html/style_registry.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace odr::internal::html { + +/// Names each distinct style block a class, defined once in ``. An +/// inline `style` is the one shape a browser cannot share across the cells of +/// a sheet. +class StyleRegistry { +public: + /// The class @p style is written as; `nullptr` leaves it inline. + const std::string *use(const std::string &style); + + /// Names nothing further, so a block first seen after this stays inline. + void close() { m_closed = true; } + + [[nodiscard]] bool has_rules() const { return !m_order.empty(); } + + /// One rule per line, each preceded by a newline. + void write_rules(std::ostream &out) const; + +private: + /// Node-based: the pointers in `m_order` outlive every insertion. + std::unordered_map m_entries; + std::vector *> m_order; + bool m_closed{false}; +}; + +} // namespace odr::internal::html diff --git a/src/odr/internal/odf/odf_style.cpp b/src/odr/internal/odf/odf_style.cpp index 8960a5745..a2b65f4cc 100644 --- a/src/odr/internal/odf/odf_style.cpp +++ b/src/odr/internal/odf/odf_style.cpp @@ -507,6 +507,10 @@ void Style::resolve_table_cell_style_(const pugi::xml_node node, table_cell_properties.attribute("style:vertical-align"))) { result.vertical_align = vertical_align; } + if (const pugi::xml_attribute wrap_option = + table_cell_properties.attribute("style:wrap-option")) { + result.wrap_text = std::strcmp("wrap", wrap_option.value()) == 0; + } if (const std::optional background_color = read_color(table_cell_properties.attribute("fo:background-color"))) { result.background_color = background_color; diff --git a/src/odr/internal/oldms/spreadsheet/xls_style.cpp b/src/odr/internal/oldms/spreadsheet/xls_style.cpp index c68b52e46..92dec42f5 100644 --- a/src/odr/internal/oldms/spreadsheet/xls_style.cpp +++ b/src/odr/internal/oldms/spreadsheet/xls_style.cpp @@ -87,6 +87,8 @@ StyleRegistry::StyleRegistry(std::vector fonts, text.font_line_through = font.fixed.fStrikeOut != 0; text.font_color = icv_color(font.fixed.icv, palette); + style.table_cell_style.wrap_text = xf.fWrap != 0; + // For the solid pattern only icvFore is rendered; the other patterns are // approximated by their foreground color as well. if (xf.fls != 0) { diff --git a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.cpp b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.cpp index 9aca012ca..129cd10dc 100644 --- a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.cpp +++ b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.cpp @@ -116,6 +116,8 @@ ResolvedStyle StyleRegistry::cell_style(const std::uint32_t i) const { read_horizontal(alignment.attribute("horizontal")); result.table_cell_style.vertical_align = read_vertical(alignment.attribute("vertical")); + result.table_cell_style.wrap_text = + alignment.attribute("wrapText").as_bool(); if (const float text_rotation = alignment.attribute("textRotation").as_float(); text_rotation != 0) { diff --git a/src/odr/internal/util/stream_util.cpp b/src/odr/internal/util/stream_util.cpp index 1a969a1f6..5b594385c 100644 --- a/src/odr/internal/util/stream_util.cpp +++ b/src/odr/internal/util/stream_util.cpp @@ -155,6 +155,43 @@ class ViewStreamBuf : public std::streambuf { } // namespace +DeferredBuffer::DeferredBuffer(std::ostream &out, const std::size_t cap, + std::function release) + : m_out{&out}, m_cap{cap}, m_release{std::move(release)} {} + +void DeferredBuffer::release() { + if (m_released) { + return; + } + m_released = true; + m_release(); + m_out->write(m_held.data(), static_cast(m_held.size())); + m_held.clear(); + m_held.shrink_to_fit(); +} + +std::streamsize DeferredBuffer::xsputn(const char *data, + const std::streamsize size) { + if (m_released) { + m_out->write(data, size); + return size; + } + m_held.append(data, static_cast(size)); + if (m_held.size() > m_cap) { + release(); + } + return size; +} + +int DeferredBuffer::overflow(const int c) { + if (c == traits_type::eof()) { + return traits_type::not_eof(c); + } + const char value = traits_type::to_char_type(c); + xsputn(&value, 1); + return c; +} + ViewStream::ViewStream(std::string_view view) : std::istream(nullptr), m_sbuf{std::make_unique(view)} { rdbuf(m_sbuf.get()); diff --git a/src/odr/internal/util/stream_util.hpp b/src/odr/internal/util/stream_util.hpp index 6cf33b962..1ad3ae7b5 100644 --- a/src/odr/internal/util/stream_util.hpp +++ b/src/odr/internal/util/stream_util.hpp @@ -1,7 +1,10 @@ #pragma once +#include +#include #include #include +#include #include #include @@ -19,6 +22,29 @@ std::istream &pipe_until(std::istream &in, std::ostream &out, char until_char, bool inclusive); std::string read_until(std::istream &in, char until_char, bool inclusive); +/// Holds what is written until `release`d, then passes it and everything after +/// straight through. `cap` bytes release it early. +class DeferredBuffer final : public std::streambuf { +public: + /// @p release runs once, before the held bytes reach @p out: it writes the + /// prologue they need in front of them. + DeferredBuffer(std::ostream &out, std::size_t cap, + std::function release); + + void release(); + +protected: + std::streamsize xsputn(const char *data, std::streamsize size) override; + int overflow(int c) override; + +private: + std::ostream *m_out{nullptr}; + std::size_t m_cap{0}; + std::function m_release; + std::string m_held; + bool m_released{false}; +}; + class ViewStream : public std::istream { public: explicit ViewStream(std::string_view view); diff --git a/src/odr/style.cpp b/src/odr/style.cpp index 6f5da3f88..4d82d711a 100644 --- a/src/odr/style.cpp +++ b/src/odr/style.cpp @@ -93,6 +93,7 @@ void TableCellStyle::override(const TableCellStyle &other) { padding.override(other.padding); border.override(other.border); override_if_set(text_rotation, other.text_rotation); + override_if_set(wrap_text, other.wrap_text); } void GraphicStyle::override(const GraphicStyle &other) { diff --git a/src/odr/style.hpp b/src/odr/style.hpp index 867467186..32a9cc68d 100644 --- a/src/odr/style.hpp +++ b/src/odr/style.hpp @@ -225,6 +225,9 @@ struct TableCellStyle final { DirectionalStyle padding; DirectionalStyle border; std::optional text_rotation; + /// Whether the cell breaks its text into lines. Off in every spreadsheet + /// format unless the file turns it on. + std::optional wrap_text; void override(const TableCellStyle &other); }; diff --git a/test/browser/sheet/.gitignore b/test/browser/sheet/.gitignore new file mode 100644 index 000000000..41e099b75 --- /dev/null +++ b/test/browser/sheet/.gitignore @@ -0,0 +1,3 @@ +document.css +spreadsheet.css +spreadsheet.js diff --git a/test/browser/sheet/README.md b/test/browser/sheet/README.md new file mode 100644 index 000000000..4caa0b93b --- /dev/null +++ b/test/browser/sheet/README.md @@ -0,0 +1,20 @@ +# sheet checks + +What the emitted sheet script does with a cell too narrow for its text can only +be seen in a browser, so these are run by hand rather than by `odr_test`. + +```bash +test/browser/sheet/serve # extracts the css and the script, serves on :8732 +open http://localhost:8732/tests.html +``` + +`serve` lifts `document_css`, `spreadsheet_css` and `spreadsheet_js` out of +`src/odr/internal/html/frontend.cpp`, so what runs is what ships. The markup is +what `translate_sheet` writes, cut down to the shapes the script has to tell +apart: a cell that spills over an empty neighbour, one that is cut at its edge, +one that keeps the block a stated row height needs, and one that writes its +string straight into the `td`. + +The point of the checks is that raising a cell shows all of it **without moving +anything**: the box goes out of flow, so no row changes height, and a click +inside it is for the text rather than for the cell. diff --git a/test/browser/sheet/serve b/test/browser/sheet/serve new file mode 100755 index 000000000..4b56c4607 --- /dev/null +++ b/test/browser/sheet/serve @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Extracts the emitted sheet stylesheet and script and serves the checks.""" + +import functools +import http.server +import pathlib +import socketserver + +PORT = 8732 + +HERE = pathlib.Path(__file__).resolve().parent +SOURCE = HERE.parents[2] / "src" / "odr" / "internal" / "html" / "frontend.cpp" +PARTS = { + "document.css": 'constexpr std::string_view document_css = R"css(', + "spreadsheet.css": 'constexpr std::string_view spreadsheet_css = R"css(', + "spreadsheet.js": 'constexpr std::string_view spreadsheet_js = R"js(', +} + + +def extract(source: str, begin: str) -> str: + at = source.index(begin) + end = ')css";' if begin.endswith('R"css(') else ')js";' + return source[at + len(begin) : source.index(end, at)] + + +def main() -> None: + source = SOURCE.read_text() + for name, begin in PARTS.items(): + (HERE / name).write_text(extract(source, begin)) + print(f"{SOURCE.name} -> {', '.join(PARTS)}") + + handler = functools.partial( + http.server.SimpleHTTPRequestHandler, directory=str(HERE) + ) + socketserver.TCPServer.allow_reuse_address = True + with socketserver.TCPServer(("127.0.0.1", PORT), handler) as server: + print(f"http://localhost:{PORT}/tests.html") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/test/browser/sheet/tests.html b/test/browser/sheet/tests.html new file mode 100644 index 000000000..67e90eb7c --- /dev/null +++ b/test/browser/sheet/tests.html @@ -0,0 +1,148 @@ + + + + + sheet checks + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AB
+ 1 + + a string far too long for its cell + next
+ 2 + + short +
3 + a string far too long for its cell + next
+ +
+ + + + diff --git a/test/src/html_output_test.cpp b/test/src/html_output_test.cpp index 49e98357d..493543628 100644 --- a/test/src/html_output_test.cpp +++ b/test/src/html_output_test.cpp @@ -300,8 +300,11 @@ std::vector> list_variant_cases() { {"odr-public/odt/about.odt", reflow}, {"odr-public/docx/physics.docx", reflow}, - // The output a reader gets rather than an editor. + // The output a reader gets rather than an editor. A sheet is pinned + // too: a cell holding one plain string drops the run around it only + // where nothing has to carry `contenteditable`. {"odr-public/odt/style-various-1.odt", read_only}, + {"odr-public/ods/file_example_ODS_100.ods", read_only}, }; } diff --git a/test/src/html_test.cpp b/test/src/html_test.cpp index cc5d82b9f..3fbe96baf 100644 --- a/test/src/html_test.cpp +++ b/test/src/html_test.cpp @@ -597,6 +597,79 @@ DecodedFile csv_file(const std::uint32_t rows, const std::uint32_t columns) { return DecodedFile(File::from_memory(csv), FileType::comma_separated_values); } +/// A flat ODF sheet holding @p rows, each a `table:table-row`, under the +/// `table:table-column`s in @p columns. The one cell style, `ce1`, aligns a +/// cell to the top, so every cell that names it writes the same style block. +DecodedFile fods_file(const std::string &rows, + const std::string &columns = "") { + const std::string fods = + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + + columns + rows + + R"()" + R"()"; + return DecodedFile(File::from_memory(fods), + FileType::opendocument_spreadsheet); +} + +/// A cell holding @p text, styled by `ce1`, or by `ce2` where it wraps. +std::string fods_cell(const std::string &text, const bool wraps = false) { + return R"()" + text + + R"()"; +} + +/// An empty cell, which the cell beside it may spill over. +std::string fods_blank() { return ""; } + +/// @p count columns of one inch, which is 96 css pixels. Without them a column +/// is exactly its content's width and nothing can overflow it. +std::string fods_columns(const std::uint32_t count) { + return R"()"; +} + +std::string fods_row(const std::string &cells) { + return "" + cells + ""; +} + +/// How many times @p needle occurs in @p haystack, without overlap. +std::size_t count(const std::string &haystack, const std::string_view needle) { + std::size_t result = 0; + for (std::size_t at = haystack.find(needle); at != std::string::npos; + at = haystack.find(needle, at + needle.size())) { + ++result; + } + return result; +} + +std::string render_sheet(const DecodedFile &file, const HtmlConfig &config) { + std::ostringstream out; + html::translate(file, config).list_views().at(0).write_html(out); + return std::move(out).str(); +} + const HtmlView &view_at(const HtmlService &service, const std::string_view path) { const auto it = @@ -728,13 +801,121 @@ TEST(html, the_cell_budget_bounds_the_rows_by_the_width) { EXPECT_EQ(rendered(30, 3).rows, 5); } +// #822: an inline `style` is the one shape a browser cannot share across the +// cells of a sheet. +TEST(html, a_sheet_defines_a_repeated_style_once_and_names_it) { + const std::string page = + render_sheet(fods_file(fods_row(fods_cell("one") + fods_cell("two") + + fods_cell("three"))), + HtmlConfig()); + + // Named three times: the class stands in for an inline style, which outranks + // every rule the sheet stylesheets carry. + EXPECT_NE(page.find(".c0.c0.c0{vertical-align:top;white-space:nowrap}"), + std::string::npos); + EXPECT_EQ(count(page, R"()"), 3); +} + +// #822: a block is named the first time it is written, because the view is +// walked once — nothing knows yet that this one will not be written again. +TEST(html, a_sheet_names_a_style_it_writes_only_once_too) { + const std::string page = + render_sheet(fods_file(fods_row(fods_cell("one"))), HtmlConfig()); + + EXPECT_NE(page.find(".c0.c0.c0{vertical-align:top;white-space:nowrap}"), + std::string::npos); + EXPECT_EQ(page.find(R"( x-p > x-s > text` is four nodes per cell before any content. +TEST(html, a_cell_holding_one_plain_string_writes_no_box_of_its_own) { + const std::string page = + render_sheet(fods_file(fods_row(fods_cell("one"))), HtmlConfig()); + + EXPECT_NE(page.find(">one"), std::string::npos); + EXPECT_EQ(page.find(" #include +#include #include #include @@ -40,3 +41,46 @@ TEST(ViewStream, seek_out_of_range) { in.seekg(-1, std::ios::beg); EXPECT_TRUE(in.fail()); } + +// Nothing reaches the stream until the buffer is released, and the release +// writes the prologue the held bytes need in front of them. +TEST(DeferredBuffer, holds_until_released) { + std::ostringstream out; + stream::DeferredBuffer buffer(out, 1024, [&out] { out << "head"; }); + std::ostream deferred(&buffer); + + deferred << "body"; + EXPECT_EQ(out.str(), ""); + + buffer.release(); + EXPECT_EQ(out.str(), "headbody"); + + deferred << "tail"; + EXPECT_EQ(out.str(), "headbodytail"); +} + +// Past the cap it releases itself, so what it holds is bounded. +TEST(DeferredBuffer, releases_itself_past_the_cap) { + std::ostringstream out; + stream::DeferredBuffer buffer(out, 4, [&out] { out << "head"; }); + std::ostream deferred(&buffer); + + deferred << "abc"; + EXPECT_EQ(out.str(), ""); + + deferred << "de"; + EXPECT_EQ(out.str(), "headabcde"); +} + +// Releasing twice writes the prologue once. +TEST(DeferredBuffer, releases_once) { + std::ostringstream out; + stream::DeferredBuffer buffer(out, 1024, [&out] { out << "head"; }); + std::ostream deferred(&buffer); + + deferred << "body"; + buffer.release(); + buffer.release(); + + EXPECT_EQ(out.str(), "headbody"); +} diff --git a/wasm/src/wasm_html.cpp b/wasm/src/wasm_html.cpp index 723ab9960..2f4eeae8d 100644 --- a/wasm/src/wasm_html.cpp +++ b/wasm/src/wasm_html.cpp @@ -183,6 +183,11 @@ HtmlConfig to_html_config(const emscripten::val &value) { limit["rows"].as(), limit["columns"].as())); } + if (const emscripten::val buffer = value["spreadsheetStyleBuffer"]; + !buffer.isUndefined() && !buffer.isNull()) { + config.spreadsheet_style_buffer = + static_cast(buffer.as()); + } if (const emscripten::val limit = value["spreadsheetCellLimit"]; !limit.isUndefined()) { // as a `number`, not a BigInt - a cell budget is nowhere near 2^53