Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_file/src/office/edit.rs

8.3 KiB, 5 runs

created by r1870400018:22928, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Editing a document somebody else wrote: what the two prose vocabularies share.
2//!
3//! # The text a document holds is not in one place
4//!
5//! A sentence in a `.docx` is spread across as many `<w:t>` elements as the writer felt like -- a
6//! spell-check mark, a bookmark, a language change or a single bold word splits a run -- and an `.odt`
7//! spreads it across character data and `<text:span>` and `<text:s>`. So a find that looked inside one
8//! element at a time would miss every phrase a writer had touched, which is most of the interesting
9//! ones.
10//!
11//! The answer here is a [`Piece`]: one span of the source and the text it holds. A paragraph is a
12//! *group* of pieces, its text is their concatenation, and a match is found in the concatenation and
13//! then pushed back down onto the pieces it covered. The replacement lands whole in the piece holding
14//! the START of the match, so it keeps that run's formatting, and the rest of the match is removed
15//! from the pieces after it. Which is what a person doing it by hand would do.
16//!
17//! # Everything else in the file is copied
18//!
19//! Nothing here rebuilds a document. Each changed piece becomes one
20//! [`Splice`](oxedyne_fe2o3_text::xml::Splice), and a splice replaces bytes -- so the comments, the
21//! bookmarks, the tracked changes, the theme and the parts this code has never heard of arrive at the
22//! other end exactly as they left. See [`crate::office`] on why that is the whole point of the third
23//! verb.
24//!
25//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
26//! Anthropic Claude
27
28use oxedyne_fe2o3_core::prelude::*;
29use oxedyne_fe2o3_text::xml::Span;
30
31/// One find-and-replace, as a caller asks for it.
32#[derive(Clone, Debug, PartialEq)]
33pub struct Find {
34 pub find: String,
35 pub replace: String,
36 pub nth: Option<usize>, // 1-based; None means every occurrence
37}
38
39impl Find {
40
41 /// A replacement of every occurrence.
42 pub fn every(find: impl Into<String>, replace: impl Into<String>) -> Self {
43 Self { find: find.into(), replace: replace.into(), nth: None }
44 }
45
46 /// A replacement of one occurrence, counted from one in document order.
47 pub fn at(find: impl Into<String>, replace: impl Into<String>, nth: usize) -> Self {
48 Self { find: find.into(), replace: replace.into(), nth: Some(nth) }
49 }
50}
51
52/// One run of text in the source, and where it sits.
53///
54/// `span` is what a splice replaces when the text changes, and what it covers is the format's own
55/// affair: a `<w:t>` is claimed whole, because a replacement with a space at either end needs an
56/// attribute putting on the element, and an `.odt`'s character data is claimed as itself.
57#[derive(Clone, Debug)]
58pub struct Piece {
59 pub span: Span,
60 pub text: String, // with entity references resolved
61}
62
63impl Piece {
64
65 pub fn new(span: Span, text: impl Into<String>) -> Self {
66 Self { span, text: text.into() }
67 }
68}
69
70/// A piece whose text has changed, and what it now says.
71#[derive(Clone, Debug)]
72pub struct Change {
73 pub group: usize, // which group it belonged to
74 pub at: usize, // where in the group
75 pub piece: Piece,
76 // Empty where the whole of it was taken by a replacement in an earlier piece, which is the
77 // ordinary result of a match that spanned two runs.
78 pub text: String,
79}
80
81/// What one [`Find`] did, so a caller can refuse a `find` that matched nothing.
82#[derive(Clone, Debug, Default)]
83pub struct Tally {
84 pub found: usize, // occurrences the document held
85 pub changed: usize, // occurrences replaced
86}
87
88/// Applies a list of find-and-replace edits to grouped pieces, giving the pieces that changed.
89///
90/// The groups are the units a match may not cross -- a paragraph, a cell -- and they are in document
91/// order, because `nth` counts occurrences through the document and not through a paragraph.
92///
93/// An edit whose `find` is nowhere is an ERROR NAMING THE STRING, and so is an `nth` past the end. A
94/// silent no-op is the failure mode this is written against: a caller told "the document was edited"
95/// has no way to discover that one of its four replacements did nothing, and will report the document
96/// as changed.
97pub fn apply(groups: &[Vec<Piece>], edits: &[Find]) -> Outcome<(Vec<Change>, Vec<Tally>)> {
98 // The working text of every piece, which each edit in turn reads and writes. Sequential rather
99 // than parallel, so an edit sees what the one before it did -- the same rule a text editor's
100 // find-and-replace follows, and the only one under which two edits on overlapping text have a
101 // defined result.
102 let mut now: Vec<Vec<String>> = groups.iter()
103 .map(|g| g.iter().map(|p| p.text.clone()).collect())
104 .collect();
105 let mut tallies = Vec::with_capacity(edits.len());
106 for e in edits {
107 if e.find.is_empty() {
108 return Err(err!(
109 "An edit asked for an empty string to be found, which is every position in the \
110 document at once."; Invalid, Input));
111 }
112 if let Some(0) = e.nth {
113 return Err(err!(
114 "An edit asked for occurrence 0 of '{}'. Occurrences are counted from one.", e.find;
115 Invalid, Input, Range));
116 }
117 let mut tally = Tally::default();
118 for g in 0..now.len() {
119 let (joined, bounds) = join(&now[g]);
120 // Found before anything is decided, so the count is of the document as this edit met it.
121 let hits = hits_of(&joined, &e.find);
122 if hits.is_empty() {
123 continue;
124 }
125 let mut wanted = Vec::new();
126 for h in hits {
127 tally.found += 1;
128 match e.nth {
129 None => wanted.push(h),
130 Some(n) if n == tally.found => wanted.push(h),
131 Some(_) => {}
132 }
133 }
134 if wanted.is_empty() {
135 continue;
136 }
137 tally.changed += wanted.len();
138 now[g] = spread(&joined, &bounds, &wanted, e.find.len(), &e.replace);
139 }
140 if tally.found == 0 {
141 return Err(err!(
142 "'{}' is not in this document, so there was nothing to replace. Nothing has been \
143 changed. Read the document and quote a phrase it actually holds -- and remember that \
144 a writer's own formatting splits a sentence into runs, so a phrase broken by a \
145 footnote or a field will not be found as one string.", e.find;
146 Invalid, Input, NotFound));
147 }
148 if let Some(n) = e.nth {
149 if tally.changed == 0 {
150 return Err(err!(
151 "Occurrence {} of '{}' was asked for and the document holds {}. Nothing has been \
152 changed.", n, e.find, tally.found; Invalid, Input, Range));
153 }
154 }
155 tallies.push(tally);
156 }
157 // Only what actually moved becomes a splice, so a document whose edits all replaced text with
158 // itself is written back as the bytes it arrived as.
159 let mut out = Vec::new();
160 for (g, group) in groups.iter().enumerate() {
161 for (i, piece) in group.iter().enumerate() {
162 if now[g][i] != piece.text {
163 out.push(Change {
164 group: g,
165 at: i,
166 piece: piece.clone(),
167 text: now[g][i].clone(),
168 });
169 }
170 }
171 }
172 Ok((out, tallies))
173}
174
175/// The group's text, and where each piece starts and ends within it.
176fn join(pieces: &[String]) -> (String, Vec<Span>) {
177 let mut joined = String::new();
178 let mut bounds = Vec::with_capacity(pieces.len());
179 for p in pieces {
180 let start = joined.len();
181 joined.push_str(p);
182 bounds.push(start..joined.len());
183 }
184 (joined, bounds)
185}
186
187/// Where a needle occurs, non-overlapping, left to right.
188fn hits_of(haystack: &str, needle: &str) -> Vec<usize> {
189 let mut out = Vec::new();
190 let mut from = 0;
191 while let Some(k) = haystack[from..].find(needle) {
192 let at = from + k;
193 out.push(at);
194 from = at + needle.len();
195 }
196 out
197}
198
199/// The group's pieces, with the matches replaced.
200///
201/// The replacement goes in the piece holding the match's START -- so it wears that run's formatting,
202/// which is the formatting of the first character of what was replaced -- and the pieces after it lose
203/// only the part of the match they held.
204fn spread(
205 joined: &str,
206 bounds: &[Span],
207 hits: &[usize],
208 len: usize,
209 replace: &str,
210)
211 -> Vec<String>
212{
213 let mut out = Vec::with_capacity(bounds.len());
214 for b in bounds {
215 let mut text = String::new();
216 let mut at = b.start;
217 for hit in hits {
218 let (s, e) = (*hit, hit + len);
219 if e <= b.start || s >= b.end {
220 continue;
221 }
222 let from = s.max(b.start);
223 let to = e.min(b.end);
224 if at < from {
225 text.push_str(&joined[at..from]);
226 }
227 // The piece holding the start takes the replacement; the others take nothing, which is
228 // how a match spread over three runs collapses into the first of them.
229 if s >= b.start {
230 text.push_str(replace);
231 }
232 at = to;
233 }
234 if at < b.end {
235 text.push_str(&joined[at..b.end]);
236 }
237 out.push(text);
238 }
239 out
240}