From 423c35174dcc49d7d07cc08338392245851a3b42 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 16 Jul 2026 20:56:08 +0200 Subject: [PATCH 1/5] feat(odf): sub/superscript, percent line-height, first-line indent - style:text-position: new public FontPosition enum + TextStyle::font_position; both value components are read - the position ("super"/"sub" or a signed percent offset) and the optional relative font size, which scales the inherited size like percent font-size does. Rendered as vertical-align. - fo:line-height: percent values were dropped; they are relative to the font size, matching CSS line-height semantics, so they now pass through. Percent margins remain dropped. - fo:text-indent: new ParagraphStyle::text_indent, rendered as text-indent. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KH4XvPFfdUvH7HMp8rmqZT --- src/odr/internal/html/document_style.cpp | 24 +++++++++ src/odr/internal/html/document_style.hpp | 2 + src/odr/internal/odf/AGENTS.md | 8 +-- src/odr/internal/odf/README.md | 8 +-- src/odr/internal/odf/odf_style.cpp | 57 ++++++++++++++++++-- src/odr/style.cpp | 6 +++ src/odr/style.hpp | 9 ++++ test/src/document_test.cpp | 69 ++++++++++++++++++++++++ 8 files changed, 173 insertions(+), 10 deletions(-) diff --git a/src/odr/internal/html/document_style.cpp b/src/odr/internal/html/document_style.cpp index ef159b49a..80086cbf0 100644 --- a/src/odr/internal/html/document_style.cpp +++ b/src/odr/internal/html/document_style.cpp @@ -71,6 +71,19 @@ const char *html::translate_font_style(const FontStyle font_style) { } } +const char *html::translate_font_position(const FontPosition font_position) { + switch (font_position) { + case FontPosition::normal: + return "baseline"; + case FontPosition::super: + return "super"; + case FontPosition::sub: + return "sub"; + default: + return ""; // TODO log + } +} + std::string html::translate_outer_page_style(const PageLayout &page_layout) { std::string result; if (const std::optional width = page_layout.width; @@ -153,6 +166,13 @@ std::string html::translate_text_style(const TextStyle &text_style) { .append(color(*background_color)) .append(";"); } + if (const std::optional font_position = + text_style.font_position; + font_position.has_value()) { + result.append("vertical-align:") + .append(translate_font_position(*font_position)) + .append(";"); + } return result; } @@ -193,6 +213,10 @@ html::translate_paragraph_style(const ParagraphStyle ¶graph_style) { line_height.has_value()) { result.append("line-height:").append(line_height->to_string()).append(";"); } + if (const std::optional text_indent = paragraph_style.text_indent; + text_indent.has_value()) { + result.append("text-indent:").append(text_indent->to_string()).append(";"); + } return result; } diff --git a/src/odr/internal/html/document_style.hpp b/src/odr/internal/html/document_style.hpp index 203a00820..29174320e 100644 --- a/src/odr/internal/html/document_style.hpp +++ b/src/odr/internal/html/document_style.hpp @@ -8,6 +8,7 @@ enum class HorizontalAlign; enum class VerticalAlign; enum class FontWeight; enum class FontStyle; +enum class FontPosition; class Frame; class Rect; @@ -31,6 +32,7 @@ const char *translate_horizontal_align(HorizontalAlign horizontal_align); const char *translate_vertical_align(VerticalAlign vertical_align); const char *translate_font_weight(FontWeight font_weight); const char *translate_font_style(FontStyle font_style); +const char *translate_font_position(FontPosition font_position); std::string translate_outer_page_style(const PageLayout &page_layout); std::string translate_inner_page_style(const PageLayout &page_layout); diff --git a/src/odr/internal/odf/AGENTS.md b/src/odr/internal/odf/AGENTS.md index b3c9e1037..8197c7a84 100644 --- a/src/odr/internal/odf/AGENTS.md +++ b/src/odr/internal/odf/AGENTS.md @@ -63,8 +63,10 @@ flatten-at-build, not a runtime walk. Parent beats family as the copy source. A Font names are indirected through `style:font-face` → `svg:font-family`. Master pages are parsed into the element tree *and* the style index. -**Percent units are special-cased**: percent font-size multiplies the inherited -size; percent margins/line-height are currently **dropped** (open work). +**Percent units are special-cased**: percent font-size (and the relative size +of `style:text-position`) multiplies the inherited size; percent line-height +passes through (CSS semantics match); percent margins are currently +**dropped** (open work). **Decryption is manifest-driven, two layouts.** `odf_crypto.cpp` supports either a single `encrypted-package` blob (decrypt → inflate → new ZIP filesystem) or @@ -112,7 +114,7 @@ The structural/foundational gaps, roughly by value: 5. **Repeated / covered spreadsheet cells are heuristic.** Several `// TODO covered cells` / `// TODO mark as repeated` in `odf_parser.cpp`; empty-row/cell detection is approximate. -6. **Style gaps beyond missing properties**: percent margins/line-height dropped; +6. **Style gaps beyond missing properties**: percent margins dropped; `transparent`/alpha colours → `nullopt`; the Style-vs-element cascade layering is provisional (`// TODO use override?`). List/outline numbering is indexed but not rendered as numbers. diff --git a/src/odr/internal/odf/README.md b/src/odr/internal/odf/README.md index ed01b127a..6bafad9ac 100644 --- a/src/odr/internal/odf/README.md +++ b/src/odr/internal/odf/README.md @@ -44,11 +44,13 @@ Roughly ordered by importance. - [x] underline, strike through - [x] color, background color - [x] shadow - - [ ] superscript, subscript (`style:text-position`) + - [x] superscript, subscript (`style:text-position`, incl. relative font + size) - [x] paragraph - [x] alignment - - [x] margins - - [x] line height + - [x] margins (percentages are dropped) + - [x] line height (absolute and percentage) + - [x] first line indent (`fo:text-indent`) - [x] links - [x] images - [x] internal and external references diff --git a/src/odr/internal/odf/odf_style.cpp b/src/odr/internal/odf/odf_style.cpp index fe54381df..331aabd4e 100644 --- a/src/odr/internal/odf/odf_style.cpp +++ b/src/odr/internal/odf/odf_style.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -59,6 +60,34 @@ std::optional read_font_style(const pugi::xml_attribute attribute) { return {}; } +/// first component of `style:text-position`: "super", "sub" or a signed +/// percent offset from the baseline +std::optional +read_font_position(const pugi::xml_attribute attribute) { + if (!attribute) { + return {}; + } + const char *value = attribute.value(); + if (std::strncmp("super", value, 5) == 0) { + return FontPosition::super; + } + if (std::strncmp("sub", value, 3) == 0) { + return FontPosition::sub; + } + char *end{nullptr}; + const double offset = std::strtod(value, &end); + if (end == value) { + return {}; + } + if (offset > 0) { + return FontPosition::super; + } + if (offset < 0) { + return FontPosition::sub; + } + return FontPosition::normal; +} + std::optional read_text_align(const pugi::xml_attribute attribute) { if (!attribute) { return {}; @@ -294,6 +323,24 @@ void Style::resolve_text_style_(const StyleRegistry *registry, read_color(text_properties.attribute("fo:background-color"))) { result.background_color = background_color; } + if (const pugi::xml_attribute text_position = + text_properties.attribute("style:text-position")) { + if (const std::optional font_position = + read_font_position(text_position)) { + result.font_position = font_position; + } + // optional second component: relative font size in percent + if (const char *scale = std::strchr(text_position.value(), ' '); + scale != nullptr && result.font_size.has_value()) { + char *end{nullptr}; + if (const double magnitude = std::strtod(scale + 1, &end); + end != scale + 1 && magnitude > 0) { + result.font_size = + Measure(result.font_size->magnitude() * magnitude * 1e-2, + result.font_size->unit()); + } + } + } } void Style::resolve_paragraph_style_(const pugi::xml_node node, @@ -342,10 +389,12 @@ void Style::resolve_paragraph_style_(const pugi::xml_node node, } if (const std::optional line_height = read_measure(paragraph_properties.attribute("fo:line-height"))) { - // TODO - if (line_height->unit().name() != "%") { - result.line_height = line_height; - } + // percent line height is relative to the font size, as in CSS + result.line_height = line_height; + } + if (const std::optional text_indent = + read_measure(paragraph_properties.attribute("fo:text-indent"))) { + result.text_indent = text_indent; } } diff --git a/src/odr/style.cpp b/src/odr/style.cpp index 96a950536..c10a8972f 100644 --- a/src/odr/style.cpp +++ b/src/odr/style.cpp @@ -68,6 +68,9 @@ void TextStyle::override(const TextStyle &other) { if (other.background_color.has_value()) { background_color = other.background_color; } + if (other.font_position.has_value()) { + font_position = other.font_position; + } } void ParagraphStyle::override(const ParagraphStyle &other) { @@ -78,6 +81,9 @@ void ParagraphStyle::override(const ParagraphStyle &other) { if (other.line_height.has_value()) { line_height = other.line_height; } + if (other.text_indent.has_value()) { + text_indent = other.text_indent; + } } void TableStyle::override(const TableStyle &other) { diff --git a/src/odr/style.hpp b/src/odr/style.hpp index 16a0c0056..d298bf00d 100644 --- a/src/odr/style.hpp +++ b/src/odr/style.hpp @@ -24,6 +24,13 @@ enum class FontStyle { italic, }; +/// @brief Collection of vertical font positions (sub/superscript). +enum class FontPosition { + normal, + super, + sub, +}; + /// @brief Collection of text alignments. enum class TextAlign { left, @@ -133,6 +140,7 @@ struct TextStyle final { std::optional font_shadow; std::optional font_color; std::optional background_color; + std::optional font_position; void override(const TextStyle &other); }; @@ -142,6 +150,7 @@ struct ParagraphStyle final { std::optional text_align; DirectionalStyle margin; std::optional line_height; + std::optional text_indent; void override(const ParagraphStyle &other); }; diff --git a/test/src/document_test.cpp b/test/src/document_test.cpp index 9d3011704..4d3e88a0d 100644 --- a/test/src/document_test.cpp +++ b/test/src/document_test.cpp @@ -13,6 +13,27 @@ using namespace odr; using namespace odr::test; +namespace { + +Element find_paragraph_with_text_prefix(const Element root, + const std::string &prefix) { + for (const Element child : root.children()) { + if (child.type() == ElementType::paragraph) { + if (const Element first_child = child.first_child(); + first_child && first_child.type() == ElementType::text && + first_child.as_text().content().starts_with(prefix)) { + return child; + } + } + if (const Element match = find_paragraph_with_text_prefix(child, prefix)) { + return match; + } + } + return {}; +} + +} // namespace + TEST(Document, odt) { const std::unique_ptr logger = Logger::create_stdio("odr-test", LogLevel::verbose); @@ -81,6 +102,54 @@ TEST(Document, odt_element_path2) { EXPECT_EQ(cell_via_path.as_text().content(), "B1"); } +TEST(Document, odt_text_position) { + const std::unique_ptr logger = + Logger::create_stdio("odr-test", LogLevel::verbose); + + const DocumentFile document_file( + TestData::test_file_path("odr-public/odt/style-various-1.odt"), *logger); + const Document document = document_file.document(); + const Element root = document.root_element(); + + const Element superscript = + find_paragraph_with_text_prefix(root, "Superscript"); + ASSERT_TRUE(superscript); + const TextStyle superscript_style = superscript.as_paragraph().text_style(); + EXPECT_EQ(FontPosition::super, superscript_style.font_position); + // `style:text-position="super 58%"` scales the inherited 12pt font + ASSERT_TRUE(superscript_style.font_size.has_value()); + EXPECT_NEAR(6.96, superscript_style.font_size->magnitude(), 1e-9); + + const Element subscript = find_paragraph_with_text_prefix(root, "Subscript"); + ASSERT_TRUE(subscript); + const TextStyle subscript_style = subscript.as_paragraph().text_style(); + EXPECT_EQ(FontPosition::sub, subscript_style.font_position); + + const Element normal = find_paragraph_with_text_prefix(root, "Default"); + ASSERT_TRUE(normal); + EXPECT_FALSE(normal.as_paragraph().text_style().font_position.has_value()); +} + +TEST(Document, odt_line_height_and_text_indent) { + const std::unique_ptr logger = + Logger::create_stdio("odr-test", LogLevel::verbose); + + const DocumentFile document_file( + TestData::test_file_path("odr-public/odt/file-sample_100kB.odt"), + *logger); + const Document document = document_file.document(); + const Element root = document.root_element(); + + const Element heading = + find_paragraph_with_text_prefix(root, "Lorem ipsum dolor"); + ASSERT_TRUE(heading); + EXPECT_EQ(Measure("0.25in"), heading.as_paragraph().style().text_indent); + + const Element body = find_paragraph_with_text_prefix(root, "Morbi viverra"); + ASSERT_TRUE(body); + EXPECT_EQ(Measure("120%"), body.as_paragraph().style().line_height); +} + TEST(Document, odg) { const std::unique_ptr logger = Logger::create_stdio("odr-test", LogLevel::verbose); From e9c634b4abf4d6283483411c02c4b91ec56ca7cd Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 16 Jul 2026 21:20:59 +0200 Subject: [PATCH 2/5] fix(html): emit percent line-height as unitless ratio Addresses review feedback on #611: a CSS percentage line-height on the x-p block resolves against the block's (inherited) font size, while the document text font size is emitted on nested x-s spans. A unitless ratio inherits per element and recomputes against each element's own font size, matching ODF percent line-height semantics. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AEvWWwe1EyeWH7ANHoAvPH --- src/odr/internal/html/document_style.cpp | 12 +++++++++++- src/odr/internal/odf/AGENTS.md | 4 ++-- src/odr/internal/odf/odf_style.cpp | 3 ++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/odr/internal/html/document_style.cpp b/src/odr/internal/html/document_style.cpp index 80086cbf0..2315c58b4 100644 --- a/src/odr/internal/html/document_style.cpp +++ b/src/odr/internal/html/document_style.cpp @@ -211,7 +211,17 @@ html::translate_paragraph_style(const ParagraphStyle ¶graph_style) { } if (const std::optional line_height = paragraph_style.line_height; line_height.has_value()) { - result.append("line-height:").append(line_height->to_string()).append(";"); + // a percent line height is relative to the text font size, which is + // emitted on nested text spans; a CSS percentage would resolve against + // this block's font size, while a unitless ratio inherits per element + if (line_height->unit().name() == "%") { + const Measure ratio(line_height->magnitude() * 1e-2, DynamicUnit("")); + result.append("line-height:").append(ratio.to_string()).append(";"); + } else { + result.append("line-height:") + .append(line_height->to_string()) + .append(";"); + } } if (const std::optional text_indent = paragraph_style.text_indent; text_indent.has_value()) { diff --git a/src/odr/internal/odf/AGENTS.md b/src/odr/internal/odf/AGENTS.md index 8197c7a84..1de85c0e5 100644 --- a/src/odr/internal/odf/AGENTS.md +++ b/src/odr/internal/odf/AGENTS.md @@ -65,8 +65,8 @@ Master pages are parsed into the element tree *and* the style index. **Percent units are special-cased**: percent font-size (and the relative size of `style:text-position`) multiplies the inherited size; percent line-height -passes through (CSS semantics match); percent margins are currently -**dropped** (open work). +passes through (the HTML renderer emits it as a unitless CSS ratio); percent +margins are currently **dropped** (open work). **Decryption is manifest-driven, two layouts.** `odf_crypto.cpp` supports either a single `encrypted-package` blob (decrypt → inflate → new ZIP filesystem) or diff --git a/src/odr/internal/odf/odf_style.cpp b/src/odr/internal/odf/odf_style.cpp index 331aabd4e..e48bb534e 100644 --- a/src/odr/internal/odf/odf_style.cpp +++ b/src/odr/internal/odf/odf_style.cpp @@ -389,7 +389,8 @@ void Style::resolve_paragraph_style_(const pugi::xml_node node, } if (const std::optional line_height = read_measure(paragraph_properties.attribute("fo:line-height"))) { - // percent line height is relative to the font size, as in CSS + // percent line height is relative to the text font size; the HTML + // translation emits it as a unitless ratio result.line_height = line_height; } if (const std::optional text_indent = From c5adb3a5542aff995064e0f3e732e115468b35e6 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 16 Jul 2026 21:26:23 +0200 Subject: [PATCH 3/5] test: update ODF reference outputs for style changes Regenerated after the sub/superscript, percent line-height (now emitted as a unitless ratio), and first-line indent changes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AEvWWwe1EyeWH7ANHoAvPH --- test/data/reference-output/odr-private | 2 +- test/data/reference-output/odr-public | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/data/reference-output/odr-private b/test/data/reference-output/odr-private index ce0bc6872..cbc4cb6ba 160000 --- a/test/data/reference-output/odr-private +++ b/test/data/reference-output/odr-private @@ -1 +1 @@ -Subproject commit ce0bc68726928f9ca41f3a15f37928f53a7b9705 +Subproject commit cbc4cb6bac86488eacc0f7f91125a1b9d069e6f5 diff --git a/test/data/reference-output/odr-public b/test/data/reference-output/odr-public index 5a443f6a4..d6b2cbaf5 160000 --- a/test/data/reference-output/odr-public +++ b/test/data/reference-output/odr-public @@ -1 +1 @@ -Subproject commit 5a443f6a4cc6ae7245359d35c1625a767bc2a20c +Subproject commit d6b2cbaf5169ed4fdb4796e3dd3947da2aeb9efc From 148e6932d22d07f2b310ef9f8b270fab04602f03 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 16 Jul 2026 21:54:56 +0200 Subject: [PATCH 4/5] update refs --- test/data/reference-output/odr-private | 2 +- test/data/reference-output/odr-public | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/data/reference-output/odr-private b/test/data/reference-output/odr-private index cbc4cb6ba..39657e818 160000 --- a/test/data/reference-output/odr-private +++ b/test/data/reference-output/odr-private @@ -1 +1 @@ -Subproject commit cbc4cb6bac86488eacc0f7f91125a1b9d069e6f5 +Subproject commit 39657e8189ef916e876443647c68475cba75361c diff --git a/test/data/reference-output/odr-public b/test/data/reference-output/odr-public index d6b2cbaf5..c63bab13a 160000 --- a/test/data/reference-output/odr-public +++ b/test/data/reference-output/odr-public @@ -1 +1 @@ -Subproject commit d6b2cbaf5169ed4fdb4796e3dd3947da2aeb9efc +Subproject commit c63bab13af2e0f2cac853ae71917cda2be3f3042 From bcbd419891c026eb9f77fd54b19d230c2015f6eb Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 16 Jul 2026 22:31:58 +0200 Subject: [PATCH 5/5] update refs again --- test/data/reference-output/odr-private | 2 +- test/data/reference-output/odr-public | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/data/reference-output/odr-private b/test/data/reference-output/odr-private index 39657e818..54d96fc25 160000 --- a/test/data/reference-output/odr-private +++ b/test/data/reference-output/odr-private @@ -1 +1 @@ -Subproject commit 39657e8189ef916e876443647c68475cba75361c +Subproject commit 54d96fc254ccd78629facaa3d74e2934ba6e6ab0 diff --git a/test/data/reference-output/odr-public b/test/data/reference-output/odr-public index c63bab13a..d5ae180a2 160000 --- a/test/data/reference-output/odr-public +++ b/test/data/reference-output/odr-public @@ -1 +1 @@ -Subproject commit c63bab13af2e0f2cac853ae71917cda2be3f3042 +Subproject commit d5ae180a2992d28e3be2f6bdf6197b4be492898e