From bf253dd9d93c19618780b747e61be88609b1d90d Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 5 Sep 2026 21:42:41 +0200 Subject: [PATCH 1/3] feat(style): render a right-to-left document right-to-left `style:writing-mode`, `w:bidi` and `a:pPr@rtl` are read into a new `TextDirection` on `ParagraphStyle` and `PageLayout`. The view's root carries the document's as ``; a paragraph states its own only where it differs, which keeps `direction:ltr` off every docx paragraph. `w:jc`'s `start`/`end` stop resolving to `left`/`right`: they follow the paragraph's direction, so a right-to-left `start` paragraph rendered flush left where Word flushes it right. `TextAlign` gains the two values and they reach the css as `text-align:start`/`end`. `fo:text-align`'s same-named values stay absolute. [OpenDocument] 20.386 calls them direction-relative, but no producer writes them that way - LibreOffice inverts the two against `w:jc` on a round-trip, so a `w:jc="start"` paragraph it flushes right comes back out as `fo:text-align="end"`. Towards opendocument-app/OpenDocument.droid#653. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TP23uvwVtgEzZZTJnWsJC2 --- CHANGELOG.md | 12 ++ apple/include/OdrCoreObjC/ODRStyle.h | 13 ++ apple/src/ODRStyle.mm | 7 + apple/swift/Style+Optionals.swift | 6 + jni/CMakeLists.txt | 1 + .../app/opendocument/core/PageLayout.java | 6 +- .../app/opendocument/core/ParagraphStyle.java | 4 + jni/java/app/opendocument/core/TextAlign.java | 2 +- .../app/opendocument/core/TextDirection.java | 14 ++ jni/src/jni_style.cpp | 9 +- python/src/bind_style.cpp | 12 +- src/odr/internal/html/common.hpp | 6 + src/odr/internal/html/document.cpp | 39 +++++- src/odr/internal/html/document_element.cpp | 3 +- src/odr/internal/html/document_style.cpp | 24 +++- src/odr/internal/html/document_style.hpp | 6 +- src/odr/internal/html/html_writer.cpp | 6 +- src/odr/internal/html/html_writer.hpp | 2 +- src/odr/internal/odf/README.md | 6 +- src/odr/internal/odf/odf_style.cpp | 25 ++++ src/odr/internal/ooxml/ooxml_util.cpp | 29 +++- src/odr/internal/ooxml/ooxml_util.hpp | 5 + src/odr/internal/ooxml/presentation/README.md | 1 + .../presentation/ooxml_presentation_style.cpp | 5 + src/odr/internal/ooxml/text/README.md | 3 +- .../ooxml/text/ooxml_text_document.cpp | 3 + .../internal/ooxml/text/ooxml_text_style.cpp | 4 + src/odr/style.cpp | 1 + src/odr/style.hpp | 17 +++ test/src/internal/odf/odf_flat_file_test.cpp | 127 ++++++++++++++++++ .../ooxml/ooxml_presentation_style_test.cpp | 16 +++ .../internal/ooxml/ooxml_text_style_test.cpp | 49 +++++++ 32 files changed, 442 insertions(+), 21 deletions(-) create mode 100644 jni/java/app/opendocument/core/TextDirection.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c26a6ae3..64ff6ff5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,18 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- A right-to-left document renders right-to-left. `style:writing-mode`, + `w:bidi` and `a:pPr@rtl` are read into a new `TextDirection` on + `ParagraphStyle` and `PageLayout`, and the view's root carries it as + ``. Towards + [OpenDocument.droid#653](https://github.com/opendocument-app/OpenDocument.droid/issues/653). + +- **Breaking**: `TextAlign` gains `start` and `end`, which `w:jc` now resolves + to instead of `left` / `right`; they follow the paragraph's direction. + `fo:text-align`'s same-named values stay absolute. Existing enumerators keep + their values, so only an exhaustive `switch` needs changing. Mirrored in + every binding. + - A printed sheet drops our row/column ruler and is capped to the page width rather than cut off at the right edge. Print only; the on-screen view is unchanged. Towards #816. diff --git a/apple/include/OdrCoreObjC/ODRStyle.h b/apple/include/OdrCoreObjC/ODRStyle.h index e1320289a..73121bdac 100644 --- a/apple/include/OdrCoreObjC/ODRStyle.h +++ b/apple/include/OdrCoreObjC/ODRStyle.h @@ -26,13 +26,22 @@ typedef NS_ENUM(NSInteger, ODRBreakType) { ODRBreakTypeColumn, } NS_SWIFT_NAME(BreakType); +/// `Start`/`End` name the edge `ODRTextDirection` decides. typedef NS_ENUM(NSInteger, ODRTextAlign) { ODRTextAlignLeft = 0, ODRTextAlignRight, ODRTextAlignCenter, ODRTextAlignJustify, + ODRTextAlignStart, + ODRTextAlignEnd, } NS_SWIFT_NAME(TextAlign); +/// The base direction a line of text runs in. +typedef NS_ENUM(NSInteger, ODRTextDirection) { + ODRTextDirectionLeftToRight = 0, + ODRTextDirectionRightToLeft, +} NS_SWIFT_NAME(TextDirection); + typedef NS_ENUM(NSInteger, ODRHorizontalAlign) { ODRHorizontalAlignLeft = 0, ODRHorizontalAlignCenter, @@ -161,6 +170,8 @@ NS_SWIFT_NAME(ParagraphStyle) @interface ODRParagraphStyle : NSObject /// `ODRTextAlign`, boxed. @property(nonatomic, readonly, nullable) NSNumber *textAlign; +/// `ODRTextDirection`, boxed; `nil` where the style says nothing. +@property(nonatomic, readonly, nullable) NSNumber *direction; @property(nonatomic, readonly) ODRDirectionalMeasure *margin; @property(nonatomic, readonly, nullable) ODRMeasure *lineHeight; @property(nonatomic, readonly, nullable) ODRMeasure *textIndent; @@ -250,6 +261,8 @@ NS_SWIFT_NAME(PageLayout) @property(nonatomic, readonly) ODRDirectionalMeasure *margin; /// `ODRColor`, boxed in an `NSValue`. @property(nonatomic, readonly, nullable) NSValue *backgroundColor; +/// `ODRTextDirection`, boxed; `nil` where the layout says nothing. +@property(nonatomic, readonly, nullable) NSNumber *direction; - (instancetype)init NS_UNAVAILABLE; + (instancetype)new NS_UNAVAILABLE; diff --git a/apple/src/ODRStyle.mm b/apple/src/ODRStyle.mm index 13a509935..788d59395 100644 --- a/apple/src/ODRStyle.mm +++ b/apple/src/ODRStyle.mm @@ -25,6 +25,11 @@ ODR_SAME_ENUM(ODRTextAlignRight, odr::TextAlign::right); ODR_SAME_ENUM(ODRTextAlignCenter, odr::TextAlign::center); ODR_SAME_ENUM(ODRTextAlignJustify, odr::TextAlign::justify); +ODR_SAME_ENUM(ODRTextAlignStart, odr::TextAlign::start); +ODR_SAME_ENUM(ODRTextAlignEnd, odr::TextAlign::end); + +ODR_SAME_ENUM(ODRTextDirectionLeftToRight, odr::TextDirection::left_to_right); +ODR_SAME_ENUM(ODRTextDirectionRightToLeft, odr::TextDirection::right_to_left); ODR_SAME_ENUM(ODRHorizontalAlignLeft, odr::HorizontalAlign::left); ODR_SAME_ENUM(ODRHorizontalAlignCenter, odr::HorizontalAlign::center); ODR_SAME_ENUM(ODRHorizontalAlignRight, odr::HorizontalAlign::right); @@ -216,6 +221,7 @@ @implementation ODRParagraphStyle + (instancetype)styleWithHandle:(const odr::ParagraphStyle &)handle { ODRParagraphStyle *const result = [[ODRParagraphStyle alloc] init]; result->_textAlign = box_enum(handle.text_align); + result->_direction = box_enum(handle.direction); result->_margin = [ODRDirectionalMeasure directionalWithHandle:handle.margin]; result->_lineHeight = box(handle.line_height); result->_textIndent = box(handle.text_indent); @@ -299,6 +305,7 @@ + (instancetype)layoutWithHandle:(const odr::PageLayout &)handle { result->_printOrientation = box_enum(handle.print_orientation); result->_margin = [ODRDirectionalMeasure directionalWithHandle:handle.margin]; result->_backgroundColor = box(handle.background_color); + result->_direction = box_enum(handle.direction); return result; } diff --git a/apple/swift/Style+Optionals.swift b/apple/swift/Style+Optionals.swift index 199c940ad..7c9030ab6 100644 --- a/apple/swift/Style+Optionals.swift +++ b/apple/swift/Style+Optionals.swift @@ -38,6 +38,9 @@ extension TextStyle { extension ParagraphStyle { public var alignment: TextAlign? { textAlign?.asEnum(TextAlign.self) } + public var baseDirection: TextDirection? { + direction?.asEnum(TextDirection.self) + } } extension TableCellStyle { @@ -64,6 +67,9 @@ extension PageLayout { printOrientation?.asEnum(PrintOrientation.self) } public var background: Color? { backgroundColor?.asColor } + public var baseDirection: TextDirection? { + direction?.asEnum(TextDirection.self) + } } extension Frame { diff --git a/jni/CMakeLists.txt b/jni/CMakeLists.txt index 56229c119..a0b330638 100644 --- a/jni/CMakeLists.txt +++ b/jni/CMakeLists.txt @@ -157,6 +157,7 @@ add_jar(odr_java "java/app/opendocument/core/TableStyle.java" "java/app/opendocument/core/Text.java" "java/app/opendocument/core/TextAlign.java" + "java/app/opendocument/core/TextDirection.java" "java/app/opendocument/core/TextFile.java" "java/app/opendocument/core/TextRoot.java" "java/app/opendocument/core/TextStyle.java" diff --git a/jni/java/app/opendocument/core/PageLayout.java b/jni/java/app/opendocument/core/PageLayout.java index d6447f3be..a3e25a60a 100644 --- a/jni/java/app/opendocument/core/PageLayout.java +++ b/jni/java/app/opendocument/core/PageLayout.java @@ -7,17 +7,21 @@ public final class PageLayout { public final PrintOrientation printOrientation; public final DirectionalMeasure margin; public final Color backgroundColor; + /** The base direction the page's text runs in; {@code null} if the layout says nothing. */ + public final TextDirection direction; PageLayout( Measure width, Measure height, int printOrientation, DirectionalMeasure margin, - Color backgroundColor) { + Color backgroundColor, + int direction) { this.width = width; this.height = height; this.printOrientation = PrintOrientation.fromNative(printOrientation); this.margin = margin; this.backgroundColor = backgroundColor; + this.direction = TextDirection.fromNative(direction); } } diff --git a/jni/java/app/opendocument/core/ParagraphStyle.java b/jni/java/app/opendocument/core/ParagraphStyle.java index 3c3b09646..9afe55a34 100644 --- a/jni/java/app/opendocument/core/ParagraphStyle.java +++ b/jni/java/app/opendocument/core/ParagraphStyle.java @@ -3,6 +3,8 @@ /** Style of a paragraph. Mirrors {@code odr::ParagraphStyle}; fields may be {@code null}. */ public final class ParagraphStyle { public final TextAlign textAlign; + /** The base direction the paragraph's text runs in; {@code null} if the style says nothing. */ + public final TextDirection direction; public final DirectionalMeasure margin; public final Measure lineHeight; public final Measure textIndent; @@ -13,12 +15,14 @@ public final class ParagraphStyle { ParagraphStyle( int textAlign, + int direction, DirectionalMeasure margin, Measure lineHeight, Measure textIndent, int breakBefore, int breakAfter) { this.textAlign = TextAlign.fromNative(textAlign); + this.direction = TextDirection.fromNative(direction); this.margin = margin; this.lineHeight = lineHeight; this.textIndent = textIndent; diff --git a/jni/java/app/opendocument/core/TextAlign.java b/jni/java/app/opendocument/core/TextAlign.java index 74c587930..93ea5d946 100644 --- a/jni/java/app/opendocument/core/TextAlign.java +++ b/jni/java/app/opendocument/core/TextAlign.java @@ -2,7 +2,7 @@ /** Mirrors {@code odr::TextAlign}; constant order must match the C++ declaration. */ public enum TextAlign { - LEFT, RIGHT, CENTER, JUSTIFY; + LEFT, RIGHT, CENTER, JUSTIFY, START, END; static TextAlign fromNative(int code) { return code < 0 ? null : values()[code]; diff --git a/jni/java/app/opendocument/core/TextDirection.java b/jni/java/app/opendocument/core/TextDirection.java new file mode 100644 index 000000000..b7be9165a --- /dev/null +++ b/jni/java/app/opendocument/core/TextDirection.java @@ -0,0 +1,14 @@ +package app.opendocument.core; + +/** Mirrors {@code odr::TextDirection}; constant order must match the C++ declaration. */ +public enum TextDirection { + LEFT_TO_RIGHT, RIGHT_TO_LEFT; + + static TextDirection fromNative(int code) { + return code < 0 ? null : values()[code]; + } + + int toNative() { + return ordinal(); + } +} diff --git a/jni/src/jni_style.cpp b/jni/src/jni_style.cpp index 913f67c30..121162d2b 100644 --- a/jni/src/jni_style.cpp +++ b/jni/src/jni_style.cpp @@ -272,9 +272,10 @@ jobject make_text_style(JNIEnv *env, const odr::TextStyle &style) { jobject make_paragraph_style(JNIEnv *env, const odr::ParagraphStyle &style) { return new_object( env, "app/opendocument/core/ParagraphStyle", - "(ILapp/opendocument/core/DirectionalMeasure;" + "(IILapp/opendocument/core/DirectionalMeasure;" "Lapp/opendocument/core/Measure;Lapp/opendocument/core/Measure;II)V", - enum_code(style.text_align), make_directional_measure(env, style.margin), + enum_code(style.text_align), enum_code(style.direction), + make_directional_measure(env, style.margin), make_measure(env, style.line_height), make_measure(env, style.text_indent), enum_code(style.break_before), enum_code(style.break_after)); @@ -333,11 +334,11 @@ jobject make_page_layout(JNIEnv *env, const odr::PageLayout &layout) { env, "app/opendocument/core/PageLayout", "(Lapp/opendocument/core/Measure;Lapp/opendocument/core/Measure;I" "Lapp/opendocument/core/DirectionalMeasure;" - "Lapp/opendocument/core/Color;)V", + "Lapp/opendocument/core/Color;I)V", make_measure(env, layout.width), make_measure(env, layout.height), enum_code(layout.print_orientation), make_directional_measure(env, layout.margin), - make_color(env, layout.background_color)); + make_color(env, layout.background_color), enum_code(layout.direction)); } jobject make_table_dimensions(JNIEnv *env, diff --git a/python/src/bind_style.cpp b/python/src/bind_style.cpp index caf5d366f..971b6ab46 100644 --- a/python/src/bind_style.cpp +++ b/python/src/bind_style.cpp @@ -47,7 +47,13 @@ void odr_python::bind_style(py::module_ &m) { .value("left", odr::TextAlign::left) .value("right", odr::TextAlign::right) .value("center", odr::TextAlign::center) - .value("justify", odr::TextAlign::justify); + .value("justify", odr::TextAlign::justify) + .value("start", odr::TextAlign::start) + .value("end", odr::TextAlign::end); + + py::enum_(m, "TextDirection") + .value("left_to_right", odr::TextDirection::left_to_right) + .value("right_to_left", odr::TextDirection::right_to_left); py::enum_(m, "HorizontalAlign") .value("left", odr::HorizontalAlign::left) @@ -149,6 +155,7 @@ void odr_python::bind_style(py::module_ &m) { py::class_(m, "ParagraphStyle") .def(py::init<>()) .def_readwrite("text_align", &odr::ParagraphStyle::text_align) + .def_readwrite("direction", &odr::ParagraphStyle::direction) .def_readwrite("margin", &odr::ParagraphStyle::margin) .def_readwrite("line_height", &odr::ParagraphStyle::line_height) .def_readwrite("text_indent", &odr::ParagraphStyle::text_indent) @@ -197,5 +204,6 @@ void odr_python::bind_style(py::module_ &m) { .def_readwrite("height", &odr::PageLayout::height) .def_readwrite("print_orientation", &odr::PageLayout::print_orientation) .def_readwrite("margin", &odr::PageLayout::margin) - .def_readwrite("background_color", &odr::PageLayout::background_color); + .def_readwrite("background_color", &odr::PageLayout::background_color) + .def_readwrite("direction", &odr::PageLayout::direction); } diff --git a/src/odr/internal/html/common.hpp b/src/odr/internal/html/common.hpp index 1496840f7..79ec37f4f 100644 --- a/src/odr/internal/html/common.hpp +++ b/src/odr/internal/html/common.hpp @@ -9,6 +9,7 @@ #include #include #include +#include namespace odr { struct Color; @@ -34,11 +35,16 @@ struct WritingState { [[nodiscard]] HtmlResources &resources() const { return *m_resources; } [[nodiscard]] const Logger &logger() const { return *m_logger; } + /// The view's base direction, stated on its root. + [[nodiscard]] TextDirection direction() const { return m_direction; } + void set_direction(const TextDirection direction) { m_direction = direction; } + private: HtmlWriter *m_out; const HtmlConfig *m_config; HtmlResources *m_resources; const Logger *m_logger; + TextDirection m_direction{TextDirection::left_to_right}; }; /// Writes the viewport meta tag. Precedence: `config.viewport_content` (raw, diff --git a/src/odr/internal/html/document.cpp b/src/odr/internal/html/document.cpp index 2f2566c8a..e0867e269 100644 --- a/src/odr/internal/html/document.cpp +++ b/src/odr/internal/html/document.cpp @@ -111,6 +111,38 @@ std::optional document_content_pixels(const Document &document, return result; } +/// A spreadsheet states its direction per table, which is not read yet. +TextDirection document_direction(const Document &document) { + const Element root = document.root_element(); + + std::optional result; + switch (document.document_type()) { + case DocumentType::text: + result = root.as_text_root().page_layout().direction; + break; + case DocumentType::presentation: + for (const Element child : root.children()) { + result = child.as_slide().page_layout().direction; + if (result.has_value()) { + break; + } + } + break; + case DocumentType::drawing: + for (const Element child : root.children()) { + result = child.as_page().page_layout().direction; + if (result.has_value()) { + break; + } + } + break; + default: + break; + } + + return result.value_or(TextDirection::left_to_right); +} + /// A spreadsheet answers the viewport question with its own mode. std::optional viewport_mode_override(const Document &document, const HtmlConfig &config) { @@ -121,14 +153,17 @@ viewport_mode_override(const Document &document, const HtmlConfig &config) { /// @p name titles the view; empty when the whole document is written as one /// file, which no one view names. -void front(const Document &document, const WritingState &state, +void front(const Document &document, WritingState &state, const std::string &name, const std::optional content_pixels) { HtmlWriter &out = state.out(); const bool paged_content = is_paged_content(document, state.config()); - out.write_begin(); + state.set_direction(document_direction(document)); + + out.write_begin(HtmlElementOptions().set_attributes(HtmlAttributesVector{ + {"dir", translate_text_direction(state.direction())}})); out.write_header_begin(); out.write_header_charset("UTF-8"); out.write_header_title( diff --git a/src/odr/internal/html/document_element.cpp b/src/odr/internal/html/document_element.cpp index cbf35ebd3..b9a1d5c7d 100644 --- a/src/odr/internal/html/document_element.cpp +++ b/src/odr/internal/html/document_element.cpp @@ -454,7 +454,8 @@ void html::translate_paragraph(const Element &element, state.out().write_element_begin( "x-p", HtmlElementOptions().set_inline(true).set_style( - "display:block;" + translate_paragraph_style(paragraph.style()) + + "display:block;" + + translate_paragraph_style(paragraph.style(), state.direction()) + translate_block_font_style(paragraph.text_style()))); if (!marker.empty()) { state.out().write_element_begin( diff --git a/src/odr/internal/html/document_style.cpp b/src/odr/internal/html/document_style.cpp index 3ecd6c96a..5293557c1 100644 --- a/src/odr/internal/html/document_style.cpp +++ b/src/odr/internal/html/document_style.cpp @@ -19,6 +19,21 @@ const char *html::translate_text_align(const TextAlign text_align) { return "center"; case TextAlign::justify: return "justify"; + case TextAlign::start: + return "start"; + case TextAlign::end: + return "end"; + default: + return ""; // TODO log + } +} + +const char *html::translate_text_direction(const TextDirection direction) { + switch (direction) { + case TextDirection::left_to_right: + return "ltr"; + case TextDirection::right_to_left: + return "rtl"; default: return ""; // TODO log } @@ -232,8 +247,15 @@ std::string html::translate_block_font_style(const TextStyle &text_style) { } std::string -html::translate_paragraph_style(const ParagraphStyle ¶graph_style) { +html::translate_paragraph_style(const ParagraphStyle ¶graph_style, + const TextDirection base) { std::string result; + if (const std::optional direction = paragraph_style.direction; + direction.has_value() && *direction != base) { + result.append("direction:") + .append(translate_text_direction(*direction)) + .append(";"); + } if (const std::optional text_align = paragraph_style.text_align; text_align.has_value()) { result.append("text-align:") diff --git a/src/odr/internal/html/document_style.hpp b/src/odr/internal/html/document_style.hpp index eef457707..51c7a1b18 100644 --- a/src/odr/internal/html/document_style.hpp +++ b/src/odr/internal/html/document_style.hpp @@ -5,6 +5,7 @@ namespace odr { enum class TextAlign; +enum class TextDirection; enum class HorizontalAlign; enum class VerticalAlign; enum class FontWeight; @@ -33,6 +34,7 @@ namespace odr::internal::html { /// `nullptr` for a break turned off: the css default already says it. const char *translate_break(BreakType break_type); const char *translate_text_align(TextAlign text_align); +const char *translate_text_direction(TextDirection direction); const char *translate_horizontal_align(HorizontalAlign horizontal_align); const char *translate_vertical_align(VerticalAlign vertical_align); const char *translate_font_weight(FontWeight font_weight); @@ -45,7 +47,9 @@ std::string translate_inner_page_style(const PageLayout &page_layout); std::string translate_text_style(const TextStyle &text_style); /// The part of a text style a block may carry: what paints belongs on the run. std::string translate_block_font_style(const TextStyle &text_style); -std::string translate_paragraph_style(const ParagraphStyle ¶graph_style); +/// A direction equal to @p base is left unwritten. +std::string translate_paragraph_style(const ParagraphStyle ¶graph_style, + TextDirection base); std::string translate_table_style(const TableStyle &table_style); std::string translate_table_column_style(const TableColumnStyle &table_column_style); diff --git a/src/odr/internal/html/html_writer.cpp b/src/odr/internal/html/html_writer.cpp index 4fe2f0549..b61cdcf30 100644 --- a/src/odr/internal/html/html_writer.cpp +++ b/src/odr/internal/html/html_writer.cpp @@ -130,9 +130,11 @@ HtmlWriter::HtmlWriter(std::ostream &out, const HtmlConfig &config) util::string::repeat(config.html_indent_string, config.html_indent)} {} -void HtmlWriter::write_begin() { +void HtmlWriter::write_begin(const HtmlElementOptions &options) { out() << "\n"; - out() << ""; + out() << ""; } void HtmlWriter::write_end() { diff --git a/src/odr/internal/html/html_writer.hpp b/src/odr/internal/html/html_writer.hpp index 08fc275e5..59a955520 100644 --- a/src/odr/internal/html/html_writer.hpp +++ b/src/odr/internal/html/html_writer.hpp @@ -52,7 +52,7 @@ class HtmlWriter { std::uint32_t current_indent = 0); HtmlWriter(std::ostream &out, const HtmlConfig &config); - void write_begin(); + void write_begin(const HtmlElementOptions &options = {}); void write_end(); void write_header_begin(); diff --git a/src/odr/internal/odf/README.md b/src/odr/internal/odf/README.md index a22746528..89b4c94e7 100644 --- a/src/odr/internal/odf/README.md +++ b/src/odr/internal/odf/README.md @@ -47,7 +47,9 @@ Roughly ordered by importance. - [x] superscript, subscript (`style:text-position`, incl. relative font size) - [x] paragraph - - [x] alignment + - [x] alignment (`start` / `end` are absolute here, unlike `w:jc`'s) + - [x] base direction (`style:writing-mode`; the vertical modes are not laid + out vertically) - [x] margins (percentages are dropped) - [x] line height (absolute and percentage) - [x] first line indent (`fo:text-indent`) @@ -87,7 +89,7 @@ Roughly ordered by importance. - [x] transform (`draw:transform`, its operation list composed to one matrix) - [ ] mirror (`style:mirror`, and `draw:mirror-*` on a shape with no enhanced geometry) -- [x] page layout (size, orientation, margins) +- [x] page layout (size, orientation, margins, base direction) - [ ] annotations (`office:annotation`) ### Text documents (`.odt`) diff --git a/src/odr/internal/odf/odf_style.cpp b/src/odr/internal/odf/odf_style.cpp index b4cd338f0..4b0aede0f 100644 --- a/src/odr/internal/odf/odf_style.cpp +++ b/src/odr/internal/odf/odf_style.cpp @@ -105,6 +105,9 @@ std::optional read_break(const pugi::xml_attribute attribute) { return BreakType::none; } +/// [OpenDocument] 20.386. `start`/`end` are absolute here, unlike `w:jc`'s: +/// producers write `start` for a plain left, and LibreOffice inverts the two +/// against `w:jc` on a round-trip. std::optional read_text_align(const pugi::xml_attribute attribute) { if (!attribute) { return {}; @@ -125,6 +128,22 @@ std::optional read_text_align(const pugi::xml_attribute attribute) { return {}; } +/// [OpenDocument] 20.404. The vertical modes name no horizontal direction. +std::optional +read_text_direction(const pugi::xml_attribute attribute) { + if (!attribute) { + return {}; + } + const char *value = attribute.value(); + if (std::strcmp("lr-tb", value) == 0 || std::strcmp("lr", value) == 0) { + return TextDirection::left_to_right; + } + if (std::strcmp("rl-tb", value) == 0 || std::strcmp("rl", value) == 0) { + return TextDirection::right_to_left; + } + return {}; +} + std::optional read_vertical_align(const pugi::xml_attribute attribute) { if (!attribute) { @@ -237,6 +256,8 @@ PageLayout read_page_layout(const pugi::xml_node node) { read_measure(page_layout_properties.attribute("fo:page-height")); result.print_orientation = read_print_orientation( page_layout_properties.attribute("style:print-orientation")); + result.direction = read_text_direction( + page_layout_properties.attribute("style:writing-mode")); result.margin = DirectionalStyle( read_measure(page_layout_properties.attribute("fo:margin"))); if (const pugi::xml_attribute margin_right = @@ -388,6 +409,10 @@ void Style::resolve_paragraph_style_(const pugi::xml_node node, read_text_align(paragraph_properties.attribute("fo:text-align"))) { result.text_align = text_align; } + if (const std::optional direction = read_text_direction( + paragraph_properties.attribute("style:writing-mode"))) { + result.direction = direction; + } if (const std::optional margin = read_measure(paragraph_properties.attribute("fo:margin"))) { // TODO diff --git a/src/odr/internal/ooxml/ooxml_util.cpp b/src/odr/internal/ooxml/ooxml_util.cpp index 905a6ae51..3712961db 100644 --- a/src/odr/internal/ooxml/ooxml_util.cpp +++ b/src/odr/internal/ooxml/ooxml_util.cpp @@ -248,15 +248,22 @@ ooxml::read_font_style_attribute(const pugi::xml_attribute attribute) { return font_style_from_value(attribute.value()); } +/// [ECMA-376] 17.18.44 ST_Jc. `start`/`end` are relative to the direction. std::optional ooxml::read_text_align_attribute(const pugi::xml_attribute attribute) { const char *val = attribute.value(); - if (std::strcmp("left", val) == 0 || std::strcmp("start", val) == 0) { + if (std::strcmp("left", val) == 0) { return TextAlign::left; } - if (std::strcmp("right", val) == 0 || std::strcmp("end", val) == 0) { + if (std::strcmp("right", val) == 0) { return TextAlign::right; } + if (std::strcmp("start", val) == 0) { + return TextAlign::start; + } + if (std::strcmp("end", val) == 0) { + return TextAlign::end; + } if (std::strcmp("center", val) == 0) { return TextAlign::center; } @@ -286,6 +293,24 @@ ooxml::read_drawing_text_align_attribute(const pugi::xml_attribute attribute) { return {}; } +std::optional +ooxml::read_text_direction_attribute(const pugi::xml_node node) { + if (!node) { + return {}; + } + return read_on_off_attribute(node) ? TextDirection::right_to_left + : TextDirection::left_to_right; +} + +std::optional ooxml::read_drawing_text_direction_attribute( + const pugi::xml_attribute attribute) { + if (!attribute) { + return {}; + } + return read_on_off_attribute(attribute) ? TextDirection::right_to_left + : TextDirection::left_to_right; +} + std::optional ooxml::read_vertical_align_attribute(const pugi::xml_attribute attribute) { const char *val = attribute.value(); diff --git a/src/odr/internal/ooxml/ooxml_util.hpp b/src/odr/internal/ooxml/ooxml_util.hpp index fa1cc8557..b1748f0e9 100644 --- a/src/odr/internal/ooxml/ooxml_util.hpp +++ b/src/odr/internal/ooxml/ooxml_util.hpp @@ -49,6 +49,11 @@ std::optional read_font_style_attribute(pugi::xml_attribute); std::optional read_font_style_attribute(pugi::xml_node); std::optional read_text_align_attribute(pugi::xml_attribute); std::optional read_drawing_text_align_attribute(pugi::xml_attribute); +/// [ECMA-376] 17.3.1.6 `w:bidi`; absent says nothing. +std::optional read_text_direction_attribute(pugi::xml_node); +/// [ECMA-376] 21.1.2.2.7 `a:pPr/@rtl`. +std::optional + read_drawing_text_direction_attribute(pugi::xml_attribute); std::optional read_vertical_align_attribute(pugi::xml_attribute); std::optional read_drawing_vertical_align_attribute(pugi::xml_attribute); diff --git a/src/odr/internal/ooxml/presentation/README.md b/src/odr/internal/ooxml/presentation/README.md index 7527bc545..2644a34b1 100644 --- a/src/odr/internal/ooxml/presentation/README.md +++ b/src/odr/internal/ooxml/presentation/README.md @@ -49,6 +49,7 @@ Roughly ordered by importance. - [x] superscript, subscript (`@baseline`) - [x] paragraph - [x] alignment (`a:pPr/@algn`) + - [x] base direction (`a:pPr/@rtl`) - [x] indentation / left & right margins (`@marL` / `@marR`) - [x] line height (`a:lnSpc`), space before / after (`a:spcPts` only) - [x] shape fill (`p:spPr/a:solidFill`) and text anchor (`a:bodyPr/@anchor`) diff --git a/src/odr/internal/ooxml/presentation/ooxml_presentation_style.cpp b/src/odr/internal/ooxml/presentation/ooxml_presentation_style.cpp index ce6b74de5..83fae106e 100644 --- a/src/odr/internal/ooxml/presentation/ooxml_presentation_style.cpp +++ b/src/odr/internal/ooxml/presentation/ooxml_presentation_style.cpp @@ -214,6 +214,11 @@ void presentation::resolve_paragraph_style(const pugi::xml_node node, paragraph_properties.attribute("algn"))) { result.text_align = text_align; } + if (const std::optional direction = + read_drawing_text_direction_attribute( + paragraph_properties.attribute("rtl"))) { + result.direction = direction; + } if (const std::optional margin_left = read_emus_attribute(paragraph_properties.attribute("marL"))) { result.margin.left = margin_left; diff --git a/src/odr/internal/ooxml/text/README.md b/src/odr/internal/ooxml/text/README.md index 607eb7c1e..2dabe1dcb 100644 --- a/src/odr/internal/ooxml/text/README.md +++ b/src/odr/internal/ooxml/text/README.md @@ -52,7 +52,8 @@ Roughly ordered by importance. - [x] shadow - [ ] superscript, subscript - [x] paragraph - - [x] alignment + - [x] alignment (`start` / `end` stay relative to the direction) + - [x] base direction (`w:bidi`, on the paragraph and on `w:sectPr`) - [x] indentation / left & right margins - [x] top / bottom margins, line height (`w:spacing`, incl. `w:contextualSpacing`) diff --git a/src/odr/internal/ooxml/text/ooxml_text_document.cpp b/src/odr/internal/ooxml/text/ooxml_text_document.cpp index 4e7b450d8..6b44f00ca 100644 --- a/src/odr/internal/ooxml/text/ooxml_text_document.cpp +++ b/src/odr/internal/ooxml/text/ooxml_text_document.cpp @@ -51,6 +51,9 @@ PageLayout read_page_layout(const pugi::xml_node body) { : PrintOrientation::portrait; } + result.direction = + read_text_direction_attribute(section_properties.child("w:bidi")); + const pugi::xml_node page_margin = section_properties.child("w:pgMar"); result.margin.right = read_page_margin(page_margin.attribute("w:right")); result.margin.top = read_page_margin(page_margin.attribute("w:top")); diff --git a/src/odr/internal/ooxml/text/ooxml_text_style.cpp b/src/odr/internal/ooxml/text/ooxml_text_style.cpp index 8b436d99d..575d3c6f3 100644 --- a/src/odr/internal/ooxml/text/ooxml_text_style.cpp +++ b/src/odr/internal/ooxml/text/ooxml_text_style.cpp @@ -60,6 +60,10 @@ void resolve_paragraph_style_(const pugi::xml_node node, paragraph_properties.child("w:jc").attribute("w:val"))) { result.text_align = text_align; } + if (const std::optional direction = + read_text_direction_attribute(paragraph_properties.child("w:bidi"))) { + result.direction = direction; + } if (const std::optional margin_left = read_twips_attribute( paragraph_properties.child("w:ind").attribute("w:left"))) { result.margin.left = margin_left; diff --git a/src/odr/style.cpp b/src/odr/style.cpp index f1989501c..6f5da3f88 100644 --- a/src/odr/style.cpp +++ b/src/odr/style.cpp @@ -63,6 +63,7 @@ void TextStyle::override(const TextStyle &other) { void ParagraphStyle::override(const ParagraphStyle &other) { override_if_set(text_align, other.text_align); + override_if_set(direction, other.direction); margin.override(other.margin); override_if_set(line_height, other.line_height); override_if_set(text_indent, other.text_indent); diff --git a/src/odr/style.hpp b/src/odr/style.hpp index 3ee31476b..5cd408197 100644 --- a/src/odr/style.hpp +++ b/src/odr/style.hpp @@ -30,11 +30,24 @@ enum class FontPosition { }; /// @brief Collection of text alignments. +/// +/// @ref TextAlign::start and @ref TextAlign::end name the edge @ref +/// TextDirection decides; the rest name an absolute side. enum class TextAlign { left, right, center, justify, + start, + end, +}; + +/// @brief Collection of base directions a line of text runs in. +/// +/// Vertical writing modes have no value here. +enum class TextDirection { + left_to_right, + right_to_left, }; /// @brief Collection of horizontal alignments. @@ -165,6 +178,8 @@ struct TextStyle final { /// @brief Represents a style for paragraphs. struct ParagraphStyle final { std::optional text_align; + /// The base direction the paragraph's text runs in. + std::optional direction; DirectionalStyle margin; std::optional line_height; std::optional text_indent; @@ -235,6 +250,8 @@ struct PageLayout final { DirectionalStyle margin; /// The ground the page is painted on; unset leaves it to the viewer. std::optional background_color; + /// The base direction, inherited by paragraphs stating none of their own. + std::optional direction; }; } // namespace odr diff --git a/test/src/internal/odf/odf_flat_file_test.cpp b/test/src/internal/odf/odf_flat_file_test.cpp index d618fa7df..03b71d9d8 100644 --- a/test/src/internal/odf/odf_flat_file_test.cpp +++ b/test/src/internal/odf/odf_flat_file_test.cpp @@ -560,6 +560,133 @@ TEST(FlatOpenDocumentFile, it_renders_its_embedded_image_embedded_or_linked) { } } +namespace { + +/// `direction` of every top-level paragraph, in document order. +std::vector> +directions_of(const std::string &source) { + const Document document = DocumentFile::from_memory(source).document(); + + std::vector> result; + for (const Element child : document.root_element().children()) { + result.push_back(child.as_paragraph().style().direction); + } + return result; +} + +} // namespace + +/// [OpenDocument] 20.404. +TEST(FlatOpenDocumentFile, a_right_to_left_writing_mode_reads_as_a_direction) { + EXPECT_EQ( + std::vector>( + {std::nullopt, TextDirection::right_to_left, std::nullopt}), + directions_of(three_paragraphs("P1", R"(style:writing-mode="rl-tb")"))); +} + +/// Neither is a left-to-right claim. +TEST(FlatOpenDocumentFile, a_writing_mode_without_a_side_names_no_direction) { + for (const char *mode : {"tb-rl", "tb-lr", "tb", "page"}) { + EXPECT_EQ(std::vector>( + {std::nullopt, std::nullopt, std::nullopt}), + directions_of(three_paragraphs( + "P1", R"(style:writing-mode=")" + std::string(mode) + "\""))) + << mode; + } +} + +/// The default paragraph style, reached through the family fallback. +TEST(FlatOpenDocumentFile, + the_default_style_carries_direction_down_the_family_chain) { + const std::string source = flat_text( + R"(one)" + R"(two)", + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()"); + + EXPECT_EQ(std::vector>( + {TextDirection::right_to_left, TextDirection::right_to_left}), + directions_of(source)); +} + +/// The root says it once; a paragraph repeating it stays silent. +TEST(FlatOpenDocumentFile, a_direction_reaches_the_css_only_where_it_differs) { + const std::string rtl = + render(three_paragraphs("P1", R"(style:writing-mode="rl-tb")")); + EXPECT_EQ(1U, count(rtl, "direction:rtl")); + EXPECT_EQ(0U, count(rtl, "direction:ltr")); + + const std::string ltr = + render(three_paragraphs("P1", R"(style:writing-mode="lr-tb")")); + EXPECT_EQ(0U, count(ltr, "direction:ltr")); +} + +namespace { + +/// A page layout the master page names, carrying @p properties. +std::string page_layout_document(const std::string &properties) { + return flat_text(R"(one)", + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()"); +} + +} // namespace + +TEST(FlatOpenDocumentFile, the_page_direction_becomes_the_root_direction) { + EXPECT_EQ(1U, + count(render(page_layout_document(R"(style:writing-mode="rl-tb")")), + R"()")); + EXPECT_EQ(1U, + count(render(page_layout_document(R"(style:writing-mode="lr-tb")")), + R"()")); + EXPECT_EQ(1U, count(render(page_layout_document(R"(fo:page-width="21cm")")), + R"()")); +} + +/// Absolute here, unlike `w:jc`'s. +TEST(FlatOpenDocumentFile, start_and_end_alignment_are_the_sides_they_name) { + const auto align_of = [](const char *value) { + const std::string properties = + R"(fo:text-align=")" + std::string(value) + "\""; + const Document document = + DocumentFile::from_memory(three_paragraphs("P1", properties)) + .document(); + + // the middle one is the one carrying `P1` + std::vector> aligns; + for (const Element child : document.root_element().children()) { + aligns.push_back(child.as_paragraph().style().text_align); + } + return aligns.at(1); + }; + + EXPECT_EQ(TextAlign::left, align_of("start")); + EXPECT_EQ(TextAlign::right, align_of("end")); + EXPECT_EQ(TextAlign::left, align_of("left")); + EXPECT_EQ(TextAlign::right, align_of("right")); + + // and a right-to-left page does not move them + EXPECT_EQ(1U, + count(render(three_paragraphs("P1", R"(fo:text-align="start")")), + "text-align:left")); +} + TEST(FlatOpenDocumentFile, it_saves_back_as_one_xml_file) { const Document document = DocumentFile::from_memory(flat_text("Hello")).document(); diff --git a/test/src/internal/ooxml/ooxml_presentation_style_test.cpp b/test/src/internal/ooxml/ooxml_presentation_style_test.cpp index 4c19b1762..875e77460 100644 --- a/test/src/internal/ooxml/ooxml_presentation_style_test.cpp +++ b/test/src/internal/ooxml/ooxml_presentation_style_test.cpp @@ -340,6 +340,22 @@ TEST(ooxml_presentation_style, line_spacing_is_a_percent_or_a_length) { EXPECT_EQ(Measure(18, DynamicUnit("pt")), *points.line_height); } +/// [ECMA-376] 21.1.2.2.7. Absent says nothing, `0` says left-to-right. +TEST(ooxml_presentation_style, rtl_reads_as_a_paragraph_direction) { + const auto direction_of = [](const char *xml) { + pugi::xml_document document; + ParagraphStyle style; + resolve_paragraph_style(node_of(xml, document), style); + return style.direction; + }; + + EXPECT_EQ(TextDirection::right_to_left, + direction_of(R"()")); + EXPECT_EQ(TextDirection::left_to_right, + direction_of(R"()")); + EXPECT_FALSE(direction_of(R"()").has_value()); +} + TEST(ooxml_presentation_style, paragraph_spacing_is_taken_absolute_only) { pugi::xml_document document; ParagraphStyle style; diff --git a/test/src/internal/ooxml/ooxml_text_style_test.cpp b/test/src/internal/ooxml/ooxml_text_style_test.cpp index eec6ffef1..738d35207 100644 --- a/test/src/internal/ooxml/ooxml_text_style_test.cpp +++ b/test/src/internal/ooxml/ooxml_text_style_test.cpp @@ -555,6 +555,55 @@ TEST(ooxml_text_style, frame_wrap_on_both_sides_follows_the_frame) { style_of("center", "largest").horizontal_position); } +/// [ECMA-376] 17.3.1.6. Off has to be told from unsaid. +TEST(ooxml_text_style, bidi_is_inherited_until_a_style_turns_it_off) { + pugi::xml_document document; + const StyleRegistry registry = registry_of( + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()", + document); + + const auto direction = [&](const char *name) { + const Style *style = registry.style(name); + EXPECT_NE(nullptr, style); + return style->resolved().paragraph_style.direction; + }; + + EXPECT_EQ(TextDirection::right_to_left, direction("arabic")); + EXPECT_EQ(TextDirection::right_to_left, direction("derived")); + EXPECT_EQ(TextDirection::left_to_right, direction("latin")); + EXPECT_FALSE(direction("plain").has_value()); +} + +/// [ECMA-376] 17.18.44 ST_Jc. +TEST(ooxml_text_style, start_and_end_alignment_stay_relative_to_the_direction) { + pugi::xml_document document; + const StyleRegistry registry = registry_of( + R"()" + R"()" + R"()" + R"()" + R"()" + R"()", + document); + + const auto align_of = [&](const char *name) { + const Style *style = registry.style(name); + EXPECT_NE(nullptr, style); + return style->resolved().paragraph_style.text_align; + }; + + EXPECT_EQ(TextAlign::start, align_of("s")); + EXPECT_EQ(TextAlign::end, align_of("e")); + EXPECT_EQ(TextAlign::left, align_of("l")); + EXPECT_EQ(TextAlign::right, align_of("r")); +} + /// A page-relative offset has no meaning for a frame that stays in the flow. TEST(ooxml_text_style, frame_offset_is_read_where_it_flows_with_the_text) { pugi::xml_document document; From 4288bce46384151820947ab8c78b40023a758812 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 5 Sep 2026 21:48:49 +0200 Subject: [PATCH 2/3] docs(style): say that TextAlign start/end carry the css meaning A reader whose format means something else by the names maps into the absolute values, which is what `fo:text-align` does. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TP23uvwVtgEzZZTJnWsJC2 --- src/odr/internal/odf/odf_style.cpp | 4 +--- src/odr/style.hpp | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/odr/internal/odf/odf_style.cpp b/src/odr/internal/odf/odf_style.cpp index 4b0aede0f..8960a5745 100644 --- a/src/odr/internal/odf/odf_style.cpp +++ b/src/odr/internal/odf/odf_style.cpp @@ -105,9 +105,7 @@ std::optional read_break(const pugi::xml_attribute attribute) { return BreakType::none; } -/// [OpenDocument] 20.386. `start`/`end` are absolute here, unlike `w:jc`'s: -/// producers write `start` for a plain left, and LibreOffice inverts the two -/// against `w:jc` on a round-trip. +/// [OpenDocument] 20.386. `start`/`end` are absolute here, unlike `w:jc`'s. std::optional read_text_align(const pugi::xml_attribute attribute) { if (!attribute) { return {}; diff --git a/src/odr/style.hpp b/src/odr/style.hpp index 5cd408197..867467186 100644 --- a/src/odr/style.hpp +++ b/src/odr/style.hpp @@ -32,7 +32,7 @@ enum class FontPosition { /// @brief Collection of text alignments. /// /// @ref TextAlign::start and @ref TextAlign::end name the edge @ref -/// TextDirection decides; the rest name an absolute side. +/// TextDirection decides, as css reads them; the rest name an absolute side. enum class TextAlign { left, right, From ac66210e84a8030b34cd4e041812f2c07b630da2 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 5 Sep 2026 22:03:57 +0200 Subject: [PATCH 3/3] test: a right-to-left fixture for odt and docx, and the reference output Two hand-authored packages, one attribute per paragraph, pinning the contrast the formats disagree on: odf `start`/`end` are the absolute sides, `w:jc`'s follow the direction. Checked against LibreOffice edge by edge. All eight docx paragraphs match it. Seven of nine odt paragraphs do; the two that do not are the same case, an `rl-tb` paragraph stating no alignment, which LibreOffice flushes left and css flushes to the start edge. Reference output: 677 files gain the root `dir`, 146 docx paragraphs move from `text-align:left` to `start`, and no visible text moves. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TP23uvwVtgEzZZTJnWsJC2 --- test/data.cmake | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/data.cmake b/test/data.cmake index 6f22a9ade..2b923d3c3 100644 --- a/test/data.cmake +++ b/test/data.cmake @@ -7,7 +7,7 @@ odr_test_data( PATH "input/odr-public" URL "https://github.com/opendocument-app/OpenDocument.test.git" - REVISION "a64cddf5413dc0a1140d9ecd560e9eec4f7a8d03") + REVISION "2a69662064eee0b4fb368bf99a875d77ee4e74b1") odr_test_data( PATH "input/odr-private" @@ -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 "1f930506b317de555b769e49536640fd89051dc0") + REVISION "8d9a5459118e25eb44db8b86564b7848cb5a4b66") odr_test_data( PATH "reference-output/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git" - REVISION "fe59450aaf323c4964e704a4c399100877bf2520") + REVISION "a83bd65792fba59ad2555c9e56a54bcb8f4605f0")