Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 31 additions & 9 deletions docs/design/document-editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 `<w:r><w:rPr/></w:r>`
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).
81 changes: 81 additions & 0 deletions src/odr/document.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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 &paragraph,
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 &paragraph,
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 &paragraph) const {
ElementIdentifier paragraph_id{};
paragraphs_(paragraph, paragraph_id)->paragraph_merge_next(paragraph_id);
}

Paragraph Document::insert_paragraph_after(const Paragraph &paragraph) 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<internal::abstract::ReadableFilesystem> files =
m_impl->as_filesystem()) {
Expand Down
20 changes: 20 additions & 0 deletions src/odr/document.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

namespace odr::internal::abstract {
class Document;
class ParagraphAdapter;
} // namespace odr::internal::abstract

namespace odr {
Expand All @@ -19,6 +20,7 @@ class DocumentFile;
class Element;
class File;
class Filesystem;
class Paragraph;
class Text;

/// Represents a document.
Expand Down Expand Up @@ -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 &paragraph,
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 &paragraph) const;

/// An empty paragraph after @p paragraph, of the same style.
[[nodiscard]] Paragraph
insert_paragraph_after(const Paragraph &paragraph) const;

/// @}

/// The files the document is packaged from; empty for a document that is
Expand All @@ -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 &paragraph, ElementIdentifier &identifier) const;

friend DocumentFile;
};

Expand Down
20 changes: 20 additions & 0 deletions src/odr/internal/abstract/document.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 20 additions & 0 deletions src/odr/internal/odf/odf_document.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions src/odr/internal/ooxml/text/ooxml_text_document.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading