From b4d806978a75e2eb467556494a97591b44c4c9cc Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Tue, 18 Aug 2026 20:56:11 +0200 Subject: [PATCH 1/2] feat(docx): space paragraphs and size table rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `w:spacing` becomes the paragraph's top and bottom margin and its line height, `w:trHeight` the table row's height, and `w:contextualSpacing` drops the spacing towards a neighbour of the same style so a list stays tight. A table now resolves its `w:tblStyle`, whose paragraph and text properties the element cascade carries into the table — without it the doc defaults would loosen every cell. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012jwYugqjwV6vTLJYRbFgtP --- CHANGELOG.md | 5 + src/odr/internal/html/document_style.cpp | 2 +- src/odr/internal/ooxml/ooxml_util.cpp | 23 ++++ src/odr/internal/ooxml/ooxml_util.hpp | 2 + src/odr/internal/ooxml/text/AGENTS.md | 20 ++- src/odr/internal/ooxml/text/README.md | 10 +- .../ooxml/text/ooxml_text_document.cpp | 3 + .../internal/ooxml/text/ooxml_text_style.cpp | 111 ++++++++++++++-- .../internal/ooxml/text/ooxml_text_style.hpp | 4 + test/data.cmake | 4 +- .../internal/ooxml/ooxml_text_style_test.cpp | 123 ++++++++++++++++++ 11 files changed, 286 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 639b7be53..933efcd57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,11 @@ The release run heads these entries with the version and opens a fresh named glyph variant such as `hyphen.case` is the one painted: a simple font's `/Encoding` names select through the font program's charset. - A pdf whose Flate streams omit the ADLER32 trailer opens. +- A `.docx` is spaced the way word spaces it: paragraph spacing before and after + and line height (`w:spacing`), the `w:contextualSpacing` that keeps a list + tight, and table row heights (`w:trHeight`, as a minimum height). +- A `.docx` table follows its table style: the paragraph and text properties + `w:tblStyle` names reach everything in the table. ## v6.8.0 - 2026-08-18 diff --git a/src/odr/internal/html/document_style.cpp b/src/odr/internal/html/document_style.cpp index b4fa4ed1a..5d4e3d0bd 100644 --- a/src/odr/internal/html/document_style.cpp +++ b/src/odr/internal/html/document_style.cpp @@ -286,7 +286,7 @@ html::translate_table_column_style(const TableColumnStyle &table_column_style) { std::string html::translate_table_row_style(const TableRowStyle &table_row_style) { std::string result; - // TODO that does not work with HTML; height would need to be applied to the + // html takes a row height as a minimum; capping one would have to go on the // cells if (const std::optional height = table_row_style.height; height.has_value()) { diff --git a/src/odr/internal/ooxml/ooxml_util.cpp b/src/odr/internal/ooxml/ooxml_util.cpp index 6ab1f8a3c..4aea768d2 100644 --- a/src/odr/internal/ooxml/ooxml_util.cpp +++ b/src/odr/internal/ooxml/ooxml_util.cpp @@ -147,6 +147,29 @@ std::optional ooxml::read_width_attribute(const pugi::xml_node node) { return {}; } +/// [ECMA-376] 17.17.4 ST_OnOff, as an attribute that is off when absent. +bool ooxml::read_on_off_attribute(const pugi::xml_attribute attribute) { + if (!attribute) { + return false; + } + const char *value = attribute.value(); + return std::strcmp("0", value) != 0 && std::strcmp("false", value) != 0 && + std::strcmp("off", value) != 0; +} + +/// [ECMA-376] 17.17.4 ST_OnOff, as an element that is on unless `w:val` says +/// otherwise. +bool ooxml::read_on_off_attribute(const pugi::xml_node node) { + if (!node) { + return false; + } + const pugi::xml_attribute value = node.attribute("w:val"); + if (!value) { + return true; + } + return read_on_off_attribute(value); +} + bool ooxml::read_line_attribute(const pugi::xml_node node) { if (!node) { return false; diff --git a/src/odr/internal/ooxml/ooxml_util.hpp b/src/odr/internal/ooxml/ooxml_util.hpp index 299e1344c..6f4ce0d5e 100644 --- a/src/odr/internal/ooxml/ooxml_util.hpp +++ b/src/odr/internal/ooxml/ooxml_util.hpp @@ -33,6 +33,8 @@ std::optional read_emus_attribute(pugi::xml_attribute); std::optional read_twips_attribute(pugi::xml_attribute); std::optional read_pct_attribute(pugi::xml_attribute); std::optional read_width_attribute(pugi::xml_node); +bool read_on_off_attribute(pugi::xml_attribute); +bool read_on_off_attribute(pugi::xml_node); bool read_line_attribute(pugi::xml_attribute); bool read_line_attribute(pugi::xml_node); std::optional read_shadow_attribute(pugi::xml_attribute); diff --git a/src/odr/internal/ooxml/text/AGENTS.md b/src/odr/internal/ooxml/text/AGENTS.md index c087fdf86..10439e358 100644 --- a/src/odr/internal/ooxml/text/AGENTS.md +++ b/src/odr/internal/ooxml/text/AGENTS.md @@ -54,7 +54,17 @@ Partial styles overlay a `wStyle` reference with the element's direct props — paragraphs additionally fold in the paragraph-mark run props (`w:pPr/w:rPr`). The *element-tree* cascade is then computed live: `get_intermediate_style` walks the element parent chain from docDefaults down, `.override()`-ing each partial. -Table styles are direct-only (no `w:tblStyle` reference resolution). +A table resolves its `w:tblStyle` the same way a paragraph resolves its +`w:pStyle`, and contributes the whole resolved style — a table style carries the +paragraph and text properties of everything inside the table, and the cascade is +what carries them down. Its conditional formats (`w:tblStylePr`) are ignored. + +**Contextual spacing is decided per paragraph, not per style.** +`w:contextualSpacing` drops the spacing towards a neighbouring paragraph of the +same style, which is what keeps a list tight, so it cannot live in the resolved +style: `partial_paragraph_style` compares the `w:pStyle` of the adjacent `w:p` +siblings and zeroes the margin it applies to. `Style` carries the flag +separately from its `ResolvedStyle` so an inherited one is seen. **Editing & save.** `is_editable` → true. `text_set_content` tokenises the new string and splices `w:t` (with `xml:space="preserve"` for spaces) / `w:tab` nodes @@ -87,7 +97,9 @@ Style/element coverage is in [`README.md`](README.md). Foundational gaps: 3. **Theme fonts unhandled.** `w:rFonts w:asciiTheme="minorHAnsi"` (etc.) is ignored — only literal `w:ascii` names are read (README example `Sample large docx.docx`). -4. **Style stubs**: `resolve_table_row_style_` and `resolve_graphic_style_` are - empty; table cell width is parsed but not applied; the `w:default="1"` style - flag is ignored. +4. **Style stubs**: `resolve_graphic_style_` is empty; table cell width is + parsed but not applied; the `w:default="1"` style flag is ignored. Paragraph + spacing reads `w:before`/`w:after`/`w:line` but not `w:beforeLines`/ + `w:afterLines`, and drops the value an autospacing flag shadows rather than + computing what word would. 5. **Comments / annotations** not modelled. diff --git a/src/odr/internal/ooxml/text/README.md b/src/odr/internal/ooxml/text/README.md index ec649652c..e3ec12619 100644 --- a/src/odr/internal/ooxml/text/README.md +++ b/src/odr/internal/ooxml/text/README.md @@ -54,12 +54,18 @@ Roughly ordered by importance. - [x] paragraph - [x] alignment - [x] indentation / left & right margins - - [ ] top / bottom margins, line height + - [x] top / bottom margins, line height (`w:spacing`, incl. + `w:contextualSpacing`) + - [ ] `w:beforeLines` / `w:afterLines`, autospacing (the value next to an + autospacing flag is dropped rather than computed) - [x] tables - [x] table width - [x] cell vertical alignment, borders + - [x] row height (`w:trHeight`, as a minimum — `w:hRule="exact"` is not) + - [x] table style reference (`w:tblStyle`, cascading its paragraph and text + properties into the table) - [ ] cell width (parsed but not applied) - - [ ] table row styles + - [ ] conditional table formatting (`w:tblStylePr`: banding, first row, …) - [x] page layout (`w:sectPr`: size, orientation, margins) - [ ] one layout per section; the first section's applies to the document - [ ] graphic / drawing styles diff --git a/src/odr/internal/ooxml/text/ooxml_text_document.cpp b/src/odr/internal/ooxml/text/ooxml_text_document.cpp index 1c5b2d47a..1ec69490d 100644 --- a/src/odr/internal/ooxml/text/ooxml_text_document.cpp +++ b/src/odr/internal/ooxml/text/ooxml_text_document.cpp @@ -671,6 +671,9 @@ class ElementAdapter final : public abstract::ElementAdapter, if (element.type == ElementType::span) { return m_document->style_registry().partial_text_style(element.node); } + if (element.type == ElementType::table) { + return m_document->style_registry().partial_table_style(element.node); + } return {}; } diff --git a/src/odr/internal/ooxml/text/ooxml_text_style.cpp b/src/odr/internal/ooxml/text/ooxml_text_style.cpp index f1eac2224..1486f667e 100644 --- a/src/odr/internal/ooxml/text/ooxml_text_style.cpp +++ b/src/odr/internal/ooxml/text/ooxml_text_style.cpp @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -75,6 +76,34 @@ void resolve_paragraph_style_(const pugi::xml_node node, paragraph_properties.child("w:ind").attribute("w:end"))) { result.margin.right = margin_right; } + + const pugi::xml_node spacing = paragraph_properties.child("w:spacing"); + + // an autospacing flag makes word compute the spacing itself and ignore the + // value next to it + if (!read_on_off_attribute(spacing.attribute("w:beforeAutospacing"))) { + if (const std::optional margin_top = + read_twips_attribute(spacing.attribute("w:before"))) { + result.margin.top = margin_top; + } + } + if (!read_on_off_attribute(spacing.attribute("w:afterAutospacing"))) { + if (const std::optional margin_bottom = + read_twips_attribute(spacing.attribute("w:after"))) { + result.margin.bottom = margin_bottom; + } + } + 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 + const char *line_rule = spacing.attribute("w:lineRule").value(); + if (std::strcmp("atLeast", line_rule) == 0 || + std::strcmp("exact", line_rule) == 0) { + result.line_height = read_twips_attribute(line); + } else { + result.line_height = Measure(line.as_double() / 2.4, DynamicUnit("%")); + } + } } void resolve_table_style_(const pugi::xml_node node, TableStyle &result) { @@ -86,10 +115,19 @@ void resolve_table_style_(const pugi::xml_node node, TableStyle &result) { } } -void resolve_table_row_style_(pugi::xml_node /*node*/, - TableRowStyle & /*result*/) { - // TODO - // auto table_row_properties = node.child("w:trPr"); +void resolve_table_row_style_(const pugi::xml_node node, + TableRowStyle &result) { + const pugi::xml_node table_row_properties = node.child("w:trPr"); + + // `auto` makes the row grow with its content, which is what html does anyway; + // word omits the rule for `atLeast`, so a bare `w:val` is a minimum height + const pugi::xml_node height = table_row_properties.child("w:trHeight"); + if (std::strcmp("auto", height.attribute("w:hRule").value()) != 0) { + if (const std::optional height_value = + read_twips_attribute(height.attribute("w:val"))) { + result.height = height_value; + } + } } void resolve_table_cell_style_(const pugi::xml_node node, @@ -127,6 +165,18 @@ void resolve_graphic_style_(pugi::xml_node, GraphicStyle &) { // TODO } +/// Whether `node` is a paragraph carrying the paragraph style `style_name` +/// names, an absent name matching an absent `w:pStyle`. +bool has_paragraph_style(const pugi::xml_node node, + const pugi::xml_attribute style_name) { + if (std::strcmp("w:p", node.name()) != 0) { + return false; + } + return std::strcmp( + node.child("w:pPr").child("w:pStyle").attribute("w:val").value(), + style_name.value()) == 0; +} + } // namespace Style::Style(const pugi::xml_node node) : m_node{node} { @@ -140,6 +190,7 @@ Style::Style(std::string name, const pugi::xml_node node, const Style *parent) : m_name{std::move(name)}, m_node{node}, m_parent{parent} { if (parent != nullptr) { m_resolved = parent->m_resolved; + m_contextual_spacing = parent->m_contextual_spacing; } resolve_style_(); @@ -151,7 +202,14 @@ const Style *Style::parent() const { return m_parent; } const ResolvedStyle &Style::resolved() const { return m_resolved; } +bool Style::contextual_spacing() const { return m_contextual_spacing; } + void Style::resolve_style_() { + if (const pugi::xml_node contextual_spacing = + m_node.child("w:pPr").child("w:contextualSpacing")) { + m_contextual_spacing = read_on_off_attribute(contextual_spacing); + } + resolve_text_style_(m_node, m_resolved.text_style); resolve_paragraph_style_(m_node, m_resolved.paragraph_style); resolve_table_style_(m_node, m_resolved.table_style); @@ -204,29 +262,58 @@ StyleRegistry::partial_text_style(const pugi::xml_node node) const { ResolvedStyle StyleRegistry::partial_paragraph_style(const pugi::xml_node node) const { + const pugi::xml_node paragraph_properties = node.child("w:pPr"); + const pugi::xml_attribute style_name = + paragraph_properties.child("w:pStyle").attribute("w:val"); + ResolvedStyle result; // TODO consider w:default="1" - if (const pugi::xml_attribute style_name = - node.child("w:pPr").child("w:pStyle").attribute("w:val")) { - if (const Style *style = this->style(style_name.value())) { - result = style->resolved(); - } + const Style *style = style_name ? this->style(style_name.value()) : nullptr; + if (style != nullptr) { + result = style->resolved(); } resolve_paragraph_style_(node, result.paragraph_style); - result.override(partial_text_style(node.child("w:pPr"))); + result.override(partial_text_style(paragraph_properties)); + + bool contextual_spacing = style != nullptr && style->contextual_spacing(); + if (const pugi::xml_node contextual_spacing_node = + paragraph_properties.child("w:contextualSpacing")) { + contextual_spacing = read_on_off_attribute(contextual_spacing_node); + } + if (contextual_spacing) { + // [ECMA-376] 17.3.1.9: the spacing towards a neighbouring paragraph of the + // same style is dropped, which is what keeps a list tight + const Measure none(0, DynamicUnit("in")); + if (has_paragraph_style(node.previous_sibling(), style_name)) { + result.paragraph_style.margin.top = none; + } + if (has_paragraph_style(node.next_sibling(), style_name)) { + result.paragraph_style.margin.bottom = none; + } + } return result; } ResolvedStyle StyleRegistry::partial_table_style(const pugi::xml_node node) const { ResolvedStyle result; + // a table style also carries the paragraph and text properties of everything + // in the table, which the element tree cascades down from here + if (const pugi::xml_attribute style_name = + node.child("w:tblPr").child("w:tblStyle").attribute("w:val")) { + if (const Style *style = this->style(style_name.value())) { + result = style->resolved(); + } + } resolve_table_style_(node, result.table_style); return result; } ResolvedStyle -StyleRegistry::partial_table_row_style(const pugi::xml_node) const { - return {}; +StyleRegistry::partial_table_row_style(const pugi::xml_node node) const { + ResolvedStyle result; + resolve_table_row_style_(node, result.table_row_style); + return result; } ResolvedStyle diff --git a/src/odr/internal/ooxml/text/ooxml_text_style.hpp b/src/odr/internal/ooxml/text/ooxml_text_style.hpp index ba7e94421..9658a5a17 100644 --- a/src/odr/internal/ooxml/text/ooxml_text_style.hpp +++ b/src/odr/internal/ooxml/text/ooxml_text_style.hpp @@ -18,6 +18,9 @@ class Style final { [[nodiscard]] const Style *parent() const; [[nodiscard]] const ResolvedStyle &resolved() const; + /// `w:contextualSpacing`, which is applied per paragraph and so is not part + /// of the resolved style. + [[nodiscard]] bool contextual_spacing() const; private: std::string m_name; @@ -25,6 +28,7 @@ class Style final { const Style *m_parent{nullptr}; ResolvedStyle m_resolved; + bool m_contextual_spacing{false}; void resolve_style_(); void resolve_default_style_(); diff --git a/test/data.cmake b/test/data.cmake index ac2734e82..f925e7d64 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 "4c90050f653012ac27a1c274dfad95c05b33624f") + REVISION "c667f1d619e31909ec81effe9148c79283291c01") odr_test_data( PATH "reference-output/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git" - REVISION "38975babdef0e35bc8fcb7d6f5c50d018740e7b7") + REVISION "fabb5651c6406570b735cf6acd1213a14f5b390c") diff --git a/test/src/internal/ooxml/ooxml_text_style_test.cpp b/test/src/internal/ooxml/ooxml_text_style_test.cpp index 7cdb826e7..739151450 100644 --- a/test/src/internal/ooxml/ooxml_text_style_test.cpp +++ b/test/src/internal/ooxml/ooxml_text_style_test.cpp @@ -37,6 +37,11 @@ StyleRegistry registry_of(const std::string &xml, return StyleRegistry(document.child("w:styles")); } +pugi::xml_node node_of(const char *xml, pugi::xml_document &document) { + EXPECT_TRUE(document.load_string(xml)); + return document.first_child(); +} + } // namespace TEST(ooxml_text_style, based_on_chain_inherits) { @@ -114,3 +119,121 @@ TEST(ooxml_text_style, unknown_based_on_target) { EXPECT_EQ(nullptr, style->parent()); EXPECT_EQ(nullptr, registry.style("gone")); } + +TEST(ooxml_text_style, paragraph_spacing) { + pugi::xml_document document; + const pugi::xml_node paragraph = node_of( + R"()", + document); + + const ParagraphStyle style = + StyleRegistry().partial_paragraph_style(paragraph).paragraph_style; + + ASSERT_TRUE(style.margin.top.has_value()); + EXPECT_EQ(Measure(240 / 1440.0, DynamicUnit("in")), *style.margin.top); + ASSERT_TRUE(style.margin.bottom.has_value()); + EXPECT_EQ(Measure(120 / 1440.0, DynamicUnit("in")), *style.margin.bottom); + // `w:line` without a rule is `auto`: 240ths of a line + ASSERT_TRUE(style.line_height.has_value()); + EXPECT_EQ(Measure(150, DynamicUnit("%")), *style.line_height); +} + +TEST(ooxml_text_style, paragraph_spacing_exact_line) { + pugi::xml_document document; + const pugi::xml_node paragraph = node_of( + R"()", + document); + + const ParagraphStyle style = + StyleRegistry().partial_paragraph_style(paragraph).paragraph_style; + + ASSERT_TRUE(style.line_height.has_value()); + EXPECT_EQ(Measure(480 / 1440.0, DynamicUnit("in")), *style.line_height); +} + +/// An autospacing flag makes word ignore the value written next to it. +TEST(ooxml_text_style, paragraph_spacing_autospacing) { + pugi::xml_document document; + const pugi::xml_node paragraph = node_of( + R"()", + document); + + const ParagraphStyle style = + StyleRegistry().partial_paragraph_style(paragraph).paragraph_style; + + EXPECT_FALSE(style.margin.top.has_value()); + EXPECT_FALSE(style.margin.bottom.has_value()); +} + +TEST(ooxml_text_style, table_row_height) { + pugi::xml_document document; + const pugi::xml_node row = node_of( + R"()", document); + + const TableRowStyle style = + StyleRegistry().partial_table_row_style(row).table_row_style; + + ASSERT_TRUE(style.height.has_value()); + EXPECT_EQ(Measure(720 / 1440.0, DynamicUnit("in")), *style.height); +} + +/// `auto` lets the row grow with its content, which html does on its own. +TEST(ooxml_text_style, table_row_height_auto) { + pugi::xml_document document; + const pugi::xml_node row = node_of( + R"()", + document); + + const TableRowStyle style = + StyleRegistry().partial_table_row_style(row).table_row_style; + + EXPECT_FALSE(style.height.has_value()); +} + +/// `w:contextualSpacing` drops the spacing towards a neighbour of the same +/// style, and only towards one. +TEST(ooxml_text_style, paragraph_contextual_spacing) { + pugi::xml_document document; + const pugi::xml_node body = node_of( + R"()" + R"()" + R"()" + R"()" + R"()", + document); + + const ParagraphStyle style = + StyleRegistry() + .partial_paragraph_style(body.last_child().previous_sibling()) + .paragraph_style; + + // the paragraph above is a `list` too, the one below is not + ASSERT_TRUE(style.margin.top.has_value()); + EXPECT_EQ(Measure(0, DynamicUnit("in")), *style.margin.top); + ASSERT_TRUE(style.margin.bottom.has_value()); + EXPECT_EQ(Measure(240 / 1440.0, DynamicUnit("in")), *style.margin.bottom); +} + +/// A table style also carries the properties of the paragraphs in the table. +TEST(ooxml_text_style, table_style_reference) { + pugi::xml_document document; + const StyleRegistry registry = registry_of( + R"()" + R"()" + R"()", + document); + + pugi::xml_document table_document; + const pugi::xml_node table = + node_of(R"()", + table_document); + + const ParagraphStyle style = + registry.partial_table_style(table).paragraph_style; + + ASSERT_TRUE(style.margin.bottom.has_value()); + EXPECT_EQ(Measure(0, DynamicUnit("in")), *style.margin.bottom); + ASSERT_TRUE(style.text_align.has_value()); + EXPECT_EQ(TextAlign::center, *style.text_align); +} From b1c25cdc37e7d3efee516d40c6a89ba478ad0a0a Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Tue, 18 Aug 2026 21:11:20 +0200 Subject: [PATCH 2/2] fix(docx): find a contextual-spacing neighbour past a wrapper A `w:sdt` renders as nothing but its children and a `w:bookmarkEnd` is not content at all, yet either sitting between two paragraphs of the same style kept `w:contextualSpacing` from seeing them as neighbours. The lookup now walks document order: it steps over marker elements, descends into the wrappers and climbs back out of them, and stops at a `w:tbl` or the end of the container so a paragraph never neighbours one outside it. Also records what `w:lineRule="atLeast"` does not do: css has no minimum line height, so it lowers to the same fixed `line-height` as `exact`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012jwYugqjwV6vTLJYRbFgtP --- src/odr/internal/ooxml/text/AGENTS.md | 6 ++- src/odr/internal/ooxml/text/README.md | 3 ++ .../internal/ooxml/text/ooxml_text_style.cpp | 54 ++++++++++++++++++- .../internal/ooxml/ooxml_text_style_test.cpp | 36 +++++++++++++ 4 files changed, 96 insertions(+), 3 deletions(-) diff --git a/src/odr/internal/ooxml/text/AGENTS.md b/src/odr/internal/ooxml/text/AGENTS.md index 10439e358..44b904ef5 100644 --- a/src/odr/internal/ooxml/text/AGENTS.md +++ b/src/odr/internal/ooxml/text/AGENTS.md @@ -101,5 +101,9 @@ Style/element coverage is in [`README.md`](README.md). Foundational gaps: parsed but not applied; the `w:default="1"` style flag is ignored. Paragraph spacing reads `w:before`/`w:after`/`w:line` but not `w:beforeLines`/ `w:afterLines`, and drops the value an autospacing flag shadows rather than - computing what word would. + computing what word would. `w:lineRule="atLeast"` lowers to the same fixed + `line-height` as `exact`, because css has no minimum: right where the value + exceeds the natural line, tight where a taller font would have grown it. + Expressing it needs a second field on the public `ParagraphStyle`, since the + renderer cannot tell the two rules apart from one `Measure`. 5. **Comments / annotations** not modelled. diff --git a/src/odr/internal/ooxml/text/README.md b/src/odr/internal/ooxml/text/README.md index e3ec12619..e1cf48fe4 100644 --- a/src/odr/internal/ooxml/text/README.md +++ b/src/odr/internal/ooxml/text/README.md @@ -58,6 +58,9 @@ Roughly ordered by importance. `w:contextualSpacing`) - [ ] `w:beforeLines` / `w:afterLines`, autospacing (the value next to an autospacing flag is dropped rather than computed) + - [ ] `w:lineRule="atLeast"` as a *minimum* — css has no minimum line + height, so the value is applied as the line height and a line whose font + is taller than it does not grow the way word grows it - [x] tables - [x] table width - [x] cell vertical alignment, borders diff --git a/src/odr/internal/ooxml/text/ooxml_text_style.cpp b/src/odr/internal/ooxml/text/ooxml_text_style.cpp index 1486f667e..b016a89d5 100644 --- a/src/odr/internal/ooxml/text/ooxml_text_style.cpp +++ b/src/odr/internal/ooxml/text/ooxml_text_style.cpp @@ -165,6 +165,56 @@ void resolve_graphic_style_(pugi::xml_node, GraphicStyle &) { // TODO } +/// A `w:sdt` and its `w:sdtContent` become a group, which the html renderer +/// writes as nothing but its children — the paragraphs around one are +/// neighbours on the page. +bool is_transparent_wrapper(const pugi::xml_node node) { + const char *name = node.name(); + return std::strcmp("w:sdt", name) == 0 || + std::strcmp("w:sdtContent", name) == 0; +} + +bool is_block(const pugi::xml_node node) { + const char *name = node.name(); + return std::strcmp("w:p", name) == 0 || std::strcmp("w:tbl", name) == 0; +} + +/// The block `node` puts on the given side, `node` itself unless it wraps one; +/// nothing for a marker element such as `w:bookmarkEnd`. +pugi::xml_node block_within(const pugi::xml_node node, const bool previous) { + if (!is_transparent_wrapper(node)) { + return is_block(node) ? node : pugi::xml_node(); + } + for (pugi::xml_node child = previous ? node.last_child() : node.first_child(); + child; + child = previous ? child.previous_sibling() : child.next_sibling()) { + if (const pugi::xml_node block = block_within(child, previous)) { + return block; + } + } + return {}; +} + +/// The block that neighbours `node` in document order, stepping over marker +/// elements and seeing through the wrappers. Stops at anything else — a cell +/// or the body end — so a paragraph never neighbours one outside its container. +pugi::xml_node block_neighbour(pugi::xml_node node, const bool previous) { + while (true) { + for (pugi::xml_node sibling = previous ? node.previous_sibling() + : node.next_sibling(); + sibling; sibling = previous ? sibling.previous_sibling() + : sibling.next_sibling()) { + if (const pugi::xml_node block = block_within(sibling, previous)) { + return block; + } + } + if (!is_transparent_wrapper(node.parent())) { + return {}; + } + node = node.parent(); + } +} + /// Whether `node` is a paragraph carrying the paragraph style `style_name` /// names, an absent name matching an absent `w:pStyle`. bool has_paragraph_style(const pugi::xml_node node, @@ -284,10 +334,10 @@ StyleRegistry::partial_paragraph_style(const pugi::xml_node node) const { // [ECMA-376] 17.3.1.9: the spacing towards a neighbouring paragraph of the // same style is dropped, which is what keeps a list tight const Measure none(0, DynamicUnit("in")); - if (has_paragraph_style(node.previous_sibling(), style_name)) { + if (has_paragraph_style(block_neighbour(node, true), style_name)) { result.paragraph_style.margin.top = none; } - if (has_paragraph_style(node.next_sibling(), style_name)) { + if (has_paragraph_style(block_neighbour(node, false), style_name)) { result.paragraph_style.margin.bottom = none; } } diff --git a/test/src/internal/ooxml/ooxml_text_style_test.cpp b/test/src/internal/ooxml/ooxml_text_style_test.cpp index 739151450..098f43c1c 100644 --- a/test/src/internal/ooxml/ooxml_text_style_test.cpp +++ b/test/src/internal/ooxml/ooxml_text_style_test.cpp @@ -237,3 +237,39 @@ TEST(ooxml_text_style, table_style_reference) { ASSERT_TRUE(style.text_align.has_value()); EXPECT_EQ(TextAlign::center, *style.text_align); } + +/// A `w:sdt` renders as nothing but its children, and a marker element is not +/// content either, so neither breaks the neighbourhood. +TEST(ooxml_text_style, paragraph_contextual_spacing_through_wrappers) { + pugi::xml_document document; + const pugi::xml_node body = node_of( + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()", + document); + + const StyleRegistry registry; + const ParagraphStyle inside_wrapper = + registry + .partial_paragraph_style( + body.child("w:sdt").child("w:sdtContent").child("w:p")) + .paragraph_style; + const ParagraphStyle after_wrapper = + registry.partial_paragraph_style(body.child("w:p")).paragraph_style; + + // the paragraph below reaches out of the wrapper and past the marker + ASSERT_TRUE(inside_wrapper.margin.bottom.has_value()); + EXPECT_EQ(Measure(0, DynamicUnit("in")), *inside_wrapper.margin.bottom); + // and is seen from the other side too, while the table below is not a + // paragraph of the same style + ASSERT_TRUE(after_wrapper.margin.top.has_value()); + EXPECT_EQ(Measure(0, DynamicUnit("in")), *after_wrapper.margin.top); + ASSERT_TRUE(after_wrapper.margin.bottom.has_value()); + EXPECT_EQ(Measure(240 / 1440.0, DynamicUnit("in")), + *after_wrapper.margin.bottom); +}