diff --git a/docs/design/README.md b/docs/design/README.md index a3c9728b0..f51e63526 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -73,7 +73,8 @@ - text changes - removal of content - adding paragraphs - - changing formatting: bold, italic, underline, highlight, font size + - changing formatting: bold, italic, underline, strikethrough, highlight, + colour, font size - plain text (txt) editing - spreadsheets: recompute cell values whose content has functions attached - open question: do this only offline (in `odr.core`), or also online (in JS)? diff --git a/docs/design/document-editing.md b/docs/design/document-editing.md index f48e5c949..2519a6f9c 100644 --- a/docs/design/document-editing.md +++ b/docs/design/document-editing.md @@ -11,9 +11,9 @@ Status: **landed.** The schema, the replay, the browser editor and the pptx write side are all in the code; each section says what is in and what is not. Scope of this work: an edit that spans several runs, a new paragraph, and a -delete or a replace that reaches across both. Inline formatting (bold, italic, -highlight) is *not* in it — decision 5 below says why the schema takes it later -without changing. +delete or a replace that reaches across both. Inline formatting is *not* in +it — decision 5 below says why the schema takes it later without changing, +and [Inline formatting](#inline-formatting) is the plan for it. ## What the editor has to express @@ -114,8 +114,8 @@ number used before it was created, or created twice, throws. Toggling bold on part of a run is, in both formats, "split the run, restyle the middle one". The split is decision 3's first two ops, and what is left is one -op naming whole runs — `setMark {ids, mark, on}`. No offsets, no new -addressing. +op naming whole runs — `setTextStyle {id, style}`, decisions 8 to 16. No +offsets, no new addressing. **Why it is not in this work:** the split is the same machinery either way, and ODF reaches a mark through a named automatic style it may have to create, which @@ -189,7 +189,8 @@ a path in a version-2 world names the wrong element rather than none. | `setCell` | `sheet`, `column`, `row`, `value` | unchanged; see [`spreadsheet-editing.md`](spreadsheet-editing.md) | Every `id` field on an op that creates an element is negative (decision 4). -Every other id is one the page wrote. +Every other id is one the page wrote. [Inline formatting](#inline-formatting) +adds `setTextStyle` to this table. ### The four reader gestures, as ops @@ -359,6 +360,226 @@ of the range land in a paragraph with no run: there is no run to anchor the edit to, so the edit is taken and changes nothing. Deleting one needs a gesture 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 +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. + +### What the reader changes, and where each format keeps it + +| On the wire | `TextStyle` | ODF `style:text-properties` | docx `w:rPr` | pptx `a:rPr` | +|---|---|---|---|---| +| `bold` | `font_weight` | `fo:font-weight="bold"` / `"normal"` | `` / `` | `b="1"` / `b="0"` | +| `italic` | `font_style` | `fo:font-style="italic"` / `"normal"` | `` / `` | `i="1"` / `i="0"` | +| `underline` | `font_underline` | `style:text-underline-style="solid"` / `"none"` | `` / `"none"` | `u="sng"` / `u="none"` | +| `strikethrough` | `font_line_through` | `style:text-line-through-style="solid"` / `"none"` | `` / `` | `strike="sngStrike"` / `"noStrike"` | +| `highlight` | `background_color` | `fo:background-color="#rrggbb"` / `"transparent"` | `` or `` (decision 14) | `` | +| `color` | `font_color` | `fo:color="#rrggbb"` | `` | `` | +| `size` | `font_size` | `fo:font-size="12pt"` | ``, in half-points | `sz="1200"`, in hundredths of a point | + +Every cell of the three format columns is what `odf_style.cpp`, +`ooxml_text_style.cpp` and `ooxml_presentation_style.cpp` read today, so a +saved file renders what the editor showed. The one exception is `w:shd`, and +decision 14 says what to do about it. The companions LibreOffice writes next +to an underline, `style:text-underline-width` and `style:text-underline-color`, +are optional in the schema and the writer leaves them out. + +### The op + +```json +{"op": "setTextStyle", "id": 41, + "style": {"bold": true, "highlight": "#ffff00", "size": "14pt"}} +``` + +| op | fields | what it does | +|---|---|---| +| `setTextStyle` | `id`, `style` | states the listed properties on one run; a property not listed is untouched | + +`style` holds any of the seven keys. The four toggles carry `true` or +`false`; `highlight` carries `#rrggbb` or `null` for none; `color` carries +`#rrggbb`; `size` carries a length as `Measure` spells it, which is what the +page's `font-size` already says. The envelope stays at version 2, since no +existing op changes meaning. + +### 8. The wire carries a value, not a toggle + +The sketch in [`editing.md`](editing.md) said `setMark {ids, mark, on}`. It +does not survive colour and size, which are not on or off, and it did not +survive a toggle either: "toggle bold" needs the run's current state, and the +browser has it while replay would have to look it up. So the browser resolves +the gesture and the wire carries the result. Replay stays a pure function of +the log, which is decision 4's argument again. + +### 9. Off is written, never removed + +`bold: false` writes `fo:font-weight="normal"`, `` or +`b="0"`. It never removes the property, because the cascade underneath may be +bold — a heading style, a list style — and the reader asked for not bold, not +for whatever the cascade says. The same holds for `highlight: null`, which is +`transparent` in ODF and `w:val="none"` in docx. `color` has no null in this +work; the open questions say why. + +### 10. One run per op + +`setText` names one run, and so does this. A gesture over twenty runs is +twenty ops. A list of ids would be shorter on the wire, but coalescing decides +it: two `setTextStyle` on one run merge into one op where the later keys win, +and one on a run a later `removeElement` takes away drops. With a list, both +become set arithmetic over ids. + +### 11. A run shares its container's style, so a mark gives it a container of its own + +`data-odr-id` sits on the registry's text element: a text node in ODF, a +`w:t` in docx, an `a:t` in pptx. All three take their style from the element +around them — a `text:span`, a `w:r`, an `a:r` — and share it with every +sibling inside it; `text_insert` relies on exactly that to give a new run the +style of its neighbour. So a mark on one run must first give that run a +container nobody else is in: + +- **docx and pptx** cut the `w:r` / `a:r` around the run, copying `w:rPr` / + `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. + +Marking part of a run is then what decision 5 said: `setText` and +`insertText` split the run, and `setTextStyle` names the middle one. The +browser emits the three; the engine sees whole runs only. + +### 12. ODF reaches a mark through a fresh automatic style, never an edited one + +An automatic style may be shared by any number of spans, so writing into one +restyles text the reader never selected. The writer instead adds a +`` to `content.xml`'s +`office:automatic-styles`, under a name unused in the registry's index (both +files' names are in it), and points the span at it. Its +`style:text-properties` are the span's old automatic style copied with the +delta applied; a named style on the span becomes the copy's +`style:parent-style-name` instead. The new node joins the index, so a +`document_edit_test` that renders after replay sees it. One style per distinct +result is enough — a map the writer keeps for the length of a replay — and +LibreOffice collapses duplicates on its next save either way. + +### 13. `w:rPr` is a sequence, and the writer keeps its order + +The schema orders the children of `w:rPr`, and Word refuses a file that +breaks it as unreadable. Of the elements this work writes the order is `w:b`, +`w:i`, `w:strike`, `w:color`, `w:sz`, `w:highlight`, `w:u`, `w:shd`. The writer +inserts each at its position among the children already there, and replaces +one already present. The complex-script twins `w:bCs`, `w:iCs` and `w:szCs` +follow their sibling, as Word writes them, so a run holding both scripts does +not come out half bold. `a:rPr` in pptx is attributes for five of the seven +and ordered children for `a:solidFill` and `a:highlight`, with the fill before +the highlight; `a:rPr` must be the first child of `a:r`, so a run without one +gets it made. + +### 14. Highlight is the character background, and docx spells it two ways + +This answers the open question in [`editing.md`](editing.md). ODF has one +property for it, `fo:background-color` on the text properties, and pptx has +`a:highlight` taking any colour. docx has `w:highlight`, which takes one of +sixteen names, and `w:shd`, which takes any colour and which the reader does +not read yet. The wire carries a colour, since that is what two of three +formats want. The docx writer spells one of the sixteen as `w:highlight` and +anything else as `w:shd w:val="clear" w:fill`, and the reader learns `w:shd` +on a run in the same step — otherwise a saved highlight renders as none on +reopen, which is drift decision 7 of [`editing.md`](editing.md) exists to +catch. A host that wants Word's palette offers the sixteen and never meets +`w:shd`. + +### 15. The editor writes the css the renderer writes + +An edited page must look like a fresh render of the saved file, which is what +makes the two comparable. `translate_text_style` puts the style inline on the +`x-s`, so the editor sets the same declarations on the same attribute: +`font-weight:bold`, `font-style:italic`, `text-decoration:underline`, +`text-decoration:line-through`, `color`, `background-color`, `font-size`. The +selection state a host shows (decision 16) reads the same declarations back. + +One thing to fix before copying it: the renderer writes two `text-decoration` +declarations for a run that is both underlined and struck through, and the +second wins, so such a run shows one line. It has to become one declaration, +`text-decoration:underline line-through`, and the editor writes that form. + +### 16. The gesture reaches the editor two ways, and both land in one function + +- **The host asks.** `odr.editing.format(style)` applies a partial style to + the current selection, since a mobile host has buttons and no keyboard. + For the buttons to show state, the editor reports the style of the + selection through `odr.onSelectionChange(style)`, one key per property and + a key left out where the selection is mixed. +- **The browser asks.** `formatBold`, `formatItalic`, `formatUnderline` and + `formatStrikeThrough` leave the refused list of decision 13 in + [`editing.md`](editing.md) and join the whitelist. Chrome raises them for + ctrl/cmd+B, I and U. They are chords, so they are the *shortcuts* class of + decision 12, and a host that keeps that class keeps these too. + +Formatting sits behind the scope gate, decision 14 of +[`editing.md`](editing.md): under `paragraph` every formatting gesture +refuses with `outOfScope`, whatever it covers, and only `document` takes it. +A host that offers the narrow scope today keeps offering exactly what it +tested. A collapsed caret inside a word marks the word, as Word does; a +collapsed caret at a word boundary refuses with `range`, and the open +questions hold what it should do instead. + +Undo needs nothing new. A step already holds its ops and the two halves of +taking it back; here the halves are the runs' old and new `style` attributes. + +### The adapter surface + +`Text::set_style(delta)` on the handle, beside `Text::set_content`, since the +run keeps its id and what changes is what it holds. It reaches +`TextAdapter::text_set_style(id, delta)`, defaulting to `UnsupportedOperation` +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` 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. + +### 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. +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. +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, + the word rule for a collapsed caret, and a check page in + `test/browser/text` asserting the log of each gesture. + +### Open questions + +- **A pending mark.** A caret between words that toggles bold means "what I + type next is bold". That needs an empty run the caret can sit in, and + Chrome places a caret in an empty inline unreliably. Refusing it is + honest and not what a reader expects. +- **Colour back to automatic.** docx has `w:color w:val="auto"`; ODF has + nothing but removal, which decision 9 forbids. Until this is answered a + colour once set can only become another colour. +- **A range over a text box.** A replace refuses one, because it holds text + the reader did not mean to lose. A mark loses nothing, so marking the runs + inside it would be safe. The whitelist rule says refuse until decided. +- **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. + ## Open questions - **A list item** is a paragraph in a list. Enter at the end of one should make diff --git a/docs/design/editing.md b/docs/design/editing.md index 391cadd29..f1dd9e7d7 100644 --- a/docs/design/editing.md +++ b/docs/design/editing.md @@ -35,9 +35,11 @@ file — with no live connection between the browser and C++. counterpart, since a `.txt` is not a document. - `back_translate` CLI replays an envelope onto a source document and `save`s it. -What is **not** done is inline formatting — bold, italic, underline, highlight. -Decision 5 of [`document-editing.md`](document-editing.md) says why the schema -takes it without changing. +What is **not** done is inline formatting — bold, italic, underline, +strikethrough, highlight, colour and size. Decision 5 of +[`document-editing.md`](document-editing.md) says why the schema takes it +without changing, and its [Inline formatting](document-editing.md#inline-formatting) +section is the plan. ## Decisions @@ -406,8 +408,8 @@ host's call. The core carries no policy, only one seam, | Scope | What the document editor takes | |---|---| -| `document` (default) | everything in decision 13 | -| `paragraph` | an edit that starts and ends in one paragraph | +| `document` (default) | everything in decision 13, and inline formatting once it lands | +| `paragraph` | an edit that starts and ends in one paragraph, and no formatting | Scope `paragraph` refuses Enter, Backspace at a paragraph start, a paste holding a line break, and a selection over two paragraphs. A paragraph is a @@ -437,14 +439,17 @@ Each per-editor document holds what its own step decided. | The op envelope and a replay that dispatches over it | landed | | The write side of the engines — odf, ooxml text, ooxml presentation | landed | | The browser editor, owning the edit and its own undo | landed | -| **Inline formatting** — bold, italic, underline, highlight | **not started** | +| **Inline formatting** — bold, italic, underline, strikethrough, highlight, colour, size | **not started**; planned in [`document-editing.md`](document-editing.md#inline-formatting) | Formatting is the one left, and decision 5 of [`document-editing.md`](document-editing.md) is why the schema takes it without changing: toggling a mark on part of a run is, in both formats, "split the run, restyle the middle one", and the split is already two operations we have. What -it needs is a `setMark {ids, mark, on}` and, for ODF, the automatic style a mark -is reached through — a style-registry change with nothing to do with the ops. +it needs is one op, `setTextStyle {id, style}`, carrying values rather than +toggles, and two things on the write side: a container of the run's own, +because a run shares its `text:span` / `w:r` / `a:r` with its siblings, and for +ODF the automatic style a mark is reached through. Decisions 8 to 16 there +hold the rest. The **conformance corpus** decision 7 asks for is still not built. What stands in for it is that both sides pin the same operation shapes: the browser check @@ -460,8 +465,11 @@ version drifting until a new check page caught it. - ~~Is a `data-odr-id` needed on structural elements as insertion anchors?~~ **Answered: on paragraphs**, which is what a split or an insert anchors on. Nothing above them needed one. -- Highlight in ODF/OOXML: character background vs. a highlight-specific property — - which maps cleanly to a single toggle? +- ~~Highlight in ODF/OOXML: character background vs. a highlight-specific property — + which maps cleanly to a single toggle?~~ **Answered:** the character + background, carried as a colour. docx spells it as `w:highlight` for its + sixteen names and `w:shd` for any other; decision 14 of + [`document-editing.md`](document-editing.md). - `element_is_editable` answers one bool, and ooxml text answers `true` for everything. A refusal needs a reason, because the reason is what a host puts on a snackbar (decision 7 in