diff --git a/CHANGELOG.md b/CHANGELOG.md index 70cefe484..f80accefd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- `Text::set_style` and the edit op `setTextStyle {id, style}` write bold, + italic, underline, strikethrough, highlight, colour and size onto a run of + an odf document. The other engines refuse it. + - A run that is both underlined and struck through renders both lines. The page wrote two `text-decoration` declarations, and the second replaced the first. diff --git a/docs/design/document-editing.md b/docs/design/document-editing.md index 2519a6f9c..df1a34a4b 100644 --- a/docs/design/document-editing.md +++ b/docs/design/document-editing.md @@ -362,8 +362,9 @@ that names the frame rather than a range across text. ## Inline formatting -Status: **not started.** It is the one step of [`editing.md`](editing.md) -still open. It covers what a reader changes on a stretch of text without +Status: **in progress.** It is the one step of [`editing.md`](editing.md) +still open; the order of work below says what is in. It covers what a reader +changes on a stretch of text without changing the text: bold, italic, underline, strikethrough, highlight, colour and size. Font name, superscript and subscript are not in it; nothing asked for them, and each is the same shape once these seven are in. @@ -443,12 +444,14 @@ container nobody else is in: `a:rPr` into each part. `paragraph_split` already does this copy when it walks up through a run, so this is that walk stopped one level early. The delta is then applied to the copy that holds the run and nothing else. -- **ODF** wraps the run in a new `text:span` when it has siblings or sits bare - in the `text:p`, and reuses the span when the run is alone in it. Either - way the span gets a fresh automatic style (decision 12). The style resolver - walks the element parent chain - ([`odf/AGENTS.md`](../../src/odr/internal/odf/AGENTS.md)), so a span inside - a span already resolves. +- **ODF** cuts the `text:span` the same way when the run sits in one, and + wraps a run that sits bare in the `text:p` (or in a link) in a new + `text:span`. Either way the span gets a fresh automatic style (decision + 12): a copy of the cut span's style with the delta applied, or the delta + alone for a new span, since the resolver cascades down the element chain. + +`TreeEditor::isolate` is the cut, shared by the three engines: a split before +the run and a split after it, each copying the container's shell. Marking part of a run is then what decision 5 said: `setText` and `insertText` split the run, and `setTextStyle` names the middle one. The @@ -541,10 +544,11 @@ run keeps its id and what changes is what it holds. It reaches by decision 7. The container of decision 11 is a new registry element the run's parent link then names, which no handle held before. -The delta is a `TextStyle` whose set fields are the change, with one thing it -cannot say: `optional` empty means unstated, and the wire's -`highlight: null` means none. Whether that is a field on `TextStyle` or a type -of its own is the first question step 1 answers. +The delta is a `TextStyle` whose set fields are the change. The wire's +`highlight: null` is a `background_color` with alpha 0, which is what a +highlight taken away is: `transparent` in ODF, `none` in docx. `font_name`, +`font_shadow` and `font_position` are not written, and a delta setting one +refuses. ### Order of work @@ -552,12 +556,12 @@ Each step is a pull request that builds and tests on its own. 1. **The renderer.** One `text-decoration` declaration (decision 15). Small, and no reference page holds both lines on one run today, so it changes no - reference output. + reference output. **Landed.** 2. **The op and the ODF write side.** `setTextStyle`, `Text::set_style`, the hook, the span and automatic style rules, and `document_edit_test` cases from inline fixtures: a mark on a shared span, on a bare text node, on a run alone in its span, off over a bold paragraph style. A headless - LibreOffice reopen of the saved file is the oracle. + LibreOffice reopen of the saved file is the oracle. **Landed.** 3. **docx and pptx.** The run cut, the `w:rPr` order, `w:shd` on the read side, the `a:rPr` children. The same cases, over Word and Impress fixtures. 4. **The browser.** `format()`, `onSelectionChange`, the four input types, @@ -579,6 +583,11 @@ Each step is a pull request that builds and tests on its own. - **Where the size list comes from.** A host offers sizes; the editor takes any length. Whether the ODF percentage sizes the reader resolves are ever written back as absolute is a question the fixtures answer. +- **`transparent` is read as unstated.** `read_color` answers nothing for + `fo:background-color="transparent"`, so a highlight taken away on a run + inside a highlighted paragraph still shows in our render, not in + LibreOffice's. Reading it as alpha 0 fixes it and moves every page whose + styles write `transparent`. ## Open questions diff --git a/src/odr/document.cpp b/src/odr/document.cpp index d387edddb..e85c1e539 100644 --- a/src/odr/document.cpp +++ b/src/odr/document.cpp @@ -7,15 +7,23 @@ #include #include +#include +#include + #include #include +#include +#include +#include #include +#include #include #include #include #include #include +#include #include #include #include @@ -109,6 +117,61 @@ CellValue parse_cell_value(const nlohmann::json &json) { throw std::invalid_argument("unknown cell value type " + type); } +/// `#rrggbb`, as the page spells one. +Color parse_color(const std::string &text) { + const auto is_hex = [](const char c) { + return std::isxdigit(static_cast(c)) != 0; + }; + if (text.size() != 7 || text[0] != '#' || + !std::ranges::all_of(text.substr(1), is_hex)) { + throw std::invalid_argument("not a color: " + text); + } + return Color::from_rgb( + static_cast(std::strtoul(text.c_str() + 1, nullptr, 16))); +} + +/// A length with a fixed size, as `Measure` spells it (`14pt`). +Measure parse_font_size(const std::string &text) { + static constexpr std::array units{"pt", "px", "in", + "cm", "mm", "pc"}; + const Measure size(text); + if (!(size.magnitude() > 0) || + !std::ranges::contains(units, size.unit().name())) { + throw std::invalid_argument("not a font size: " + text); + } + return size; +} + +/// The `style` of a `setTextStyle` op: a toggle as a bool, a colour as +/// `#rrggbb`, `null` for no highlight (`docs/design/document-editing.md`). +TextStyle parse_text_style(const nlohmann::json &json) { + TextStyle style; + for (const auto &[key, value] : json.items()) { + if (key == "bold") { + style.font_weight = + value.get() ? FontWeight::bold : FontWeight::normal; + } else if (key == "italic") { + style.font_style = + value.get() ? FontStyle::italic : FontStyle::normal; + } else if (key == "underline") { + style.font_underline = value.get(); + } else if (key == "strikethrough") { + style.font_line_through = value.get(); + } else if (key == "highlight") { + style.background_color = value.is_null() + ? Color(0, 0, 0, 0) + : parse_color(value.get()); + } else if (key == "color") { + style.font_color = parse_color(value.get()); + } else if (key == "size") { + style.font_size = parse_font_size(value.get()); + } else { + throw std::invalid_argument("unknown text style property " + key); + } + } + return style; +} + /// The @p ordinal -th sheet in document order, which is how an op names one. Sheet sheet_at(const Element root, const std::uint32_t ordinal) { std::uint32_t seen = 0; @@ -213,6 +276,12 @@ void Document::edit(const std::string_view operations, continue; } + if (name == "setTextStyle") { + text_of(operation, "id") + .set_style(parse_text_style(operation.at("style"))); + continue; + } + if (name == "insertText") { const auto text = operation.at("text").get(); const std::int64_t address = reserve(operation); diff --git a/src/odr/document.hpp b/src/odr/document.hpp index 63a3e7d64..a76065ec2 100644 --- a/src/odr/document.hpp +++ b/src/odr/document.hpp @@ -55,10 +55,11 @@ class Document final { /// The wire format our browser-side editor produces: /// `{"version": 2, "ops": [{"op": "setCell", "sheet": 0, "column": 1, /// "row": 2, "value": {"type": "number", "number": 12.5, "text": "12.5"}}]}`. - /// A value is typed `number`, `string` or `empty`; `setText` names a text - /// element by the `id` the render wrote into the page instead - /// (`docs/design/document-editing.md`). Editing a single element in process - /// is @ref Text::set_content and needs none of this. + /// A value is typed `number`, `string` or `empty`; `setText` and + /// `setTextStyle` name a text element by the `id` the render wrote into the + /// page instead (`docs/design/document-editing.md`). Editing a single + /// element in process is @ref Text::set_content or @ref Text::set_style and + /// needs none of this. /// @throws std::invalid_argument on the first operation it cannot apply, /// leaving the ones before it applied - a host replays onto a fresh /// decode. diff --git a/src/odr/document_element.cpp b/src/odr/document_element.cpp index 881ec5ba6..72ac097e4 100644 --- a/src/odr/document_element.cpp +++ b/src/odr/document_element.cpp @@ -542,6 +542,17 @@ TextStyle Text::style() const { return exists_() ? m_adapter2->text_style(m_identifier) : TextStyle(); } +void Text::set_style(const TextStyle &style) const { + if (!exists_()) { + return; + } + if (style.font_name.has_value() || style.font_shadow.has_value() || + style.font_position.has_value()) { + throw UnsupportedOperation(); + } + m_adapter2->text_set_style(m_identifier, style); +} + std::string Link::href() const { return exists_() ? m_adapter2->link_href(m_identifier) : ""; } diff --git a/src/odr/document_element.hpp b/src/odr/document_element.hpp index ca85bd28a..3a603d255 100644 --- a/src/odr/document_element.hpp +++ b/src/odr/document_element.hpp @@ -464,6 +464,10 @@ class Text final : public ElementBase { void set_content(const std::string &text) const; [[nodiscard]] TextStyle style() const; + /// States the set fields of @p style on the run and leaves the rest. A + /// `background_color` with alpha 0 removes a highlight. `font_name`, + /// `font_shadow` and `font_position` refuse with `UnsupportedOperation`. + void set_style(const TextStyle &style) const; }; /// Represents a link element in a document. diff --git a/src/odr/internal/abstract/document.hpp b/src/odr/internal/abstract/document.hpp index 91dc00f8d..31757dfad 100644 --- a/src/odr/internal/abstract/document.hpp +++ b/src/odr/internal/abstract/document.hpp @@ -401,6 +401,13 @@ class TextAdapter { [[nodiscard]] virtual TextStyle text_style(ElementIdentifier element_id) const = 0; + /// States the set fields of @p style on the run and leaves the rest. A run + /// sharing its style container with a sibling gets one of its own first. + virtual void + text_set_style([[maybe_unused]] const ElementIdentifier element_id, + [[maybe_unused]] const TextStyle &style) const { + throw UnsupportedOperation(); + } }; class LinkAdapter { diff --git a/src/odr/internal/odf/AGENTS.md b/src/odr/internal/odf/AGENTS.md index 7c6f41919..ebef6bf11 100644 --- a/src/odr/internal/odf/AGENTS.md +++ b/src/odr/internal/odf/AGENTS.md @@ -180,9 +180,13 @@ since re-routing one between the shapes it names is layout rather than decoding. The structural/foundational gaps, roughly by value: -1. **Editing is text-content only.** No structural edits (insert/delete/move - elements), no attribute or style editing. `text_set_content` splices the DOM - for one text run; that's the whole editor. +1. **Editing is runs, paragraphs and the seven text properties.** No other + attribute or style editing. `text_set_style` cuts the `text:span` around + the run (`TreeEditor::isolate`) or wraps a bare run in a new one, and + points it at a fresh automatic style `T` + (`StyleRegistry::create_text_style`): a copy of the span's automatic + style plus the delta, since an automatic style may be shared, or a child + of its named style. The new style joins the index. 2. **Spreadsheet editing is one cell value.** `sheet_set_cell` writes `office:value-type`/`office:value` *and* the `text:p` under the cell — the file states the value and shows a rendering of it, and setting one without diff --git a/src/odr/internal/odf/odf_document.cpp b/src/odr/internal/odf/odf_document.cpp index 136096f3e..f4b683b58 100644 --- a/src/odr/internal/odf/odf_document.cpp +++ b/src/odr/internal/odf/odf_document.cpp @@ -38,7 +38,7 @@ namespace odr::internal::odf { namespace { std::unique_ptr -create_element_adapter(const Document &document, ElementRegistry ®istry); +create_element_adapter(Document &document, ElementRegistry ®istry); } Document::Document(const FileType file_type, const DocumentType document_type, @@ -250,7 +250,7 @@ using AdapterBase = internal::RegistryElementAdapter< class ElementAdapter final : public AdapterBase { public: - ElementAdapter(const Document &document, ElementRegistry ®istry) + ElementAdapter(Document &document, ElementRegistry ®istry) : AdapterBase(registry), m_document(&document) {} [[nodiscard]] bool @@ -776,6 +776,30 @@ class ElementAdapter final : public AdapterBase { text_style(const ElementIdentifier element_id) const override { return get_intermediate_style(element_id).text_style; } + /// Cuts the span around the run and copies its style, or wraps a bare run + /// in a new span carrying the delta alone. + void text_set_style(const ElementIdentifier element_id, + const TextStyle &style) const override { + const ElementIdentifier parent_id = element_parent(element_id); + ElementIdentifier span_id = null_element_id; + const char *base_name = nullptr; + if (parent_id != null_element_id && + element_type(parent_id) == ElementType::span) { + span_id = TreeEditor(*m_registry).isolate(element_id); + base_name = get_node(span_id).attribute("text:style-name").value(); + } else { + span_id = wrap_in_span(element_id); + } + + pugi::xml_node span_node = get_node(span_id); + const std::string name = m_document->style_registry().create_text_style( + automatic_styles_of(span_node), base_name, style); + pugi::xml_attribute attribute = span_node.attribute("text:style-name"); + if (!attribute) { + attribute = span_node.prepend_attribute("text:style-name"); + } + attribute.set_value(name.c_str()); + } [[nodiscard]] std::string link_href(const ElementIdentifier element_id) const override { @@ -1038,7 +1062,7 @@ class ElementAdapter final : public AdapterBase { } private: - const Document *m_document{nullptr}; + Document *m_document{nullptr}; mutable std::mutex m_charts_mutex; mutable std::unordered_map> m_charts; @@ -1219,6 +1243,50 @@ class ElementAdapter final : public AdapterBase { return cell->element_id; } + /// A new `text:span` around the nodes of @p element_id, taking its place in + /// the tree. + [[nodiscard]] ElementIdentifier + wrap_in_span(const ElementIdentifier element_id) const { + const NodeSpan nodes = TreeEditor(*m_registry).node_span(element_id); + pugi::xml_node span_node = + nodes.first.parent().insert_child_before("text:span", nodes.first); + // the moves invalidate `next_sibling`, so where the span ends is read first + const pugi::xml_node end = nodes.last.next_sibling(); + for (pugi::xml_node node = nodes.first; node != end;) { + const pugi::xml_node next = node.next_sibling(); + span_node.append_move(node); + node = next; + } + + const auto &[span_id, unused] = + m_registry->create_element(ElementType::span, span_node); + m_registry->insert_sibling_before(element_id, span_id); + m_registry->unlink_child(element_id); + m_registry->append_child(span_id, element_id); + return span_id; + } + + /// The `office:automatic-styles` of the file @p node sits in, made where + /// there is none. + [[nodiscard]] static pugi::xml_node + automatic_styles_of(const pugi::xml_node node) { + pugi::xml_node root; + for (const pugi::xml_node child : node.root().children()) { + if (child.type() == pugi::xml_node_type::node_element) { + root = child; + break; + } + } + if (const pugi::xml_node automatic_styles = + root.child("office:automatic-styles")) { + return automatic_styles; + } + if (const pugi::xml_node body = root.child("office:body")) { + return root.insert_child_before("office:automatic-styles", body); + } + return root.append_child("office:automatic-styles"); + } + /// The only child of @p element_id, null where it has none or several. [[nodiscard]] ElementIdentifier only_child(const ElementIdentifier element_id) const { @@ -1502,7 +1570,7 @@ class ElementAdapter final : public AdapterBase { }; std::unique_ptr -create_element_adapter(const Document &document, ElementRegistry ®istry) { +create_element_adapter(Document &document, ElementRegistry ®istry) { return std::make_unique(document, registry); } diff --git a/src/odr/internal/odf/odf_style.cpp b/src/odr/internal/odf/odf_style.cpp index f79033d9d..cb584995f 100644 --- a/src/odr/internal/odf/odf_style.cpp +++ b/src/odr/internal/odf/odf_style.cpp @@ -6,10 +6,13 @@ #include #include #include +#include #include #include #include +#include + namespace odr::internal::odf { namespace { @@ -733,6 +736,110 @@ void StyleRegistry::generate_master_pages_(Document &document) { } } +namespace { + +/// `#rrggbb`, or `transparent` for a colour with no alpha. +std::string color_value(const Color &color) { + if (color.alpha == 0) { + return "transparent"; + } + return fmt::format("#{:06x}", color.rgb()); +} + +/// Writes the set fields of @p style as attributes of @p properties, the +/// asian and complex variants beside each western one. +void write_text_properties(pugi::xml_node properties, const TextStyle &style) { + const auto set = [&](const char *name, const std::string &value) { + pugi::xml_attribute attribute = properties.attribute(name); + if (!attribute) { + attribute = properties.append_attribute(name); + } + attribute.set_value(value.c_str()); + }; + + if (style.font_size.has_value()) { + const std::string value = style.font_size->to_string(); + set("fo:font-size", value); + set("style:font-size-asian", value); + set("style:font-size-complex", value); + } + if (style.font_weight.has_value()) { + const std::string value = + *style.font_weight == FontWeight::bold ? "bold" : "normal"; + set("fo:font-weight", value); + set("style:font-weight-asian", value); + set("style:font-weight-complex", value); + } + if (style.font_style.has_value()) { + const std::string value = + *style.font_style == FontStyle::italic ? "italic" : "normal"; + set("fo:font-style", value); + set("style:font-style-asian", value); + set("style:font-style-complex", value); + } + if (style.font_underline.has_value()) { + set("style:text-underline-style", *style.font_underline ? "solid" : "none"); + } + if (style.font_line_through.has_value()) { + set("style:text-line-through-style", + *style.font_line_through ? "solid" : "none"); + } + if (style.font_color.has_value()) { + set("fo:color", color_value(*style.font_color)); + } + if (style.background_color.has_value()) { + set("fo:background-color", color_value(*style.background_color)); + } +} + +} // namespace + +std::string StyleRegistry::create_text_style(pugi::xml_node automatic_styles, + const char *base_name, + const TextStyle &style) { + std::string name; + for (;; ++m_next_text_style) { + name = "T" + std::to_string(m_next_text_style); + if (!m_index_style.contains(name)) { + break; + } + } + + pugi::xml_node node = automatic_styles.append_child("style:style"); + node.append_attribute("style:name").set_value(name.c_str()); + node.append_attribute("style:family").set_value("text"); + + pugi::xml_node properties; + if (base_name != nullptr && *base_name != '\0') { + const auto base_it = m_index_style.find(base_name); + const pugi::xml_node base = + base_it != std::end(m_index_style) ? base_it->second : pugi::xml_node(); + // an automatic style may be shared, so it is copied; a named one is + // inherited from + if (base && + std::strcmp(base.parent().name(), "office:automatic-styles") == 0) { + if (const pugi::xml_attribute parent = + base.attribute("style:parent-style-name")) { + node.append_copy(parent); + } + if (const pugi::xml_node base_properties = + base.child("style:text-properties")) { + properties = node.append_copy(base_properties); + } + } else { + node.append_attribute("style:parent-style-name").set_value(base_name); + } + } + if (!properties) { + properties = node.append_child("style:text-properties"); + } + write_text_properties(properties, style); + + m_index_style[name] = node; + generate_style_(name, node); + return name; +} + Style *StyleRegistry::style(const char *name) const { if (const auto style_it = m_styles.find(name); style_it != std::end(m_styles)) { diff --git a/src/odr/internal/odf/odf_style.hpp b/src/odr/internal/odf/odf_style.hpp index c9d159716..d4e51328e 100644 --- a/src/odr/internal/odf/odf_style.hpp +++ b/src/odr/internal/odf/odf_style.hpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -82,7 +83,16 @@ class StyleRegistry final { [[nodiscard]] ElementIdentifier master_page_of_style(const char *style_name) const; + /// A text style under @p automatic_styles carrying @p style, under a fresh + /// name it answers: a copy of the automatic style @p base_name names, a + /// child of a named one, the delta alone where it is null. + std::string create_text_style(pugi::xml_node automatic_styles, + const char *base_name, const TextStyle &style); + private: + /// Where the search for a free `T` name starts. + std::uint32_t m_next_text_style{1}; + std::unordered_map m_index_font_face; std::unordered_map m_index_default_style; std::unordered_map m_index_style; diff --git a/src/odr/internal/xml/xml_tree_edit.hpp b/src/odr/internal/xml/xml_tree_edit.hpp index 20dfde2cf..a0de3c829 100644 --- a/src/odr/internal/xml/xml_tree_edit.hpp +++ b/src/odr/internal/xml/xml_tree_edit.hpp @@ -85,6 +85,24 @@ template class TreeEditor final { return split_level_(element_id, stays_id); } + /// Cuts the parent of @p element_id around it and answers the part holding + /// it alone; each part keeps the parent's shell. + ElementIdentifier isolate(const ElementIdentifier element_id) const { + ElementIdentifier holder_id = m_registry->element_at(element_id).parent_id; + if (holder_id == null_element_id) { + throw std::invalid_argument("TreeEditor::isolate: no parent to cut"); + } + if (const ElementIdentifier previous_id = + m_registry->element_at(element_id).previous_sibling_id; + previous_id != null_element_id) { + holder_id = split(holder_id, previous_id); + } + if (m_registry->element_at(element_id).next_sibling_id != null_element_id) { + static_cast(split(holder_id, element_id)); + } + return holder_id; + } + /// Takes the children of @p element_id's next sibling and removes it. void merge_next(const ElementIdentifier element_id) const { const ElementIdentifier next_id = diff --git a/test/src/document_edit_test.cpp b/test/src/document_edit_test.cpp index 57e89e3f0..b5a9811bd 100644 --- a/test/src/document_edit_test.cpp +++ b/test/src/document_edit_test.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -12,6 +13,7 @@ #include #include +#include #include #include #include @@ -596,6 +598,235 @@ TEST(DocumentEdit, a_split_through_something_that_is_not_a_span_refuses) { UnsupportedOperation); } +namespace { + +/// A paragraph under a bold automatic style, two spans sharing an italic +/// automatic style, a span under a named style, and a bare run - the four +/// places a mark lands. +Document styled_text() { + const std::string source = + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"(bold )" + R"(one)" + R"(two)" + R"(three four)" + R"()"; + return DecodedFile( + open_strategy::open_file(std::make_shared(source), {}, + Logger::null())) + .as_document_file() + .document(); +} + +std::string style_op(const Element run, const std::string &style) { + return R"({"op":"setTextStyle","id":)" + id_of(run) + R"(,"style":)" + style + + "}"; +} + +} // namespace + +TEST(DocumentEdit, a_style_op_on_a_bare_run_gives_it_a_span_of_its_own) { + const Document document = two_paragraph_text(); + const Element run = run_at(document, 0, 0); + ASSERT_EQ(run.parent().type(), ElementType::paragraph); + + document.edit(ops(style_op(run, R"({"bold":true})"))); + + EXPECT_EQ(run.parent().type(), ElementType::span); + EXPECT_EQ(run.as_text().style().font_weight, FontWeight::bold); + EXPECT_EQ(run_at(document, 0, 2).as_text().style().font_weight, std::nullopt); + EXPECT_EQ(paragraph_texts(document), + (std::vector{"one two three", "second"})); +} + +TEST(DocumentEdit, a_style_op_on_a_run_alone_in_its_span_keeps_the_span) { + const Document document = styled_text(); + const Element run = run_at(document, 1, 0); + const Element span = run.parent(); + ASSERT_EQ(span.type(), ElementType::span); + + document.edit(ops(style_op(run, R"({"bold":true})"))); + + EXPECT_EQ(run.parent(), span); + EXPECT_EQ(run.as_text().style().font_weight, FontWeight::bold); +} + +TEST(DocumentEdit, a_style_op_copies_a_shared_automatic_style) { + const Document document = styled_text(); + const Element one = run_at(document, 1, 0); + const Element two = run_at(document, 1, 1); + + document.edit(ops(style_op(one, R"({"bold":true})"))); + + // the copy keeps the italic and the sibling under the old style stays as it + // was + EXPECT_EQ(one.as_text().style().font_style, FontStyle::italic); + EXPECT_EQ(one.as_text().style().font_weight, FontWeight::bold); + EXPECT_EQ(two.as_text().style().font_style, FontStyle::italic); + EXPECT_EQ(two.as_text().style().font_weight, std::nullopt); +} + +TEST(DocumentEdit, a_style_op_under_a_named_style_inherits_from_it) { + const Document document = styled_text(); + const Element three = run_at(document, 1, 2); + + document.edit(ops(style_op(three, R"({"bold":true})"))); + + EXPECT_EQ(three.as_text().style().font_style, FontStyle::italic); + EXPECT_EQ(three.as_text().style().font_weight, FontWeight::bold); +} + +TEST(DocumentEdit, a_style_turned_off_is_written_over_the_paragraph) { + const Document document = styled_text(); + const Element run = run_at(document, 0, 0); + ASSERT_EQ(run.as_text().style().font_weight, FontWeight::bold); + + document.edit(ops(style_op(run, R"({"bold":false})"))); + + EXPECT_EQ(run.as_text().style().font_weight, FontWeight::normal); +} + +TEST(DocumentEdit, every_property_reaches_the_run) { + const Document document = two_paragraph_text(); + const Element run = run_at(document, 0, 1); + + document.edit(ops(style_op( + run, + R"({"bold":true,"italic":true,"underline":true,"strikethrough":true,)" + R"("highlight":"#ffff00","color":"#ff0000","size":"14pt"})"))); + + const TextStyle style = run.as_text().style(); + EXPECT_EQ(style.font_weight, FontWeight::bold); + EXPECT_EQ(style.font_style, FontStyle::italic); + EXPECT_EQ(style.font_underline, true); + EXPECT_EQ(style.font_line_through, true); + ASSERT_TRUE(style.background_color.has_value()); + EXPECT_EQ(style.background_color->rgb(), 0xffff00U); + ASSERT_TRUE(style.font_color.has_value()); + EXPECT_EQ(style.font_color->rgb(), 0xff0000U); + ASSERT_TRUE(style.font_size.has_value()); + EXPECT_EQ(style.font_size->to_string(), "14pt"); +} + +TEST(DocumentEdit, a_highlight_of_null_takes_the_highlight_away) { + const Document document = two_paragraph_text(); + const Element run = run_at(document, 0, 1); + + document.edit(ops(style_op(run, R"({"highlight":"#ffff00"})") + "," + + style_op(run, R"({"highlight":null})"))); + + EXPECT_EQ(run.as_text().style().background_color, std::nullopt); +} + +TEST(DocumentEdit, a_second_style_op_keeps_what_the_first_wrote) { + const Document document = two_paragraph_text(); + const Element run = run_at(document, 0, 0); + + document.edit(ops(style_op(run, R"({"bold":true})") + "," + + style_op(run, R"({"italic":true})"))); + + EXPECT_EQ(run.as_text().style().font_weight, FontWeight::bold); + EXPECT_EQ(run.as_text().style().font_style, FontStyle::italic); +} + +TEST(DocumentEdit, a_style_op_on_a_split_run_marks_the_middle_only) { + const Document document = styled_text(); + const Element run = run_at(document, 1, 0); + + // the browser splits `one` into `o`, `n`, `e` and marks the `n` + document.edit(ops(R"({"op":"setText","id":)" + id_of(run) + + R"(,"text":"o"},)" + R"({"op":"insertText","after":)" + + id_of(run) + R"(,"text":"n","id":-1},)" + + R"({"op":"insertText","after":-1,"text":"e","id":-2},)" + + R"({"op":"setTextStyle","id":-1,"style":{"bold":true}})")); + + EXPECT_EQ(text_of(paragraph_at(document, 1)), "onetwothree four"); + EXPECT_EQ(run_at(document, 1, 0).as_text().style().font_weight, std::nullopt); + EXPECT_EQ(run_at(document, 1, 1).as_text().style().font_weight, + FontWeight::bold); + EXPECT_EQ(run_at(document, 1, 2).as_text().style().font_weight, std::nullopt); + // each part sits in a span of its own, all italic from the copied style + for (const std::uint32_t ordinal : {0U, 1U, 2U}) { + const Element part = run_at(document, 1, ordinal); + EXPECT_EQ(part.parent().type(), ElementType::span); + EXPECT_EQ(part.as_text().style().font_style, FontStyle::italic); + } +} + +TEST(DocumentEdit, a_style_op_survives_a_save) { + const Document document = styled_text(); + document.edit(ops(style_op(run_at(document, 1, 0), R"({"bold":true})") + "," + + style_op(run_at(document, 0, 0), R"({"bold":false})"))); + + const Document reloaded = + DecodedFile(open_strategy::open_file( + std::make_shared(std::string( + document.save_to_memory().memory_data().value())), + {}, Logger::null())) + .as_document_file() + .document(); + + EXPECT_EQ(run_at(reloaded, 1, 0).as_text().style().font_weight, + FontWeight::bold); + EXPECT_EQ(run_at(reloaded, 1, 0).as_text().style().font_style, + FontStyle::italic); + EXPECT_EQ(run_at(reloaded, 1, 1).as_text().style().font_weight, std::nullopt); + EXPECT_EQ(run_at(reloaded, 0, 0).as_text().style().font_weight, + FontWeight::normal); +} + +TEST(DocumentEdit, a_style_op_with_an_unknown_property_refuses) { + const Document document = two_paragraph_text(); + const Element run = run_at(document, 0, 0); + + EXPECT_THROW(document.edit(ops(style_op(run, R"({"blink":true})"))), + std::invalid_argument); + EXPECT_THROW(document.edit(ops(style_op(run, R"({"color":"red"})"))), + std::invalid_argument); + EXPECT_THROW(document.edit(ops(style_op(run, R"({"size":"large"})"))), + std::invalid_argument); + // no fixed size, so no engine can write it + EXPECT_THROW(document.edit(ops(style_op(run, R"({"size":"2em"})"))), + std::invalid_argument); + EXPECT_EQ(run.as_text().style().font_weight, std::nullopt); +} + +TEST(DocumentEdit, a_read_only_engine_refuses_a_style_op) { + const Document document = + DecodedFile(open_strategy::open_file(std::make_shared( + std::string(R"({\rtf1 hello})")), + {}, Logger::null())) + .as_document_file() + .document(); + const Element run = run_at(document, 0, 0); + ASSERT_TRUE(run); + + EXPECT_THROW(document.edit(ops(style_op(run, R"({"bold":true})"))), + UnsupportedOperation); +} + +TEST(DocumentEdit, a_style_the_handle_does_not_write_refuses) { + const Document document = two_paragraph_text(); + TextStyle style; + style.font_position = FontPosition::super; + + EXPECT_THROW(run_at(document, 0, 0).as_text().set_style(style), + UnsupportedOperation); +} + TEST(DocumentEdit, a_paragraph_edit_refuses_another_documents_element) { const Document document = two_paragraph_text(); const Document other = two_paragraph_text();