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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ The release run heads these entries with the version and opens a fresh

## Unreleased

- 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.

- **Fix**: a `.docx` edit keeps the space at either end of a run, by stating
`xml:space="preserve"` on the `w:t` that needs it.

- **Breaking** (wire only): an edit operation names its element by the id the
render writes into the page, `data-odr-id`, not by a document path. The
envelope is `{"version": 2, ...}`; version 1 is refused. An editable render
Expand Down
12 changes: 12 additions & 0 deletions docs/design/document-editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,16 @@ Every other id is one the page wrote.
{"op": "mergeParagraph", "paragraph": 9}]
```

## Where the C++ API puts an edit

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
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.

## The adapter surface

Alongside `TextAdapter::text_set_content`, all defaulting to
Expand Down Expand Up @@ -255,8 +265,10 @@ Each step is a pull request that builds and tests on its own.

1. **Address by id.** `data-odr-id` on runs and paragraphs,
`Document::element_by_id`, `setText` by id, envelope version 2.
**Landed.**
2. **Runs come and go.** `insertText` and `removeElement`, the registry links
they need, odf and ooxml text. A selection spanning runs is replayable.
**Landed.**
3. **Paragraphs split and merge.** `splitParagraph`, `mergeParagraph`,
`insertParagraph`.
4. **The browser editor.** Model-first, owns the DOM mutation, records the ops,
Expand Down
3 changes: 3 additions & 0 deletions src/odr/definitions.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ using ElementIdentifier = std::uint64_t;

static constexpr ElementIdentifier null_element_id{0};

/// Which side of an anchor a new element goes on.
enum class Placement : std::uint8_t { before, after };

} // namespace odr
110 changes: 101 additions & 9 deletions src/odr/document.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
#include <sstream>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <utility>

#include <nlohmann/json.hpp>
Expand Down Expand Up @@ -127,18 +128,59 @@ void Document::edit(const std::string_view operations,
throw std::invalid_argument("unsupported edit version");
}

// an operation that creates an element states a negative id for it, and a
// later one names it by that number (`docs/design/document-editing.md`)
std::unordered_map<std::int64_t, ElementIdentifier> minted;

// the element @p field names, checked to be one this document holds
const auto element_of = [&](const nlohmann::json &operation,
const char *field) {
const auto identifier = operation.at(field).get<ElementIdentifier>();
const auto address = operation.at(field).get<std::int64_t>();
ElementIdentifier identifier{};
if (address < 0) {
const auto entry = minted.find(address);
if (entry == std::end(minted)) {
throw std::invalid_argument("element " + std::to_string(address) +
" has not been created");
}
identifier = entry->second;
} else {
identifier = static_cast<ElementIdentifier>(address);
}
const Element element = element_by_id(identifier);
if (!element) {
throw std::invalid_argument("element " + std::to_string(identifier) +
throw std::invalid_argument("element " + std::to_string(address) +
" not found");
}
return element;
};

// the run @p field names, refusing an element that is not one
const auto text_of = [&](const nlohmann::json &operation, const char *field) {
const Element element = element_of(operation, field);
const Text text = element.as_text();
if (!text) {
throw std::invalid_argument("element " +
std::to_string(element.identifier()) +
" is not a text element");
}
return text;
};

// the negative id an operation reserves, checked before anything is created
// so that a refusal changes nothing
const auto reserve = [&](const nlohmann::json &operation) {
const auto address = operation.at("id").get<std::int64_t>();
if (address >= 0) {
throw std::invalid_argument("a created element needs a negative id");
}
if (minted.contains(address)) {
throw std::invalid_argument("element " + std::to_string(address) +
" has been created twice");
}
return address;
};

for (const nlohmann::json &operation : json.at("ops")) {
const auto name = operation.at("op").get<std::string>();

Expand All @@ -151,14 +193,28 @@ void Document::edit(const std::string_view operations,
}

if (name == "setText") {
const Element element = element_of(operation, "id");
const Text text = element.as_text();
if (!text) {
throw std::invalid_argument("element " +
std::to_string(element.identifier()) +
" is not a text element");
text_of(operation, "id")
.set_content(operation.at("text").get<std::string>());
continue;
}

if (name == "insertText") {
const bool after = operation.contains("after");
if (after == operation.contains("before")) {
throw std::invalid_argument(
"insertText names one of `after` and `before`");
}
text.set_content(operation.at("text").get<std::string>());
const std::int64_t address = reserve(operation);
const Text anchor = text_of(operation, after ? "after" : "before");
const auto text = operation.at("text").get<std::string>();
const Text created = after ? insert_text_after(anchor, text)
: insert_text_before(anchor, text);
minted.emplace(address, created.identifier());
continue;
}

if (name == "removeElement") {
remove(element_of(operation, "id"));
continue;
}

Expand All @@ -181,6 +237,42 @@ Element Document::element_by_id(const ElementIdentifier identifier) const {
return {adapter, identifier};
}

ElementIdentifier Document::check_(const Element &element) const {
if (element_by_id(element.identifier()) != element) {
throw std::invalid_argument("element is not this document's");
}
return element.identifier();
}

void Document::remove(const Element &element) const {
m_impl->element_adapter()->element_remove(check_(element));
}

Text Document::insert_text_before(const Text &anchor,
const std::string &text) const {
return insert_text_(anchor, Placement::before, text);
}

Text Document::insert_text_after(const Text &anchor,
const std::string &text) const {
return insert_text_(anchor, Placement::after, text);
}

Text Document::insert_text_(const Text &anchor, const Placement where,
const std::string &text) const {
const internal::abstract::ElementAdapter *adapter = m_impl->element_adapter();
const ElementIdentifier anchor_id = check_(anchor);
const internal::abstract::TextAdapter *runs =
adapter->text_adapter(anchor_id);
if (runs == nullptr) {
throw std::invalid_argument("element " + std::to_string(anchor_id) +
" is not a text element");
}
const ElementIdentifier identifier =
runs->text_insert(anchor_id, where, text);
return {adapter, identifier, adapter->text_adapter(identifier)};
}

Filesystem Document::as_filesystem() const {
if (std::shared_ptr<internal::abstract::ReadableFilesystem> files =
m_impl->as_filesystem()) {
Expand Down
23 changes: 23 additions & 0 deletions src/odr/document.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class DocumentFile;
class Element;
class File;
class Filesystem;
class Text;

/// Represents a document.
class Document final {
Expand Down Expand Up @@ -65,13 +66,35 @@ class Document final {
/// exist where this document holds no such id.
[[nodiscard]] Element element_by_id(ElementIdentifier identifier) const;

/// @name Structural edits
/// Each throws `UnsupportedOperation` where the engine cannot write, and
/// `std::invalid_argument` for an element of another document.
/// @{

/// Removes @p element and its subtree; its identifier stays taken.
void remove(const Element &element) const;

/// A run beside @p anchor, in the same parent, so it takes the same style.
[[nodiscard]] Text insert_text_before(const Text &anchor,
const std::string &text) const;
[[nodiscard]] Text insert_text_after(const Text &anchor,
const std::string &text) const;

/// @}

/// The files the document is packaged from; empty for a document that is
/// one file.
[[nodiscard]] Filesystem as_filesystem() const;

private:
std::shared_ptr<internal::abstract::Document> m_impl;

/// @p element 's identifier, checked to be one this document holds.
[[nodiscard]] ElementIdentifier check_(const Element &element) const;

[[nodiscard]] Text insert_text_(const Text &anchor, Placement where,
const std::string &text) const;

friend DocumentFile;
};

Expand Down
17 changes: 17 additions & 0 deletions src/odr/internal/abstract/document.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// for the element model itself: `FrameAdapter` defaults its shape readers, and
// a default needs the complete type.
#include <odr/document_element.hpp>
#include <odr/exceptions.hpp>
#include <odr/quantity.hpp>

#include <cstdint>
Expand Down Expand Up @@ -107,6 +108,13 @@ class ElementAdapter {
element_navigate_path(ElementIdentifier element_id,
const DocumentPath &path) const = 0;

/// Removes @p element_id and its subtree; it keeps its id and stops being
/// reachable, so an id already handed out never names something else.
virtual void
element_remove([[maybe_unused]] const ElementIdentifier element_id) const {
throw UnsupportedOperation();
}

[[nodiscard]] virtual const TextRootAdapter *
text_root_adapter([[maybe_unused]] const ElementIdentifier element_id) const {
return nullptr;
Expand Down Expand Up @@ -332,6 +340,15 @@ class TextAdapter {
virtual void text_set_content(ElementIdentifier element_id,
const std::string &text) const = 0;

/// A run beside @p element_id, in the same parent, so it takes the same
/// style.
virtual ElementIdentifier
text_insert([[maybe_unused]] const ElementIdentifier element_id,
[[maybe_unused]] const Placement where,
[[maybe_unused]] const std::string &text) const {
throw UnsupportedOperation();
}

[[nodiscard]] virtual TextStyle
text_style(ElementIdentifier element_id) const = 0;
};
Expand Down
77 changes: 77 additions & 0 deletions src/odr/internal/common/element_registry.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <deque>
#include <limits>
#include <stdexcept>
Expand Down Expand Up @@ -123,6 +124,45 @@ class ElementRegistry {
parent.last_child_id);
}

/// Links @p child_id beside @p anchor_id. Indices, as @ref link_child wants
/// them, and the element's own chain rather than one a payload holds.
void insert_sibling_after(const ElementIdentifier anchor_id,
const ElementIdentifier child_id) {
insert_sibling_(anchor_id, child_id, Placement::after);
}
void insert_sibling_before(const ElementIdentifier anchor_id,
const ElementIdentifier child_id) {
insert_sibling_(anchor_id, child_id, Placement::before);
}

/// Unlinks @p child_id from that same chain; it keeps its id and its
/// payload and stops being reachable.
void unlink_child(const ElementIdentifier child_id) {
Element &child = element_at(child_id);
const Id parent_id = child.parent_id;
if (parent_id == null_element_id) {
throw std::invalid_argument(
"ElementRegistry::unlink_child: child has no parent");
}
const Id previous_id = child.previous_sibling_id;
const Id next_id = child.next_sibling_id;

if (previous_id != null_element_id) {
element_at(previous_id).next_sibling_id = next_id;
} else {
element_at(parent_id).first_child_id = next_id;
}
if (next_id != null_element_id) {
element_at(next_id).previous_sibling_id = previous_id;
} else {
element_at(parent_id).last_child_id = previous_id;
}

child.parent_id = null_element_id;
child.previous_sibling_id = null_element_id;
child.next_sibling_id = null_element_id;
}

protected:
~ElementRegistry() = default;

Expand Down Expand Up @@ -160,6 +200,43 @@ class ElementRegistry {
last_id = static_cast<Id>(child_id);
}

void insert_sibling_(const ElementIdentifier anchor_id,
const ElementIdentifier child_id,
const Placement where) {
Element &anchor = element_at(anchor_id);
const Id parent_id = anchor.parent_id;
if (parent_id == null_element_id) {
throw std::invalid_argument(
"ElementRegistry::insert_sibling: anchor has no parent");
}
Element &child = element_at(child_id);
if (child.parent_id != null_element_id) {
throw std::invalid_argument(
"ElementRegistry::insert_sibling: child already has a parent");
}

const Id previous_id = where == Placement::after
? static_cast<Id>(anchor_id)
: anchor.previous_sibling_id;
const Id next_id = where == Placement::after ? anchor.next_sibling_id
: static_cast<Id>(anchor_id);

child.parent_id = parent_id;
child.previous_sibling_id = previous_id;
child.next_sibling_id = next_id;

if (previous_id != null_element_id) {
element_at(previous_id).next_sibling_id = static_cast<Id>(child_id);
} else {
element_at(parent_id).first_child_id = static_cast<Id>(child_id);
}
if (next_id != null_element_id) {
element_at(next_id).previous_sibling_id = static_cast<Id>(child_id);
} else {
element_at(parent_id).last_child_id = static_cast<Id>(child_id);
}
}

void check_element_id(const ElementIdentifier id) const {
if (id == null_element_id) {
throw std::out_of_range(
Expand Down
Loading
Loading