Skip to main content

odox_core/
edit.rs

1//! Changing the text of a paragraph, and what an edit may refuse.
2//!
3//! A paragraph's text is not one string in the tree. It is text nodes, inside
4//! spans and links and marks, with `text:s`, `text:tab` and `text:line-break`
5//! standing for the whitespace ODF will not write literally, and with elements
6//! that contribute no characters at all — a bookmark, a frame, a note, a field
7//! — sitting between them. An editor works on the flat string and this module
8//! maps the edit back: characters are deleted from the nodes that hold them,
9//! new text goes into the node at the insertion point, and every zero-length
10//! element stays where it was. DESIGN.md §11.
11//!
12//! The string the editor sees, [`text`], is built from the tree and not from
13//! the renderer's layout, because the two differ: the renderer draws a tab as
14//! four spaces and a footnote as its citation. Here a tab is a tab, and a
15//! footnote contributes nothing and is kept.
16//
17// Author: David M. Anderson
18// Built with AI assistance (Claude, Anthropic)
19
20use std::fmt;
21use std::ops::Range;
22
23use crate::xml::{Attribute, Element, Name, Node, Ns};
24
25/// Why an edit was not made. Each is a state of the document rather than a
26/// failure, and the window says which.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum Refused {
29    /// The cell is under a neighbour's span; the neighbour is the one to edit.
30    Covered,
31    /// The cell holds a formula, which this version does not edit.
32    Formula,
33    /// There is no such sheet, slide, shape or paragraph.
34    NotFound,
35}
36
37impl fmt::Display for Refused {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            Self::Covered => write!(f, "the cell is covered by a neighbour's span"),
41            Self::Formula => write!(f, "the cell holds a formula"),
42            Self::NotFound => write!(f, "nothing is there to edit"),
43        }
44    }
45}
46
47impl std::error::Error for Refused {}
48
49/// What one node contributes to a paragraph's text.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51enum Kind {
52    /// A text node, this many characters long.
53    Text(usize),
54    /// `text:s`, this many spaces.
55    Spaces(usize),
56    /// `text:tab`.
57    Tab,
58    /// `text:line-break`.
59    LineBreak,
60    /// An element that contributes nothing and is kept where it is.
61    Marker,
62}
63
64impl Kind {
65    fn len(self) -> usize {
66        match self {
67            Self::Text(n) | Self::Spaces(n) => n,
68            Self::Tab | Self::LineBreak => 1,
69            Self::Marker => 0,
70        }
71    }
72}
73
74/// One node's place in the paragraph's text.
75#[derive(Debug)]
76struct Segment {
77    /// The route from the paragraph to the node.
78    path: Vec<usize>,
79    /// Where its characters begin.
80    start: usize,
81    kind: Kind,
82}
83
84/// Whether an element holds text on the paragraph's behalf: a span, a link, a
85/// ruby or a field whose contents are the paragraph's own characters. Anything
86/// else inside a paragraph is a marker, and stays; so is one of these with
87/// nothing in it, which is what lets a split carry it to one side.
88fn is_inline_container(element: &Element) -> bool {
89    element.name.ns == Ns::Text
90        && !element.children.is_empty()
91        && matches!(
92            &*element.name.local,
93            "span" | "a" | "bibliography-mark" | "ruby" | "ruby-base" | "meta" | "meta-field"
94        )
95}
96
97/// The containers above, whether or not they hold anything: what an edit may
98/// drop once it has emptied one.
99fn is_inline_container_name(element: &Element) -> bool {
100    element.name.ns == Ns::Text
101        && matches!(
102            &*element.name.local,
103            "span" | "a" | "bibliography-mark" | "ruby" | "ruby-base" | "meta" | "meta-field"
104        )
105}
106
107fn segments(paragraph: &Element) -> Vec<Segment> {
108    let mut out = Vec::new();
109    let mut path = Vec::new();
110    let mut at = 0;
111    collect(paragraph, &mut path, &mut at, &mut out);
112    out
113}
114
115fn collect(parent: &Element, path: &mut Vec<usize>, at: &mut usize, out: &mut Vec<Segment>) {
116    for (index, child) in parent.children.iter().enumerate() {
117        let kind = match child {
118            Node::Text(t) | Node::CData(t) => Kind::Text(t.chars().count()),
119            Node::Comment(_) | Node::ProcessingInstruction(_) => continue,
120            Node::Element(e) if e.is(&Ns::Text, "s") => {
121                Kind::Spaces(e.attr_usize(&Ns::Text, "c").unwrap_or(1))
122            }
123            Node::Element(e) if e.is(&Ns::Text, "tab") => Kind::Tab,
124            Node::Element(e) if e.is(&Ns::Text, "line-break") => Kind::LineBreak,
125            Node::Element(e) if is_inline_container(e) => {
126                path.push(index);
127                collect(e, path, at, out);
128                path.pop();
129                continue;
130            }
131            Node::Element(_) => Kind::Marker,
132        };
133        path.push(index);
134        out.push(Segment {
135            path: path.clone(),
136            start: *at,
137            kind,
138        });
139        path.pop();
140        *at += kind.len();
141    }
142}
143
144/// The paragraph's text as an editor sees it: every character the tree
145/// holds, in order, with ODF's whitespace elements as the characters they
146/// stand for.
147pub fn text(paragraph: &Element) -> String {
148    let mut out = String::new();
149    write_text(paragraph, &mut out);
150    out
151}
152
153fn write_text(parent: &Element, out: &mut String) {
154    for child in &parent.children {
155        match child {
156            Node::Text(t) | Node::CData(t) => out.push_str(t),
157            Node::Element(e) if e.is(&Ns::Text, "s") => {
158                for _ in 0..e.attr_usize(&Ns::Text, "c").unwrap_or(1) {
159                    out.push(' ');
160                }
161            }
162            Node::Element(e) if e.is(&Ns::Text, "tab") => out.push('\t'),
163            Node::Element(e) if e.is(&Ns::Text, "line-break") => out.push('\n'),
164            Node::Element(e) if is_inline_container(e) => write_text(e, out),
165            Node::Element(_) | Node::Comment(_) | Node::ProcessingInstruction(_) => {}
166        }
167    }
168}
169
170/// Replace a range of the paragraph's text, in characters, with new text.
171///
172/// The characters are removed from the nodes that hold them and the new text
173/// goes into the text node at the start of the range, or into a new one where
174/// none is there. Every element that contributes no characters stays where it
175/// was, so a bookmark inside the deleted range is a bookmark still. Whitespace
176/// is then written the way ODF requires, so the new text may hold tabs,
177/// newlines and runs of spaces.
178///
179/// A range past the end of the text is clamped to it.
180pub fn replace(paragraph: &mut Element, range: Range<usize>, with: &str) {
181    let len = text(paragraph).chars().count();
182    let start = range.start.min(len);
183    let end = range.end.clamp(start, len);
184    let added = with.chars().count();
185    // Where the replaced range begins inside a text node, the new text goes
186    // into that node before the old comes out, so that what replaces a bold
187    // word is bold and a span emptied by the edit is not emptied first.
188    let holder = segments(paragraph).into_iter().find(|s| {
189        matches!(s.kind, Kind::Text(_)) && s.start <= start && start < s.start + s.kind.len()
190    });
191    if start < end
192        && added > 0
193        && let Some(segment) = holder
194    {
195        insert_into(paragraph, &segment.path, start - segment.start, with);
196        cut(paragraph, start + added..end + added, |_| false);
197    } else {
198        cut(paragraph, start..end, |_| false);
199        insert(paragraph, start, with);
200    }
201    normalize(paragraph);
202}
203
204/// Split a paragraph at a character offset into the paragraph before it and
205/// the paragraph after, each with the original's style and attributes.
206///
207/// A zero-length element at the split point stays with the first half. The
208/// second half drops any `xml:id` or `text:id`, which name one element and
209/// cannot name two.
210pub fn split(paragraph: &Element, at: usize) -> (Element, Element) {
211    let len = text(paragraph).chars().count();
212    let at = at.min(len);
213    let mut first = paragraph.clone();
214    cut(&mut first, at..len, |start| start > at);
215    let mut second = paragraph.clone();
216    cut(&mut second, 0..at, |start| start <= at);
217    second.attrs.retain(|a| {
218        !(a.name.is(&Ns::Text, "id")
219            || a.name.local.as_ref() == "id" && a.name.prefix.as_deref() == Some("xml"))
220    });
221    normalize(&mut first);
222    normalize(&mut second);
223    (first, second)
224}
225
226/// Join a paragraph onto the end of another. The first keeps its style; the
227/// second's contents, markers included, follow the first's.
228pub fn join(first: &mut Element, second: Element) {
229    first.children.extend(second.children);
230    first.self_closing = false;
231    normalize(first);
232}
233
234/// What a paragraph becomes when its whole text is replaced by what an editor
235/// hands back: one paragraph, or several where the new text holds newlines
236/// the old one did not.
237///
238/// The old and the new text are compared from both ends, and only the middle
239/// they differ in is replaced, so spans and markers outside it are untouched
240/// and a marker inside it stays where it was. A newline inside that middle is
241/// a paragraph break: the paragraph is split there, each half with the
242/// original's style, and the newline itself is not kept. A newline outside
243/// it is a `text:line-break` the editor was shown and left alone.
244pub fn rewrite(paragraph: &Element, edited: &str) -> Vec<Element> {
245    let before = text(paragraph);
246    let old: Vec<char> = before.chars().collect();
247    let new: Vec<char> = edited.chars().collect();
248    let prefix = old.iter().zip(&new).take_while(|(a, b)| a == b).count();
249    let suffix = old[prefix..]
250        .iter()
251        .rev()
252        .zip(new[prefix..].iter().rev())
253        .take_while(|(a, b)| a == b)
254        .count();
255    let inserted: String = new[prefix..new.len() - suffix].iter().collect();
256
257    let mut whole = paragraph.clone();
258    replace(&mut whole, prefix..old.len() - suffix, &inserted);
259
260    // The breaks, as offsets into the rewritten text, last first so that each
261    // split leaves the earlier offsets where they were.
262    let mut breaks: Vec<usize> = inserted
263        .chars()
264        .enumerate()
265        .filter(|(_, c)| *c == '\n')
266        .map(|(i, _)| prefix + i)
267        .collect();
268    breaks.reverse();
269    let mut after = Vec::new();
270    for at in breaks {
271        let (first, mut second) = split(&whole, at);
272        // The newline itself, which the split left at the head of the second.
273        replace(&mut second, 0..1, "");
274        after.push(second);
275        whole = first;
276    }
277    after.push(whole);
278    after.reverse();
279    after
280}
281
282/// Replace the paragraph at a path under a root with what the editor hands
283/// back, which may be several paragraphs. Answers how many now stand there.
284///
285/// # Errors
286///
287/// The path leads to nothing, or to something that is not a paragraph.
288pub fn apply(root: &mut Element, path: &[usize], edited: &str) -> Result<usize, Refused> {
289    let (last, above) = path.split_last().ok_or(Refused::NotFound)?;
290    let parent = root.at_mut(above).ok_or(Refused::NotFound)?;
291    let Some(Node::Element(paragraph)) = parent.children.get(*last) else {
292        return Err(Refused::NotFound);
293    };
294    if !(paragraph.is(&Ns::Text, "p") || paragraph.is(&Ns::Text, "h")) {
295        return Err(Refused::NotFound);
296    }
297    let paragraphs = rewrite(paragraph, edited);
298    let count = paragraphs.len();
299    parent
300        .children
301        .splice(*last..=*last, paragraphs.into_iter().map(Node::Element));
302    Ok(count)
303}
304
305/// Join the paragraph at a path onto the paragraph before it among its
306/// parent's children, and answer where the joined paragraph is.
307///
308/// # Errors
309///
310/// The path leads to nothing, or there is no paragraph before it.
311pub fn join_with_previous(root: &mut Element, path: &[usize]) -> Result<Vec<usize>, Refused> {
312    let (last, above) = path.split_last().ok_or(Refused::NotFound)?;
313    let parent = root.at_mut(above).ok_or(Refused::NotFound)?;
314    let is_paragraph = |node: &Node| matches!(node, Node::Element(e) if e.is(&Ns::Text, "p") || e.is(&Ns::Text, "h"));
315    if !parent.children.get(*last).is_some_and(is_paragraph) {
316        return Err(Refused::NotFound);
317    }
318    let previous = parent.children[..*last]
319        .iter()
320        .rposition(is_paragraph)
321        .ok_or(Refused::NotFound)?;
322    // Whitespace between the two, which was between them and is now inside
323    // neither, goes too.
324    let Node::Element(second) = parent.children.remove(*last) else {
325        return Err(Refused::NotFound);
326    };
327    parent.children.drain(previous + 1..*last);
328    let Some(Node::Element(first)) = parent.children.get_mut(previous) else {
329        return Err(Refused::NotFound);
330    };
331    join(first, second);
332    let mut at = above.to_vec();
333    at.push(previous);
334    Ok(at)
335}
336
337/// Delete a range of characters, and any zero-length element whose position
338/// the predicate names.
339///
340/// Segments are visited last to first, so removing a node never moves one
341/// that is still to be visited.
342fn cut(paragraph: &mut Element, range: Range<usize>, drop_marker: impl Fn(usize) -> bool) {
343    for segment in segments(paragraph).into_iter().rev() {
344        let end = segment.start + segment.kind.len();
345        let overlap_start = range.start.max(segment.start);
346        let overlap_end = range.end.min(end);
347        let overlaps = overlap_start < overlap_end;
348        let remove = match segment.kind {
349            Kind::Marker => drop_marker(segment.start),
350            Kind::Tab | Kind::LineBreak => overlaps,
351            Kind::Spaces(n) => {
352                if !overlaps {
353                    continue;
354                }
355                let left = n - (overlap_end - overlap_start);
356                if left > 0
357                    && let Some(e) = paragraph.at_mut(&segment.path)
358                {
359                    set_count(e, left);
360                }
361                left == 0
362            }
363            Kind::Text(_) => {
364                if !overlaps {
365                    continue;
366                }
367                let Some((parent, index)) = parent_of(paragraph, &segment.path) else {
368                    continue;
369                };
370                let Some(Node::Text(t) | Node::CData(t)) = parent.children.get_mut(index) else {
371                    continue;
372                };
373                let kept: String = t
374                    .chars()
375                    .enumerate()
376                    .filter(|(i, _)| {
377                        let at = segment.start + i;
378                        !(overlap_start..overlap_end).contains(&at)
379                    })
380                    .map(|(_, c)| c)
381                    .collect();
382                *t = kept;
383                t.is_empty()
384            }
385        };
386        if remove && let Some((parent, index)) = parent_of(paragraph, &segment.path) {
387            parent.children.remove(index);
388            prune_emptied(paragraph, &segment.path[..segment.path.len() - 1]);
389        }
390    }
391}
392
393/// A span or a link left with nothing in it says nothing, and goes; and so
394/// does the one it was in, if that is now empty too. One that was empty
395/// before the edit is not touched, because it is not this edit's: it never
396/// had a child removed.
397///
398/// Done as soon as the container empties, while the segments still to be
399/// visited are all before it and its own index is still right.
400fn prune_emptied(paragraph: &mut Element, container: &[usize]) {
401    let mut path = container.to_vec();
402    while !path.is_empty() {
403        let Some((parent, index)) = parent_of(paragraph, &path) else {
404            return;
405        };
406        match parent.children.get(index) {
407            Some(Node::Element(e)) if is_inline_container_name(e) && e.children.is_empty() => {
408                parent.children.remove(index);
409                path.pop();
410            }
411            _ => return,
412        }
413    }
414}
415
416/// Put text at a character offset.
417///
418/// Into the text node whose characters surround the offset; failing that, the
419/// one that ends there, then the one that begins there; failing that, as a new
420/// text node after whatever ends there, which puts new text after a bookmark
421/// at the same offset rather than before it.
422fn insert(paragraph: &mut Element, at: usize, with: &str) {
423    if with.is_empty() {
424        return;
425    }
426    let segments = segments(paragraph);
427    let text_at = |predicate: &dyn Fn(&Segment, usize) -> bool| {
428        segments
429            .iter()
430            .find(|s| matches!(s.kind, Kind::Text(_)) && predicate(s, s.start + s.kind.len()))
431    };
432    let target = text_at(&|s, end| s.start < at && at < end)
433        .or_else(|| text_at(&|_, end| end == at))
434        .or_else(|| text_at(&|s, _| s.start == at));
435    if let Some(segment) = target {
436        insert_into(paragraph, &segment.path, at - segment.start, with);
437        return;
438    }
439    // No text node touches the offset: a new one goes after the last segment
440    // ending at it, before the first beginning at it, or at the paragraph's
441    // end.
442    let after = segments
443        .iter()
444        .rev()
445        .find(|s| s.start + s.kind.len() == at)
446        .map(|s| s.path.clone());
447    let before = segments
448        .iter()
449        .find(|s| s.start >= at)
450        .map(|s| s.path.clone());
451    let node = Node::Text(with.to_owned());
452    if let Some(path) = after
453        && let Some((parent, index)) = parent_of(paragraph, &path)
454    {
455        parent.children.insert(index + 1, node);
456    } else if let Some(path) = before
457        && let Some((parent, index)) = parent_of(paragraph, &path)
458    {
459        parent.children.insert(index, node);
460    } else {
461        paragraph.children.push(node);
462        paragraph.self_closing = false;
463    }
464}
465
466/// Put text into the text node at a path, at a character offset within it.
467fn insert_into(paragraph: &mut Element, path: &[usize], offset: usize, with: &str) {
468    if let Some((parent, index)) = parent_of(paragraph, path)
469        && let Some(Node::Text(t) | Node::CData(t)) = parent.children.get_mut(index)
470    {
471        let byte = t.char_indices().nth(offset).map_or(t.len(), |(b, _)| b);
472        t.insert_str(byte, with);
473    }
474}
475
476/// The parent of the node a path leads to, and the node's index in it.
477fn parent_of<'a>(paragraph: &'a mut Element, path: &[usize]) -> Option<(&'a mut Element, usize)> {
478    let (last, above) = path.split_last()?;
479    Some((paragraph.at_mut(above)?, *last))
480}
481
482fn set_count(space: &mut Element, count: usize) {
483    if count <= 1 {
484        space.remove_attr(&Ns::Text, "c");
485    } else {
486        let name = space
487            .attrs
488            .iter()
489            .find(|a| a.name.is(&Ns::Text, "c"))
490            .map_or_else(
491                || {
492                    Name::new(
493                        space.name.prefix.as_deref().unwrap_or("text"),
494                        "c",
495                        Ns::Text,
496                    )
497                },
498                |a| a.name.clone(),
499            );
500        space.set_attr(name, count.to_string());
501    }
502}
503
504/// Write whitespace the way ODF requires.
505///
506/// A conforming reader collapses a run of spaces to one and drops the spaces
507/// at the start of a paragraph, and reads a literal tab or newline as a
508/// space. So a tab becomes `text:tab`, a newline `text:line-break`, a run of
509/// spaces one literal space and a `text:s` for the rest, and a run at the very
510/// start a `text:s` for all of them. Text nodes are split where an element has
511/// to go; nothing else in the tree is touched.
512fn normalize(paragraph: &mut Element) {
513    let prefix = paragraph
514        .name
515        .prefix
516        .as_deref()
517        .unwrap_or("text")
518        .to_owned();
519    let mut previous_was_space = true;
520    normalize_in(paragraph, &prefix, &mut previous_was_space);
521}
522
523fn normalize_in(parent: &mut Element, prefix: &str, previous_was_space: &mut bool) {
524    merge_text(parent);
525    let mut index = 0;
526    while index < parent.children.len() {
527        match &mut parent.children[index] {
528            Node::Text(t) | Node::CData(t) => {
529                let replacement = encode(t, prefix, previous_was_space);
530                match replacement {
531                    None => index += 1,
532                    Some(nodes) => {
533                        let count = nodes.len();
534                        parent.children.splice(index..=index, nodes);
535                        index += count;
536                    }
537                }
538            }
539            Node::Element(e) if is_inline_container(e) => {
540                normalize_in(e, prefix, previous_was_space);
541                index += 1;
542            }
543            Node::Element(e) => {
544                // Whitespace elements are whitespace; a marker is nothing,
545                // and what came before it still stands.
546                if e.is(&Ns::Text, "s") || e.is(&Ns::Text, "tab") || e.is(&Ns::Text, "line-break") {
547                    *previous_was_space = true;
548                }
549                index += 1;
550            }
551            Node::Comment(_) | Node::ProcessingInstruction(_) => index += 1,
552        }
553    }
554}
555
556/// Join text nodes that sit side by side into one.
557///
558/// The parser keeps an entity reference as a text node of its own, so a
559/// paragraph reads `a`, `&lt;`, `b` as three nodes; delete the middle one and
560/// the two left would serialize as one and parse back as one, which is a tree
561/// that is not equal to itself across a round trip. Joined here, it is.
562fn merge_text(parent: &mut Element) {
563    let mut index = 1;
564    while index < parent.children.len() {
565        let joinable = matches!(
566            (&parent.children[index - 1], &parent.children[index]),
567            (Node::Text(_), Node::Text(_))
568        );
569        if joinable {
570            let Node::Text(tail) = parent.children.remove(index) else {
571                unreachable!("matched a text node");
572            };
573            if let Node::Text(head) = &mut parent.children[index - 1] {
574                head.push_str(&tail);
575            }
576        } else {
577            index += 1;
578        }
579    }
580}
581
582/// The nodes a text node becomes, or `None` where it is already as ODF
583/// writes it.
584fn encode(text: &str, prefix: &str, previous_was_space: &mut bool) -> Option<Vec<Node>> {
585    let needs_work = text.contains(['\t', '\n', '\r'])
586        || text.contains("  ")
587        || (*previous_was_space && text.starts_with(' '));
588    if !needs_work {
589        if let Some(last) = text.chars().last() {
590            *previous_was_space = last == ' ';
591        }
592        return None;
593    }
594    let mut nodes = Vec::new();
595    let mut run = String::new();
596    let mut spaces = 0usize;
597    let flush_spaces = |nodes: &mut Vec<Node>,
598                        run: &mut String,
599                        spaces: &mut usize,
600                        previous_was_space: &mut bool| {
601        if *spaces == 0 {
602            return;
603        }
604        // One literal space where a space may be literal, and `text:s` for
605        // whatever a reader would otherwise collapse.
606        let mut counted = *spaces;
607        if !*previous_was_space {
608            run.push(' ');
609            counted -= 1;
610        }
611        if counted > 0 {
612            if !run.is_empty() {
613                nodes.push(Node::Text(std::mem::take(run)));
614            }
615            let mut s = Element::new(prefix, "s", Ns::Text);
616            if counted > 1 {
617                s.attrs.push(Attribute {
618                    name: Name::new(prefix, "c", Ns::Text),
619                    value: counted.to_string(),
620                });
621            }
622            nodes.push(Node::Element(s));
623        }
624        *spaces = 0;
625        *previous_was_space = true;
626    };
627    for c in text.chars() {
628        match c {
629            ' ' => spaces += 1,
630            // A carriage return is not a character ODF has a spelling for; a
631            // newline follows it wherever it was typed.
632            '\r' => {}
633            '\t' | '\n' => {
634                flush_spaces(&mut nodes, &mut run, &mut spaces, previous_was_space);
635                if !run.is_empty() {
636                    nodes.push(Node::Text(std::mem::take(&mut run)));
637                }
638                let local = if c == '\t' { "tab" } else { "line-break" };
639                nodes.push(Node::Element(Element::new(prefix, local, Ns::Text)));
640                *previous_was_space = true;
641            }
642            other => {
643                flush_spaces(&mut nodes, &mut run, &mut spaces, previous_was_space);
644                run.push(other);
645                *previous_was_space = false;
646            }
647        }
648    }
649    flush_spaces(&mut nodes, &mut run, &mut spaces, previous_was_space);
650    if !run.is_empty() {
651        nodes.push(Node::Text(run));
652    }
653    Some(nodes)
654}