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());