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