From 74da8e83a829380ea59f8c672f6979ae3d841b86 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Fri, 14 Aug 2026 20:20:47 +0200 Subject: [PATCH 1/2] fix(html): put a page-anchored frame on the page, not in the text A frame took its placement from its wrap alone, so one anchored to the page floated into the running text: a letter's address, date and footer boxes landed between the paragraphs instead of in their fields. Only a frame anchored in the text reads the wrap now; one anchored to the page sits at its own coordinates on it. The reference output also picks up the shipped css and js, which had drifted from what the engine emits since #679, #682, #683 and #685 - the same html rendered differently there than freshly generated. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01J8PCMZUVxuGstmmgQzEdif --- CHANGELOG.md | 3 +++ src/odr/internal/html/document_style.cpp | 15 ++++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e404ba67..0e01e34b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,9 @@ The release run heads these entries with the version and opens a fresh - A pdf page is the size of its crop box and shows what is on it and no more, as a viewer shows it. - A pdf's JPEG 2000 images render. New dependency: `openjpeg`. +- A frame anchored to the page sits where the page says, whatever the text + around it wraps like - a letter's address and date boxes land in their fields + instead of in the running text. ## v6.5.0 - 2026-08-10 diff --git a/src/odr/internal/html/document_style.cpp b/src/odr/internal/html/document_style.cpp index 86d42071..b4fa4ed1 100644 --- a/src/odr/internal/html/document_style.cpp +++ b/src/odr/internal/html/document_style.cpp @@ -422,13 +422,18 @@ std::string html::translate_frame_properties(const Frame &frame) { horizontal_position = *style.horizontal_position; } + // Only a frame anchored in the text takes its place from the wrap. Text does + // not flow around a page-anchored one: css cannot place a box away from where + // it sits in the flow and still have the flow react to it. + const AnchorType anchor_type = frame.anchor_type(); + const bool in_text = anchor_type != AnchorType::at_page; + // The frame is what its image sizes against. std::string result; - if (const AnchorType anchor_type = frame.anchor_type(); - anchor_type == AnchorType::as_char) { + if (anchor_type == AnchorType::as_char) { result += "position:relative;"; result += "display:inline-block;"; - } else if (text_wrap == TextWrap::before) { + } else if (in_text && text_wrap == TextWrap::before) { result += "position:relative;"; result += "display:block;"; result += "float:right;clear:both;"; @@ -446,7 +451,7 @@ std::string html::translate_frame_properties(const Frame &frame) { result += width->to_string(); result += ");"; } - } else if (text_wrap == TextWrap::after) { + } else if (in_text && text_wrap == TextWrap::after) { result += "position:relative;"; result += "display:block;"; result += "float:left;clear:both;"; @@ -457,7 +462,7 @@ std::string html::translate_frame_properties(const Frame &frame) { if (const std::optional y = frame.y(); y.has_value()) { result += "margin-top:" + y->to_string() + ";"; } - } else if (text_wrap == TextWrap::none) { + } else if (in_text && text_wrap == TextWrap::none) { result += "position:relative;"; result += "display:block;"; if (const std::optional x = frame.x(); x.has_value()) { From 968c0d1b9f4630bae9f52ebaf282545db4bbf2e8 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Fri, 14 Aug 2026 20:44:55 +0200 Subject: [PATCH 2/2] fix(odf): lay a text document out on the master page it names A text document took the first master page in the file. A paragraph may name the one its page uses (20.283), which is how a letter template asks for the margins that leave room for its letterhead - 9.85cm at the top of the DIN 5008 template, where the address boxes sit. One page box is all this renders, so only a name that still precedes every written word counts; taking a later one put a whole document on the landscape page a single section had asked for. Frames are passed over on the way: they are anchored, not written. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01J8PCMZUVxuGstmmgQzEdif --- CHANGELOG.md | 2 ++ src/odr/internal/odf/odf_document.cpp | 37 ++++++++++++++++++++++++++- src/odr/internal/odf/odf_style.cpp | 19 ++++++++++++++ src/odr/internal/odf/odf_style.hpp | 3 +++ test/data.cmake | 4 +-- 5 files changed, 62 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e01e34b..d3a744bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,8 @@ The release run heads these entries with the version and opens a fresh - A frame anchored to the page sits where the page says, whatever the text around it wraps like - a letter's address and date boxes land in their fields instead of in the running text. +- A text document is laid out on the master page it names, so a letter template + keeps the margins that leave room for its letterhead. ## v6.5.0 - 2026-08-10 diff --git a/src/odr/internal/odf/odf_document.cpp b/src/odr/internal/odf/odf_document.cpp index bd70201a..14f76fec 100644 --- a/src/odr/internal/odf/odf_document.cpp +++ b/src/odr/internal/odf/odf_document.cpp @@ -343,8 +343,43 @@ class ElementAdapter final : public abstract::ElementAdapter, } return {}; } + static bool is_anchored_frame(const pugi::xml_node node) { + return std::strcmp(node.name(), "draw:frame") == 0; + } + + /// Whether laying this out would put a word on the page; a frame is anchored, + /// not written. + static bool writes_text(const pugi::xml_node node) { + for (const pugi::xml_node child : node.children()) { + if (child.type() == pugi::node_pcdata && + !std::string_view(child.value()).empty()) { + return true; + } + if (!is_anchored_frame(child) && writes_text(child)) { + return true; + } + } + return false; + } + [[nodiscard]] ElementIdentifier text_root_first_master_page( - [[maybe_unused]] const ElementIdentifier element_id) const override { + const ElementIdentifier element_id) const override { + // A paragraph may name the master page its page uses (20.283). One page box + // is all we lay out, so only a name ahead of every written word counts. + for (const pugi::xml_node child : get_node(element_id).children()) { + if (is_anchored_frame(child)) { + continue; + } + if (const ElementIdentifier master_page_id = + m_document->style_registry().master_page_of_style( + child.attribute("text:style-name").value()); + master_page_id != null_element_id) { + return master_page_id; + } + if (writes_text(child)) { + break; + } + } return m_document->style_registry().first_master_page(); } diff --git a/src/odr/internal/odf/odf_style.cpp b/src/odr/internal/odf/odf_style.cpp index 7f2fda60..c5a0370b 100644 --- a/src/odr/internal/odf/odf_style.cpp +++ b/src/odr/internal/odf/odf_style.cpp @@ -688,4 +688,23 @@ ElementIdentifier StyleRegistry::first_master_page() const { return m_first_master_page_element; } +ElementIdentifier +StyleRegistry::master_page_of_style(const char *style_name) const { + // Bounded against a style naming itself as its own parent. + for (std::uint32_t depth = 0; depth < 16 && style_name[0] != '\0'; ++depth) { + const auto style_it = m_index_style.find(style_name); + if (style_it == std::end(m_index_style)) { + break; + } + const pugi::xml_node node = style_it->second; + if (const pugi::xml_attribute master_page_name = + node.attribute("style:master-page-name"); + master_page_name && master_page_name.value()[0] != '\0') { + return master_page(master_page_name.value()); + } + style_name = node.attribute("style:parent-style-name").value(); + } + return {}; +} + } // namespace odr::internal::odf diff --git a/src/odr/internal/odf/odf_style.hpp b/src/odr/internal/odf/odf_style.hpp index 3f6620ea..c9d15971 100644 --- a/src/odr/internal/odf/odf_style.hpp +++ b/src/odr/internal/odf/odf_style.hpp @@ -78,6 +78,9 @@ class StyleRegistry final { [[nodiscard]] ElementIdentifier master_page(const std::string &name) const; [[nodiscard]] ElementIdentifier first_master_page() const; + /// The master page a style or one of its bases names (20.283); null if none. + [[nodiscard]] ElementIdentifier + master_page_of_style(const char *style_name) const; private: std::unordered_map m_index_font_face; diff --git a/test/data.cmake b/test/data.cmake index 63114a56..feb0c849 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 "b8d4a6de30ba901dc120573e7ceb9ac4ecef71b7") + REVISION "7e3b094d53590374a19aa9cd9219c418740b72b5") odr_test_data( PATH "reference-output/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git" - REVISION "6d22a2435767a97f51fab53b2a6ac9f11ebd8198") + REVISION "3cd78a400b871854e1affb7b9209704b49a9c1d4")