From f552d1e15e4b24c249d2274a7be4b3d343381159 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 29 Aug 2026 10:06:45 +0200 Subject: [PATCH] feat(html): report the sheet a view cut, and budget its cells `spreadsheet_limit` dropped every cell past it and wrote nothing in its place: the `` simply ended, and nothing came back to say so. A reader saw a document that looked complete and was not, and an embedder could only find out by opening the document a second time to measure it. `HtmlView::sheet_cut` answers with the extent the sheet's cells span against the extent the markup carries, or nothing where the view writes every cell. It is measured without rendering and cached, so the host can decide before it renders; a view writing several sheets into one file answers for the first it cut. `spreadsheet_cell_limit` is the second half. As a rectangle alone the limit punished a tall-narrow sheet - 5 columns by 100000 rows is half a million cells and an ordinary export - to guard against a wide one, where 10000x500 is five million and no webview survives it. Budgeting cells and deriving the rows from the width the sheet turns out to have is the same protection with the row cap raised tenfold. Bound in python, jni, wasm and apple; wasm reaches `spreadsheetLimit` for the first time, without which a host there cannot act on the report. Fixes #740 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Re7MMYiM7fL58uELzKGy77 --- CHANGELOG.md | 6 ++ apple/include/OdrCoreObjC/ODRHtml.h | 19 +++++ apple/src/ODRHtml.mm | 32 ++++++++ apple/src/ODRPrivate.h | 4 + jni/CMakeLists.txt | 1 + .../app/opendocument/core/HtmlConfig.java | 9 +- .../app/opendocument/core/HtmlSheetCut.java | 26 ++++++ jni/java/app/opendocument/core/HtmlView.java | 10 +++ jni/src/jni_convert.hpp | 3 + jni/src/jni_html.cpp | 8 ++ jni/src/jni_style.cpp | 34 ++++++++ jni/tests/app/opendocument/core/HtmlTest.java | 28 +++++++ python/src/bind_html.cpp | 10 +++ python/tests/test_html.py | 22 +++++ src/odr/html.cpp | 4 + src/odr/html.hpp | 22 ++++- src/odr/internal/abstract/html_service.hpp | 2 + src/odr/internal/html/document.cpp | 79 +++++++++++++++++- src/odr/internal/html/document_element.cpp | 52 +++++++++--- src/odr/internal/html/document_element.hpp | 11 +++ src/odr/internal/html/html_service.cpp | 6 ++ src/odr/internal/html/html_service.hpp | 1 + test/src/html_test.cpp | 82 +++++++++++++++++++ wasm/js/index.d.ts | 16 ++++ wasm/src/wasm_html.cpp | 28 +++++++ 25 files changed, 498 insertions(+), 17 deletions(-) create mode 100644 jni/java/app/opendocument/core/HtmlSheetCut.java 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()) {