diff --git a/src/odr/internal/html/document_style.cpp b/src/odr/internal/html/document_style.cpp index ef159b49..2315c58b 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; } @@ -191,7 +211,21 @@ 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()) { + 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 203a0082..29174320 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 b3c9e103..1de85c0e 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 (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 @@ -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 ed01b127..6bafad9a 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 fe54381d..e48bb534 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,13 @@ 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 text font size; the HTML + // translation emits it as a unitless ratio + 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 96a95053..c10a8972 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 16a0c005..d298bf00 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/data/reference-output/odr-private b/test/data/reference-output/odr-private index ce0bc687..54d96fc2 160000 --- a/test/data/reference-output/odr-private +++ b/test/data/reference-output/odr-private @@ -1 +1 @@ -Subproject commit ce0bc68726928f9ca41f3a15f37928f53a7b9705 +Subproject commit 54d96fc254ccd78629facaa3d74e2934ba6e6ab0 diff --git a/test/data/reference-output/odr-public b/test/data/reference-output/odr-public index 5a443f6a..d5ae180a 160000 --- a/test/data/reference-output/odr-public +++ b/test/data/reference-output/odr-public @@ -1 +1 @@ -Subproject commit 5a443f6a4cc6ae7245359d35c1625a767bc2a20c +Subproject commit d5ae180a2992d28e3be2f6bdf6197b4be492898e diff --git a/test/src/document_test.cpp b/test/src/document_test.cpp index 9d301170..4d3e88a0 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);