From 375cd9c5f093b4ed0a7c57d3d0161d2a5e95fc20 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 5 Sep 2026 21:45:50 +0200 Subject: [PATCH] feat(document): fit a printed sheet to the paper the file states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sheet's page style — the paper it is printed on — was neither parsed nor exposed, so the print stylesheet had nothing to fit against and left the browser to squeeze the sheet to the page, which distorts the column widths the file gives it. New `Sheet::page_layout()`, read for ods from the master page the table style names, the way a paragraph's page is; empty for the formats whose print setup is unparsed. `translate_sheet` states the factor the paper asks for per sheet, and the print stylesheet applies it as a `zoom`, so the sheet prints at the file's own proportions. Only ever down, and the stylesheet's cap stays as the guard for paper the file did not expect. Towards #816. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fp26La4hFAu9VRMHPjKmXd --- CHANGELOG.md | 7 ++ .../include/OdrCoreObjC/ODRDocumentElement.h | 2 + apple/src/ODRDocumentElement.mm | 9 +++ jni/java/app/opendocument/core/Sheet.java | 7 ++ jni/src/jni_document.cpp | 9 +++ python/src/bind_document.cpp | 1 + src/odr/document_element.cpp | 4 + src/odr/document_element.hpp | 3 + src/odr/internal/abstract/document.hpp | 4 + src/odr/internal/csv/csv_document.cpp | 4 + src/odr/internal/html/document_element.cpp | 52 ++++++++++++- src/odr/internal/html/frontend.cpp | 10 +-- src/odr/internal/iwork/iwork_document.cpp | 5 ++ src/odr/internal/odf/odf_document.cpp | 15 ++++ .../oldms/spreadsheet/xls_document.cpp | 5 ++ .../ooxml_spreadsheet_document.cpp | 5 ++ test/src/document_test.cpp | 37 ++++++++++ test/src/html_test.cpp | 73 ++++++++++++++++++- 18 files changed, 243 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64ff6ff5b..89faee8dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- 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. + +- A printed sheet is fitted to the paper the file states, at the file's own + column proportions. Towards #816. + - A right-to-left document renders right-to-left. `style:writing-mode`, `w:bidi` and `a:pPr@rtl` are read into a new `TextDirection` on `ParagraphStyle` and `PageLayout`, and the view's root carries it as diff --git a/apple/include/OdrCoreObjC/ODRDocumentElement.h b/apple/include/OdrCoreObjC/ODRDocumentElement.h index 8a763d683..44c87f190 100644 --- a/apple/include/OdrCoreObjC/ODRDocumentElement.h +++ b/apple/include/OdrCoreObjC/ODRDocumentElement.h @@ -117,6 +117,8 @@ NS_SWIFT_NAME(Slide) NS_SWIFT_NAME(Sheet) @interface ODRSheet : ODRElement @property(nonatomic, readonly, copy) NSString *name; +/// The paper the sheet is printed on, where the file states one. +@property(nonatomic, readonly) ODRPageLayout *pageLayout; @property(nonatomic, readonly) ODRTableDimensions dimensions; /// The dimensions actually filled with content, optionally within `range`. - (ODRTableDimensions)contentDimensions NS_SWIFT_NAME(contentDimensions()); diff --git a/apple/src/ODRDocumentElement.mm b/apple/src/ODRDocumentElement.mm index b69080f38..b3e1e42a3 100644 --- a/apple/src/ODRDocumentElement.mm +++ b/apple/src/ODRDocumentElement.mm @@ -308,6 +308,15 @@ - (NSString *)name { [&] { return to_nsstring(self.handle.as_sheet().name()); }, @""); } +- (ODRPageLayout *)pageLayout { + return guarded_value( + [&]() -> ODRPageLayout * { + return [ODRPageLayout + layoutWithHandle:self.handle.as_sheet().page_layout()]; + }, + nil); +} + - (ODRTableDimensions)dimensions { return guarded_value( [&] { return to_dimensions(self.handle.as_sheet().dimensions()); }, diff --git a/jni/java/app/opendocument/core/Sheet.java b/jni/java/app/opendocument/core/Sheet.java index 86047071e..ef6d026b8 100644 --- a/jni/java/app/opendocument/core/Sheet.java +++ b/jni/java/app/opendocument/core/Sheet.java @@ -12,6 +12,11 @@ public String name() { return nameNative(handle()); } + /** The paper the sheet is printed on, where the file states one. */ + public PageLayout pageLayout() { + return pageLayoutNative(handle()); + } + public TableDimensions dimensions() { return dimensionsNative(handle()); } @@ -51,6 +56,8 @@ public TableCellStyle cellStyle(int column, int row) { private native String nameNative(long handle); + private native PageLayout pageLayoutNative(long handle); + private native TableDimensions dimensionsNative(long handle); private native TableDimensions contentNative(long handle, int rows, int columns); diff --git a/jni/src/jni_document.cpp b/jni/src/jni_document.cpp index cf3409a99..22e35789d 100644 --- a/jni/src/jni_document.cpp +++ b/jni/src/jni_document.cpp @@ -395,6 +395,15 @@ Java_app_opendocument_core_Sheet_nameNative(JNIEnv *env, jobject, env, [&] { return to_jstring(env, element(handle).as_sheet().name()); }); } +extern "C" JNIEXPORT jobject JNICALL +Java_app_opendocument_core_Sheet_pageLayoutNative(JNIEnv *env, jobject, + jlong handle) { + return guarded(env, [&] { + return odr_jni::make_page_layout(env, + element(handle).as_sheet().page_layout()); + }); +} + extern "C" JNIEXPORT jobject JNICALL Java_app_opendocument_core_Sheet_dimensionsNative(JNIEnv *env, jobject, jlong handle) { diff --git a/python/src/bind_document.cpp b/python/src/bind_document.cpp index f22af4278..d7da14191 100644 --- a/python/src/bind_document.cpp +++ b/python/src/bind_document.cpp @@ -191,6 +191,7 @@ void odr_python::bind_document(py::module_ &m) { bind_element(m, "Sheet") .def("name", &odr::Sheet::name) + .def("page_layout", &odr::Sheet::page_layout) .def("dimensions", &odr::Sheet::dimensions) .def("content", &odr::Sheet::content, py::arg("range")) .def("cell", &odr::Sheet::cell, py::arg("column"), py::arg("row"), diff --git a/src/odr/document_element.cpp b/src/odr/document_element.cpp index d7dc24aca..f41cb3be0 100644 --- a/src/odr/document_element.cpp +++ b/src/odr/document_element.cpp @@ -327,6 +327,10 @@ std::string Sheet::name() const { return exists_() ? m_adapter2->sheet_name(m_identifier) : ""; } +PageLayout Sheet::page_layout() const { + return exists_() ? m_adapter2->sheet_page_layout(m_identifier) : PageLayout(); +} + TableDimensions Sheet::dimensions() const { return exists_() ? m_adapter2->sheet_dimensions(m_identifier) : TableDimensions(); diff --git a/src/odr/document_element.hpp b/src/odr/document_element.hpp index 706b15c29..5886ef044 100644 --- a/src/odr/document_element.hpp +++ b/src/odr/document_element.hpp @@ -312,6 +312,9 @@ class Sheet final : public ElementBase { [[nodiscard]] std::string name() const; + /// The paper the sheet is meant to be printed on, where the file states one. + [[nodiscard]] PageLayout page_layout() const; + [[nodiscard]] TableDimensions dimensions() const; [[nodiscard]] TableDimensions content(std::optional range) const; diff --git a/src/odr/internal/abstract/document.hpp b/src/odr/internal/abstract/document.hpp index 07241beca..b43ccbc0d 100644 --- a/src/odr/internal/abstract/document.hpp +++ b/src/odr/internal/abstract/document.hpp @@ -258,6 +258,10 @@ class SheetAdapter { [[nodiscard]] virtual std::string sheet_name(ElementIdentifier element_id) const = 0; + /// Empty where the file states no paper. + [[nodiscard]] virtual PageLayout + sheet_page_layout(ElementIdentifier element_id) const = 0; + [[nodiscard]] virtual TableDimensions sheet_dimensions(ElementIdentifier element_id) const = 0; [[nodiscard]] virtual TableDimensions diff --git a/src/odr/internal/csv/csv_document.cpp b/src/odr/internal/csv/csv_document.cpp index fc0d30406..1cc21e042 100644 --- a/src/odr/internal/csv/csv_document.cpp +++ b/src/odr/internal/csv/csv_document.cpp @@ -160,6 +160,10 @@ class ElementAdapter final : public abstract::ElementAdapter, [[maybe_unused]] const ElementIdentifier element_id) const override { return "csv"; } + [[nodiscard]] PageLayout sheet_page_layout( + [[maybe_unused]] const ElementIdentifier element_id) const override { + return {}; + } [[nodiscard]] TableDimensions sheet_dimensions( [[maybe_unused]] const ElementIdentifier element_id) const override { return m_document->dimensions(); diff --git a/src/odr/internal/html/document_element.cpp b/src/odr/internal/html/document_element.cpp index b9a1d5c7d..d7589d7a0 100644 --- a/src/odr/internal/html/document_element.cpp +++ b/src/odr/internal/html/document_element.cpp @@ -183,6 +183,40 @@ TableDimensions html::sheet_rendered_extent(const Sheet &sheet, return {end_row, end_column}; } +namespace { + +/// 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, + const std::uint32_t end_column) { + const PageLayout page_layout = sheet.page_layout(); + const std::optional page = html::css_pixels(page_layout.width); + if (!page.has_value()) { + return {}; + } + const double printable = + *page - html::css_pixels(page_layout.margin.left).value_or(0) - + html::css_pixels(page_layout.margin.right).value_or(0); + + // the ruler does not print + double content = 0; + for (std::uint32_t column = 0; column < end_column; ++column) { + const std::optional width = + html::css_pixels(sheet.column_style(column).width); + if (!width.has_value()) { + return {}; + } + content += *width; + } + + if (printable <= 0 || content <= printable) { + return {}; + } + return printable / content; +} + +} // namespace + std::optional html::sheet_cut(const Sheet &sheet, const HtmlConfig &config) { const TableDimensions rendered = sheet_rendered_extent(sheet, config); @@ -199,13 +233,25 @@ std::optional html::sheet_cut(const Sheet &sheet, } 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; + const std::optional print_fit = sheet_print_fit(sheet, end_column); + + state.out().write_element_begin( + "table", HtmlElementOptions() + .set_class("odr-sheet") + .set_style([&]() -> std::optional { + if (!print_fit.has_value()) { + return std::nullopt; + } + // `Measure` renders no exponent form + return "--odr-print-fit:" + + Measure(*print_fit, DynamicUnit()).to_string() + + ";"; + }())); + state.out().write_element_begin("col", HtmlElementOptions() .set_close_type(HtmlCloseType::none) diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index 12ed40a13..59e620c00 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -112,15 +112,15 @@ body{margin:0;background:var(--odr-sheet-canvas)} .odr-sheet-sort:hover{background:var(--odr-sheet-wash-ruler)} .odr-sheet-sort::after{content:"\25BE";font-size:15px;line-height:1} .odr-sheet-sort-asc::after{content:"\25B4"} -/* Paper cannot scroll, so a sheet wider than the page is cut off; only - `!important` beats the inline column width. The ruler collapses rather than - `display:none`, which would take the cells out of their rows and shift every - column left. */ +/* `--odr-print-fit` is written per sheet by `translate_sheet`; `zoom` keeps + the rows breaking across pages. Only `!important` beats the inline column + width. The ruler collapses rather than `display:none`, which would take the + cells out of their rows and shift every column left. */ @media print{ .odr-sheet thead{display:none} .odr-sheet-gutter{visibility:collapse;width:0} .odr-sheet-row-header{visibility:collapse;padding:0;box-shadow:none} -.odr-sheet{max-width:100%} +.odr-sheet{zoom:var(--odr-print-fit,1);max-width:100%} .odr-sheet col{min-width:0!important} } )css"; diff --git a/src/odr/internal/iwork/iwork_document.cpp b/src/odr/internal/iwork/iwork_document.cpp index 9a59ef14e..bad41a953 100644 --- a/src/odr/internal/iwork/iwork_document.cpp +++ b/src/odr/internal/iwork/iwork_document.cpp @@ -209,6 +209,11 @@ class ElementAdapter final : public abstract::ElementAdapter, sheet_name(const ElementIdentifier element_id) const override { return m_registry->sheet_element_at(element_id).name; } + /// TODO the print setup of a `.numbers` sheet is not read. + [[nodiscard]] PageLayout sheet_page_layout( + [[maybe_unused]] const ElementIdentifier element_id) const override { + return {}; + } [[nodiscard]] TableDimensions sheet_dimensions(const ElementIdentifier element_id) const override { const ElementRegistry::Sheet &sheet = diff --git a/src/odr/internal/odf/odf_document.cpp b/src/odr/internal/odf/odf_document.cpp index b2a1b0ebb..a1336ddd7 100644 --- a/src/odr/internal/odf/odf_document.cpp +++ b/src/odr/internal/odf/odf_document.cpp @@ -460,6 +460,21 @@ class ElementAdapter final : public abstract::ElementAdapter, sheet_name(const ElementIdentifier element_id) const override { return get_node(element_id).attribute("table:name").value(); } + [[nodiscard]] PageLayout + sheet_page_layout(const ElementIdentifier element_id) const override { + // The table style names the master page (20.383); a sheet that names none + // takes the first. + ElementIdentifier master_page_id = + m_document->style_registry().master_page_of_style( + get_node(element_id).attribute("table:style-name").value()); + if (master_page_id == null_element_id) { + master_page_id = m_document->style_registry().first_master_page(); + } + if (master_page_id == null_element_id) { + return {}; + } + return master_page_page_layout(master_page_id); + } [[nodiscard]] TableDimensions sheet_dimensions(const ElementIdentifier element_id) const override { return m_registry->sheet_element_at(element_id).dimensions; diff --git a/src/odr/internal/oldms/spreadsheet/xls_document.cpp b/src/odr/internal/oldms/spreadsheet/xls_document.cpp index 986c9abc7..a3df0d20d 100644 --- a/src/odr/internal/oldms/spreadsheet/xls_document.cpp +++ b/src/odr/internal/oldms/spreadsheet/xls_document.cpp @@ -123,6 +123,11 @@ class ElementAdapter final : public abstract::ElementAdapter, sheet_name(const ElementIdentifier element_id) const override { return m_registry->sheet_element_at(element_id).name; } + /// TODO `SETUP` ([MS-XLS] 2.4.257) is not read. + [[nodiscard]] PageLayout sheet_page_layout( + [[maybe_unused]] const ElementIdentifier element_id) const override { + return {}; + } [[nodiscard]] TableDimensions sheet_dimensions(const ElementIdentifier element_id) const override { return m_registry->sheet_element_at(element_id).dimensions; diff --git a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.cpp b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.cpp index b18cc88a9..d1fe65e0c 100644 --- a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.cpp +++ b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.cpp @@ -184,6 +184,11 @@ class ElementAdapter final : public abstract::ElementAdapter, sheet_name(const ElementIdentifier element_id) const override { return m_registry->sheet_element_at(element_id).name; } + /// TODO `pageSetup` is not read; a column width here is a `ch` either way. + [[nodiscard]] PageLayout sheet_page_layout( + [[maybe_unused]] const ElementIdentifier element_id) const override { + return {}; + } [[nodiscard]] TableDimensions sheet_dimensions(const ElementIdentifier element_id) const override { return m_registry->sheet_element_at(element_id).dimensions; diff --git a/test/src/document_test.cpp b/test/src/document_test.cpp index e37c46e16..7cf5c204b 100644 --- a/test/src/document_test.cpp +++ b/test/src/document_test.cpp @@ -136,6 +136,43 @@ TEST(Document, docx_page_layout) { EXPECT_EQ(Measure("1in"), page_layout.margin.left); } +// #816 +TEST(Document, ods_sheet_page_layout) { + const Logger logger = Logger::create_stdio("odr-test", LogLevel::verbose); + + const DocumentFile document_file( + TestData::test_file_path("odr-public/ods/file_example_ODS_100.ods"), + logger); + + const Document document = document_file.document(); + const PageLayout page_layout = + (*document.root_element().children().begin()).as_sheet().page_layout(); + EXPECT_EQ(Measure("8.2681in"), page_layout.width); + EXPECT_EQ(Measure("11.6929in"), page_layout.height); + EXPECT_EQ(PrintOrientation::portrait, page_layout.print_orientation); + EXPECT_EQ(Measure("0.7874in"), page_layout.margin.top); + EXPECT_EQ(Measure("0.7874in"), page_layout.margin.right); + EXPECT_EQ(Measure("0.7874in"), page_layout.margin.bottom); + EXPECT_EQ(Measure("0.7874in"), page_layout.margin.left); +} + +// Producers commonly state margins only and leave the paper to the printer. +TEST(Document, ods_sheet_page_layout_without_a_paper_size) { + const Logger logger = Logger::create_stdio("odr-test", LogLevel::verbose); + + const DocumentFile document_file( + TestData::test_file_path("odr-public/ods/file_example_ODS_10.ods"), + logger); + + const Document document = document_file.document(); + const PageLayout page_layout = + (*document.root_element().children().begin()).as_sheet().page_layout(); + + EXPECT_FALSE(page_layout.width.has_value()); + EXPECT_FALSE(page_layout.height.has_value()); + EXPECT_EQ(Measure("0.7874in"), page_layout.margin.left); +} + TEST(Document, xlsx_sheet_names) { const Logger logger = Logger::create_stdio("odr-test", LogLevel::verbose); diff --git a/test/src/html_test.cpp b/test/src/html_test.cpp index e76d7aad1..cc5d82b9f 100644 --- a/test/src/html_test.cpp +++ b/test/src/html_test.cpp @@ -741,11 +741,82 @@ TEST(html, a_printed_sheet_drops_the_ruler_and_fits_the_page) { EXPECT_NE(sheet.find(".odr-sheet thead{display:none}"), std::string::npos); EXPECT_NE(sheet.find(".odr-sheet-gutter{visibility:collapse"), std::string::npos); - EXPECT_NE(sheet.find(".odr-sheet{max-width:100%}"), std::string::npos); + EXPECT_NE( + sheet.find(".odr-sheet{zoom:var(--odr-print-fit,1);max-width:100%}"), + std::string::npos); EXPECT_NE(sheet.find(".odr-sheet col{min-width:0!important}"), std::string::npos); } +namespace { + +/// The print fit the sheet states, or nothing where it states none. +std::optional print_fit_of(const std::string &page) { + constexpr std::string_view key = "--odr-print-fit:"; + const std::size_t at = page.find(key); + if (at == std::string::npos) { + return {}; + } + return std::stod(page.substr(at + key.length())); +} + +} // namespace + +// #816 +TEST(html, a_sheet_is_fitted_to_the_paper_the_file_states) { + // 8 columns of 0.889in against A4 less 0.7874in margins + const std::optional fit = print_fit_of( + render("odr-public/ods/file_example_ODS_100.ods", HtmlConfig())); + ASSERT_TRUE(fit.has_value()); + EXPECT_NEAR((8.2681 - 2 * 0.7874) / (8 * 0.889), *fit, 1e-4); +} + +// Without a stated paper there is nothing to fit against. +TEST(html, a_sheet_that_states_no_paper_states_no_fit) { + EXPECT_FALSE(print_fit_of(render("odr-public/ods/file_example_ODS_10.ods", + HtmlConfig())) + .has_value()); +} + +namespace { + +/// A one-column flat ods on 8.5in paper with 1in margins. +std::string flat_ods_sheet(const std::string &width) { + return R"( + + + + + + + + + +a +)"; +} + +std::optional flat_ods_fit(const std::string &width) { + const DecodedFile file(File::from_memory(flat_ods_sheet(width)), + FileType::opendocument_spreadsheet); + std::ostringstream out; + html::translate(file, HtmlConfig()).list_views().at(0).write_html(out); + return print_fit_of(std::move(out).str()); +} + +} // namespace + +// The printable width is 6.5in. +TEST(html, a_sheet_is_only_ever_fitted_down) { + EXPECT_FALSE(flat_ods_fit("3in").has_value()); + EXPECT_FALSE(flat_ods_fit("6.5in").has_value()); + + const std::optional wide = flat_ods_fit("13in"); + ASSERT_TRUE(wide.has_value()); + EXPECT_NEAR(0.5, *wide, 1e-6); +} + 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());