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

- `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.
Expand Down
37 changes: 23 additions & 14 deletions docs/design/document-editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -541,23 +544,24 @@ 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<Color>` 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

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,
Expand All @@ -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

Expand Down
69 changes: 69 additions & 0 deletions src/odr/document.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,23 @@

#include <odr/internal/abstract/document.hpp>
#include <odr/internal/common/filesystem.hpp>
#include <odr/quantity.hpp>
#include <odr/style.hpp>

#include <odr/internal/common/sheet_dependencies.hpp>
#include <odr/internal/util/file_util.hpp>

#include <algorithm>
#include <array>
#include <cctype>
#include <cstdint>
#include <cstdlib>
#include <fstream>
#include <memory>
#include <sstream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <unordered_map>
#include <utility>
#include <vector>
Expand Down Expand Up @@ -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<unsigned char>(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::uint32_t>(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<std::string_view, 6> 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<bool>() ? FontWeight::bold : FontWeight::normal;
} else if (key == "italic") {
style.font_style =
value.get<bool>() ? FontStyle::italic : FontStyle::normal;
} else if (key == "underline") {
style.font_underline = value.get<bool>();
} else if (key == "strikethrough") {
style.font_line_through = value.get<bool>();
} else if (key == "highlight") {
style.background_color = value.is_null()
? Color(0, 0, 0, 0)
: parse_color(value.get<std::string>());
} else if (key == "color") {
style.font_color = parse_color(value.get<std::string>());
} else if (key == "size") {
style.font_size = parse_font_size(value.get<std::string>());
} 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;
Expand Down Expand Up @@ -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<std::string>();
const std::int64_t address = reserve(operation);
Expand Down
9 changes: 5 additions & 4 deletions src/odr/document.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions src/odr/document_element.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) : "";
}
Expand Down
4 changes: 4 additions & 0 deletions src/odr/document_element.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,10 @@ class Text final : public ElementBase<internal::abstract::TextAdapter> {
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.
Expand Down
7 changes: 7 additions & 0 deletions src/odr/internal/abstract/document.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 7 additions & 3 deletions src/odr/internal/odf/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<n>`
(`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
Expand Down
76 changes: 72 additions & 4 deletions src/odr/internal/odf/odf_document.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ namespace odr::internal::odf {

namespace {
std::unique_ptr<abstract::ElementAdapter>
create_element_adapter(const Document &document, ElementRegistry &registry);
create_element_adapter(Document &document, ElementRegistry &registry);
}

Document::Document(const FileType file_type, const DocumentType document_type,
Expand Down Expand Up @@ -250,7 +250,7 @@ using AdapterBase = internal::RegistryElementAdapter<

class ElementAdapter final : public AdapterBase {
public:
ElementAdapter(const Document &document, ElementRegistry &registry)
ElementAdapter(Document &document, ElementRegistry &registry)
: AdapterBase(registry), m_document(&document) {}

[[nodiscard]] bool
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<ElementIdentifier, std::optional<std::string>>
m_charts;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1502,7 +1570,7 @@ class ElementAdapter final : public AdapterBase {
};

std::unique_ptr<abstract::ElementAdapter>
create_element_adapter(const Document &document, ElementRegistry &registry) {
create_element_adapter(Document &document, ElementRegistry &registry) {
return std::make_unique<ElementAdapter>(document, registry);
}

Expand Down
Loading
Loading