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

## Unreleased

- A cell of an ods file takes a style: `Sheet::set_cell_style` and the
`setCellStyle` op write the fill, the horizontal alignment, and bold,
italic, underline, strikethrough, colour and size.

- **Fix**: in an ods file, a value written into a covered cell landed outside
the merge, and one written into an empty cell right of a merge failed. The
first now refuses, and the second lands in its cell.

- **Fix**: many xlsx fills showed a wrong colour. A cell now shows its fill,
theme colours and tints, italic, underline, strikethrough and every
alignment.
Expand Down
8 changes: 5 additions & 3 deletions docs/design/spreadsheet-editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ separate scripts. The coordinates are the ones an op names, never a DOM index.

## Cell formatting

Status: planned. The steps land as a stack, in this order:
Status: `.ods` writes a cell style. The steps land as a stack, in this order:

1. The xlsx reader reads what the writer writes: a solid fill from `fgColor`,
theme colours with their `tint`, italic, underline and strikethrough, and
Expand All @@ -219,7 +219,7 @@ Status: planned. The steps land as a stack, in this order:
| `bold`, `italic`, `underline`, `strikethrough` | a bool | `style:text-properties`, as for a run | `b`, `i`, `u`, `strike` in a `font` |
| `color` | `#rrggbb` | `fo:color` | `font/color/@rgb` |
| `size` | a length | `fo:font-size` | `font/sz`, in points |
| `align` | `left`, `center`, `right` or null | `fo:text-align` in `style:paragraph-properties` and `style:text-align-source="fix"`; null is `value-type` | `alignment/@horizontal`; null is `general` |
| `align` | `left`, `center` or `right` | `fo:text-align` in `style:paragraph-properties` and `style:text-align-source="fix"` | `alignment/@horizontal` |

The text keys are the ones of `setTextStyle`, and off is written, never
removed (`document-editing.md` decision 9). A cell has no `highlight`: `fill` is the
Expand All @@ -239,7 +239,7 @@ An `.ods` cell style and an `.xlsx` `xf` are shared by every cell naming them.
`style:family="table-cell"` automatic style that copies the one the cell
shows (its own, else the row's or the column's default) with the delta
applied, and points `table:style-name` at it. A formula cell and a rich cell
take a style: nothing in their content changes.
take a style: nothing in their content changes. A covered cell refuses.
- `.xlsx`: the writer appends a `font`, a `fill` and an `xf`, each only where
no equal one exists, with `applyFont`, `applyFill` and `applyAlignment` set,
sets `c/@s`, and saves `styles.xml`. A cell the file does not state is made
Expand Down Expand Up @@ -294,6 +294,8 @@ cell's inline style before the gesture, and one gesture is one undo step.
their own kinds. This also fixes `.xlsx` serials on the read side.
- Multi-line cells, insert and delete of rows and columns.
- A style for a whole row or column, so cells past the rendered extent take it.
- `align` back to the alignment by value type: `TableCellStyle` has no value
that says it.
- `.csv` save.
- Sheets past `spreadsheet_limit` or `spreadsheet_cell_limit` are not in the
page; the mode should say so where a view reports a `sheet_cut`.
Expand Down
42 changes: 42 additions & 0 deletions src/odr/document.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,38 @@ TextStyle parse_text_style(const nlohmann::json &json) {
return style;
}

/// The `style` of a `setCellStyle` op: the keys of `setTextStyle` but
/// `highlight`, and `fill` and `align`.
std::pair<TableCellStyle, TextStyle>
parse_cell_style(const nlohmann::json &json) {
TableCellStyle cell_style;
nlohmann::json text_keys = nlohmann::json::object();
for (const auto &[key, value] : json.items()) {
if (key == "fill") {
cell_style.background_color = value.is_null()
? Color(0, 0, 0, 0)
: parse_color(value.get<std::string>());
} else if (key == "align") {
const auto align = value.get<std::string>();
if (align == "left") {
cell_style.horizontal_align = HorizontalAlign::left;
} else if (align == "center") {
cell_style.horizontal_align = HorizontalAlign::center;
} else if (align == "right") {
cell_style.horizontal_align = HorizontalAlign::right;
} else {
throw std::invalid_argument("unknown alignment " + align);
}
} else if (key == "highlight") {
throw std::invalid_argument(
"a cell has no highlight; `fill` is its ground");
} else {
text_keys[key] = value;
}
}
return {cell_style, parse_text_style(text_keys)};
}

/// 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 @@ -270,6 +302,16 @@ void Document::edit(const std::string_view operations,
continue;
}

