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);