diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a2ef1b6e..968937c03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,12 @@ The release run heads these entries with the version and opens a fresh `https`, `mailto`, `ftp`, `ftps`, `tel` or a relative reference — the allowlist a PDF `/URI` action already went through. `Link::href()` is unchanged. +- `HtmlView::sheet_cut` reports the extent a sheet's cells span against the + extent the rendered markup carries, or nothing where the limits cut nothing. + Bound in python, jni, wasm and apple as `sheet_cut` / `sheetCut`. +- New `HtmlConfig::spreadsheet_cell_limit`, 500000 cells for one sheet, bounds + the rows a sheet keeps by its width; `spreadsheet_limit` rises from 10000 to + 100000 rows. wasm gains both. - A saved document opens in LibreOffice again: every zip entry's size goes into its local header instead of a trailing data descriptor, which LibreOffice rejects on a stored entry — an odf package always stores `mimetype`. diff --git a/apple/include/OdrCoreObjC/ODRHtml.h b/apple/include/OdrCoreObjC/ODRHtml.h index 33899c11a..441fb15d9 100644 --- a/apple/include/OdrCoreObjC/ODRHtml.h +++ b/apple/include/OdrCoreObjC/ODRHtml.h @@ -89,6 +89,9 @@ NS_SWIFT_NAME(HtmlConfig) /// `nil` for no limit. @property(nonatomic, strong, nullable) NSValue *spreadsheetLimit; +/// Most cells written for one sheet; bounds the rows by the sheet's width. +/// `nil` for no budget. +@property(nonatomic, strong, nullable) NSNumber *spreadsheetCellLimit; @property(nonatomic) BOOL spreadsheetLimitByContent; @property(nonatomic) ODRHtmlTableGridlines spreadsheetGridlines; @@ -175,6 +178,18 @@ NS_SWIFT_NAME(Html) + (instancetype)new NS_UNAVAILABLE; @end +/// What a view leaves out of the sheet it renders. `odr::HtmlSheetCut`. +NS_SWIFT_NAME(HtmlSheetCut) +@interface ODRHtmlSheetCut : NSObject +/// The extent the sheet's cells span. +@property(nonatomic, readonly) ODRTableDimensions content; +/// The extent the markup carries. +@property(nonatomic, readonly) ODRTableDimensions rendered; + +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)new NS_UNAVAILABLE; +@end + /// One renderable view of a document — a slide, a sheet, a page, or the whole /// thing. `odr::HtmlView`. NS_SWIFT_NAME(HtmlView) @@ -184,6 +199,10 @@ NS_SWIFT_NAME(HtmlView) /// The path this view is served at. @property(nonatomic, readonly, copy) NSString *path; +/// The sheet this view cuts down to `spreadsheetLimit` and +/// `spreadsheetCellLimit`, or `nil` where it writes every cell. +@property(nonatomic, readonly, nullable) ODRHtmlSheetCut *sheetCut; + /// Renders the view. The resources it refers to come back alongside it. - (nullable NSString *)writeHtmlWithResources: (NSArray *_Nullable *_Nullable) diff --git a/apple/src/ODRHtml.mm b/apple/src/ODRHtml.mm index 7f6a06d8e..36cda60ff 100644 --- a/apple/src/ODRHtml.mm +++ b/apple/src/ODRHtml.mm @@ -97,6 +97,10 @@ - (instancetype)initWithNativeConfig:(const odr::HtmlConfig &)config { _spreadsheetLimit = [NSValue valueWithBytes:&limit objCType:@encode(ODRTableDimensions)]; } + _spreadsheetCellLimit = + config.spreadsheet_cell_limit.has_value() + ? @(static_cast(*config.spreadsheet_cell_limit)) + : nil; _spreadsheetLimitByContent = config.spreadsheet_limit_by_content ? YES : NO; _spreadsheetGridlines = static_cast(config.spreadsheet_gridlines); @@ -163,6 +167,12 @@ - (instancetype)initWithNativeConfig:(const odr::HtmlConfig &)config { } else { config.spreadsheet_limit.reset(); } + if (_spreadsheetCellLimit != nil) { + config.spreadsheet_cell_limit = + static_cast(_spreadsheetCellLimit.unsignedLongLongValue); + } else { + config.spreadsheet_cell_limit.reset(); + } config.spreadsheet_limit_by_content = _spreadsheetLimitByContent == YES; config.spreadsheet_gridlines = static_cast(_spreadsheetGridlines); @@ -336,6 +346,19 @@ + (instancetype)htmlWithHandle:(const odr::Html &)handle { } // namespace +@implementation ODRHtmlSheetCut + ++ (instancetype)cutWithHandle:(const odr::HtmlSheetCut &)handle { + ODRHtmlSheetCut *const result = [[ODRHtmlSheetCut alloc] init]; + result->_content = + ODRTableDimensionsMake(handle.content.rows, handle.content.columns); + result->_rendered = + ODRTableDimensionsMake(handle.rendered.rows, handle.rendered.columns); + return result; +} + +@end + @implementation ODRHtmlView { std::optional _handle; // The view's impl holds a bare pointer to its service, so the view has to @@ -367,6 +390,15 @@ - (NSString *)path { return guarded_value([&] { return to_nsstring(_handle->path()); }, @""); } +- (ODRHtmlSheetCut *)sheetCut { + return guarded_value( + [&]() -> ODRHtmlSheetCut * { + const std::optional &cut = _handle->sheet_cut(); + return cut.has_value() ? [ODRHtmlSheetCut cutWithHandle:*cut] : nil; + }, + static_cast(nil)); +} + - (nullable NSString *)writeHtmlWithResources: (NSArray **)resources error:(NSError **)error { diff --git a/apple/src/ODRPrivate.h b/apple/src/ODRPrivate.h index 295aaaeca..279d73fd7 100644 --- a/apple/src/ODRPrivate.h +++ b/apple/src/ODRPrivate.h @@ -157,6 +157,10 @@ ODRMeasure *_Nullable box(const std::optional &measure); + (instancetype)htmlWithHandle:(const odr::Html &)handle; @end +@interface ODRHtmlSheetCut (Private) ++ (instancetype)cutWithHandle:(const odr::HtmlSheetCut &)handle; +@end + @interface ODRHtmlView (Private) + (instancetype)viewWithHandle:(odr::HtmlView)handle owner:(id)owner; - (const odr::HtmlView &)handle; diff --git a/jni/CMakeLists.txt b/jni/CMakeLists.txt index 7a4474a1d..32230c008 100644 --- a/jni/CMakeLists.txt +++ b/jni/CMakeLists.txt @@ -111,6 +111,7 @@ add_jar(odr_java "java/app/opendocument/core/HtmlResource.java" "java/app/opendocument/core/HtmlResourceType.java" "java/app/opendocument/core/HtmlService.java" + "java/app/opendocument/core/HtmlSheetCut.java" "java/app/opendocument/core/HtmlTableGridlines.java" "java/app/opendocument/core/HtmlView.java" "java/app/opendocument/core/HtmlViewportMode.java" diff --git a/jni/java/app/opendocument/core/HtmlConfig.java b/jni/java/app/opendocument/core/HtmlConfig.java index 651cb96c2..fff935ca4 100644 --- a/jni/java/app/opendocument/core/HtmlConfig.java +++ b/jni/java/app/opendocument/core/HtmlConfig.java @@ -27,7 +27,14 @@ public final class HtmlConfig { public HtmlColorScheme colorScheme = HtmlColorScheme.LIGHT; /** {@code null} disables the spreadsheet limit. */ - public TableDimensions spreadsheetLimit = new TableDimensions(10000, 500); + public TableDimensions spreadsheetLimit = new TableDimensions(100000, 500); + + /** + * Most cells written for one sheet; bounds the rows by the sheet's width. {@code null} disables + * the budget. + */ + public Long spreadsheetCellLimit = 500000L; + public boolean spreadsheetLimitByContent = true; public HtmlTableGridlines spreadsheetGridlines = HtmlTableGridlines.SOFT; diff --git a/jni/java/app/opendocument/core/HtmlSheetCut.java b/jni/java/app/opendocument/core/HtmlSheetCut.java new file mode 100644 index 000000000..ce9739527 --- /dev/null +++ b/jni/java/app/opendocument/core/HtmlSheetCut.java @@ -0,0 +1,26 @@ +package app.opendocument.core; + +/** + * What a view leaves out of the sheet it renders. Mirrors {@code odr::HtmlSheetCut}. + * + * @see HtmlView#sheetCut() + */ +public final class HtmlSheetCut { + /** The extent the sheet's cells span. */ + public TableDimensions content; + + /** The extent the markup carries. */ + public TableDimensions rendered; + + public HtmlSheetCut() {} + + public HtmlSheetCut(TableDimensions content, TableDimensions rendered) { + this.content = content; + this.rendered = rendered; + } + + @Override + public String toString() { + return "HtmlSheetCut(content=" + content + ", rendered=" + rendered + ")"; + } +} diff --git a/jni/java/app/opendocument/core/HtmlView.java b/jni/java/app/opendocument/core/HtmlView.java index cd235ea4a..ab61afda8 100644 --- a/jni/java/app/opendocument/core/HtmlView.java +++ b/jni/java/app/opendocument/core/HtmlView.java @@ -22,6 +22,14 @@ public HtmlConfig config() { return configNative(handle()); } + /** + * The sheet this view cuts down to {@link HtmlConfig#spreadsheetLimit} and {@link + * HtmlConfig#spreadsheetCellLimit}, or {@code null} where it writes every cell. + */ + public HtmlSheetCut sheetCut() { + return sheetCutNative(handle()); + } + /** Renders this view; returns the HTML and its resources. */ public Html.Content writeHtml() { return writeHtmlNative(handle()); @@ -42,6 +50,8 @@ public Html bringOffline(String outputPath) { private native HtmlConfig configNative(long handle); + private native HtmlSheetCut sheetCutNative(long handle); + private native Html.Content writeHtmlNative(long handle); private native Html bringOfflineNative(long handle, String outputPath); diff --git a/jni/src/jni_convert.hpp b/jni/src/jni_convert.hpp index 485370bc9..91af3f0f0 100644 --- a/jni/src/jni_convert.hpp +++ b/jni/src/jni_convert.hpp @@ -38,6 +38,9 @@ jobject make_page_layout(JNIEnv *env, const odr::PageLayout &layout); jobject make_table_dimensions(JNIEnv *env, const odr::TableDimensions &dimensions); jobject make_table_position(JNIEnv *env, const odr::TablePosition &position); +/// `nullptr` where nothing was cut. +jobject make_html_sheet_cut(JNIEnv *env, + const std::optional &cut); jobject make_file_meta(JNIEnv *env, const odr::FileMeta &meta); jobject make_file_type_capabilities(JNIEnv *env, const odr::FileTypeCapabilities &); diff --git a/jni/src/jni_html.cpp b/jni/src/jni_html.cpp index f4d282536..2de882b60 100644 --- a/jni/src/jni_html.cpp +++ b/jni/src/jni_html.cpp @@ -341,6 +341,14 @@ Java_app_opendocument_core_HtmlView_configNative(JNIEnv *env, jobject, }); } +extern "C" JNIEXPORT jobject JNICALL +Java_app_opendocument_core_HtmlView_sheetCutNative(JNIEnv *env, jobject, + jlong handle) { + return guarded(env, [&] { + return odr_jni::make_html_sheet_cut(env, view(handle).sheet_cut()); + }); +} + extern "C" JNIEXPORT jobject JNICALL Java_app_opendocument_core_HtmlView_writeHtmlNative(JNIEnv *env, jobject, jlong handle) { diff --git a/jni/src/jni_style.cpp b/jni/src/jni_style.cpp index 11351acfb..2bc76103b 100644 --- a/jni/src/jni_style.cpp +++ b/jni/src/jni_style.cpp @@ -80,6 +80,14 @@ jobject box_integer(JNIEnv *env, const std::optional &value) { static_cast(*value)); } +jobject box_long(JNIEnv *env, const std::optional &value) { + if (!value.has_value()) { + return nullptr; + } + return call_static_object(env, "java/lang/Long", "valueOf", + "(J)Ljava/lang/Long;", static_cast(*value)); +} + /// Looks up an enum constant by its C++ code (= Java ordinal). jobject enum_from_code(JNIEnv *env, const char *class_name, const jint code) { if (code < 0) { @@ -309,6 +317,18 @@ jobject make_table_dimensions(JNIEnv *env, static_cast(dimensions.columns)); } +jobject make_html_sheet_cut(JNIEnv *env, + const std::optional &cut) { + if (!cut.has_value()) { + return nullptr; + } + return new_object(env, "app/opendocument/core/HtmlSheetCut", + "(Lapp/opendocument/core/TableDimensions;Lapp/opendocument/" + "core/TableDimensions;)V", + make_table_dimensions(env, cut->content), + make_table_dimensions(env, cut->rendered)); +} + jobject make_table_position(JNIEnv *env, const odr::TablePosition &position) { return new_object(env, "app/opendocument/core/TablePosition", "(II)V", static_cast(position.column), @@ -392,6 +412,8 @@ jobject html_config_to_java(JNIEnv *env, const odr::HtmlConfig &config) { config.spreadsheet_limit.has_value() ? make_table_dimensions(env, *config.spreadsheet_limit) : nullptr); + set_object("spreadsheetCellLimit", "Ljava/lang/Long;", + box_long(env, config.spreadsheet_cell_limit)); set_boolean("spreadsheetLimitByContent", config.spreadsheet_limit_by_content); set_object("spreadsheetGridlines", "Lapp/opendocument/core/HtmlTableGridlines;", @@ -527,6 +549,18 @@ odr::HtmlConfig html_config_from_java(JNIEnv *env, jobject config) { env->DeleteLocalRef(dimensions_cls); } } + { + jobject cell_limit = get_object("spreadsheetCellLimit", "Ljava/lang/Long;"); + if (cell_limit == nullptr) { + result.spreadsheet_cell_limit = std::nullopt; + } else { + jclass long_cls = env->GetObjectClass(cell_limit); + jmethodID long_value = env->GetMethodID(long_cls, "longValue", "()J"); + result.spreadsheet_cell_limit = static_cast( + env->CallLongMethod(cell_limit, long_value)); + env->DeleteLocalRef(long_cls); + } + } result.spreadsheet_limit_by_content = get_boolean("spreadsheetLimitByContent"); { diff --git a/jni/tests/app/opendocument/core/HtmlTest.java b/jni/tests/app/opendocument/core/HtmlTest.java index a18e85621..abb898442 100644 --- a/jni/tests/app/opendocument/core/HtmlTest.java +++ b/jni/tests/app/opendocument/core/HtmlTest.java @@ -1,6 +1,7 @@ package app.opendocument.core; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -160,6 +161,33 @@ void translateDocument() throws IOException { assertTrue(content.contains(TestFiles.ODT_WORD)); } + /** The C++ suite covers where the limits land; this proves the pair crosses JNI. */ + @Test + void spreadsheetCutReachesTheView() throws IOException { + assertEquals(Long.valueOf(500000L), new HtmlConfig().spreadsheetCellLimit); + + Path cache = Files.createDirectories(tempDir.resolve("cache")); + DecodedFile file = Odr.open(TestFiles.csvFile(tempDir).toString()); + + HtmlConfig full = new HtmlConfig(); + for (HtmlView view : Html.translate(file, cache.toString(), full).listViews()) { + assertNull(view.sheetCut()); + } + + HtmlConfig cut = new HtmlConfig(); + cut.spreadsheetLimit = new TableDimensions(2, 1); + cut.spreadsheetCellLimit = null; + HtmlService service = Html.translate(file, cache.toString(), cut); + + assertNull(service.config().spreadsheetCellLimit); + HtmlSheetCut sheetCut = service.listViews().get(1).sheetCut(); + assertNotNull(sheetCut); + assertEquals(3, sheetCut.content.rows); + assertEquals(2, sheetCut.content.columns); + assertEquals(2, sheetCut.rendered.rows); + assertEquals(1, sheetCut.rendered.columns); + } + @Test void htmlServiceViews() throws IOException { Path cache = Files.createDirectories(tempDir.resolve("cache")); diff --git a/python/src/bind_html.cpp b/python/src/bind_html.cpp index 21887c2b5..87c7a8dae 100644 --- a/python/src/bind_html.cpp +++ b/python/src/bind_html.cpp @@ -83,6 +83,8 @@ void odr_python::bind_html(py::module_ &m) { &odr::HtmlConfig::text_document_margin) .def_readwrite("color_scheme", &odr::HtmlConfig::color_scheme) .def_readwrite("spreadsheet_limit", &odr::HtmlConfig::spreadsheet_limit) + .def_readwrite("spreadsheet_cell_limit", + &odr::HtmlConfig::spreadsheet_cell_limit) .def_readwrite("spreadsheet_limit_by_content", &odr::HtmlConfig::spreadsheet_limit_by_content) .def_readwrite("spreadsheet_gridlines", @@ -117,6 +119,10 @@ void odr_python::bind_html(py::module_ &m) { .def_readwrite("output_path", &odr::HtmlConfig::output_path) .def_readwrite("resource_locator", &odr::HtmlConfig::resource_locator); + py::class_(m, "HtmlSheetCut") + .def_readonly("content", &odr::HtmlSheetCut::content) + .def_readonly("rendered", &odr::HtmlSheetCut::rendered); + py::class_(m, "HtmlPage") .def_readonly("name", &odr::HtmlPage::name) .def_readonly("path", &odr::HtmlPage::path) @@ -135,6 +141,10 @@ void odr_python::bind_html(py::module_ &m) { .def("index", &odr::HtmlView::index) .def("path", &odr::HtmlView::path) .def("config", &odr::HtmlView::config) + .def( + "sheet_cut", + [](const odr::HtmlView &view) { return view.sheet_cut(); }, + "The sheet this view cut down to the spreadsheet limits, or None.") .def( "write_html", [](const odr::HtmlView &view) { diff --git a/python/tests/test_html.py b/python/tests/test_html.py index 475d41db2..047a24afe 100644 --- a/python/tests/test_html.py +++ b/python/tests/test_html.py @@ -25,6 +25,28 @@ def test_html_config_defaults(): assert config.editable assert config.spreadsheet_limit.rows == 100 + assert config.spreadsheet_cell_limit == 500000 + config.spreadsheet_cell_limit = None + assert config.spreadsheet_cell_limit is None + + +def test_html_view_sheet_cut(csv_path, tmp_path): + cache = tmp_path / "cache" + file = pyodr.open(str(csv_path)) + + config = pyodr.HtmlConfig() + service = pyodr.html.translate(file, str(cache), config) + assert all(view.sheet_cut() is None for view in service.list_views()) + + config.spreadsheet_limit = pyodr.TableDimensions(2, 1) + config.spreadsheet_cell_limit = None + service = pyodr.html.translate(file, str(cache), config) + + cut = service.list_views()[1].sheet_cut() + assert cut is not None + assert (cut.content.rows, cut.content.columns) == (3, 2) + assert (cut.rendered.rows, cut.rendered.columns) == (2, 1) + def test_html_config_color_scheme_defaults(): config = pyodr.HtmlConfig() diff --git a/src/odr/html.cpp b/src/odr/html.cpp index cafe9718d..4c4d66644 100644 --- a/src/odr/html.cpp +++ b/src/odr/html.cpp @@ -158,6 +158,10 @@ const std::string &HtmlView::path() const { return m_impl->path(); } const HtmlConfig &HtmlView::config() const { return m_impl->config(); } +const std::optional &HtmlView::sheet_cut() const { + return m_impl->sheet_cut(); +} + HtmlResources HtmlView::write_html(std::ostream &out) const { internal::html::HtmlWriter writer(out, config()); return m_impl->write_html(writer); diff --git a/src/odr/html.hpp b/src/odr/html.hpp index 445be5a93..a1ffc2b68 100644 --- a/src/odr/html.hpp +++ b/src/odr/html.hpp @@ -139,9 +139,12 @@ struct HtmlConfig { /// The colors the output renders against. HtmlColorScheme color_scheme{HtmlColorScheme::light}; - /// Largest sheet region written; cells past it are dropped. - std::optional spreadsheet_limit{TableDimensions(10000, 500)}; - /// Trim a sheet to the cells it uses before @ref spreadsheet_limit applies. + /// Largest sheet region written, per axis; cells past it are dropped. + std::optional spreadsheet_limit{ + TableDimensions(100000, 500)}; + /// Most cells written for one sheet; bounds the rows by the sheet's width. + std::optional spreadsheet_cell_limit{500000}; + /// Trim a sheet to the cells it uses before the limits above apply. bool spreadsheet_limit_by_content{true}; /// Which gridlines a sheet paints. HtmlTableGridlines spreadsheet_gridlines{HtmlTableGridlines::soft}; @@ -230,6 +233,15 @@ struct HtmlPage final { HtmlPage(std::string name, std::string path); }; +/// What a view leaves out of the sheet it renders. See @ref +/// HtmlView::sheet_cut. +struct HtmlSheetCut final { + /// The extent the sheet's cells span. + TableDimensions content; + /// The extent the markup carries. + TableDimensions rendered; +}; + class HtmlView final { public: HtmlView(); @@ -240,6 +252,10 @@ class HtmlView final { [[nodiscard]] const std::string &path() const; [[nodiscard]] const HtmlConfig &config() const; + /// What the spreadsheet limits cut from this view's sheet, or nothing where + /// they cut nothing. A view of several sheets answers for the first it cut. + [[nodiscard]] const std::optional &sheet_cut() const; + HtmlResources write_html(std::ostream &out) const; [[nodiscard]] Html bring_offline(const std::string &output_path) const; diff --git a/src/odr/internal/abstract/html_service.hpp b/src/odr/internal/abstract/html_service.hpp index e399df114..7b061c532 100644 --- a/src/odr/internal/abstract/html_service.hpp +++ b/src/odr/internal/abstract/html_service.hpp @@ -40,6 +40,8 @@ class HtmlView { [[nodiscard]] virtual std::size_t index() const = 0; [[nodiscard]] virtual const std::string &path() const = 0; [[nodiscard]] virtual const HtmlConfig &config() const = 0; + [[nodiscard]] virtual const std::optional & + sheet_cut() const = 0; virtual HtmlResources write_html(html::HtmlWriter &out) const = 0; }; diff --git a/src/odr/internal/html/document.cpp b/src/odr/internal/html/document.cpp index fd0939606..2aab9b041 100644 --- a/src/odr/internal/html/document.cpp +++ b/src/odr/internal/html/document.cpp @@ -61,6 +61,20 @@ std::optional fragment_content_pixels(const Sheet &, return {}; } +/// Only a sheet has cells a limit can cut. +std::optional fragment_sheet_cut(const Sheet &sheet, + const HtmlConfig &config) { + return sheet_cut(sheet, config); +} +std::optional fragment_sheet_cut(const Slide &, + const HtmlConfig &) { + return {}; +} +std::optional fragment_sheet_cut(const Page &, + const HtmlConfig &) { + return {}; +} + /// The widest of them, for the view that writes every page into one file. std::optional document_content_pixels(const Document &document, const HtmlConfig &config) { @@ -206,6 +220,17 @@ class HtmlFragmentBase { [[nodiscard]] virtual std::optional content_pixels(const HtmlConfig &config) const = 0; + /// Measured on demand: a walk over the cells, and most hosts never ask. + [[nodiscard]] const std::optional & + sheet_cut(const HtmlConfig &config) const { + std::lock_guard lock(m_cut_mutex); + if (!m_cut_measured) { + m_cut = measure_sheet_cut(config); + m_cut_measured = true; + } + 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); @@ -214,10 +239,18 @@ class HtmlFragmentBase { } protected: + [[nodiscard]] virtual std::optional + measure_sheet_cut(const HtmlConfig &config) const = 0; + std::string m_name; std::size_t m_index = 0; std::string m_path; Document m_document; + +private: + mutable std::mutex m_cut_mutex; + mutable std::optional m_cut; + mutable bool m_cut_measured = false; }; class HtmlFragmentView final : public abstract::HtmlView { @@ -238,6 +271,9 @@ class HtmlFragmentView final : public abstract::HtmlView { [[nodiscard]] const HtmlConfig &config() const override { return m_service->config(); } + [[nodiscard]] const std::optional &sheet_cut() const override { + return m_fragment->sheet_cut(config()); + } [[nodiscard]] const abstract::HtmlService &service() const { return *m_service; } @@ -254,6 +290,32 @@ class HtmlFragmentView final : public abstract::HtmlView { std::shared_ptr m_fragment; }; +/// The view that writes every fragment into one file; for a workbook that is +/// every sheet, so it answers for the first one it cut. +class HtmlDocumentView final : public HtmlView { +public: + HtmlDocumentView( + const abstract::HtmlService &service, std::string name, + const std::size_t index, std::string path, + const std::vector> &fragments) + : HtmlView(service, std::move(name), index, std::move(path)), + m_fragments{&fragments} {} + + [[nodiscard]] const std::optional &sheet_cut() const override { + for (const auto &fragment : *m_fragments) { + if (const std::optional &cut = + fragment->sheet_cut(config()); + cut.has_value()) { + return cut; + } + } + return HtmlView::sheet_cut(); + } + +private: + const std::vector> *m_fragments{nullptr}; +}; + class HtmlServiceImpl final : public HtmlService { public: HtmlServiceImpl(Document document, @@ -261,8 +323,8 @@ class HtmlServiceImpl final : public HtmlService { HtmlConfig config, const Logger &logger) : HtmlService(std::move(config), logger), m_document{std::move(document)}, m_fragments{std::move(fragments)} { - m_views.emplace_back( - std::make_shared(*this, "document", 0, "document.html")); + m_views.emplace_back(std::make_shared( + *this, "document", 0, "document.html", m_fragments)); for (const auto &fragment : m_fragments) { if (fragment->name() == "document") { continue; @@ -421,6 +483,13 @@ class TextHtmlFragment final : public HtmlFragmentBase { out.write_element_end("div"); } } + +protected: + /// A text document has no sheet to cut. + [[nodiscard]] std::optional + measure_sheet_cut(const HtmlConfig &) const override { + return {}; + } }; /// A fragment rendering one top-level element handle (slide, sheet, page) @@ -445,6 +514,12 @@ class ElementHtmlFragment final : public HtmlFragmentBase { Translate(m_element, state); } +protected: + [[nodiscard]] std::optional + measure_sheet_cut(const HtmlConfig &config) const override { + return fragment_sheet_cut(m_element, config); + } + private: Handle m_element; }; diff --git a/src/odr/internal/html/document_element.cpp b/src/odr/internal/html/document_element.cpp index fc6603424..8908a8df2 100644 --- a/src/odr/internal/html/document_element.cpp +++ b/src/odr/internal/html/document_element.cpp @@ -79,27 +79,57 @@ void html::translate_element(const Element &element, } } -void html::translate_sheet(const Sheet &sheet, const WritingState &state) { - state.out().write_element_begin("table", - HtmlElementOptions().set_class("odr-sheet")); - +TableDimensions html::sheet_rendered_extent(const Sheet &sheet, + const HtmlConfig &config) { const TableDimensions dimensions = sheet.dimensions(); + std::uint32_t end_column = dimensions.columns; std::uint32_t end_row = dimensions.rows; - if (state.config().spreadsheet_limit_by_content) { - const TableDimensions content = - sheet.content(state.config().spreadsheet_limit); + if (config.spreadsheet_limit_by_content) { + // Not the sheet's own extent clamped: a cell past the window does not + // stretch what precedes it. + const TableDimensions content = sheet.content(config.spreadsheet_limit); end_column = content.columns; end_row = content.rows; } - if (state.config().spreadsheet_limit) { - end_column = - std::min(end_column, state.config().spreadsheet_limit->columns); - end_row = std::min(end_row, state.config().spreadsheet_limit->rows); + if (config.spreadsheet_limit) { + end_column = std::min(end_column, config.spreadsheet_limit->columns); + end_row = std::min(end_row, config.spreadsheet_limit->rows); } end_column = std::max(1u, end_column); + if (config.spreadsheet_cell_limit) { + const std::uint64_t rows = *config.spreadsheet_cell_limit / end_column; + end_row = static_cast( + std::min(end_row, std::max(1, rows))); + } end_row = std::max(1u, end_row); + return {end_row, end_column}; +} + +std::optional html::sheet_cut(const Sheet &sheet, + const HtmlConfig &config) { + const TableDimensions rendered = sheet_rendered_extent(sheet, config); + // Against the whole sheet, not the window: what dropping the limits would + // render. + const TableDimensions content = config.spreadsheet_limit_by_content + ? sheet.content(std::nullopt) + : sheet.dimensions(); + + if (rendered.rows >= content.rows && rendered.columns >= content.columns) { + return {}; + } + return HtmlSheetCut{content, rendered}; +} + +void html::translate_sheet(const Sheet &sheet, const WritingState &state) { + state.out().write_element_begin("table", + HtmlElementOptions().set_class("odr-sheet")); + + const TableDimensions rendered = sheet_rendered_extent(sheet, state.config()); + const std::uint32_t end_column = rendered.columns; + const std::uint32_t end_row = rendered.rows; + state.out().write_element_begin("col", HtmlElementOptions() .set_close_type(HtmlCloseType::none) diff --git a/src/odr/internal/html/document_element.hpp b/src/odr/internal/html/document_element.hpp index 9b15f8714..fc19cf7a0 100644 --- a/src/odr/internal/html/document_element.hpp +++ b/src/odr/internal/html/document_element.hpp @@ -1,5 +1,7 @@ #pragma once +#include + #include namespace odr { @@ -20,6 +22,15 @@ void translate_element(const Element &element, const WritingState &state); void translate_slide(const Slide &slide, const WritingState &state); void translate_sheet(const Sheet &sheet, const WritingState &state); + +/// The extent `translate_sheet` writes @p sheet at, once the limits in +/// @p config have applied. +[[nodiscard]] TableDimensions sheet_rendered_extent(const Sheet &sheet, + const HtmlConfig &config); + +/// Costs a pass over the cells, so measure it once. +[[nodiscard]] std::optional sheet_cut(const Sheet &sheet, + const HtmlConfig &config); void translate_page(const Page &page, const WritingState &state); void translate_master_page(const MasterPage &masterPage, diff --git a/src/odr/internal/html/html_service.cpp b/src/odr/internal/html/html_service.cpp index bcc44dfd3..2f79e3510 100644 --- a/src/odr/internal/html/html_service.cpp +++ b/src/odr/internal/html/html_service.cpp @@ -24,6 +24,12 @@ const std::string &HtmlView::path() const { return m_path; } const HtmlConfig &HtmlView::config() const { return m_service->config(); } +/// Only a sheet cuts anything, and only `html/document.cpp` renders one. +const std::optional &HtmlView::sheet_cut() const { + static const std::optional none; + return none; +} + const abstract::HtmlService &HtmlView::service() const { return *m_service; } HtmlResources HtmlView::write_html(HtmlWriter &out) const { diff --git a/src/odr/internal/html/html_service.hpp b/src/odr/internal/html/html_service.hpp index 424d27b00..c373fb7a7 100644 --- a/src/odr/internal/html/html_service.hpp +++ b/src/odr/internal/html/html_service.hpp @@ -29,6 +29,7 @@ class HtmlView : public abstract::HtmlView { [[nodiscard]] std::size_t index() const override; [[nodiscard]] const std::string &path() const override; [[nodiscard]] const HtmlConfig &config() const override; + [[nodiscard]] const std::optional &sheet_cut() const override; [[nodiscard]] const abstract::HtmlService &service() const; HtmlResources write_html(HtmlWriter &out) const override; diff --git a/test/src/html_test.cpp b/test/src/html_test.cpp index 5cfba7852..121b128f1 100644 --- a/test/src/html_test.cpp +++ b/test/src/html_test.cpp @@ -585,6 +585,28 @@ std::string render_markdown(const std::string &markdown) { return std::move(out).str(); } +/// A sheet of exactly @p rows by @p columns, with no trailing empty cells. +DecodedFile csv_file(const std::uint32_t rows, const std::uint32_t columns) { + std::string csv; + for (std::uint32_t row = 0; row < rows; ++row) { + for (std::uint32_t column = 0; column < columns; ++column) { + csv += (column == 0 ? "" : ",") + std::to_string(row * columns + column); + } + csv += "\n"; + } + return DecodedFile(File::from_memory(csv), FileType::comma_separated_values); +} + +const HtmlView &view_at(const HtmlService &service, + const std::string_view path) { + const auto it = + std::ranges::find_if(service.list_views(), [path](const HtmlView &view) { + return view.path() == path; + }); + EXPECT_NE(it, service.list_views().end()) << path; + return *it; +} + } // namespace // #737. The entity forms are resolved before the href is stored, so the filter @@ -618,3 +640,63 @@ TEST(html, a_link_that_is_navigable_keeps_its_href) { .find(R"(a)"), std::string::npos); } + +// #740: a sheet that ran into a limit used to end without saying so. +TEST(html, a_sheet_written_in_full_reports_no_cut) { + HtmlConfig config; + config.spreadsheet_limit = TableDimensions(100, 100); + config.spreadsheet_cell_limit = 10000; + + const HtmlService service = html::translate(csv_file(4, 5), config); + + EXPECT_FALSE(view_at(service, "sheet0.html").sheet_cut().has_value()); + EXPECT_FALSE(view_at(service, "document.html").sheet_cut().has_value()); +} + +TEST(html, a_sheet_cut_by_the_rectangle_reports_what_it_left_out) { + HtmlConfig config; + config.spreadsheet_limit = TableDimensions(3, 4); + config.spreadsheet_cell_limit = std::nullopt; + + const HtmlService service = html::translate(csv_file(10, 6), config); + + const std::optional &cut = + view_at(service, "sheet0.html").sheet_cut(); + ASSERT_TRUE(cut.has_value()); + EXPECT_EQ(cut->content.rows, 10); + EXPECT_EQ(cut->content.columns, 6); + EXPECT_EQ(cut->rendered.rows, 3); + EXPECT_EQ(cut->rendered.columns, 4); +} + +// The rows a sheet keeps follow how wide it turns out to be. +TEST(html, the_cell_budget_bounds_the_rows_by_the_width) { + HtmlConfig config; + config.spreadsheet_limit = TableDimensions(1000, 1000); + config.spreadsheet_cell_limit = 60; + + const auto rendered = [&config](const std::uint32_t rows, + const std::uint32_t columns) { + const HtmlService service = + html::translate(csv_file(rows, columns), config); + const std::optional &cut = + view_at(service, "sheet0.html").sheet_cut(); + return cut.has_value() ? cut->rendered : TableDimensions(rows, columns); + }; + + // 3 wide: 20 rows fit the budget, and 30 do not + EXPECT_EQ(rendered(20, 3).rows, 20); + EXPECT_EQ(rendered(30, 3).rows, 20); + // 6 wide: the same budget is half the rows + EXPECT_EQ(rendered(30, 6).rows, 10); + // the rectangle still caps a sheet the budget would let through + config.spreadsheet_limit = TableDimensions(5, 1000); + EXPECT_EQ(rendered(30, 3).rows, 5); +} + +TEST(html, a_view_that_renders_no_sheet_has_no_cut) { + const DecodedFile file(File::from_memory("c"), FileType::xml); + const HtmlService service = html::translate(file, HtmlConfig()); + + EXPECT_FALSE(service.list_views().at(0).sheet_cut().has_value()); +} diff --git a/wasm/js/index.d.ts b/wasm/js/index.d.ts index db6a20833..507715ae4 100644 --- a/wasm/js/index.d.ts +++ b/wasm/js/index.d.ts @@ -43,10 +43,22 @@ export interface Detection { mimeType: string; } +/** What a view leaves out of the sheet it renders. */ +export interface SheetCut { + /** The extent the sheet's cells span. */ + contentRows: number; + contentColumns: number; + /** The extent the markup carries. */ + renderedRows: number; + renderedColumns: number; +} + export interface View { name: string; index: number; path: string; + /** Set where `spreadsheetLimit` or `spreadsheetCellLimit` cut this view's sheet. */ + sheetCut?: SheetCut; } /** A resource the markup links to rather than inlining. Fetch with @@ -85,6 +97,10 @@ export interface HtmlConfig { pageRangeBegin?: number; pageRangeEnd?: number; colorScheme?: number; + /** Largest sheet region written, per axis; `null` drops the cap. */ + spreadsheetLimit?: { rows: number; columns: number } | null; + /** Most cells written for one sheet; `null` drops the budget. */ + spreadsheetCellLimit?: number | null; spreadsheetGridlines?: number; viewportMode?: number; /** The width the output is shown at, in css pixels; fits paged content to it. */ diff --git a/wasm/src/wasm_html.cpp b/wasm/src/wasm_html.cpp index 26c885d4f..891d8d2ec 100644 --- a/wasm/src/wasm_html.cpp +++ b/wasm/src/wasm_html.cpp @@ -6,6 +6,7 @@ #include +#include #include #include #include @@ -66,6 +67,15 @@ emscripten::val list_views(const Handle handle) { entry.set("name", view.name()); entry.set("index", static_cast(view.index())); entry.set("path", view.path()); + if (const std::optional &cut = view.sheet_cut(); + cut.has_value()) { + emscripten::val sheet_cut = emscripten::val::object(); + sheet_cut.set("contentRows", cut->content.rows); + sheet_cut.set("contentColumns", cut->content.columns); + sheet_cut.set("renderedRows", cut->rendered.rows); + sheet_cut.set("renderedColumns", cut->rendered.columns); + entry.set("sheetCut", sheet_cut); + } result.call("push", entry); } return ok(result); @@ -151,6 +161,24 @@ HtmlConfig to_html_config(const emscripten::val &value) { read_enum(value, "colorScheme", config.color_scheme); read_enum(value, "spreadsheetGridlines", config.spreadsheet_gridlines); + // `null` drops a limit, which is how a host renders a cut sheet in full; + // absent leaves the default in place. + if (const emscripten::val limit = value["spreadsheetLimit"]; + !limit.isUndefined()) { + config.spreadsheet_limit = limit.isNull() + ? std::optional() + : std::optional(TableDimensions( + limit["rows"].as(), + limit["columns"].as())); + } + if (const emscripten::val limit = value["spreadsheetCellLimit"]; + !limit.isUndefined()) { + // as a `number`, not a BigInt - a cell budget is nowhere near 2^53 + config.spreadsheet_cell_limit = + limit.isNull() + ? std::optional() + : std::optional(static_cast(limit.as())); + } read_enum(value, "viewportMode", config.viewport_mode); if (const emscripten::val width = value["viewportWidth"]; !width.isUndefined() && !width.isNull()) {