diff --git a/AGENTS.md b/AGENTS.md index 35dadf611..5ff5986e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,12 @@ bytes ─▶ magic/open_strategy ─▶ DecodedFile ─▶ Document ─▶ Eleme `ElementType` is the shared enum in `src/odr/document_element.hpp`. +A **manual page break** reaches the renderer two ways, as the formats state it +two ways: `ParagraphStyle::break_before`/`break_after` for odf and ooxml, where +it is a style property, and `ElementType::page_break` for rtf and `.doc`, where +it is a node. Both split the text view's page box. The *automatic* breaks a +producer's layout recorded — odf's `text:soft-page-break` — are not parsed. + ## Directory map | Path | What | diff --git a/CHANGELOG.md b/CHANGELOG.md index a998fa6fb..9a441a8f6 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 +- A manual page break starts a new page box, and prints as `break-before:page`. + New `BreakType` and `ParagraphStyle::break_before`/`break_after`, mirrored in + the JNI, Apple and Python bindings. Towards #174. + +- ODF's `text:soft-page-break` is no longer part of the element tree, so a + `DocumentPath` past one shifts. + - New `DocumentFile::thumbnail()`, the preview the package carries or `nullopt`, mirrored in the JNI, Apple and Python bindings. Closes #21. diff --git a/apple/include/OdrCoreObjC/ODRStyle.h b/apple/include/OdrCoreObjC/ODRStyle.h index a0c0dc760..1dcd7ad07 100644 --- a/apple/include/OdrCoreObjC/ODRStyle.h +++ b/apple/include/OdrCoreObjC/ODRStyle.h @@ -19,6 +19,13 @@ typedef NS_ENUM(NSInteger, ODRFontPosition) { ODRFontPositionSub, } NS_SWIFT_NAME(FontPosition); +/// A manual break, `ODRBreakTypeNone` being one the document turns off. +typedef NS_ENUM(NSInteger, ODRBreakType) { + ODRBreakTypeNone = 0, + ODRBreakTypePage, + ODRBreakTypeColumn, +} NS_SWIFT_NAME(BreakType); + typedef NS_ENUM(NSInteger, ODRTextAlign) { ODRTextAlignLeft = 0, ODRTextAlignRight, @@ -157,6 +164,10 @@ NS_SWIFT_NAME(ParagraphStyle) @property(nonatomic, readonly) ODRDirectionalMeasure *margin; @property(nonatomic, readonly, nullable) ODRMeasure *lineHeight; @property(nonatomic, readonly, nullable) ODRMeasure *textIndent; +/// `ODRBreakType`, boxed; `nil` where the style says nothing. +@property(nonatomic, readonly, nullable) NSNumber *breakBefore; +/// `ODRBreakType`, boxed; `nil` where the style says nothing. +@property(nonatomic, readonly, nullable) NSNumber *breakAfter; - (instancetype)init NS_UNAVAILABLE; + (instancetype)new NS_UNAVAILABLE; diff --git a/apple/src/ODRStyle.mm b/apple/src/ODRStyle.mm index fca09ff4b..5b18eda30 100644 --- a/apple/src/ODRStyle.mm +++ b/apple/src/ODRStyle.mm @@ -18,6 +18,9 @@ ODR_SAME_ENUM(ODRFontPositionNormal, odr::FontPosition::normal); ODR_SAME_ENUM(ODRFontPositionSuper, odr::FontPosition::super); ODR_SAME_ENUM(ODRFontPositionSub, odr::FontPosition::sub); +ODR_SAME_ENUM(ODRBreakTypeNone, odr::BreakType::none); +ODR_SAME_ENUM(ODRBreakTypePage, odr::BreakType::page); +ODR_SAME_ENUM(ODRBreakTypeColumn, odr::BreakType::column); ODR_SAME_ENUM(ODRTextAlignLeft, odr::TextAlign::left); ODR_SAME_ENUM(ODRTextAlignRight, odr::TextAlign::right); ODR_SAME_ENUM(ODRTextAlignCenter, odr::TextAlign::center); @@ -216,6 +219,8 @@ + (instancetype)styleWithHandle:(const odr::ParagraphStyle &)handle { result->_margin = [ODRDirectionalMeasure directionalWithHandle:handle.margin]; result->_lineHeight = box(handle.line_height); result->_textIndent = box(handle.text_indent); + result->_breakBefore = box_enum(handle.break_before); + result->_breakAfter = box_enum(handle.break_after); return result; } diff --git a/jni/CMakeLists.txt b/jni/CMakeLists.txt index 809a319cb..56229c119 100644 --- a/jni/CMakeLists.txt +++ b/jni/CMakeLists.txt @@ -68,6 +68,7 @@ add_jar(odr_java "java/app/opendocument/core/Archive.java" "java/app/opendocument/core/ArchiveFile.java" "java/app/opendocument/core/Bookmark.java" + "java/app/opendocument/core/BreakType.java" "java/app/opendocument/core/Circle.java" "java/app/opendocument/core/Color.java" "java/app/opendocument/core/CustomShape.java" diff --git a/jni/java/app/opendocument/core/BreakType.java b/jni/java/app/opendocument/core/BreakType.java new file mode 100644 index 000000000..d137c3a8c --- /dev/null +++ b/jni/java/app/opendocument/core/BreakType.java @@ -0,0 +1,14 @@ +package app.opendocument.core; + +/** Mirrors {@code odr::BreakType}; constant order must match the C++ declaration. */ +public enum BreakType { + NONE, PAGE, COLUMN; + + static BreakType fromNative(int code) { + return code < 0 ? null : values()[code]; + } + + int toNative() { + return ordinal(); + } +} diff --git a/jni/java/app/opendocument/core/ParagraphStyle.java b/jni/java/app/opendocument/core/ParagraphStyle.java index 4ea4b68bf..3c3b09646 100644 --- a/jni/java/app/opendocument/core/ParagraphStyle.java +++ b/jni/java/app/opendocument/core/ParagraphStyle.java @@ -6,11 +6,23 @@ public final class ParagraphStyle { public final DirectionalMeasure margin; public final Measure lineHeight; public final Measure textIndent; + /** A break the author put before the paragraph; {@code null} if the style says nothing. */ + public final BreakType breakBefore; + /** A break the author put after the paragraph; {@code null} if the style says nothing. */ + public final BreakType breakAfter; - ParagraphStyle(int textAlign, DirectionalMeasure margin, Measure lineHeight, Measure textIndent) { + ParagraphStyle( + int textAlign, + DirectionalMeasure margin, + Measure lineHeight, + Measure textIndent, + int breakBefore, + int breakAfter) { this.textAlign = TextAlign.fromNative(textAlign); this.margin = margin; this.lineHeight = lineHeight; this.textIndent = textIndent; + this.breakBefore = BreakType.fromNative(breakBefore); + this.breakAfter = BreakType.fromNative(breakAfter); } } diff --git a/jni/src/jni_style.cpp b/jni/src/jni_style.cpp index 8afa6cb7f..f15831e85 100644 --- a/jni/src/jni_style.cpp +++ b/jni/src/jni_style.cpp @@ -273,10 +273,11 @@ jobject make_paragraph_style(JNIEnv *env, const odr::ParagraphStyle &style) { return new_object( env, "app/opendocument/core/ParagraphStyle", "(ILapp/opendocument/core/DirectionalMeasure;" - "Lapp/opendocument/core/Measure;Lapp/opendocument/core/Measure;)V", + "Lapp/opendocument/core/Measure;Lapp/opendocument/core/Measure;II)V", enum_code(style.text_align), make_directional_measure(env, style.margin), make_measure(env, style.line_height), - make_measure(env, style.text_indent)); + make_measure(env, style.text_indent), enum_code(style.break_before), + enum_code(style.break_after)); } jobject make_table_style(JNIEnv *env, const odr::TableStyle &style) { diff --git a/python/src/bind_style.cpp b/python/src/bind_style.cpp index d6ae325ed..86f58a322 100644 --- a/python/src/bind_style.cpp +++ b/python/src/bind_style.cpp @@ -38,6 +38,11 @@ void odr_python::bind_style(py::module_ &m) { .value("super", odr::FontPosition::super) .value("sub", odr::FontPosition::sub); + py::enum_(m, "BreakType") + .value("none", odr::BreakType::none) + .value("page", odr::BreakType::page) + .value("column", odr::BreakType::column); + py::enum_(m, "TextAlign") .value("left", odr::TextAlign::left) .value("right", odr::TextAlign::right) @@ -146,7 +151,9 @@ void odr_python::bind_style(py::module_ &m) { .def_readwrite("text_align", &odr::ParagraphStyle::text_align) .def_readwrite("margin", &odr::ParagraphStyle::margin) .def_readwrite("line_height", &odr::ParagraphStyle::line_height) - .def_readwrite("text_indent", &odr::ParagraphStyle::text_indent); + .def_readwrite("text_indent", &odr::ParagraphStyle::text_indent) + .def_readwrite("break_before", &odr::ParagraphStyle::break_before) + .def_readwrite("break_after", &odr::ParagraphStyle::break_after); py::class_(m, "TableStyle") .def(py::init<>()) diff --git a/src/odr/internal/html/document.cpp b/src/odr/internal/html/document.cpp index 17f588253..2f2566c8a 100644 --- a/src/odr/internal/html/document.cpp +++ b/src/odr/internal/html/document.cpp @@ -441,6 +441,22 @@ class HtmlServiceImpl final : public HtmlService { mutable HtmlResources m_resources; }; +/// Only a paragraph carries a break: one inside a table or a frame could not +/// split the page box. +bool breaks_page(const std::optional &break_type) { + return break_type == BreakType::page; +} + +bool breaks_page_before(const Element &element) { + return element.type() == ElementType::paragraph && + breaks_page(element.as_paragraph().style().break_before); +} + +bool breaks_page_after(const Element &element) { + return element.type() == ElementType::paragraph && + breaks_page(element.as_paragraph().style().break_after); +} + class TextHtmlFragment final : public HtmlFragmentBase { public: explicit TextHtmlFragment(std::string name, const std::size_t index, @@ -459,8 +475,24 @@ class TextHtmlFragment final : public HtmlFragmentBase { const TextRoot element = root.as_text_root(); if (state.config().text_document_margin) { - const PageLayout page_layout = element.page_layout(); + write_pages(out, state, element); + } else { + out.write_element_begin("div", + HtmlElementOptions().set_class("odr-text-flow")); + translate_children(element.children(), state); + out.write_element_end("div"); + } + } +private: + /// One page box per run of content between the author's manual breaks. Not + /// pagination: nothing computes where a page ends, so the boxes differ in + /// height. `.odr-pages` stacks them. + static void write_pages(HtmlWriter &out, const WritingState &state, + const TextRoot &element) { + const PageLayout page_layout = element.page_layout(); + + const auto begin_page = [&] { out.write_element_begin( "div", HtmlElementOptions() @@ -470,17 +502,34 @@ class TextHtmlFragment final : public HtmlFragmentBase { "div", HtmlElementOptions() .set_class("odr-page-inner") .set_style(translate_inner_page_style(page_layout))); - - translate_children(element.children(), state); - - out.write_element_end("div"); + }; + const auto end_page = [&] { out.write_element_end("div"); - } else { - out.write_element_begin("div", - HtmlElementOptions().set_class("odr-text-flow")); - translate_children(element.children(), state); out.write_element_end("div"); + }; + + begin_page(); + // Otherwise a leading or doubled break opens an empty sheet. + bool empty = true; + bool pending_break = false; + + for (const Element child : element.children()) { + if (child.type() == ElementType::page_break) { + // The node is the break, not content. + pending_break = true; + continue; + } + if ((pending_break || breaks_page_before(child)) && !empty) { + end_page(); + begin_page(); + empty = true; + } + pending_break = breaks_page_after(child); + translate_element(child, state); + empty = false; } + + end_page(); } protected: diff --git a/src/odr/internal/html/document_element.cpp b/src/odr/internal/html/document_element.cpp index 97359674f..cbf35ebd3 100644 --- a/src/odr/internal/html/document_element.cpp +++ b/src/odr/internal/html/document_element.cpp @@ -141,13 +141,14 @@ void html::translate_element(const Element &element, case ElementType::custom_shape: translate_custom_shape(element, state); break; + case ElementType::page_break: + translate_page_break(element, state); + break; case ElementType::group: translate_children(element.children(), state); break; default: - // The element and its whole subtree are dropped. Logged rather than - // thrown: a renderer shows what it can, and the corpus run is what ranks - // the gaps by how often they actually occur. + // Dropped with its whole subtree; a renderer shows what it can. ODR_WARNING(state.logger(), "html: dropped unhandled element " << element_type_name(element.type())); break; @@ -436,6 +437,15 @@ bool has_content(const ElementRange &children) { } // namespace +void html::translate_page_break(const Element & /*element*/, + const WritingState &state) { + // Reached only where the page box cannot be split; `TextHtmlFragment` takes + // the breaks among the root's children. + state.out().write_element_begin( + "div", HtmlElementOptions().set_style("break-before:page")); + state.out().write_element_end("div"); +} + void html::translate_paragraph(const Element &element, const WritingState &state, const std::string &marker) { diff --git a/src/odr/internal/html/document_element.hpp b/src/odr/internal/html/document_element.hpp index fc19cf7a0..ec0cd1966 100644 --- a/src/odr/internal/html/document_element.hpp +++ b/src/odr/internal/html/document_element.hpp @@ -38,6 +38,8 @@ void translate_master_page(const MasterPage &masterPage, void translate_text(const Element &element, const WritingState &state); void translate_line_break(const Element &element, const WritingState &state); +/// A manual page break carried as an element, which only rtf and `.doc` do. +void translate_page_break(const Element &element, const WritingState &state); /// `marker`, when set, is written inside the paragraph ahead of its content. void translate_paragraph(const Element &element, const WritingState &state, const std::string &marker = ""); diff --git a/src/odr/internal/html/document_style.cpp b/src/odr/internal/html/document_style.cpp index 1eb761477..1c1635ff1 100644 --- a/src/odr/internal/html/document_style.cpp +++ b/src/odr/internal/html/document_style.cpp @@ -51,6 +51,19 @@ const char *html::translate_vertical_align(const VerticalAlign vertical_align) { } } +const char *html::translate_break(const BreakType break_type) { + switch (break_type) { + case BreakType::page: + return "page"; + case BreakType::column: + return "column"; + case BreakType::none: + // The css initial value; the model needs it only for style inheritance. + return nullptr; + } + return nullptr; +} + const char *html::translate_font_weight(const FontWeight font_weight) { switch (font_weight) { case FontWeight::normal: @@ -269,6 +282,20 @@ html::translate_paragraph_style(const ParagraphStyle ¶graph_style) { text_indent.has_value()) { result.append("text-indent:").append(text_indent->to_string()).append(";"); } + // Paged media only; the sheet it starts is `TextHtmlFragment`'s business. + if (const std::optional break_before = + paragraph_style.break_before; + break_before.has_value()) { + if (const char *value = translate_break(*break_before); value != nullptr) { + result.append("break-before:").append(value).append(";"); + } + } + if (const std::optional break_after = paragraph_style.break_after; + break_after.has_value()) { + if (const char *value = translate_break(*break_after); value != nullptr) { + result.append("break-after:").append(value).append(";"); + } + } return result; } diff --git a/src/odr/internal/html/document_style.hpp b/src/odr/internal/html/document_style.hpp index 433dd9a1b..eef457707 100644 --- a/src/odr/internal/html/document_style.hpp +++ b/src/odr/internal/html/document_style.hpp @@ -10,6 +10,7 @@ enum class VerticalAlign; enum class FontWeight; enum class FontStyle; enum class FontPosition; +enum class BreakType; class Frame; class Rect; @@ -29,6 +30,8 @@ struct PageLayout; namespace odr::internal::html { +/// `nullptr` for a break turned off: the css default already says it. +const char *translate_break(BreakType break_type); const char *translate_text_align(TextAlign text_align); const char *translate_horizontal_align(HorizontalAlign horizontal_align); const char *translate_vertical_align(VerticalAlign vertical_align); diff --git a/src/odr/internal/odf/odf_parser.cpp b/src/odr/internal/odf/odf_parser.cpp index 8a652222e..dcb0baff8 100644 --- a/src/odr/internal/odf/odf_parser.cpp +++ b/src/odr/internal/odf/odf_parser.cpp @@ -329,8 +329,9 @@ parse_any_element_tree(ElementRegistry ®istry, const pugi::xml_node node) { {"text:index-body", create_default_tree_parser(ElementType::group)}, // A `draw:measure` writes its label as `text:measure` runs. {"text:measure", create_default_tree_parser(ElementType::group)}, - {"text:soft-page-break", - create_default_tree_parser(ElementType::page_break)}, + // No `text:soft-page-break`: [OpenDocument] 5.1.1 records the + // producer's own layout there, not an authored break, which is + // `fo:break-before` on the paragraph style. {"text:date", create_default_tree_parser(ElementType::group)}, {"text:time", create_default_tree_parser(ElementType::group)}, {"text:section", create_default_tree_parser(ElementType::group)}, diff --git a/src/odr/internal/odf/odf_style.cpp b/src/odr/internal/odf/odf_style.cpp index d3d975bd2..b4cd338f0 100644 --- a/src/odr/internal/odf/odf_style.cpp +++ b/src/odr/internal/odf/odf_style.cpp @@ -89,6 +89,22 @@ read_font_position(const pugi::xml_attribute attribute) { return FontPosition::normal; } +/// [OpenDocument] 20.86/20.87. +std::optional read_break(const pugi::xml_attribute attribute) { + if (!attribute) { + return {}; + } + const char *value = attribute.value(); + if (std::strcmp("page", value) == 0) { + return BreakType::page; + } + if (std::strcmp("column", value) == 0) { + return BreakType::column; + } + // `auto`: a style overriding an inherited break has to say so + return BreakType::none; +} + std::optional read_text_align(const pugi::xml_attribute attribute) { if (!attribute) { return {}; @@ -417,6 +433,14 @@ void Style::resolve_paragraph_style_(const pugi::xml_node node, read_measure(paragraph_properties.attribute("fo:text-indent"))) { result.text_indent = text_indent; } + if (const std::optional break_before = + read_break(paragraph_properties.attribute("fo:break-before"))) { + result.break_before = break_before; + } + if (const std::optional break_after = + read_break(paragraph_properties.attribute("fo:break-after"))) { + result.break_after = break_after; + } } void Style::resolve_table_style_(const pugi::xml_node node, diff --git a/src/odr/internal/ooxml/text/ooxml_text_style.cpp b/src/odr/internal/ooxml/text/ooxml_text_style.cpp index b016a89d5..442cb36d5 100644 --- a/src/odr/internal/ooxml/text/ooxml_text_style.cpp +++ b/src/odr/internal/ooxml/text/ooxml_text_style.cpp @@ -93,6 +93,15 @@ void resolve_paragraph_style_(const pugi::xml_node node, result.margin.bottom = margin_bottom; } } + // [ECMA-376] 17.3.1.23. `w:val="0"` clears an inherited break, so off has to + // be told from silence. + if (const pugi::xml_node page_break_before = + paragraph_properties.child("w:pageBreakBefore")) { + result.break_before = read_on_off_attribute(page_break_before) + ? BreakType::page + : BreakType::none; + } + if (const pugi::xml_attribute line = spacing.attribute("w:line")) { // [ECMA-376] 17.3.1.33: `atLeast`/`exact` measure in twips, the default // `auto` in 240ths of a line diff --git a/src/odr/internal/rtf/AGENTS.md b/src/odr/internal/rtf/AGENTS.md index 054490731..df6c3fcb8 100644 --- a/src/odr/internal/rtf/AGENTS.md +++ b/src/odr/internal/rtf/AGENTS.md @@ -83,7 +83,7 @@ pictures and lists are stages 2–5 in `PLAN.md`. had no place to put the decoded byte of a `\'hh`, and `\ucN` counts an escape as one character where a text run counts bytes. - **`\page` emits `ElementType::page_break`** (a child of root, as `oldms/text` - does) even though the html renderer ignores that type today. It closes an open + does), which the html renderer splits the page box on. It closes an open paragraph without opening one, as `\sect` and the end of the file do: writers emit `\par\page`, so manufacturing one there would blank-line every page break. Only `\par` itself (and `\row`) materialises an empty paragraph. diff --git a/src/odr/style.cpp b/src/odr/style.cpp index 36f4ec4aa..c4b5d6ec9 100644 --- a/src/odr/style.cpp +++ b/src/odr/style.cpp @@ -66,6 +66,8 @@ void ParagraphStyle::override(const ParagraphStyle &other) { margin.override(other.margin); override_if_set(line_height, other.line_height); override_if_set(text_indent, other.text_indent); + override_if_set(break_before, other.break_before); + override_if_set(break_after, other.break_after); } void TableStyle::override(const TableStyle &other) { diff --git a/src/odr/style.hpp b/src/odr/style.hpp index bd4f7d6c7..a0c494506 100644 --- a/src/odr/style.hpp +++ b/src/odr/style.hpp @@ -51,6 +51,17 @@ enum class VerticalAlign { bottom, }; +/// @brief Collection of manual breaks, as a document sets them; not the +/// automatic ones a layout engine computes. +/// +/// @ref BreakType::none is a break turned explicitly off, which an unset +/// @ref ParagraphStyle::break_before is not. +enum class BreakType { + none, + page, + column, +}; + /// @brief Collection of print orientations. enum class PrintOrientation { portrait, @@ -157,6 +168,9 @@ struct ParagraphStyle final { DirectionalStyle margin; std::optional line_height; std::optional text_indent; + /// A break the author put before or after the paragraph. + std::optional break_before; + std::optional break_after; void override(const ParagraphStyle &other); }; diff --git a/test/data.cmake b/test/data.cmake index c8c999755..8458ee947 100644 --- a/test/data.cmake +++ b/test/data.cmake @@ -17,9 +17,9 @@ odr_test_data( odr_test_data( PATH "reference-output/odr-public" URL "https://github.com/opendocument-app/OpenDocument.test.output.git" - REVISION "ac2bd3dd210505f4282a300089693a3a3a309a50") + REVISION "3dcc4fbc30dc9c2f8e3392c3aec06e455c5420a4") odr_test_data( PATH "reference-output/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git" - REVISION "949af844a65d091ed21cb6e9d1a3516a739ee710") + REVISION "9e9cc5859cc2c2c36d973f6a568133e2ecc4ec35") diff --git a/test/src/html_output_test.cpp b/test/src/html_output_test.cpp index 82c45515e..49e98357d 100644 --- a/test/src/html_output_test.cpp +++ b/test/src/html_output_test.cpp @@ -194,9 +194,8 @@ TEST_P(HtmlOutputTests, html_meta) { const std::string output_path_tmp = output_path + "/tmp"; fs::create_directories(output_path_tmp); - // The renderer used to get the null logger, so nothing it reported was ever - // read. Warnings only: its debug output is per pdf operator and per svm - // action, and would bury the corpus log. + // Warnings only: the debug output is per pdf operator and per svm action, + // and would bury the corpus log. const Logger render_logger = Logger::create_stdio("odr-test", LogLevel::warning); HtmlService service = diff --git a/test/src/internal/odf/odf_flat_file_test.cpp b/test/src/internal/odf/odf_flat_file_test.cpp index 742c025ee..d618fa7df 100644 --- a/test/src/internal/odf/odf_flat_file_test.cpp +++ b/test/src/internal/odf/odf_flat_file_test.cpp @@ -14,9 +14,11 @@ #include #include +#include #include #include #include +#include #include #include #include @@ -192,6 +194,139 @@ TEST(FlatOpenDocumentFile, styles_resolve_from_the_single_root) { EXPECT_EQ(style.font_weight, FontWeight::bold); } +namespace { + +/// One paragraph per entry, the second carrying @p style_name. +std::string three_paragraphs(const std::string &style_name, + const std::string &properties) { + return flat_text(R"(one)" + R"(two)" + R"(three)", + R"()" + R"()" + R"()" + R"()"); +} + +/// The rendered document, with the page box the margin config turns on. +std::string render(const std::string &source) { + HtmlConfig config((std::filesystem::current_path() / "flat_break").string()); + config.text_document_margin = true; + + std::ostringstream out; + html::translate(DecodedFile(File::from_memory(source)), config) + .list_views() + .at(0) + .write_html(out); + return out.str(); +} + +/// `break_before` of every top-level paragraph, in document order. +std::vector> +breaks_before_of(const std::string &source) { + const Document document = DocumentFile::from_memory(source).document(); + + std::vector> result; + for (const Element child : document.root_element().children()) { + result.push_back(child.as_paragraph().style().break_before); + } + return result; +} + +std::size_t count(const std::string &haystack, const std::string &needle) { + std::size_t result = 0; + for (std::size_t at = haystack.find(needle); at != std::string::npos; + at = haystack.find(needle, at + needle.size())) { + ++result; + } + return result; +} + +/// Told apart from the stylesheet's own mentions of the class. +std::size_t page_boxes(const std::string &html) { + return count(html, R"(class="odr-page-outer")"); +} + +} // namespace + +/// [OpenDocument] 20.86; not the `text:soft-page-break` element. +TEST(FlatOpenDocumentFile, + a_manual_page_break_is_read_off_the_paragraph_style) { + EXPECT_EQ( + std::vector>( + {std::nullopt, BreakType::page, std::nullopt}), + breaks_before_of(three_paragraphs("P1", R"(fo:break-before="page")"))); +} + +/// `auto` clears an inherited break; unset says nothing. +TEST(FlatOpenDocumentFile, an_automatic_break_reads_as_none_rather_than_unset) { + EXPECT_EQ( + std::vector>( + {std::nullopt, BreakType::none, std::nullopt}), + breaks_before_of(three_paragraphs("P1", R"(fo:break-before="auto")"))); +} + +/// And states itself in css for paged media. +TEST(FlatOpenDocumentFile, a_manual_page_break_splits_the_page_box) { + const std::string html = + render(three_paragraphs("P1", R"(fo:break-before="page")")); + + EXPECT_EQ(2U, page_boxes(html)); + EXPECT_EQ(1U, count(html, "break-before:page")); +} + +/// It would otherwise open a sheet the document does not have. +TEST(FlatOpenDocumentFile, a_break_on_the_first_block_leaves_no_empty_sheet) { + const std::string source = + flat_text(R"(one)" + R"(two)", + R"()" + R"()" + R"()" + R"()"); + + EXPECT_EQ(1U, page_boxes(render(source))); +} + +/// [OpenDocument] 5.1.1: the producer's own layout, so not an element of ours. +TEST(FlatOpenDocumentFile, a_soft_page_break_is_not_content) { + const std::string source = + flat_text(R"(onetwo)"); + + const Document document = DocumentFile::from_memory(source).document(); + const Element paragraph = + first_of_type(document.root_element(), ElementType::paragraph); + ASSERT_TRUE(paragraph); + for (const Element child : paragraph.children()) { + EXPECT_EQ(ElementType::text, child.type()); + } + + EXPECT_EQ(1U, page_boxes(render(source))); +} + +/// Without the page box there is no sheet to split. +TEST(FlatOpenDocumentFile, a_reflowed_document_states_the_break_in_css_only) { + HtmlConfig config((std::filesystem::current_path() / "flat_reflow").string()); + config.text_document_margin = false; + + std::ostringstream out; + html::translate(DecodedFile(File::from_memory( + three_paragraphs("P1", R"(fo:break-before="page")"))), + config) + .list_views() + .at(0) + .write_html(out); + + EXPECT_EQ(0U, page_boxes(out.str())); + EXPECT_EQ(1U, count(out.str(), "break-before:page")); +} + /// Without a package there is nowhere to put an image but the markup. TEST(FlatOpenDocumentFile, an_embedded_image_is_internal_and_decodes) { const Document document = diff --git a/test/src/internal/ooxml/ooxml_text_style_test.cpp b/test/src/internal/ooxml/ooxml_text_style_test.cpp index 098f43c1c..b6c7bdb4a 100644 --- a/test/src/internal/ooxml/ooxml_text_style_test.cpp +++ b/test/src/internal/ooxml/ooxml_text_style_test.cpp @@ -1,5 +1,7 @@ #include +#include + #include #include #include @@ -44,6 +46,33 @@ pugi::xml_node node_of(const char *xml, pugi::xml_document &document) { } // namespace +/// [ECMA-376] 17.3.1.23. `w:val="0"` clears an inherited break, so off has to +/// be told from unsaid. `sample1.docx`'s `TOCHeading` is such a style. +TEST(ooxml_text_style, + page_break_before_is_inherited_until_a_style_turns_it_off) { + pugi::xml_document document; + const StyleRegistry registry = registry_of( + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()", + document); + + const auto break_before = [&](const char *name) { + const Style *style = registry.style(name); + EXPECT_NE(nullptr, style); + return style->resolved().paragraph_style.break_before; + }; + + EXPECT_EQ(BreakType::page, break_before("head")); + EXPECT_EQ(BreakType::page, break_before("derived")); + EXPECT_EQ(BreakType::none, break_before("toc")); + EXPECT_FALSE(break_before("plain").has_value()); +} + TEST(ooxml_text_style, based_on_chain_inherits) { pugi::xml_document document; const StyleRegistry registry = registry_of(based_on_chain(3), document);