if (name == "setCellStyle") {
const auto [cell_style, text_style] =
parse_cell_style(operation.at("style"));
sheet_at(root_element(), operation.at("sheet").get<std::uint32_t>())
.set_cell_style(operation.at("column").get<std::uint32_t>(),
operation.at("row").get<std::uint32_t>(), cell_style,
text_style);
continue;
}

if (name == "setText") {
text_of(operation, "id")
.set_content(operation.at("text").get<std::string>());
Expand Down
19 changes: 19 additions & 0 deletions src/odr/document_element.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,25 @@ void Sheet::clear_cell(const std::uint32_t column,
set_cell(column, row, CellValue());
}

void Sheet::set_cell_style(const std::uint32_t column, const std::uint32_t row,
const TableCellStyle &cell_style,
const TextStyle &text_style) const {
if (!exists_()) {
return;
}
const auto stated = [](const auto &sides) {
return sides.right || sides.top || sides.left || sides.bottom;
};
if (cell_style.vertical_align || stated(cell_style.padding) ||
stated(cell_style.border) || cell_style.text_rotation ||
cell_style.wrap_text || text_style.font_name || text_style.font_shadow ||
text_style.background_color || text_style.font_position) {
throw UnsupportedOperation();
}
m_adapter2->sheet_set_cell_style(m_identifier, column, row, cell_style,
text_style);
}

TableStyle Sheet::style() const {
return exists_() ? m_adapter2->sheet_style(m_identifier) : TableStyle();
}
Expand Down
7 changes: 7 additions & 0 deletions src/odr/document_element.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,13 @@ class Sheet final : public ElementBase<internal::abstract::SheetAdapter> {
/// Takes the cell's value away, keeping the style it carries. Not the same
/// as writing an empty string.
void clear_cell(std::uint32_t column, std::uint32_t row) const;
/// States the set fields of both styles on the cell and leaves the rest; a
/// fill of alpha 0 takes the fill away.
/// @throws UnsupportedOperation where the cell cannot be styled, or a style
/// sets a field no engine writes.
void set_cell_style(std::uint32_t column, std::uint32_t row,
const TableCellStyle &cell_style,
const TextStyle &text_style) const;

[[nodiscard]] TableStyle style() const;
[[nodiscard]] TableColumnStyle column_style(std::uint32_t column) const;
Expand Down
10 changes: 10 additions & 0 deletions src/odr/internal/abstract/document.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,16 @@ class SheetAdapter {
virtual void sheet_set_cell(ElementIdentifier element_id,
std::uint32_t column, std::uint32_t row,
const CellValue &value) const = 0;
/// States the set fields of @p cell_style and @p text_style on the cell at
/// (@p column, @p row) and leaves the rest. The cell's content is untouched.
virtual void
sheet_set_cell_style([[maybe_unused]] ElementIdentifier element_id,
[[maybe_unused]] std::uint32_t column,
[[maybe_unused]] std::uint32_t row,
[[maybe_unused]] const TableCellStyle &cell_style,
[[maybe_unused]] const TextStyle &text_style) const {
throw UnsupportedOperation();
}

[[nodiscard]] virtual TableStyle
sheet_style(ElementIdentifier element_id) const = 0;
Expand Down
9 changes: 9 additions & 0 deletions src/odr/internal/odf/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,15 @@ unknown mimetype are tolerated.
the same walk `spreadsheet.js::runOf` makes over the page. A cell that holds
a formula, a link, a line break or several paragraphs refuses. Refusals are
decided before any node is cut.
- **A cell style.** `sheet_set_cell_style` claims the cell as a value write
does and points `table:style-name` at a fresh automatic style `ce<n>`
(`StyleRegistry::create_cell_style`): a copy of the automatic style the
cell shows (its own, else the row's or column's default) plus the delta, or
a child of a named one. One base and one delta make one style. The fill is
`fo:background-color`, the alignment `fo:text-align` with
`style:text-align-source="fix"`, and the text keys are `text_set_style`'s.
- **A covered position** refuses a value and a style. The index holds no
covered cell, so `is_covered` walks the row's DOM.
- **A repeated cell** is written by cutting the run: `claim_cell` copies the
`table:table-row` and the `table:table-cell` around the position and leaves
the original node as the one written, so its element survives.
Expand Down
95 changes: 82 additions & 13 deletions src/odr/internal/odf/odf_document.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,9 @@ class ElementAdapter final : public AdapterBase {
if (!writable(value)) {
throw UnsupportedOperation();
}
if (is_covered(element_id, column, row)) {
throw UnsupportedOperation();
}
const ElementRegistry::Sheet::Cell *cell =
m_registry->sheet_element_at(element_id).cell(column, row);

Expand Down Expand Up @@ -550,6 +553,40 @@ class ElementAdapter final : public AdapterBase {
drop_stale_results(element_id, column, row);
}

void sheet_set_cell_style(const ElementIdentifier element_id,
const std::uint32_t column, const std::uint32_t row,
const TableCellStyle &cell_style,
const TextStyle &text_style) const override {
const ElementRegistry::Sheet::Cell *cell =
m_registry->sheet_element_at(element_id).cell(column, row);

if (is_covered(element_id, column, row)) {
throw UnsupportedOperation();
}

ElementIdentifier cell_id = null_element_id;
if (cell == nullptr) {
cell_id = grow_to_cell(element_id, column, row);
} else {
cell_id = cell->element_id;
if (cell_id == null_element_id ||
m_registry->sheet_cell_element_at(cell_id).is_repeated) {
cell_id = claim_cell(element_id, column, row); // `cell` is stale after
}
}

pugi::xml_node node = get_node(cell_id);
const std::string name = m_document->style_registry().create_cell_style(
automatic_styles_of(node),
cell_style_name(element_id, cell_id, {column, row}), cell_style,
text_style);
pugi::xml_attribute attribute = node.attribute("table:style-name");
if (!attribute) {
attribute = node.prepend_attribute("table:style-name");
}
attribute.set_value(name.c_str());
}

/// The sheets of the document in the order an operation names them by.
[[nodiscard]] std::vector<ElementIdentifier> sheets_() const {
std::vector<ElementIdentifier> result;
Expand Down Expand Up @@ -1156,6 +1193,30 @@ class ElementAdapter final : public AdapterBase {
return row_entry->node;
}

/// Whether a span covers (@p column, @p row). The index holds no covered
/// cell, so the row's dom is asked ([ODF 1.2] 9.1.5).
[[nodiscard]] bool is_covered(const ElementIdentifier sheet_id,
const std::uint32_t column,
const std::uint32_t row) const {
const ElementRegistry::Sheet::Row *row_entry =
m_registry->sheet_element_at(sheet_id).row(row);
if (row_entry == nullptr) {
return false;
}
std::uint32_t begin = 0;
for (const pugi::xml_node cell : row_entry->node.children()) {
const std::string_view name = cell.name();
if (name != "table:table-cell" && name != "table:covered-table-cell") {
continue;
}
begin += cell.attribute("table:number-columns-repeated").as_uint(1);
if (column < begin) {
return name == "table:covered-table-cell";
}
}
return false;
}

/// Gives (@p column, @p row) an element of its own: cuts the row and the
/// cell run it is one position of, and states the `text:p` an empty cell
/// has none of. Reindexes: every pointer read before is stale.
Expand All @@ -1165,13 +1226,12 @@ class ElementAdapter final : public AdapterBase {
const ElementRegistry::Sheet &sheet =
m_registry->sheet_element_at(sheet_id);

const std::span<const ElementRegistry::Sheet::Cell> cells =
sheet.row_cells(*sheet.row(row));
const ElementRegistry::Sheet::Cell *cell_entry = sheet.cell(column, row);
const std::size_t cell_index = cell_entry - cells.data();
const std::uint32_t cell_begin =
cell_index == 0 ? 0 : cells[cell_index - 1].end;
pugi::xml_node cell_node = cell_entry->node;
// not the end of the cell before: a span leaves a gap after that one
const std::uint32_t cell_begin =
cell_entry->end -
cell_node.attribute("table:number-columns-repeated").as_uint(1);

// the row first: the cell keeps its node, so its own run is unmoved
split_row_at(sheet, row);
Expand Down Expand Up @@ -1562,6 +1622,22 @@ class ElementAdapter final : public AdapterBase {
get_partial_cell_style(const ElementIdentifier sheet_id,
const ElementIdentifier cell_id,
const TablePosition &position) const {
if (const char *style_name = cell_style_name(sheet_id, cell_id, position);
style_name != nullptr) {
if (const Style *style = m_document->style_registry().style(style_name);
style != nullptr) {
return style->resolved();
}
}
return {};
}

/// The style the cell shows: its own, else its row's or its column's
/// default. Null where none of them names one.
[[nodiscard]] const char *
cell_style_name(const ElementIdentifier sheet_id,
const ElementIdentifier cell_id,
const TablePosition &position) const {
const char *style_name = nullptr;

if (cell_id != null_element_id) {
Expand Down Expand Up @@ -1602,14 +1678,7 @@ class ElementAdapter final : public AdapterBase {
}
}

if (style_name != nullptr) {
if (const Style *style = m_document->style_registry().style(style_name);
style != nullptr) {
return style->resolved();
}
}

return {};
return style_name;
}
};

Expand Down
Loading
Loading