diff --git a/CHANGELOG.md b/CHANGELOG.md index 2129d9aa3..c79d846b4 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 +- A paragraph splits, merges and is inserted — `splitParagraph`, + `mergeParagraph`, `insertParagraph`, and the matching `Document` methods in + C++. Enter, Backspace at a paragraph start and a delete across paragraphs. + - An edit can span several runs: the new `insertText` and `removeElement` operations, and `Document::remove` / `insert_text_before` / `insert_text_after` in C++. ODF and `.docx`; every other format refuses. diff --git a/docs/design/document-editing.md b/docs/design/document-editing.md index ff6ad7da2..596b61ba0 100644 --- a/docs/design/document-editing.md +++ b/docs/design/document-editing.md @@ -210,8 +210,9 @@ Every other id is one the page wrote. A **handle** says what an element holds — `Text::set_content`, `Sheet::set_cell`. The **document** says what the tree holds — -`Document::remove`, `Document::insert_text_before` / `insert_text_after`, and -the paragraph operations below. An `Element` is an immutable handle, so +`Document::remove`, `insert_text_before` / `insert_text_after`, +`split_paragraph`, `merge_paragraph_with_next` and `insert_paragraph_after`. An +`Element` is an immutable handle, so restructuring the tree through one would leave a handle naming something unreachable; and the document is what owns the tree either way. Each structural call refuses an element of another document. @@ -270,21 +271,42 @@ Each step is a pull request that builds and tests on its own. they need, odf and ooxml text. A selection spanning runs is replayable. **Landed.** 3. **Paragraphs split and merge.** `splitParagraph`, `mergeParagraph`, - `insertParagraph`. + `insertParagraph`. **Landed.** 4. **The browser editor.** Model-first, owns the DOM mutation, records the ops, and carries undo/redo (decision 6). 5. **pptx writes.** `save`, `is_editable`, `is_savable`, the capability row and the new hooks over `a:p` / `a:r`. +## What a split does to what is around it + +`splitParagraph` names a **descendant**, not a direct child, because the caret +sits in a run and the run sits in a span. So the split walks from that run up +to the paragraph and splits **every element on the way**: a run inside a span +leaves the span in both halves, and the tail keeps the formatting the span +carried. The same holds for a link, so the tail is still a link to the same +place. + +Only a **span** and a **link** are split through. Anything else — a frame +between the run and the paragraph, say — refuses with `UnsupportedOperation`, +because what a copy of it would mean is the format's question rather than this +one's. + +A copy carries the original's attributes **and the property children the +format writes ahead of the content** — `w:pPr` on a paragraph, `w:rPr` on a +run. Those sit before the first child the registry knows about, which is how +the copy finds them without naming a tag. ODF states the same thing as an +attribute, so the rule covers both. + +A split exactly at the end of a span leaves an **empty copy of that span** +behind. It is valid in both formats — the corpus is full of `` +that producers wrote themselves — and pruning it would cost a branch to save +nothing a reader sees. + ## Open questions -- A run inside a **link** or a **bookmark** splits differently: splitting the - paragraph has to decide whether the link follows the tail. Today it would, - because the link is a child that moves whole. Whether that is right is a - question for step 3. - **A list item** is a paragraph in a list. Enter at the end of one should make - a new list item, not a bare paragraph. Step 3 splits what the element tree - says is a paragraph; the list case is not covered. + a new list item, not a bare paragraph. `splitParagraph` splits what the + element tree says is a paragraph; the list case is not covered. - The **plain-text view** (`html/text_file.cpp`) is still its own editor and still answers to nobody. Unchanged by this work, and still the open question at the end of [`editing.md`](editing.md). diff --git a/src/odr/document.cpp b/src/odr/document.cpp index 3d47aa29a..07bba66be 100644 --- a/src/odr/document.cpp +++ b/src/odr/document.cpp @@ -167,6 +167,19 @@ void Document::edit(const std::string_view operations, return text; }; + // the paragraph @p field names, refusing an element that is not one + const auto paragraph_of = [&](const nlohmann::json &operation, + const char *field) { + const Element element = element_of(operation, field); + const Paragraph paragraph = element.as_paragraph(); + if (!paragraph) { + throw std::invalid_argument("element " + + std::to_string(element.identifier()) + + " is not a paragraph"); + } + return paragraph; + }; + // the negative id an operation reserves, checked before anything is created // so that a refusal changes nothing const auto reserve = [&](const nlohmann::json &operation) { @@ -218,6 +231,28 @@ void Document::edit(const std::string_view operations, continue; } + if (name == "splitParagraph") { + const std::int64_t address = reserve(operation); + const Paragraph paragraph = paragraph_of(operation, "paragraph"); + const Element after = operation.contains("after") + ? element_of(operation, "after") + : Element(); + minted.emplace(address, split_paragraph(paragraph, after).identifier()); + continue; + } + + if (name == "mergeParagraph") { + merge_paragraph_with_next(paragraph_of(operation, "paragraph")); + continue; + } + + if (name == "insertParagraph") { + const std::int64_t address = reserve(operation); + const Paragraph after = paragraph_of(operation, "after"); + minted.emplace(address, insert_paragraph_after(after).identifier()); + continue; + } + throw std::invalid_argument("unknown operation " + name); } } @@ -273,6 +308,52 @@ Text Document::insert_text_(const Text &anchor, const Placement where, return {adapter, identifier, adapter->text_adapter(identifier)}; } +/// The adapter @p paragraph answers to, refusing an element that is not a +/// paragraph of this document. +const internal::abstract::ParagraphAdapter * +Document::paragraphs_(const Paragraph ¶graph, + ElementIdentifier &identifier) const { + identifier = check_(paragraph); + const internal::abstract::ParagraphAdapter *paragraphs = + m_impl->element_adapter()->paragraph_adapter(identifier); + if (paragraphs == nullptr) { + throw std::invalid_argument("element " + std::to_string(identifier) + + " is not a paragraph"); + } + return paragraphs; +} + +Paragraph Document::split_paragraph(const Paragraph ¶graph, + const Element &after) const { + ElementIdentifier paragraph_id{}; + const internal::abstract::ParagraphAdapter *paragraphs = + paragraphs_(paragraph, paragraph_id); + // an element that does not exist splits before every child: Enter at the + // start of the paragraph + const ElementIdentifier after_id = after ? check_(after) : null_element_id; + + const internal::abstract::ElementAdapter *adapter = m_impl->element_adapter(); + const ElementIdentifier identifier = + paragraphs->paragraph_split(paragraph_id, after_id); + return {adapter, identifier, adapter->paragraph_adapter(identifier)}; +} + +void Document::merge_paragraph_with_next(const Paragraph ¶graph) const { + ElementIdentifier paragraph_id{}; + paragraphs_(paragraph, paragraph_id)->paragraph_merge_next(paragraph_id); +} + +Paragraph Document::insert_paragraph_after(const Paragraph ¶graph) const { + ElementIdentifier paragraph_id{}; + const internal::abstract::ParagraphAdapter *paragraphs = + paragraphs_(paragraph, paragraph_id); + + const internal::abstract::ElementAdapter *adapter = m_impl->element_adapter(); + const ElementIdentifier identifier = + paragraphs->paragraph_insert_after(paragraph_id); + return {adapter, identifier, adapter->paragraph_adapter(identifier)}; +} + Filesystem Document::as_filesystem() const { if (std::shared_ptr files = m_impl->as_filesystem()) { diff --git a/src/odr/document.hpp b/src/odr/document.hpp index 2e90e227e..4d62f09b2 100644 --- a/src/odr/document.hpp +++ b/src/odr/document.hpp @@ -10,6 +10,7 @@ namespace odr::internal::abstract { class Document; +class ParagraphAdapter; } // namespace odr::internal::abstract namespace odr { @@ -19,6 +20,7 @@ class DocumentFile; class Element; class File; class Filesystem; +class Paragraph; class Text; /// Represents a document. @@ -80,6 +82,21 @@ class Document final { [[nodiscard]] Text insert_text_after(const Text &anchor, const std::string &text) const; + /// Splits @p paragraph after @p after - one of its descendants, or an + /// element that does not exist to move every child - into a new paragraph + /// of the same style. Refuses where an element between the two is one it + /// will not split. + [[nodiscard]] Paragraph split_paragraph(const Paragraph ¶graph, + const Element &after) const; + + /// @p paragraph takes the children of the paragraph after it, which then + /// goes. What @ref split_paragraph undoes. + void merge_paragraph_with_next(const Paragraph ¶graph) const; + + /// An empty paragraph after @p paragraph, of the same style. + [[nodiscard]] Paragraph + insert_paragraph_after(const Paragraph ¶graph) const; + /// @} /// The files the document is packaged from; empty for a document that is @@ -95,6 +112,9 @@ class Document final { [[nodiscard]] Text insert_text_(const Text &anchor, Placement where, const std::string &text) const; + [[nodiscard]] const internal::abstract::ParagraphAdapter * + paragraphs_(const Paragraph ¶graph, ElementIdentifier &identifier) const; + friend DocumentFile; }; diff --git a/src/odr/internal/abstract/document.hpp b/src/odr/internal/abstract/document.hpp index f7312004d..ebb63e3fb 100644 --- a/src/odr/internal/abstract/document.hpp +++ b/src/odr/internal/abstract/document.hpp @@ -321,6 +321,26 @@ class ParagraphAdapter { paragraph_style(ElementIdentifier element_id) const = 0; [[nodiscard]] virtual TextStyle paragraph_text_style(ElementIdentifier element_id) const = 0; + + /// Splits @p element_id after @p after_id - one of its descendants, or null + /// to move every child - into a new paragraph of the same style. + virtual ElementIdentifier + paragraph_split([[maybe_unused]] const ElementIdentifier element_id, + [[maybe_unused]] const ElementIdentifier after_id) const { + throw UnsupportedOperation(); + } + + /// Takes the children of the paragraph after @p element_id and removes it. + virtual void paragraph_merge_next( + [[maybe_unused]] const ElementIdentifier element_id) const { + throw UnsupportedOperation(); + } + + /// An empty paragraph after @p element_id, carrying the same style. + virtual ElementIdentifier paragraph_insert_after( + [[maybe_unused]] const ElementIdentifier element_id) const { + throw UnsupportedOperation(); + } }; class SpanAdapter { diff --git a/src/odr/internal/odf/odf_document.cpp b/src/odr/internal/odf/odf_document.cpp index f5c2b9b9b..01dd16a86 100644 --- a/src/odr/internal/odf/odf_document.cpp +++ b/src/odr/internal/odf/odf_document.cpp @@ -577,6 +577,26 @@ class ElementAdapter final : public AdapterBase { return get_intermediate_style(element_id).text_style; } + [[nodiscard]] ElementIdentifier + paragraph_split(const ElementIdentifier element_id, + const ElementIdentifier after_id) const override { + return TreeEditor(*m_registry).split(element_id, after_id); + } + + void paragraph_merge_next(const ElementIdentifier element_id) const override { + const ElementIdentifier next_id = element_next_sibling(element_id); + if (next_id == null_element_id || + element_type(next_id) != ElementType::paragraph) { + throw std::invalid_argument("no paragraph follows the one to merge into"); + } + TreeEditor(*m_registry).merge_next(element_id); + } + + [[nodiscard]] ElementIdentifier + paragraph_insert_after(const ElementIdentifier element_id) const override { + return TreeEditor(*m_registry).insert_sibling_after(element_id); + } + [[nodiscard]] ParagraphStyle paragraph_style(const ElementIdentifier element_id) const override { return get_intermediate_style(element_id).paragraph_style; diff --git a/src/odr/internal/ooxml/text/ooxml_text_document.cpp b/src/odr/internal/ooxml/text/ooxml_text_document.cpp index 9100eec10..f7b25943b 100644 --- a/src/odr/internal/ooxml/text/ooxml_text_document.cpp +++ b/src/odr/internal/ooxml/text/ooxml_text_document.cpp @@ -189,6 +189,26 @@ class ElementAdapter final : public AdapterBase { return get_intermediate_style(element_id).text_style; } + [[nodiscard]] ElementIdentifier + paragraph_split(const ElementIdentifier element_id, + const ElementIdentifier after_id) const override { + return TreeEditor(*m_registry).split(element_id, after_id); + } + + void paragraph_merge_next(const ElementIdentifier element_id) const override { + const ElementIdentifier next_id = element_next_sibling(element_id); + if (next_id == null_element_id || + element_type(next_id) != ElementType::paragraph) { + throw std::invalid_argument("no paragraph follows the one to merge into"); + } + TreeEditor(*m_registry).merge_next(element_id); + } + + [[nodiscard]] ElementIdentifier + paragraph_insert_after(const ElementIdentifier element_id) const override { + return TreeEditor(*m_registry).insert_sibling_after(element_id); + } + [[nodiscard]] ParagraphStyle paragraph_style(const ElementIdentifier element_id) const override { return get_intermediate_style(element_id).paragraph_style; diff --git a/src/odr/internal/xml/xml_tree_edit.hpp b/src/odr/internal/xml/xml_tree_edit.hpp index db6f4ba40..20dfde2cf 100644 --- a/src/odr/internal/xml/xml_tree_edit.hpp +++ b/src/odr/internal/xml/xml_tree_edit.hpp @@ -2,6 +2,9 @@ #include #include +#include + +#include #include @@ -49,8 +52,146 @@ template class TreeEditor final { m_registry->unlink_child(element_id); } + /// Splits @p element_id after @p after_id - one of its descendants, or null + /// to move every child - and answers the copy holding what followed. Every + /// element on the way up is split too, and only a span and a link are ones + /// it will split through. + ElementIdentifier split(const ElementIdentifier element_id, + const ElementIdentifier after_id) const { + ElementIdentifier stays_id = after_id; + ElementIdentifier level_id = element_id; + + if (after_id != null_element_id) { + level_id = m_registry->element_at(after_id).parent_id; + if (!is_ancestor_(element_id, level_id)) { + throw std::invalid_argument( + "TreeEditor::split: the element to split after is not a " + "descendant"); + } + for (ElementIdentifier at_id = level_id; at_id != element_id; + at_id = m_registry->element_at(at_id).parent_id) { + const ElementType type = m_registry->element_at(at_id).type; + if (type != ElementType::span && type != ElementType::link) { + throw UnsupportedOperation(); + } + } + } + + while (level_id != element_id) { + split_level_(level_id, stays_id); + stays_id = level_id; + level_id = m_registry->element_at(level_id).parent_id; + } + return split_level_(element_id, stays_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 = + m_registry->element_at(element_id).next_sibling_id; + if (next_id == null_element_id) { + throw std::invalid_argument("TreeEditor::merge_next: nothing follows"); + } + move_children_(next_id, null_element_id, element_id); + remove(next_id); + } + + /// An empty element of the same kind after @p element_id. + ElementIdentifier + insert_sibling_after(const ElementIdentifier element_id) const { + return clone_shell_after_(element_id); + } + private: Registry *m_registry{nullptr}; + + /// A copy of @p element_id right after it, with what the format says about + /// it and none of its content. + ElementIdentifier + clone_shell_after_(const ElementIdentifier element_id) const { + const pugi::xml_node node = m_registry->element_at(element_id).node; + const ElementType type = m_registry->element_at(element_id).type; + + pugi::xml_node copy = node.parent().insert_child_after( + node.name(), node_span(element_id).last); + for (const pugi::xml_attribute attribute : node.attributes()) { + copy.append_copy(attribute); + } + // `w:pPr`, `w:rPr` and their like sit ahead of the first child the + // registry knows, which is where the content starts and they end + const pugi::xml_node content = first_child_node_(element_id); + for (pugi::xml_node child = node.first_child(); child && child != content; + child = child.next_sibling()) { + copy.append_copy(child); + } + + const auto &[copy_id, unused] = m_registry->create_element(type, copy); + m_registry->insert_sibling_after(element_id, copy_id); + return copy_id; + } + + /// Copies @p element_id's shell and moves what follows @p after_id into it. + ElementIdentifier split_level_(const ElementIdentifier element_id, + const ElementIdentifier after_id) const { + const ElementIdentifier copy_id = clone_shell_after_(element_id); + move_children_(element_id, after_id, copy_id); + return copy_id; + } + + /// Moves the children of @p element_id after @p after_id - all of them where + /// that is null - to the end of @p target_id. + void move_children_(const ElementIdentifier element_id, + const ElementIdentifier after_id, + const ElementIdentifier target_id) const { + ElementIdentifier child_id = + after_id == null_element_id + ? m_registry->element_at(element_id).first_child_id + : m_registry->element_at(after_id).next_sibling_id; + while (child_id != null_element_id) { + const ElementIdentifier next_id = + m_registry->element_at(child_id).next_sibling_id; + move_child_(child_id, target_id); + child_id = next_id; + } + } + + void move_child_(const ElementIdentifier child_id, + const ElementIdentifier target_id) const { + const NodeSpan span = node_span(child_id); + pugi::xml_node target = m_registry->element_at(target_id).node; + // the moves invalidate `next_sibling`, so where the span ends is read + // first and each step reads its own successor before it moves + const pugi::xml_node end = span.last.next_sibling(); + for (pugi::xml_node node = span.first; node != end;) { + const pugi::xml_node next = node.next_sibling(); + target.append_move(node); + node = next; + } + + m_registry->unlink_child(child_id); + m_registry->append_child(target_id, child_id); + } + + /// Whether @p element_id is @p at_id or one of the elements above it. + [[nodiscard]] bool is_ancestor_(const ElementIdentifier element_id, + const ElementIdentifier at_id) const { + for (ElementIdentifier walk_id = at_id; walk_id != null_element_id; + walk_id = m_registry->element_at(walk_id).parent_id) { + if (walk_id == element_id) { + return true; + } + } + return false; + } + + /// The node @p element_id's first child starts at, null where it has none. + [[nodiscard]] pugi::xml_node + first_child_node_(const ElementIdentifier element_id) const { + const ElementIdentifier child_id = + m_registry->element_at(element_id).first_child_id; + return child_id == null_element_id ? pugi::xml_node() + : node_span(child_id).first; + } }; } // namespace odr::internal::xml diff --git a/test/src/document_edit_test.cpp b/test/src/document_edit_test.cpp index 0ee9b3fb1..567fc0571 100644 --- a/test/src/document_edit_test.cpp +++ b/test/src/document_edit_test.cpp @@ -14,6 +14,7 @@ #include #include #include +#include using namespace odr; using namespace odr::internal; @@ -388,3 +389,223 @@ TEST(DocumentEdit, inserting_a_run_beside_something_that_is_not_one_refuses) { EXPECT_THROW((void)document.insert_text_after(paragraph.as_text(), "x"), std::invalid_argument); } +/// Every paragraph of @p document, its runs joined. +namespace { + +std::vector paragraph_texts(const Document &document) { + std::vector result; + for (const Element child : document.root_element().children()) { + if (child.type() == ElementType::paragraph) { + result.push_back(text_of(child)); + } + } + return result; +} + +} // namespace + +TEST(DocumentEdit, a_paragraph_splits_after_the_run_it_names) { + const Document document = two_paragraph_text(); + + document.edit(ops(R"({"op":"splitParagraph","paragraph":)" + + id_of(paragraph_at(document, 0)) + R"(,"after":)" + + id_of(run_at(document, 0, 0)) + R"(,"id":-1})")); + + EXPECT_EQ(paragraph_texts(document), + (std::vector{"one ", "two three", "second"})); +} + +/// Enter at the very start of a paragraph. +TEST(DocumentEdit, a_paragraph_naming_nothing_to_split_after_moves_everything) { + const Document document = two_paragraph_text(); + + document.edit(ops(R"({"op":"splitParagraph","paragraph":)" + + id_of(paragraph_at(document, 0)) + R"(,"id":-1})")); + + EXPECT_EQ(paragraph_texts(document), + (std::vector{"", "one two three", "second"})); +} + +/// Enter at the very end of a paragraph. +TEST(DocumentEdit, a_split_after_the_last_run_leaves_an_empty_paragraph) { + const Document document = two_paragraph_text(); + + document.edit(ops(R"({"op":"splitParagraph","paragraph":)" + + id_of(paragraph_at(document, 0)) + R"(,"after":)" + + id_of(run_at(document, 0, 2)) + R"(,"id":-1})")); + + EXPECT_EQ(paragraph_texts(document), + (std::vector{"one two three", "", "second"})); +} + +TEST(DocumentEdit, a_split_inside_a_span_leaves_the_span_in_both_halves) { + const Document document = two_paragraph_text(); + const Element styled = run_at(document, 0, 1); + + document.edit(ops(R"({"op":"insertText","after":)" + id_of(styled) + + R"(,"text":"TWO","id":-1},)" + + R"({"op":"splitParagraph","paragraph":)" + + id_of(paragraph_at(document, 0)) + R"(,"after":)" + + id_of(styled) + R"(,"id":-2})")); + + EXPECT_EQ(paragraph_texts(document), + (std::vector{"one two", "TWO three", "second"})); + // the tail's run still sits under a span of its own, not under the paragraph + EXPECT_EQ(run_at(document, 1, 0).parent().type(), ElementType::span); +} + +/// What Enter in the middle of a run looks like on the wire. +TEST(DocumentEdit, enter_in_the_middle_of_a_run_is_three_ops) { + const Document document = two_paragraph_text(); + const Element run = run_at(document, 0, 0); + + document.edit(ops(R"({"op":"setText","id":)" + id_of(run) + + R"(,"text":"on"},)" + R"({"op":"insertText","after":)" + + id_of(run) + R"(,"text":"e ","id":-1},)" + + R"({"op":"splitParagraph","paragraph":)" + + id_of(paragraph_at(document, 0)) + R"(,"after":)" + + id_of(run) + R"(,"id":-2})")); + + EXPECT_EQ(paragraph_texts(document), + (std::vector{"on", "e two three", "second"})); +} + +TEST(DocumentEdit, a_paragraph_takes_the_one_after_it) { + const Document document = two_paragraph_text(); + + document.edit(ops(R"({"op":"mergeParagraph","paragraph":)" + + id_of(paragraph_at(document, 0)) + "}")); + + EXPECT_EQ(paragraph_texts(document), + (std::vector{"one two threesecond"})); +} + +/// Backspace at the start of a paragraph and then Enter again puts it back. +TEST(DocumentEdit, a_merge_and_a_split_undo_each_other) { + const Document document = two_paragraph_text(); + const std::string first = id_of(paragraph_at(document, 0)); + + document.edit(ops(R"({"op":"mergeParagraph","paragraph":)" + first + "}," + + R"({"op":"splitParagraph","paragraph":)" + first + + R"(,"after":)" + id_of(run_at(document, 0, 2)) + + R"(,"id":-1})")); + + EXPECT_EQ(paragraph_texts(document), + (std::vector{"one two three", "second"})); +} + +TEST(DocumentEdit, a_merge_with_nothing_after_it_refuses) { + const Document document = two_paragraph_text(); + + EXPECT_THROW(document.edit(ops(R"({"op":"mergeParagraph","paragraph":)" + + id_of(paragraph_at(document, 1)) + "}")), + std::invalid_argument); +} + +TEST(DocumentEdit, a_new_paragraph_is_inserted_after_the_one_it_names) { + const Document document = two_paragraph_text(); + + document.edit(ops(R"({"op":"insertParagraph","after":)" + + id_of(paragraph_at(document, 0)) + R"(,"id":-1},)" + + R"({"op":"insertText","before":)" + + id_of(run_at(document, 0, 0)) + R"(,"text":"x","id":-2})")); + + EXPECT_EQ(paragraph_texts(document), + (std::vector{"xone two three", "", "second"})); +} + +TEST(DocumentEdit, a_later_op_names_a_paragraph_an_earlier_one_created) { + const Document document = two_paragraph_text(); + + document.edit(ops(R"({"op":"insertParagraph","after":)" + + id_of(paragraph_at(document, 0)) + R"(,"id":-1},)" + + R"({"op":"insertParagraph","after":-1,"id":-2})")); + + EXPECT_EQ(paragraph_texts(document).size(), 4U); +} + +TEST(DocumentEdit, an_op_naming_a_paragraph_that_is_not_one_refuses) { + const Document document = two_paragraph_text(); + + EXPECT_THROW(document.edit(ops(R"({"op":"mergeParagraph","paragraph":)" + + id_of(run_at(document, 0, 0)) + "}")), + std::invalid_argument); +} + +TEST(DocumentEdit, a_split_after_something_outside_the_paragraph_refuses) { + const Document document = two_paragraph_text(); + + EXPECT_THROW( + document.edit(ops(R"({"op":"splitParagraph","paragraph":)" + + id_of(paragraph_at(document, 0)) + R"(,"after":)" + + id_of(run_at(document, 1, 0)) + R"(,"id":-1})")), + std::invalid_argument); +} + +namespace { + +/// A paragraph whose middle run sits under a link, and one whose middle child +/// is a frame holding a paragraph of its own. +Document nested_text(const std::string &middle) { + const std::string source = + R"()" + R"()" + R"(one )" + + middle + + R"( three)"; + return DecodedFile( + open_strategy::open_file(std::make_shared(source), {}, + Logger::null())) + .as_document_file() + .document(); +} + +} // namespace + +TEST(DocumentEdit, a_split_inside_a_link_leaves_the_link_in_both_halves) { + const Document document = + nested_text(R"(two)"); + const Element linked = run_at(document, 0, 1); + + document.edit(ops(R"({"op":"insertText","after":)" + id_of(linked) + + R"(,"text":"TWO","id":-1},)" + + R"({"op":"splitParagraph","paragraph":)" + + id_of(paragraph_at(document, 0)) + R"(,"after":)" + + id_of(linked) + R"(,"id":-2})")); + + EXPECT_EQ(paragraph_texts(document), + (std::vector{"one two", "TWO three"})); + const Element tail = run_at(document, 1, 0).parent(); + ASSERT_EQ(tail.type(), ElementType::link); + EXPECT_EQ(tail.as_link().href(), "https://x.example"); +} + +/// A copy of a frame has no obvious meaning, so the split refuses. +TEST(DocumentEdit, a_split_through_something_that_is_not_a_span_refuses) { + const Document document = nested_text( + R"(two)" + R"()"); + const Element inner = run_at(document, 0, 1); + ASSERT_TRUE(inner); + + EXPECT_THROW( + document.edit(ops(R"({"op":"splitParagraph","paragraph":)" + + id_of(paragraph_at(document, 0)) + R"(,"after":)" + + id_of(inner) + R"(,"id":-1})")), + UnsupportedOperation); +} + +TEST(DocumentEdit, a_paragraph_edit_refuses_another_documents_element) { + const Document document = two_paragraph_text(); + const Document other = two_paragraph_text(); + const Paragraph paragraph = paragraph_at(other, 0).as_paragraph(); + ASSERT_TRUE(paragraph); + + EXPECT_THROW(document.merge_paragraph_with_next(paragraph), + std::invalid_argument); + EXPECT_THROW((void)document.insert_paragraph_after(paragraph), + std::invalid_argument); + EXPECT_THROW((void)document.split_paragraph(paragraph, Element()), + std::invalid_argument); +} diff --git a/test/src/document_test.cpp b/test/src/document_test.cpp index ce9f27b34..4875f9986 100644 --- a/test/src/document_test.cpp +++ b/test/src/document_test.cpp @@ -524,6 +524,62 @@ TEST(Document, edit_docx_across_runs) { "head tail and more"); } +namespace { + +/// Splits a paragraph of several runs after its first run and types on into +/// the new one - what Enter and then a keystroke produce. +std::pair +split_a_paragraph(const std::string &path, const std::string &output_name) { + std::string paragraph_path; + const Document document = edit_and_reload( + path, + [&](const Document &opened) { + const Element paragraph = paragraph_of_several_runs(opened); + EXPECT_TRUE(paragraph) << path << " holds no paragraph of three runs"; + paragraph_path = paragraph.document_path().to_string(); + const std::vector runs = runs_of(paragraph); + return nlohmann::json{{"version", 2}, + {"ops", + {{{"op", "setText"}, + {"id", runs.front().identifier()}, + {"text", "head"}}, + {{"op", "splitParagraph"}, + {"paragraph", paragraph.identifier()}, + {"after", runs.front().identifier()}, + {"id", -1}}, + {{"op", "insertText"}, + {"before", runs[1].identifier()}, + {"text", "tail "}, + {"id", -2}}}}} + .dump(); + }, + output_name); + + const DocumentPath head(paragraph_path); + return {text_of(document.root_element().navigate_path(head)), + text_of(document.root_element().navigate_path(head).next_sibling())}; +} + +} // namespace + +// Reopening is what proves the package the engine wrote is sound. +TEST(Document, edit_odt_splits_a_paragraph) { + const auto &[head, tail] = split_a_paragraph( + "odr-public/odt/style-various-1.odt", "style-various-1_edit_split.odt"); + + EXPECT_EQ(head, "head"); + EXPECT_TRUE(tail.starts_with("tail ")) << tail; +} + +TEST(Document, edit_docx_splits_a_paragraph) { + const auto &[head, tail] = + split_a_paragraph("odr-public/docx/style-various-1.docx", + "style-various-1_edit_split.docx"); + + EXPECT_EQ(head, "head"); + EXPECT_TRUE(tail.starts_with("tail ")) << tail; +} + TEST(Document, edit_docx_diff) { const Document document = edit_and_reload( "odr-public/docx/style-various-1.docx",