From 6c65ce59ec4da16228da219185e4ca3bb2e47513 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Wed, 2 Sep 2026 20:57:04 +0200 Subject: [PATCH] feat(html): honour the manual page break, and stop calling the automatic one a break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ElementType::page_break` came from ODF's `text:soft-page-break`, which [OpenDocument] 5.1.1 makes a record of where the *producer's* layout broke pages — not a break the author set. Rendering it would re-impose one writer's pagination on a viewport that has nothing to do with it, and it accounted for 240 of the 312 elements the renderer silently dropped. It is no longer parsed. The real manual break was not modelled at all: it is `fo:break-before` / `fo:break-after` on the paragraph style in ODF and `w:pageBreakBefore` in OOXML, so docx produced no break element whatsoever. New `BreakType` and `ParagraphStyle::break_before`/`break_after` carry it, with an explicit `none` for the break a style turns off — `sample1.docx`'s `TOCHeading` derives from a heading that breaks and says `w:val="0"` to stop. The text view then closes its page box and opens the next one at a break, whether it arrives as the style property or, for rtf and `.doc`, as the `page_break` node those formats write. Not pagination: nothing computes where a page *ends*, so the boxes are of unequal height and a document with no manual break is the one box it always was. `.odr-pages` already stacks and centres them. `break-before:page` goes out alongside, which is what print and PDF export need. Mirrored in the JNI, Apple and Python bindings. Reference output regenerated: 39 files, all odt/docx/doc. Their text is byte-identical; 13 gained page boxes, 14 gained a css break where the box could not be split, and 12 differ only in the `data-odr-path` indices the removed soft breaks used to occupy. Towards #174. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018cMYRXLJdiCkH65Jm2W9B5 --- AGENTS.md | 6 + CHANGELOG.md | 7 + apple/include/OdrCoreObjC/ODRStyle.h | 11 ++ apple/src/ODRStyle.mm | 5 + jni/CMakeLists.txt | 1 + jni/java/app/opendocument/core/BreakType.java | 14 ++ .../app/opendocument/core/ParagraphStyle.java | 14 +- jni/src/jni_style.cpp | 5 +- python/src/bind_style.cpp | 9 +- src/odr/internal/html/document.cpp | 67 +++++++-- src/odr/internal/html/document_element.cpp | 16 ++- src/odr/internal/html/document_element.hpp | 2 + src/odr/internal/html/document_style.cpp | 27 ++++ src/odr/internal/html/document_style.hpp | 3 + src/odr/internal/odf/odf_parser.cpp | 5 +- src/odr/internal/odf/odf_style.cpp | 24 ++++ .../internal/ooxml/text/ooxml_text_style.cpp | 9 ++ src/odr/internal/rtf/AGENTS.md | 2 +- src/odr/style.cpp | 2 + src/odr/style.hpp | 14 ++ test/data.cmake | 4 +- test/src/html_output_test.cpp | 5 +- test/src/internal/odf/odf_flat_file_test.cpp | 135 ++++++++++++++++++ .../internal/ooxml/ooxml_text_style_test.cpp | 29 ++++ 24 files changed, 392 insertions(+), 24 deletions(-) create mode 100644 jni/java/app/opendocument/core/BreakType.java 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);