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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
A
+
B
+
+
+
+
+
+ 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