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",