Skip to content

Spreadsheet editing: start with plain ods cells, then solve repeated and empty ones #766

Description

@andiwand

Editing is off for spreadsheets everywhere: odf::Document::is_editable returns true only for text/presentation/drawing (odf_document.cpp, // TODO fix spreadsheet editability), and xlsx returns false outright — and cannot save at all (ooxml_spreadsheet_document.cpp, UnsupportedOperation). So the practical target is ods, where save already rebuilds the package from content.xml.

The reason it is off is real, and it is the sparse sheet model. parse_sheet (odf_parser.cpp) only creates a sheet_cell Element for a cell that is non-empty; empty cells are recorded as a range in the sheet's position-keyed maps with null_element_id. And a cell with table:number-columns-repeated / number-rows-repeated gets one Element per column repeat, all pointing at the same pugi::xml_node, flagged is_repeated — which element_is_editable already refuses. Both are exactly the cases where writing through the current editor would be wrong: text_set_content splices the DOM of one text run, so writing a repeated cell writes all of its repeats, and an empty cell has no text run to splice at all.

First step: the cells that already work

A non-empty, non-repeated cell already has a paragraphtext subtree, element_is_editable already walks the parent chain and clears it, and DocumentPath already addresses a cell by position (DocumentPath::Cell, "cell" prefix) rather than by sibling index — so the path survives a sheet that is mostly holes. The missing piece is mostly the document-level gate.

  • Let is_editable be true for DocumentType::spreadsheet, so translate emits contenteditable + data-odr-path for those cells and html::edit can navigate back to them.
  • office:value has to move with the text. A cell carries office:value-type plus a typed attribute; the <text:p> is only the display form. Typing 42 into a float cell and rewriting only the paragraph leaves the old office:value behind, and LibreOffice reads the attribute — the cell silently reverts. So a cell edit is not a text edit: it needs to re-infer the type (or drop to string and clear the typed attributes), which is the first thing that pushes past modifiedText.
  • Formula cells (table:formula) have the same problem, worse: the text is a cached result. Simplest honest behaviour is to refuse, or to clear the formula and keep the literal.
  • A round-trip test through soffice --convert-to is the check that the saved package is actually valid, not just that the bytes changed.

The broader solution: repeated and empty cells

Both are the same shape — the element tree does not have a place to write to — and both are fixed at write time, not at parse time. Neither should inflate the model: expanding repeats or materialising every empty cell up front is what #762 is already dying from.

  • Repeated cells: split on write. Editing one cell of a number-columns-repeated="10" run means rewriting that run as up to three nodes (before / the edited one / after) and fixing the repeat counts, then repointing the affected registry entries. Same on the row axis with number-rows-repeated, which is the harder one because a row split rewrites whole <table:table-row> nodes.
  • Empty cells: materialise on write (edit empty spreadsheet cells #236). An empty cell has a position and a style but no Element. The write path needs to be able to take a DocumentPath::Cell position, create the <table:table-cell> (splitting the surrounding repeat run to make room), give it a <text:p>, and register the new Element — i.e. the same splitting primitive as above, plus insertion.
  • That primitive — give me a writable, unshared xml node for position (c, r) — is the piece worth designing first; both cases and any later style edit go through it. // TODO covered cells / // TODO mark as repeated in odf_parser.cpp sit next to it and are probably worth cleaning up in the same pass, since covered cells must never become writable targets.

What the diff protocol needs

html::edit accepts one thing: {"modifiedText": {path: string}}, and it hard-requires element.as_text(). An empty cell has no text element, so it can never appear in that map — the browser has nothing to attach data-odr-path to either, since translate_sheet writes empty cells as bare <td>. That means:

  • the renderer must mark editable cells (not only text runs) with a path, and
  • the diff needs a cell-shaped entry — value plus type — that edit can apply to a position that has no Element yet.

Worth deciding whether that is a new key alongside modifiedText or a general "modifiedCells" that supersedes the text case for sheets.

Scope

Step one is small and shippable on its own (gate + value/type handling + a round-trip test). The splitting/materialising work is the real body and only makes sense for ods until xlsx can save at all. Related: #236 (empty cells, subsumed by this), #238 (cell overflow), #762 (why nothing here may expand the model).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions