diff --git a/Cargo.lock b/Cargo.lock index 2d1b986b..8bffd34a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1763,7 +1763,7 @@ dependencies = [ name = "craft-wordmodel" version = "0.1.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "log", "proptest", "serde", diff --git a/crates/wordmodel/Cargo.toml b/crates/wordmodel/Cargo.toml index 92060204..7426a738 100644 --- a/crates/wordmodel/Cargo.toml +++ b/crates/wordmodel/Cargo.toml @@ -15,7 +15,7 @@ workspace = true test-support = [] [dependencies] -base64 = "0.22" +base64 = "0.23" log = "0.4" serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } diff --git a/crates/wordmodel/src/edit.rs b/crates/wordmodel/src/edit.rs new file mode 100644 index 00000000..4a841214 --- /dev/null +++ b/crates/wordmodel/src/edit.rs @@ -0,0 +1,1438 @@ +//! Range editing on a [`Document`]: insert text, split paragraphs, delete ranges across +//! paragraphs and tables, apply character/paragraph formatting, copy a range to a fragment and +//! paste a fragment. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use crate::para::{InlineObject, Run}; +use crate::props::{CharProps, ParaProps}; +use crate::{Block, Blocks, DocError, Document, Paragraph, Part, PartKind, Path, Pos, Result, para_block}; +use crate::{Table, table::MAX_TABLE_DEPTH}; + +/// A copied piece of a document: blocks, where the first and last paragraphs may be partial. +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +pub struct Fragment { + pub blocks: Vec, + /// Copies of the text box stories its shapes show, by the source document's part id. Pasting + /// gives each box a new story made from these. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub parts: BTreeMap, +} + +impl Fragment { + pub fn plain_text(&self) -> String { + self.to_document().plain_text(crate::StoryRef::Body) + } + /// The fragment as the body of a new document (default styles). + pub fn to_document(&self) -> Document { + let mut d = Document::new(); + d.body = self.blocks.iter().cloned().map(Arc::new).collect(); + d + } + /// Whether the fragment holds an equation (in tables too), which a copy also puts on the + /// system clipboard as MathML (#816). + pub fn has_equation(&self) -> bool { + let mut found = false; + for b in &self.blocks { + each_para(b, 0, &mut |p| found |= p.objects.iter().any(|o| matches!(o, InlineObject::Equation { .. }))); + } + found + } + /// The equation (its linear format and whether it is a display equation) when the fragment + /// is that one equation and nothing else but white space and empty paragraphs: a copy of it + /// also goes on the system clipboard in the MathML formats that equation editors read (#1297). + pub fn sole_equation(&self) -> Option<(&str, bool)> { + let mut found = None; + for b in &self.blocks { + let Block::Para(p) = b else { return None }; + for c in p.text.chars().filter(|c| !c.is_whitespace()) { + if c != crate::para::OBJ || found.is_some() { + return None; + } + // The paragraph's only visible character, so its first object. + match p.objects.first() { + Some(InlineObject::Equation { linear, display, .. }) => found = Some((linear.as_str(), *display)), + _ => return None, + } + } + } + found + } + /// The text a copy puts on the clipboard: [`Self::plain_text`] with symbol-font characters + /// (Wingdings…) as their Unicode equivalents. + pub fn clipboard_text(&self) -> String { + self.to_document().shown_text(crate::StoryRef::Body) + } + /// Add `piece` at the end, followed by a paragraph mark unless it already ends with one: how + /// Word's Spike collects what it cuts. The piece's text box stories get ids after this + /// fragment's, so pieces copied from different documents (or after an id was freed and reused) + /// never share one. + pub fn append_piece(&mut self, mut piece: Fragment) { + let Some(last) = piece.blocks.last() else { return }; + let ends_with_mark = piece.blocks.len() >= 2 && matches!(last, Block::Para(p) if p.is_empty()); + if !ends_with_mark { + let mut mark = Paragraph::new(); + if let Block::Para(p) = last { + mark.props = p.props.clone(); + } + piece.blocks.push(Block::Para(mark)); + } + let next = self.parts.keys().next_back().map_or(1, |k| k.saturating_add(1)); + let ids: BTreeMap = piece.parts.keys().zip(next..=u32::MAX).map(|(old, new)| (*old, new)).collect(); + let renumber = |b: &mut Block| { + each_para_mut(b, 0, &mut |p| { + let mut changed = false; + for o in p.objects.iter_mut() { + for slot in o.text_box_slots() { + if let Some(new) = slot.and_then(|old| ids.get(&old)) { + *slot = Some(*new); + changed = true; + } + } + } + if changed { + p.touch(); + } + }) + }; + piece.blocks.iter_mut().for_each(renumber); + for (old, mut part) in std::mem::take(&mut piece.parts) { + part.blocks.iter_mut().map(Arc::make_mut).for_each(renumber); + if let Some(new) = ids.get(&old) { + self.parts.insert(*new, part); + } + } + // The empty paragraph after the last mark is where the next piece starts. + if self.blocks.len() >= 2 && matches!(self.blocks.last(), Some(Block::Para(p)) if p.is_empty()) { + self.blocks.pop(); + } + self.blocks.append(&mut piece.blocks); + } + pub fn from_text(s: &str) -> Fragment { + let lines: Vec<&str> = s.split('\n').collect(); + Fragment { + blocks: lines.iter().map(|l| Block::Para(Paragraph::with_text(l.trim_end_matches('\r'), CharProps::default()))).collect(), + ..Default::default() + } + } +} + +/// Whether block `b`, in a block list the paragraph walk reaches at `depth`, has cells the walk +/// skips ([`crate::PARA_WALK_DEPTH`]). Bounded by that depth. +fn deeper_than_walk(b: &Block, depth: usize) -> bool { + match b { + Block::Para(_) => false, + Block::Table(t) => t + .rows + .iter() + .flat_map(|r| &r.cells) + .any(|c| depth + 1 > crate::PARA_WALK_DEPTH || c.blocks.iter().any(|x| deeper_than_walk(x, depth + 1))), + } +} + +fn order(a: &Pos, b: &Pos) -> (Pos, Pos) { + if a <= b { (a.clone(), b.clone()) } else { (b.clone(), a.clone()) } +} + +/// The blocks in `t`'s cells, row by row. +fn cell_blocks(t: &Table) -> impl DoubleEndedIterator { + t.rows.iter().flat_map(|r| r.cells.iter()).flat_map(|c| c.blocks.iter()).map(|b| &**b) +} + +/// How many tables deep `t` goes, itself included: 1 when its cells hold no table (without +/// recursion: a pasted fragment may be deep). +fn table_height(t: &Table) -> usize { + let mut most = 1; + let mut stack: Vec<(&Block, usize)> = cell_blocks(t).map(|b| (b, 1)).collect(); + while let Some((b, d)) = stack.pop() { + if let Block::Table(inner) = b { + let d = d.saturating_add(1); + most = most.max(d); + stack.extend(cell_blocks(inner).map(|cb| (cb, d))); + } + } + most +} + +/// `b` as blocks nesting at most `room` tables deep: a table that would go deeper is replaced by +/// its paragraphs (tables in its cells too, document order), as the DOCX reader does. +fn fit_tables(b: Block, room: usize, out: &mut Vec) { + match b { + Block::Table(t) if room == 0 => { + let mut stack: Vec<&Block> = cell_blocks(&t).rev().collect(); + while let Some(cb) = stack.pop() { + match cb { + Block::Para(p) => out.push(Block::Para(p.clone())), + Block::Table(inner) => stack.extend(cell_blocks(inner).rev()), + } + } + } + Block::Table(mut t) if table_height(&t) > room => { + for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) { + let mut kept = Vec::new(); + for cb in std::mem::take(&mut cell.blocks) { + fit_tables(Arc::unwrap_or_clone(cb), room.saturating_sub(1), &mut kept); + } + cell.blocks = kept.into_iter().map(Arc::new).collect(); + } + out.push(Block::Table(t)); + } + b => out.push(b), + } +} + +/// Nesting limit when following text boxes inside text boxes. +const MAX_BOX_DEPTH: usize = 8; +/// Most text box stories one paste may create (hostile documents can fan out). +const MAX_PASTED_BOXES: usize = 4096; +/// Most text box content (estimated bytes, see [`block_cost`]) one paste may copy: thousands of +/// boxes can show the same story, and each needs a story of its own. Blocks without text boxes +/// are shared between those stories, not copied, so they are not charged per box. +const MAX_PASTED_BOX_BYTES: usize = 32 << 20; + +/// What a paste may still spend on text box stories. +struct BoxBudget { + stories: usize, + bytes: usize, + /// The source stories being copied, innermost last: a box inside one of them showing it + /// again is a cycle. + active: Vec, + /// Each source story's cost ([`block_cost`]), worked out once per paste: a story too big for + /// what is left is not walked again for every box that shows it. + costs: BTreeMap, + /// Boxes left without a story because the paste's budget couldn't hold theirs. + skipped: usize, + /// A box the fragment has no story for may show this document's story of that id (a + /// fragment copied from this document without its parts). Off for another document's + /// fragment, whose ids mean nothing here. + local: bool, +} + +impl BoxBudget { + fn new() -> BoxBudget { + BoxBudget { stories: MAX_PASTED_BOXES, bytes: MAX_PASTED_BOX_BYTES, active: Vec::new(), costs: BTreeMap::new(), skipped: 0, local: true } + } +} + +/// Rough size of a block's content, and whether it holds text boxes (and so must be copied). +fn block_cost(b: &Block) -> (usize, bool) { + let (mut cost, mut boxes) = (std::mem::size_of::(), false); + each_para(b, 0, &mut |p| { + let items = p + .runs + .len() + .saturating_mul(std::mem::size_of::()) + .saturating_add(p.objects.len().saturating_mul(std::mem::size_of::())); + let objects = p.objects.iter().fold(0usize, |n, o| n.saturating_add(object_cost(o))); + cost = cost.saturating_add(p.text.len()).saturating_add(items).saturating_add(objects); + // The formatting a clone copies too: style ids, font names and the like in the runs, + // the mark and the paragraph's properties. + let runs = p.runs.iter().filter(|r| !r.props.is_empty()).fold(0usize, |n, r| n.saturating_add(serialized_len(&r.props))); + let props = serialized_len(&p.props).saturating_add(serialized_len(&p.mark)).saturating_add(p.section.as_ref().map_or(0, serialized_len)); + cost = cost.saturating_add(runs).saturating_add(props); + boxes |= box_ids(p).next().is_some(); + }); + (cost, boxes) +} + +/// Rough heap bytes a clone of `o` copies beyond its own storage: its strings and lists, and its +/// group members however deep. What it shares through `Arc` (pictures' embedded objects, charts, +/// freeforms, wrap polygons, gradients) is not copied, so not counted. +fn object_cost(o: &InlineObject) -> usize { + let float = |f: &crate::para::Float| f.alt.text.len().saturating_add(f.alt.title.len()); + let mut cost = 0usize; + let mut todo = vec![o]; + while let Some(o) = todo.pop() { + let add = match o { + InlineObject::Image { media, alt, float: f, .. } => media.len().saturating_add(alt.len()).saturating_add(float(f)), + InlineObject::Graphic { alt, float: f, .. } => alt.len().saturating_add(float(f)), + InlineObject::Shape { float: f, extra, .. } => { + let warp = extra.warp.as_ref().map_or(0, |w| { + let adj = w.adj.iter().map(|(name, _)| std::mem::size_of::<(String, i64)>().saturating_add(name.len())); + adj.fold(w.preset.len(), usize::saturating_add) + }); + float(f).saturating_add(warp) + } + InlineObject::Group { float: f, children, .. } => { + todo.extend(children.iter().map(|c| &c.obj)); + children.len().saturating_mul(std::mem::size_of::()).saturating_add(float(f)) + } + InlineObject::Field { instr, result, form, .. } => instr.len().saturating_add(result.len()).saturating_add(form.len()), + InlineObject::FieldStart { instr, .. } => instr.len(), + InlineObject::ControlStart { control } => std::mem::size_of::().saturating_add(serialized_len(control)), + InlineObject::NoteRef { custom, .. } => custom.len(), + InlineObject::BookmarkStart { name } | InlineObject::BookmarkEnd { name } => name.len(), + InlineObject::PermStart { group, editor, .. } => group.len().saturating_add(editor.len()), + InlineObject::Equation { linear, math, .. } => linear.len().saturating_add(math.heap_bytes()), + InlineObject::Ruby { base, ruby, lang, props, .. } => { + [base.len(), ruby.len(), lang.len(), std::mem::size_of::(), serialized_len(props)] + .into_iter() + .fold(0, usize::saturating_add) + } + InlineObject::Opaque { format, xml, text } => format.len().saturating_add(xml.len()).saturating_add(text.len()), + InlineObject::FieldEnd + | InlineObject::ControlEnd + | InlineObject::CommentStart { .. } + | InlineObject::CommentEnd { .. } + | InlineObject::PermEnd { .. } + | InlineObject::PositionalTab { .. } => 0, + }; + cost = cost.saturating_add(add); + } + cost +} + +/// Length of `v` written as JSON: at least the bytes of every string and list it owns. For the +/// nested formatting and control types, which share nothing through `Arc`. +fn serialized_len(v: &T) -> usize { + struct Count(usize); + impl std::io::Write for Count { + fn write(&mut self, b: &[u8]) -> std::io::Result { + self.0 = self.0.saturating_add(b.len()); + Ok(b.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut n = Count(0); + let _ = serde_json::to_writer(&mut n, v); + n.0 +} + +pub use crate::walk::{each_para, each_para_mut}; + +/// Whether a paragraph anywhere in `b` (also inside tables) uses paragraph or character style +/// `id` (tables to 16 levels deep). Table styles are the engine's `table.deleteStyle`'s job. +fn uses_style(b: &Block, id: &str, depth: usize) -> bool { + let is = |s: &Option| s.as_deref() == Some(id); + match b { + Block::Para(p) => is(&p.props.style) || is(&p.mark.style) || p.runs.iter().any(|r| is(&r.props.style)), + Block::Table(t) if depth < 16 => { + t.rows.iter().flat_map(|r| r.cells.iter()).flat_map(|c| c.blocks.iter()).any(|cb| uses_style(cb, id, depth + 1)) + } + Block::Table(_) => false, + } +} + +/// Point every paragraph and run in `b` that uses style `from` at `to`; returns how many +/// paragraphs changed. +fn restyle_block(b: &mut Block, from: &str, to: &Option, depth: usize) -> usize { + let fix = |s: &mut Option| { + if s.as_deref() == Some(from) { + s.clone_from(to); + true + } else { + false + } + }; + match b { + Block::Para(p) => { + let mut changed = fix(&mut p.props.style); + changed |= fix(&mut p.mark.style); + for r in &mut p.runs { + changed |= fix(&mut r.props.style); + } + if changed { + p.normalize(); + p.touch(); + } + usize::from(changed) + } + Block::Table(t) => { + let mut n = 0; + if depth < 16 { + for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) { + for cb in &mut cell.blocks { + if uses_style(cb, from, depth + 1) { + n += restyle_block(Arc::make_mut(cb), from, to, depth + 1); + } + } + } + } + n + } + } +} + +/// What [`Document::adopt_with`] renumbers in another document's content: media keys, lists, +/// note and comment stories, comment ids, text boxes (one it doesn't bring is dropped), revisions +/// (after ours, at `rev`; an id past the `revs` it brings is dropped) and styles (those kept under new ids; unstyled paragraphs take +/// `default_style` when it's given). +struct Relink<'a> { + media: &'a BTreeMap, + nums: &'a BTreeMap, + notes: &'a BTreeMap, + comments: &'a BTreeMap, + boxes: &'a BTreeMap, + /// Added to permission range ids: ours come first. + perms: u32, + rev: u32, + revs: u32, + styles: &'a BTreeMap, + default_style: Option<&'a str>, +} + +impl Relink<'_> { + fn block(&self, b: &mut Block) { + // Its own stack, like `each_para_mut`: tables however deeply nested are relinked (#1339). + let mut todo = vec![b]; + while let Some(b) = todo.pop() { + match b { + Block::Para(p) => self.para(p), + Block::Table(t) => { + self.style(&mut t.props.style); + self.shift_change(&mut t.props.fmt_change); + if let Some(c) = t.props.fmt_change.as_mut() { + self.style(&mut c.old.style); + } + for row in t.rows.iter_mut() { + self.shift_change(&mut row.props.fmt_change); + for cell in row.cells.iter_mut() { + self.shift_change(&mut cell.props.fmt_change); + todo.extend(cell.blocks.iter_mut().map(Arc::make_mut)); + } + } + } + } + } + } + + fn style(&self, id: &mut Option) { + if let Some(new) = id.as_ref().and_then(|i| self.styles.get(i)) { + *id = Some(new.clone()); + } + } + + /// A paragraph's style: unstyled ones take `default_style` when it's given. + fn para_style(&self, id: &mut Option) { + match (&*id, self.default_style) { + (None, Some(d)) => *id = Some(d.to_string()), + _ => self.style(id), + } + } + + fn num(&self, n: &mut Option) { + if let Some(n) = n.as_mut() + && n.num != 0 + { + n.num = self.nums.get(&n.num).copied().unwrap_or(0); + } + } + + /// Our id for the other document's revision `r`, if it has one. + fn rev_id(&self, r: u32) -> Option { + if r < self.revs { self.rev.checked_add(r) } else { None } + } + + fn shift_change(&self, c: &mut Option>>) { + match (c.as_ref().and_then(|c| self.rev_id(c.rev)), c.as_mut()) { + (Some(v), Some(x)) => x.rev = v, + _ => *c = None, + } + } + + fn para(&self, p: &mut Paragraph) { + self.num(&mut p.props.numbering); + self.para_style(&mut p.props.style); + for r in &mut p.runs { + self.style(&mut r.props.style); + } + self.style(&mut p.mark.style); + for c in p.runs.iter_mut().map(|r| &mut r.props).chain([&mut p.mark]) { + c.ins = c.ins.and_then(|r| self.rev_id(r)); + c.del = c.del.and_then(|r| self.rev_id(r)); + self.shift_change(&mut c.fmt_change); + // A tracked change's old formatting names styles and lists as the live one does. + if let Some(f) = c.fmt_change.as_mut() { + self.style(&mut f.old.style); + } + } + self.shift_change(&mut p.props.fmt_change); + if let Some(f) = p.props.fmt_change.as_mut() { + self.para_style(&mut f.old.style); + self.num(&mut f.old.numbering); + } + if let Some(s) = p.section.as_deref_mut() { + self.shift_change(&mut s.fmt_change); + } + match (p.props.num_change.as_ref().and_then(|c| self.rev_id(c.rev)), p.props.num_change.as_mut()) { + (Some(v), Some(c)) => c.rev = v, + _ => p.props.num_change = None, + } + for o in &mut p.objects { + self.object(o, 0); + } + p.touch(); + } + + fn object(&self, o: &mut InlineObject, depth: usize) { + // An id that names nothing here becomes one that names nothing at all. + let map = |m: &BTreeMap, id: &mut u32| *id = m.get(id).copied().unwrap_or(u32::MAX); + match o { + InlineObject::Image { media, .. } => { + if let Some(k) = self.media.get(media.as_str()) { + *media = k.clone(); + } + } + InlineObject::Graphic { graphic, .. } => { + let mapped = |m: &str| self.media.get(m).filter(|k| k.as_str() != m); + if graphic.items.iter().any(|i| matches!(i, crate::graphic::GraphicItem::Image { media, .. } if mapped(media).is_some())) { + for i in &mut Arc::make_mut(graphic).items { + if let crate::graphic::GraphicItem::Image { media, .. } = i + && let Some(k) = self.media.get(media.as_str()) + { + *media = k.clone(); + } + } + } + } + InlineObject::Group { children, .. } if depth < MAX_BOX_DEPTH => children.iter_mut().for_each(|c| self.object(&mut c.obj, depth + 1)), + InlineObject::NoteRef { id, .. } => map(self.notes, id), + InlineObject::CommentStart { id } | InlineObject::CommentEnd { id } => map(self.comments, id), + InlineObject::PermStart { id, .. } | InlineObject::PermEnd { id } => *id = id.saturating_add(self.perms), + _ => {} + } + for story in o.text_box_slots() { + if story.is_some_and(|id| !self.boxes.contains_key(&id)) { + *story = None; + } + } + } +} + +/// One past the highest permission range id in `blocks`. +fn perm_end<'a>(blocks: impl Iterator>) -> u32 { + let mut end = 0u32; + for b in blocks { + each_para(b, 0, &mut |p| { + for o in &p.objects { + if let InlineObject::PermStart { id, .. } | InlineObject::PermEnd { id } = o { + end = end.max(id.saturating_add(1)); + } + } + }); + } + end +} + +/// Text box story ids of a paragraph's shapes. +fn box_ids(p: &Paragraph) -> impl Iterator + '_ { + p.objects.iter().flat_map(InlineObject::text_boxes) +} + +impl Document { + /// Convert notes between footnotes and endnotes: footnotes (`foot`) become endnotes and + /// endnotes (`end`) footnotes, both at once to swap them. Each note's kind changes, as do its + /// reference marks wherever they are (the body, text boxes, the note's own mark) and the + /// note styles (Footnote Text and Footnote Reference to their endnote twins and back). + /// Returns how many notes changed. + pub fn convert_notes(&mut self, foot: bool, end: bool) -> usize { + use crate::para::NoteKind; + let flips: std::collections::HashSet = self + .parts + .iter() + .filter(|(_, p)| (foot && p.kind == PartKind::Footnote) || (end && p.kind == PartKind::Endnote)) + .map(|(id, _)| *id) + .collect(); + if flips.is_empty() { + return 0; + } + let swap = |s: &mut Option| { + let to = match s.as_deref() { + Some("FootnoteText") => "EndnoteText", + Some("EndnoteText") => "FootnoteText", + Some("FootnoteReference") => "EndnoteReference", + Some("EndnoteReference") => "FootnoteReference", + _ => return, + }; + *s = Some(to.to_string()); + }; + let refers = |p: &Paragraph| p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { id, .. } if flips.contains(id))); + // In a paragraph: flip the converted notes' marks and their runs' reference style; in a + // converted note's own story also the paragraph style. + let convert = |p: &mut Paragraph, in_note: bool| { + if !in_note && !refers(p) { + return; + } + let offs = p.object_offsets(); + let mut at = Vec::new(); + for (k, o) in p.objects.iter_mut().enumerate() { + if let InlineObject::NoteRef { kind, id, .. } = o + && flips.contains(id) + { + *kind = if *kind == NoteKind::Footnote { NoteKind::Endnote } else { NoteKind::Footnote }; + at.extend(offs.get(k).copied()); + } + } + // Reference offsets and run boundaries are ordered; do not restart at the first + // reference for every run, including runs after the last reference. + let mut at = at.into_iter().peekable(); + let mut start = 0usize; + for r in &mut p.runs { + crate::work::steps(1); + let end = start.saturating_add(r.len); + while at.peek().is_some_and(|&o| o < start) { + crate::work::steps(1); + at.next(); + } + if at.peek().is_some_and(|&o| o < end) { + swap(&mut r.props.style); + } + start = end; + } + if in_note { + swap(&mut p.props.style); + swap(&mut p.mark.style); + } + p.touch(); + }; + let touches = |b: &Block| { + let mut any = false; + each_para(b, 0, &mut |p| any |= refers(p)); + any + }; + for b in self.body.iter_mut() { + if touches(b) { + each_para_mut(Arc::make_mut(b), 0, &mut |p| convert(p, false)); + } + } + for (id, part) in self.parts.iter_mut() { + let own = flips.contains(id); + if own { + part.kind = if part.kind == PartKind::Footnote { PartKind::Endnote } else { PartKind::Footnote }; + } + for b in part.blocks.iter_mut() { + if own || touches(b) { + each_para_mut(Arc::make_mut(b), 0, &mut |p| convert(p, own)); + } + } + } + flips.len() + } + + /// Point every paragraph and run that uses paragraph or character style `from` at `to` + /// (`None`: the default style), in every story, tables included. Returns how many paragraphs + /// changed. (Tables' own table styles are retargeted by the engine's table style deletion.) + pub fn restyle(&mut self, from: &str, to: Option) -> usize { + let stories: Vec = + std::iter::once(crate::StoryRef::Body).chain(self.parts.keys().map(|k| crate::StoryRef::Part(*k))).collect(); + let mut n = 0; + for s in stories { + let Ok(bl) = self.story_mut(s) else { continue }; + for b in bl.iter_mut() { + if uses_style(b, from, 0) { + n += restyle_block(Arc::make_mut(b), from, &to, 0); + } + } + } + n + } + + /// Insert plain text at `pos` (`\n` inside `s` is a manual line break; use + /// [`Document::split_paragraph`] for paragraph breaks). Returns the position after it. + pub fn insert_text(&mut self, pos: &Pos, s: &str, props: &CharProps) -> Result { + let p = self.para_mut(pos.story, &pos.path)?; + let n = p.insert_text(pos.off, s, props)?; + Ok(Pos { off: pos.off + n, ..pos.clone() }) + } + + /// Insert an inline object at `pos`; returns the position after it. + pub fn insert_object(&mut self, pos: &Pos, obj: InlineObject, props: &CharProps) -> Result { + let p = self.para_mut(pos.story, &pos.path)?; + p.insert_object(pos.off, obj, props)?; + Ok(Pos { off: pos.off + crate::para::OBJ.len_utf8(), ..pos.clone() }) + } + + /// Split the paragraph at `pos`; returns the start of the new (second) paragraph. + pub fn split_paragraph(&mut self, pos: &Pos) -> Result { + let i = pos.path.last(); + let tail = self.para_mut(pos.story, &pos.path)?.split_off(pos.off)?; + let bl = self.container_mut(pos.story, &pos.path)?; + bl.insert((i + 1).min(bl.len()), para_block(tail)); + Ok(Pos { story: pos.story, path: pos.path.with_last(i + 1), off: 0 }) + } + + /// Insert a block before the block at `path` (same container). A table that would nest + /// deeper than [`MAX_TABLE_DEPTH`] is refused. + pub fn insert_block(&mut self, story: crate::StoryRef, path: &Path, block: Block) -> Result<()> { + if let Block::Table(t) = &block + && path.depth().saturating_add(table_height(t)) > MAX_TABLE_DEPTH + { + return Err(DocError::Invalid(format!("tables can't nest more than {MAX_TABLE_DEPTH} deep"))); + } + let i = path.last(); + let bl = self.container_mut(story, path)?; + if i > bl.len() { + return Err(DocError::BadPath(path.to_string())); + } + bl.insert(i, Arc::new(block)); + Ok(()) + } + + /// Remove the block at `path` (keeps at least one paragraph in the container). + pub fn remove_block(&mut self, story: crate::StoryRef, path: &Path) -> Result { + let i = path.last(); + let bl = self.container_mut(story, path)?; + if i >= bl.len() { + return Err(DocError::BadPath(path.to_string())); + } + let b = bl.remove(i); + if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) { + bl.push(para_block(Paragraph::new())); + } + Ok(Arc::try_unwrap(b).unwrap_or_else(|a| (*a).clone())) + } + + /// Delete `a..b`. Returns the position where the deletion collapsed to. On error the document + /// is unchanged: both ends are checked before anything is deleted. + pub fn delete_range(&mut self, a: &Pos, b: &Pos) -> Result { + let (a, b) = order(a, b); + if a == b { + return Ok(a); + } + if a.story != b.story { + return Err(DocError::Invalid("range spans stories".into())); + } + // Check both ends before the first edit, so an error never leaves the document half-deleted. + for p in [&a, &b] { + match self.para(p.story, &p.path) { + Some(para) if para.clamp(p.off) == p.off => {} + Some(_) => return Err(DocError::BadOffset(p.off)), + None => return Err(DocError::BadPath(p.path.to_string())), + } + } + // Content control markers whose partner is outside the range stay (the control is + // emptied, not broken). + let mut kept = self.kept_control_markers(&a, &b); + // The walk below groups `kept` by paragraph (`chunk_by`) and looks offsets up + // (`binary_search`), so it must be sorted; `kept_control_markers` returns it so, and this + // keeps a change there from quietly losing markers. + if !kept.is_sorted() { + kept.sort(); + } + let kept_in = |path: &Path| -> Vec { kept.iter().filter(|(p, _)| p == path).map(|(_, o)| *o).collect() }; + if a.path == b.path { + self.para_mut(a.story, &a.path)?.delete_except(a.off, b.off, &kept_in(&a.path))?; + return Ok(a); + } + if a.path.parent() == b.path.parent() { + // Same container: trim the ends, drop the blocks between, join. + let (ia, ib) = (a.path.last(), b.path.last()); + // Removing an already-empty paragraph must not impose its style or mark formatting + // on the following content. Test before trimming: deleting actual heading text is + // still an ordinary merge that keeps the first paragraph's properties. + let empty_start = self.para(a.story, &a.path).is_some_and(Paragraph::is_empty); + // Kept markers of the paragraphs in between move to the join. + let mut moved: Vec<(InlineObject, CharProps)> = Vec::new(); + // `kept` is sorted: one walk of each paragraph's objects and runs (#3614). + for group in kept.chunk_by(|x, y| x.0 == y.0) { + let Some((path, _)) = group.first() else { continue }; + if *path == a.path || *path == b.path { + continue; + } + let Some(p) = self.para(a.story, path) else { continue }; + let mut runs = p.run_ranges().peekable(); + for (off, o) in p.objects_with_offsets() { + if group.binary_search_by_key(&off, |(_, k)| *k).is_err() { + continue; + } + while runs.next_if(|(r, _)| r.end <= off).is_some() {} + let props = runs.peek().filter(|(r, _)| r.contains(&off)).map_or(&p.mark, |(_, c)| *c); + moved.push((o.clone(), props.clone())); + } + } + { + let pa = self.para_mut(a.story, &a.path)?; + let end = pa.len(); + pa.delete_except(a.off, end, &kept_in(&a.path))?; + for (o, props) in moved { + let at = pa.len(); + pa.insert_object(at, o, &props)?; + } + } + let mut tail = { + let pb = self.para_mut(b.story, &b.path)?; + pb.delete_except(0, b.off, &kept_in(&b.path))?; + pb.clone() + }; + let bl = self.container_mut(a.story, &a.path)?; + // Section breaks on the marks in between go with their paragraphs. + let between: Vec<_> = bl + .get(ia + 1..ib) + .unwrap_or_default() + .iter() + .filter_map(|b| match &**b { + Block::Para(p) => p.section.clone(), + Block::Table(_) => None, + }) + .collect(); + if ib < bl.len() && ia < ib { + bl.drain(ia + 1..=ib); + } + // The deleted marks include `a`'s, so a section break there goes too: the joined + // paragraph ends where `b`'s mark did, and its text takes the following section's + // setup, as in Word (#2127). + let sect = tail.section.take(); + let pa = self.para_mut(a.story, &a.path)?; + let keep_props = if empty_start { tail.props.clone() } else { pa.props.clone() }; + let keep_mark = empty_start.then(|| tail.mark.clone()); + pa.append(tail); + pa.props = keep_props; + if let Some(mark) = keep_mark { + pa.mark = mark; + } + let before = std::mem::replace(&mut pa.section, sect); + if a.story == crate::StoryRef::Body && a.path.0.len() == 1 { + // Nearest first, as "link to previous" resolved them before the delete. + let following = self.section_mut(a.path.last()); + for s in between.iter().rev().chain(before.iter()) { + following.absorb(s); + } + } + return Ok(a); + } + // Different containers (range enters or leaves a table): clear text in every paragraph + // inside, then remove the tables it covers whole (below). + // Whether `b` is at its paragraph's end, before that paragraph is trimmed. + let b_at_end = self.para(b.story, &b.path).is_some_and(|p| p.len() == b.off); + let paths = self.paths_between(&a, &b); + for p in paths.iter().rev() { + let (from, to) = { + let Some(para) = self.para(a.story, p) else { continue }; + let from = if *p == a.path { a.off } else { 0 }; + let to = if *p == b.path { b.off } else { para.len() }; + (from, to) + }; + self.para_mut(a.story, p)?.delete_except(from, to, &kept_in(p))?; + } + // Tables the range covers whole, in the innermost container holding both ends, go (#1198, + // #1844): those strictly between the ends, the one `a` is in when it starts at that + // table's very start, and the one `b` is in when it ends at that table's very end. A range + // that is one table and nothing else only empties it (Word's Delete keeps the table). + let common = a.path.0.iter().zip(&b.path.0).take_while(|(x, y)| x == y).count(); + let level = common - common % 3; + let (Some(&ia), Some(&ib)) = (a.path.0.get(level), b.path.0.get(level)) else { return Ok(a) }; + let (ia, ib) = (ia as usize, ib as usize); + if ia >= ib { + return Ok(a); + } + let prefix = a.path.0.get(..level).unwrap_or_default().to_vec(); + let block_path = |i: usize| { + let mut v = prefix.clone(); + v.push(i as u32); + Path(v) + }; + // Paragraphs inside the block at index `i` of the container, in document order. + let paths = self.para_paths(a.story); + let inside = |i: usize| -> Vec<&Path> { + let pre = block_path(i); + paths.iter().filter(|q| q.0.len() > pre.0.len() && q.0.starts_with(&pre.0)).collect() + }; + // A table with cells deeper than the paragraph walk goes has paragraphs `inside` never + // lists, so where it starts or ends isn't known: it is never taken as covered. + let walked = |i: usize| self.container(a.story, &block_path(i)).and_then(|bl| bl.get(i)).is_some_and(|b| !deeper_than_walk(b, level / 3)); + let a_covers = a.path.0.len() > level + 1 && a.off == 0 && walked(ia) && inside(ia).first() == Some(&&a.path); + let b_covers = b.path.0.len() > level + 1 && walked(ib) && inside(ib).last() == Some(&&b.path) && b_at_end; + let first = if a_covers { ia } else { ia + 1 }; + let last = if b_covers { ib } else { ib - 1 }; + let mut removed = 0; + { + let bl = self.container_mut(a.story, &block_path(ia))?; + for i in (first..=last.min(bl.len().saturating_sub(1))).rev() { + if matches!(bl.get(i).map(|b| &**b), Some(Block::Table(_))) { + bl.remove(i); + removed += 1; + } + } + if removed == 0 { + return Ok(a); + } + // A story or cell always ends with a paragraph. + if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) { + bl.push(para_block(Paragraph::new())); + } + } + // The paragraphs left between the ends join, as in a range within one container. + let at = if a_covers { Pos { story: a.story, path: block_path(ia), off: 0 } } else { a.clone() }; + let end = if b_covers { + Pos { story: a.story, path: block_path((ib + 1).saturating_sub(removed)), off: 0 } + } else { + Pos { story: a.story, path: block_path(ib.saturating_sub(removed)), off: 0 } + }; + let paras = |p: &Pos| p.path.0.len() == level + 1 && self.para(p.story, &p.path).is_some(); + if paras(&at) && paras(&end) && at.path <= end.path { + return self.delete_range(&at, &end); + } + if self.para(at.story, &at.path).is_some() { + return Ok(at); + } + Ok(self.clamp(&at)) + } + + /// Apply `f` to the character formatting of `a..b`. A collapsed range changes nothing (the + /// engine keeps "pending" formatting for the caret instead). + pub fn format_range(&mut self, a: &Pos, b: &Pos, f: &dyn Fn(&mut CharProps)) -> Result<()> { + let (a, b) = order(a, b); + for p in self.paths_between(&a, &b) { + let para = self.para_mut(a.story, &p)?; + let from = if p == a.path { a.off } else { 0 }; + let to = if p == b.path { b.off } else { para.len() }; + para.format(from, to, f)?; + // A fully selected paragraph also gets the mark formatted (Word does), but a hyperlink + // ends before the mark: a linked mark would carry the link into every paragraph Enter + // makes from it (issue #413). + if to == para.len() && (from == 0 || p != a.path) { + let style = para.mark.style.clone(); + f(&mut para.mark); + if para.mark.link.take().is_some() { + para.mark.style = style.filter(|s| s != "Hyperlink"); + } + } + } + Ok(()) + } + + /// The paragraphs paragraph formatting of `a..b` applies to: [`Self::paths_between`] without + /// the last paragraph when a non-empty range ends at its start (Select Paragraph ends there + /// to include the paragraph mark). A caret applies to its own paragraph (Word, #418). + pub fn para_paths_in(&self, a: &Pos, b: &Pos) -> Vec { + let (a, b) = order(a, b); + let mut paths = self.paths_between(&a, &b); + if b.off == 0 && a.path != b.path && paths.len() > 1 && paths.last() == Some(&b.path) { + paths.pop(); + } + paths + } + + /// Apply `f` to the paragraph properties of every paragraph `a..b` covers ([`Self::para_paths_in`]). + pub fn format_paragraphs(&mut self, a: &Pos, b: &Pos, f: &dyn Fn(&mut ParaProps)) -> Result<()> { + for p in self.para_paths_in(a, b) { + let para = self.para_mut(a.story, &p)?; + f(&mut para.props); + para.touch(); + } + Ok(()) + } + + /// Copy `a..b` into a fragment (with copies of the text boxes in it). + pub fn copy_range(&self, a: &Pos, b: &Pos) -> Fragment { + let mut blocks = self.copy_blocks(a, b); + // Half a content control (one marker without the other) isn't copied. + crate::control::balance_blocks(&mut blocks); + let parts = self.text_box_parts(&blocks); + Fragment { blocks, parts } + } + + /// A copy of the block at `path` (a whole table, say) as a fragment, with its text boxes. + pub fn copy_block(&self, story: crate::StoryRef, path: &Path) -> Option { + Some(self.fragment_of(vec![self.block(story, path)?.clone()])) + } + + /// A copy of rows `rows` of the table at `tp`, with the cells of each that reach into grid + /// columns `cols` (see [`crate::Table::slice`]), as a fragment holding that table. + pub fn copy_table_cells(&self, story: crate::StoryRef, tp: &Path, rows: (usize, usize), cols: (usize, usize)) -> Option { + let t = self.table(story, tp)?.slice(rows, cols); + if t.rows.is_empty() { + return None; + } + Some(self.fragment_of(vec![Block::Table(t)])) + } + + /// `blocks` as a fragment: half content controls dropped, text boxes copied along. + fn fragment_of(&self, mut blocks: Vec) -> Fragment { + crate::control::balance_blocks(&mut blocks); + let parts = self.text_box_parts(&blocks); + Fragment { blocks, parts } + } + + /// The table whose whole text `a..b` covers when the range runs across its cells: from the + /// start of its first paragraph to the end of its last. Of the tables holding both ends, the + /// outermost one covered whole (a table nested in a cell when the range covers just it). + /// `None` for a range inside one cell, or one that doesn't cover a whole table. + pub fn whole_table(&self, a: &Pos, b: &Pos) -> Option { + let (a, b) = order(a, b); + if a.story != b.story || a.off != 0 || a.path.parent() == b.path.parent() { + return None; + } + let mut paths: Option> = None; + // Table block paths are the prefixes of a cell paragraph's path one block deep, then + // every three steps (block, row, cell) deeper. + let mut n = 1usize; + while n.saturating_add(3) <= a.path.0.len() { + let pre = a.path.0.get(..n)?; + if !b.path.0.starts_with(pre) { + return None; + } + let tp = Path(pre.to_vec()); + if self.table(a.story, &tp).is_some() { + let paths = paths.get_or_insert_with(|| self.para_paths(a.story)); + let mut inside = paths.iter().filter(|p| p.0.starts_with(pre)); + if let Some(first) = inside.next() { + let last = inside.next_back().unwrap_or(first); + let end = self.para(a.story, last).map_or(0, Paragraph::len); + if *first == a.path && *last == b.path && b.off >= end { + return Some(tp); + } + } + } + n = n.saturating_add(3); + } + None + } + + /// The whole body as a fragment (with its text boxes), to insert into another document. + pub fn body_fragment(&self) -> Fragment { + let blocks: Vec = self.body.iter().map(|b| (**b).clone()).collect(); + let parts = self.text_box_parts(&blocks); + Fragment { blocks, parts } + } + + /// Another document's body as a fragment of this one, to insert: the media, lists, styles, + /// notes, comments and tracked changes it refers to come along (lists, notes, comments and + /// revisions renumbered so they can't collide with ours; a style we already have by id stays + /// ours), and its text boxes ride in [`Fragment::parts`]. + pub fn adopt(&mut self, src: Document) -> Fragment { + self.adopt_with(src, false) + } + + /// [`Self::adopt`]; with `keep_styles` its content keeps its own formatting (Word's + /// `w:matchSrc`): a style whose id we already have comes along under a new id, and its + /// document defaults go into its base styles, which its unstyled paragraphs then use. + pub fn adopt_with(&mut self, src: Document, keep_styles: bool) -> Fragment { + self.adopt_in(src, keep_styles, &mut 0) + } + + /// [`Self::adopt_with`], its permission range ids from at least `perms` on (and past ours); + /// `perms` moves past them. + fn adopt_in(&mut self, mut src: Document, keep_styles: bool, perms: &mut u32) -> Fragment { + let mut renamed = BTreeMap::new(); + let mut default_style = None; + if keep_styles { + if src.styles.get("Normal").is_none() { + Arc::make_mut(&mut src.styles).upsert(crate::styles::Style { id: "Normal".into(), name: "Normal".into(), ..Default::default() }); + } + // Ids given out here are taken too: two styles never get the same new one. + let mut given = std::collections::HashSet::new(); + let room = src.styles.styles.len().saturating_mul(2).saturating_add(self.styles.styles.len()).saturating_add(1); + for st in &src.styles.styles { + if self.styles.get(&st.id).is_some() { + let fresh = (1..=room) + .map(|n| format!("{}{n}", st.id)) + .find(|id| self.styles.get(id).is_none() && src.styles.get(id).is_none() && !given.contains(id)); + if let Some(fresh) = fresh { + given.insert(fresh.clone()); + renamed.insert(st.id.clone(), fresh); + } + } + } + default_style = Some(renamed.get("Normal").cloned().unwrap_or_else(|| "Normal".into())); + } + let restyle = |id: &mut Option| { + if let Some(new) = id.as_ref().and_then(|i| renamed.get(i)) { + *id = Some(new.clone()); + } + }; + let mut media = BTreeMap::new(); + for (key, data) in &src.media { + let ext = key.rsplit_once('.').map(|(_, e)| e).unwrap_or("png"); + media.insert(key.clone(), self.add_media(data.to_vec(), ext)); + } + let next_abs = self.numbering.abstracts.iter().map(|a| a.id.saturating_add(1)).max().unwrap_or(0); + let mut abs_map = BTreeMap::new(); + for (i, a) in src.numbering.abstracts.iter().enumerate() { + let id = next_abs.saturating_add(i as u32); + abs_map.insert(a.id, id); + let mut a = crate::numbering::AbstractNum { id, ..a.clone() }; + a.levels.iter_mut().for_each(|l| restyle(&mut l.style)); + Arc::make_mut(&mut self.numbering).abstracts.push(a); + } + let next_num = self.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1); + let mut nums = BTreeMap::new(); + for (i, n) in src.numbering.nums.iter().enumerate() { + let id = next_num.saturating_add(i as u32); + nums.insert(n.id, id); + let abstract_id = abs_map.get(&n.abstract_id).copied().unwrap_or(n.abstract_id); + let mut n = crate::numbering::Num { id, abstract_id, ..n.clone() }; + n.level_overrides.iter_mut().for_each(|(_, l)| restyle(&mut l.style)); + Arc::make_mut(&mut self.numbering).nums.push(n); + } + let renumber = |n: &mut Option| { + if let Some(n) = n.as_mut() + && n.num != 0 + { + n.num = nums.get(&n.num).copied().unwrap_or(0); + } + }; + // What a style in the other document doesn't set comes from its defaults there, and + // Word's own (no space, single lines) where those don't say. + let mut para_defaults = src.styles.default_para.clone(); + para_defaults.space_before.get_or_insert(0.0); + para_defaults.space_after.get_or_insert(0.0); + para_defaults.line_spacing.get_or_insert(crate::props::LineSpacing::Multiple(1.0)); + for st in &src.styles.styles { + let mut st = st.clone(); + // Our defaults stay ours. + st.default = false; + if keep_styles { + if let Some(new) = renamed.get(&st.id) { + st.name = format!("{} ({new})", st.name); + st.id = new.clone(); + } + restyle(&mut st.based_on); + restyle(&mut st.next); + restyle(&mut st.linked); + // A character style's runs get the defaults through their paragraph's style. + if st.based_on.is_none() && st.kind != crate::styles::StyleKind::Character { + let mut chr = src.styles.default_chr.clone(); + chr.overlay(&st.chr); + st.chr = chr; + let mut para = para_defaults.clone(); + para.overlay(&st.para); + st.para = para; + } + } + if self.styles.get(&st.id).is_none() { + renumber(&mut st.para.numbering); + Arc::make_mut(&mut self.styles).upsert(st); + } + } + let rev = u32::try_from(self.revisions.len()).unwrap_or(u32::MAX); + self.revisions.extend(src.revisions.iter().cloned()); + // Notes and comments get stories (and comments ids) of their own here; their text is + // relinked below, once every new id is known. + let mut notes = BTreeMap::new(); + for (id, part) in src.parts.iter().filter(|(_, p)| matches!(p.kind, PartKind::Footnote | PartKind::Endnote)) { + notes.insert(*id, self.add_part(part.kind, Vec::new())); + } + let mut comments = BTreeMap::new(); + let mut next_comment = self.comments.keys().next_back().map_or(0, |k| k.saturating_add(1)); + for (id, c) in &src.comments { + if src.parts.get(&c.part).is_some_and(|p| p.kind == PartKind::Comment) { + notes.insert(c.part, self.add_part(PartKind::Comment, Vec::new())); + comments.insert(*id, next_comment); + next_comment = next_comment.saturating_add(1); + } + } + let boxes: BTreeMap = src.parts.iter().filter(|(_, p)| p.kind == PartKind::TextBox).map(|(k, p)| (*k, p.clone())).collect(); + // Its permission ranges are numbered after ours, so no end of theirs closes one of ours. + let first_perm = perm_end(self.body.iter().chain(self.parts.values().flat_map(|p| p.blocks.iter()))).max(*perms); + *perms = first_perm.saturating_add(perm_end(src.body.iter().chain(src.parts.values().flat_map(|p| p.blocks.iter())))); + let link = Relink { + media: &media, + nums: &nums, + notes: ¬es, + comments: &comments, + boxes: &boxes, + perms: first_perm, + rev, + revs: u32::try_from(src.revisions.len()).unwrap_or(u32::MAX), + styles: &renamed, + default_style: default_style.as_deref(), + }; + let mut parts = boxes.clone(); + for part in parts.values_mut() { + part.blocks.iter_mut().for_each(|b| link.block(Arc::make_mut(b))); + } + // A note's or comment's text boxes get stories here now; the body's go with the fragment. + let mut budget = BoxBudget::new(); + for (old, new) in ¬es { + let Some(part) = src.parts.get(old) else { continue }; + let mut blocks: Vec = part.blocks.iter().map(|b| (**b).clone()).collect(); + blocks.iter_mut().for_each(|b| link.block(b)); + self.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget); + if let Some(p) = self.parts.get_mut(new) { + p.blocks = blocks.into_iter().map(Arc::new).collect(); + } + } + for (id, c) in &src.comments { + if let (Some(new), Some(part)) = (comments.get(id), notes.get(&c.part)) { + let parent = c.parent.and_then(|p| comments.get(&p).copied()); + // Its durable id is unique in its own document only: the writer gives the copy one. + self.comments.insert(*new, crate::Comment { parent, part: *part, durable_id: None, ..c.clone() }); + } + } + let mut blocks: Vec = src.body.iter().map(|b| (**b).clone()).collect(); + blocks.iter_mut().for_each(|b| link.block(b)); + Fragment { blocks, parts } + } + + /// [`Self::adopt_with`] as blocks to place in a story directly (a reader building one): its + /// text boxes already have stories of their own here. `perms` is the first permission range + /// id the reader hasn't given out (ranges it holds aren't in this document yet); it moves past + /// the adopted ones. + pub fn adopt_blocks(&mut self, src: Document, keep_styles: bool, perms: &mut u32) -> Vec { + let Fragment { mut blocks, parts } = self.adopt_in(src, keep_styles, perms); + let mut budget = BoxBudget::new(); + self.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget); + blocks + } + + /// Copies of the text box stories shapes in `blocks` show, nested boxes included. + fn text_box_parts(&self, blocks: &[Block]) -> BTreeMap { + let mut out = BTreeMap::new(); + let mut todo = Vec::new(); + for b in blocks { + each_para(b, 0, &mut |p| todo.extend(box_ids(p))); + } + while let Some(id) = todo.pop() { + if out.contains_key(&id) { + continue; + } + let Some(part) = self.parts.get(&id).filter(|p| p.kind == PartKind::TextBox) else { continue }; + for b in &part.blocks { + each_para(b, 0, &mut |p| todo.extend(box_ids(p))); + } + out.insert(id, part.clone()); + } + out + } + + fn copy_blocks(&self, a: &Pos, b: &Pos) -> Vec { + let (a, b) = order(a, b); + let mut out = Vec::new(); + if a.story != b.story { + return out; + } + if a.path.parent() == b.path.parent() { + let Some(bl) = self.container(a.story, &a.path) else { return out }; + for i in a.path.last()..=b.path.last() { + let Some(blk) = bl.get(i) else { break }; + match &**blk { + Block::Para(p) => { + let mut p = p.clone(); + let to = if i == b.path.last() { b.off } else { p.len() }; + let from = if i == a.path.last() { a.off } else { 0 }; + let _ = p.delete(to.min(p.len()), p.len()); + let _ = p.delete(0, from.min(p.len())); + if i == b.path.last() && to < self.para(a.story, &b.path).map(Paragraph::len).unwrap_or(0) { + p.section = None; + } + out.push(Block::Para(p)); + } + Block::Table(t) => out.push(Block::Table(t.clone())), + } + } + return out; + } + // Across the cells of a whole table: the table itself, not its cells' paragraphs (#1744). + if let Some(tp) = self.whole_table(&a, &b) + && let Some(blk) = self.block(a.story, &tp) + { + out.push(blk.clone()); + return out; + } + for p in self.paths_between(&a, &b) { + if let Some(para) = self.para(a.story, &p) { + let mut q = para.clone(); + let to = if p == b.path { b.off } else { q.len() }; + let from = if p == a.path { a.off } else { 0 }; + let _ = q.delete(to.min(q.len()), q.len()); + let _ = q.delete(0, from.min(q.len())); + q.section = None; + out.push(Block::Para(q)); + } + } + out + } + + /// Give every text box in `blocks` a new story of its own, copied from `parts` (or, for a + /// fragment without them, from this document's text box of that id). A box whose story can't + /// be found keeps no story rather than showing an unrelated one. + fn adopt_text_boxes(&mut self, blocks: &mut [Block], parts: &BTreeMap, depth: usize, budget: &mut BoxBudget) { + let mut any = false; + for b in blocks.iter() { + each_para(b, 0, &mut |p| any |= box_ids(p).next().is_some()); + } + if !any { + return; + } + for b in blocks.iter_mut() { + each_para_mut(b, 0, &mut |p| { + let mut changed = false; + for o in p.objects.iter_mut() { + for story in o.text_box_slots() { + if let Some(old) = *story { + *story = self.adopt_text_box(old, parts, depth, budget); + changed = true; + } + } + } + if changed { + p.touch(); + } + }); + } + } + + /// A new text box story copied from `old`; its own boxes get new stories too. Blocks without + /// text boxes are shared with the source (copy-on-write), the others copied. A box showing a + /// story it is inside, or whose story the paste's budget can't hold, gets none (CWE-400); + /// only that box: a later box with a smaller story is still copied while the budget lasts. + fn adopt_text_box(&mut self, old: u32, parts: &BTreeMap, depth: usize, budget: &mut BoxBudget) -> Option { + if depth >= MAX_BOX_DEPTH || budget.active.contains(&old) { + return None; + } + if budget.stories == 0 { + budget.skipped = budget.skipped.saturating_add(1); + return None; + } + let src = parts.get(&old).or_else(|| self.parts.get(&old).filter(|p| budget.local && p.kind == PartKind::TextBox))?; + // Charge what the copy really allocates before making any of it: the block slots, and the + // blocks holding text boxes (deep-copied below). The rest are shared `Arc`s, however many + // boxes show them, so they cost nothing more. Worked out once per paste. + let cost = *budget.costs.entry(old).or_insert_with(|| { + let slots = src.blocks.len().saturating_mul(std::mem::size_of::>()); + src.blocks.iter().fold(slots, |a, b| match block_cost(b) { + (c, true) => a.saturating_add(c), + (_, false) => a, + }) + }); + let Some(left) = budget.bytes.checked_sub(cost) else { + budget.skipped = budget.skipped.saturating_add(1); + return None; + }; + budget.bytes = left; + budget.stories -= 1; + let src_blocks = src.blocks.clone(); + // The copy keeps its text direction and alignment, not its link (a copy stands alone). + let body = crate::TextBody { next: None, ..src.body }; + budget.active.push(old); + let mut blocks: Blocks = Vec::with_capacity(src_blocks.len()); + for b in src_blocks { + let mut boxes = false; + each_para(&b, 0, &mut |p| boxes |= box_ids(p).next().is_some()); + if boxes { + let mut copy = (*b).clone(); + self.adopt_text_boxes(std::slice::from_mut(&mut copy), parts, depth + 1, budget); + blocks.push(Arc::new(copy)); + } else { + blocks.push(b); + } + } + budget.active.pop(); + let id = self.try_add_part(PartKind::TextBox, blocks).ok()?; + if let Some(p) = self.parts.get_mut(&id) { + p.body = body; + } + Some(id) + } + + /// The blocks of `frag`, a fragment of another document, with every text box given a new + /// story of its own here copied from the fragment's parts (as a paste does), to place them + /// in this document; a box the fragment has no story for keeps none. + pub fn adopt_fragment(&mut self, frag: &Fragment) -> Vec { + let mut blocks = frag.blocks.clone(); + let mut budget = BoxBudget { local: false, ..BoxBudget::new() }; + self.adopt_text_boxes(&mut blocks, &frag.parts, 0, &mut budget); + if budget.skipped > 0 { + log::warn!( + "{} text boxes are left empty; their stories are past what one copy brings ({MAX_PASTED_BOXES} stories, {MAX_PASTED_BOX_BYTES} bytes)", + budget.skipped + ); + } + blocks + } + + /// Insert a fragment at `pos`; returns the position after the pasted content. + pub fn insert_fragment(&mut self, pos: &Pos, frag: &Fragment) -> Result { + // Tables that would nest deeper than the limit here become their paragraphs (#1596). + let room = MAX_TABLE_DEPTH.saturating_sub(pos.path.depth()); + let mut blocks = Vec::with_capacity(frag.blocks.len()); + for b in frag.blocks.iter().cloned() { + fit_tables(b, room, &mut blocks); + } + if blocks.is_empty() { + return Ok(pos.clone()); + } + if self.para_at(pos).is_none() { + return Err(DocError::BadPath(pos.path.to_string())); + } + // Pasted text boxes get stories of their own (never shared with the copy's source). + let mut budget = BoxBudget::new(); + self.adopt_text_boxes(&mut blocks, &frag.parts, 0, &mut budget); + if budget.skipped > 0 { + // Once per paste, not once per box (review of #3874). + log::warn!( + "paste: {} text boxes are left empty; their stories are past what one paste copies ({MAX_PASTED_BOXES} stories, {MAX_PASTED_BOX_BYTES} bytes)", + budget.skipped + ); + } + // Single paragraph: inline insert keeping runs. + if blocks.len() == 1 + && let Some(Block::Para(src)) = blocks.first() + { + let src = src.clone(); + let target = self.para_mut(pos.story, &pos.path)?; + let tail = target.split_off(pos.off)?; + let added = src.len(); + let keep = target.props.clone(); + let sect = tail.section.clone(); + let mut src = src; + src.section = None; + target.append(src); + target.append(tail); + target.props = keep; + target.section = sect; + return Ok(Pos { off: pos.off + added, ..pos.clone() }); + } + // Multi-block: split, merge first into left part, last into right part. + let after = self.split_paragraph(pos)?; + let left = pos.path.clone(); + let first = blocks.remove(0); + let last = blocks.pop(); + let mut idx = left.last() + 1; + match first { + Block::Para(p) => { + let l = self.para_mut(pos.story, &left)?; + let keep = l.props.clone(); + let mut p2 = p; + let pprops = p2.props.clone(); + p2.section = None; + l.append(p2); + l.props = if keep.is_empty() { pprops } else { keep }; + } + t @ Block::Table(_) => { + self.insert_block(pos.story, &left.with_last(idx), t)?; + idx += 1; + } + } + for b in blocks { + self.insert_block(pos.story, &left.with_last(idx), b)?; + idx += 1; + } + let right = after.path.with_last(idx); + match last { + Some(Block::Para(p)) => { + let r = self.para_mut(pos.story, &right)?; + let tail = std::mem::replace(r, p.clone()); + let off = r.len(); + let sect = tail.section.clone(); + r.append(tail); + r.section = sect; + Ok(Pos { story: pos.story, path: right, off }) + } + Some(t @ Block::Table(_)) => { + self.insert_block(pos.story, &right, t)?; + Ok(Pos { story: pos.story, path: right.with_last(idx + 1), off: 0 }) + } + None => Ok(Pos { story: pos.story, path: right, off: 0 }), + } + } + + /// Replace every block of a story. + pub fn set_story(&mut self, story: crate::StoryRef, blocks: Blocks) -> Result<()> { + let s = self.story_mut(story)?; + *s = blocks; + if s.is_empty() { + s.push(para_block(Paragraph::new())); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/wordmodel/src/edit/tests.rs b/crates/wordmodel/src/edit/tests.rs new file mode 100644 index 00000000..ae7f7d64 --- /dev/null +++ b/crates/wordmodel/src/edit/tests.rs @@ -0,0 +1,913 @@ +use super::*; +use crate::{StoryRef, Table}; + +fn doc(lines: &[&str]) -> Document { + Document::from_text(&lines.join("\n")) +} + +/// Paths between two positions are those of all the story's paragraphs between them. +#[test] +fn paths_between_matches_every_path_filtered() { + let mut d = doc(&["a", "b", "c", "d", "e"]); + let mut inner = Table::new(2, 2, 100.0); + inner.rows[1].cells[0].blocks.push(crate::para_block(crate::Paragraph::new())); + let mut t = Table::new(3, 2, 200.0); + t.rows[1].cells[1].blocks.insert(0, Arc::new(Block::Table(inner))); + d.insert_block(StoryRef::Body, &Path::top(2), Block::Table(t)).unwrap(); + d.insert_block(StoryRef::Body, &Path::top(4), Block::Table(Table::new(1, 3, 200.0))).unwrap(); + let all = d.para_paths(StoryRef::Body); + for a in &all { + for b in &all { + let (pa, pb) = (Pos { story: StoryRef::Body, path: a.clone(), off: 0 }, Pos { story: StoryRef::Body, path: b.clone(), off: 0 }); + let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; + let want: Vec = all.iter().filter(|p| *p >= lo && *p <= hi).cloned().collect(); + assert_eq!(d.paths_between(&pa, &pb), want, "{a:?} {b:?}"); + } + } +} + +#[test] +fn split_and_join() { + let mut d = doc(&["Hello world"]); + let p = d.split_paragraph(&Pos::body(0, 5)).unwrap(); + assert_eq!(p, Pos::body(1, 0)); + assert_eq!(d.plain_text(StoryRef::Body), "Hello\n world"); + let at = d.delete_range(&Pos::body(0, 5), &Pos::body(1, 0)).unwrap(); + assert_eq!(at, Pos::body(0, 5)); + assert_eq!(d.plain_text(StoryRef::Body), "Hello world"); +} + +#[test] +fn delete_range_with_bad_end_leaves_document_unchanged() { + let mut d = doc(&["one", "two", "three"]); + let before = d.plain_text(StoryRef::Body); + // Missing paragraph as the second end. + assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(9, 0)).is_err()); + assert_eq!(d.plain_text(StoryRef::Body), before); + // Offset past the end of the second paragraph. + assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(1, 99)).is_err()); + assert_eq!(d.plain_text(StoryRef::Body), before); + // Offset inside a multi-byte character. + let mut d = doc(&["abc", "\u{e9}x"]); + assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(1, 1)).is_err()); + assert_eq!(d.plain_text(StoryRef::Body), "abc\n\u{e9}x"); + // Range entering a table with a bad offset at its start: the cell text stays. + let mut d = doc(&["before", "after"]); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(2, 1, 200.0))).unwrap(); + d.insert_text(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }, "x", &CharProps::default()).unwrap(); + let before = d.plain_text(StoryRef::Body); + let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 1, 0, 0]), off: 0 }; + assert!(d.delete_range(&Pos::body(0, 99), &cell).is_err()); + assert_eq!(d.plain_text(StoryRef::Body), before); +} + +#[test] +fn delete_multi() { + let mut d = doc(&["one", "two", "three"]); + d.delete_range(&Pos::body(2, 2), &Pos::body(0, 1)).unwrap(); + assert_eq!(d.plain_text(StoryRef::Body), "oree"); + assert_eq!(d.body.len(), 1); +} + +/// #2127: a range over two section breaks joins three sections into the last; the headers +/// and footers it showed by linking to earlier ones stay, each from the nearest section that +/// named it, as they resolved before the delete. +#[test] +fn delete_over_two_section_breaks_keeps_the_linked_headers() { + let mut d = doc(&["A", "B", "C"]); + let set = |default| crate::section::HeaderSet { default: Some(default), ..Default::default() }; + let s1 = crate::section::SectionProps { headers: set(1), footers: set(5), ..Default::default() }; + let s2 = crate::section::SectionProps { headers: set(2), ..Default::default() }; + let s3 = crate::section::SectionProps { margin_left: 99.0, ..Default::default() }; + for (i, s) in [(0, s1), (1, s2), (2, s3.clone())] { + if let Some(Block::Para(p)) = d.body.get_mut(i).map(Arc::make_mut) { + p.section = Some(Box::new(s)); + } + } + assert_eq!(d.sections().len(), 4); + d.delete_range(&Pos::body(0, 1), &Pos::body(2, 0)).unwrap(); + assert_eq!(d.plain_text(StoryRef::Body), "AC"); + let sections = d.sections(); + assert_eq!(sections.len(), 2); + assert_eq!(*sections[0].1, crate::section::SectionProps { headers: set(2), footers: set(5), ..s3 }); +} + +#[test] +fn format_across() { + let mut d = doc(&["abc", "def"]); + d.format_range(&Pos::body(0, 1), &Pos::body(1, 2), &|c| c.bold = Some(true)).unwrap(); + let p0 = d.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!(p0.props_of_char(0).bold, None); + assert_eq!(p0.props_of_char(1).bold, Some(true)); + let p1 = d.para(StoryRef::Body, &Path::top(1)).unwrap(); + assert_eq!(p1.props_of_char(1).bold, Some(true)); + assert_eq!(p1.props_of_char(2).bold, None); +} + +#[test] +fn copy_paste_round_trip() { + let mut d = doc(&["alpha", "beta", "gamma"]); + let f = d.copy_range(&Pos::body(0, 2), &Pos::body(2, 3)); + assert_eq!(f.plain_text(), "pha\nbeta\ngam"); + let end = d.insert_fragment(&Pos::body(2, 5), &f).unwrap(); + assert_eq!(d.plain_text(StoryRef::Body), "alpha\nbeta\ngammapha\nbeta\ngam"); + assert_eq!(end, Pos::body(4, 3)); + let one = Fragment::from_text("XY"); + let e = d.insert_fragment(&Pos::body(0, 0), &one).unwrap(); + assert_eq!(e, Pos::body(0, 2)); + assert!(d.plain_text(StoryRef::Body).starts_with("XYalpha")); +} + +/// The first paragraph of the deepest cell, following the first table at each level. +fn deepest(d: &Document) -> Path { + let (mut path, mut blocks) = (Vec::new(), &d.body); + while let Some((i, t)) = blocks.iter().enumerate().find_map(|(i, b)| if let Block::Table(t) = &**b { Some((i, t)) } else { None }) { + path.extend([i as u32, 0, 0]); + blocks = &t.rows[0].cells[0].blocks; + } + path.push(0); + Path(path) +} + +fn body_height(d: &Document) -> usize { + d.body.iter().filter_map(|b| if let Block::Table(t) = &**b { Some(table_height(t)) } else { None }).max().unwrap_or(0) +} + +/// How many times `c` is in `blocks`, the deepest cells too. +fn count(blocks: &[Arc], c: char) -> usize { + blocks + .iter() + .map(|b| match &**b { + Block::Para(p) => p.text.matches(c).count(), + Block::Table(t) => t.rows.iter().flat_map(|r| &r.cells).map(|cell| count(&cell.blocks, c)).sum(), + }) + .sum() +} + +#[test] +fn tables_nest_at_most_max_table_depth() { + let mut d = doc(&["x", ""]); + for _ in 0..MAX_TABLE_DEPTH { + let at = deepest(&d); + d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).unwrap(); + } + assert_eq!(body_height(&d), MAX_TABLE_DEPTH); + // One more in the deepest cell is refused and changes nothing; a paragraph is fine. + let (at, before) = (deepest(&d), d.clone()); + assert_eq!(at.depth(), MAX_TABLE_DEPTH); + assert!(d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).is_err()); + assert_eq!(d, before); + d.insert_block(StoryRef::Body, &at, Block::Para(Paragraph::new())).unwrap(); + // One level up, a table fits but a table holding a table doesn't. + let up = Path(at.0[..at.0.len() - 3].to_vec()); + let mut two = Table::new(1, 1, 100.0); + two.rows[0].cells[0].blocks.insert(0, Arc::new(Block::Table(Table::new(1, 1, 100.0)))); + assert!(d.insert_block(StoryRef::Body, &up, Block::Table(two)).is_err()); + d.insert_block(StoryRef::Body, &up, Block::Table(Table::new(1, 1, 100.0))).unwrap(); + assert_eq!(body_height(&d), MAX_TABLE_DEPTH); +} + +#[test] +fn pasting_a_table_into_its_own_cell_stops_at_max_table_depth() { + // #1596: copy everything and paste it into the deepest cell, again and again: each round + // doubled the nesting, until saving, copying or closing the document overflowed the stack. + let mut d = doc(&["x", ""]); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(1, 1, 100.0))).unwrap(); + d.insert_text(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }, "a", &CharProps::default()).unwrap(); + for _ in 0..8 { + let all = d.copy_range(&Pos::body(0, 0), &Pos::body(d.body.len() - 1, 0)); + let at = deepest(&d); + let off = d.para(StoryRef::Body, &at).map_or(0, Paragraph::len); + d.insert_fragment(&Pos { story: StoryRef::Body, path: at, off }, &all).unwrap(); + } + assert_eq!(body_height(&d), MAX_TABLE_DEPTH); + // Nothing is lost: every copy's text is there, below the limit as paragraphs. + assert_eq!((count(&d.body, 'x'), count(&d.body, 'a')), (256, 256)); +} + +fn text_box(d: &mut Document, pos: &Pos, text: &str) -> u32 { + let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(text, CharProps::default()))]); + d.insert_object(pos, box_shape(Some(id)), &CharProps::default()).unwrap(); + id +} + +fn box_shape(story: Option) -> InlineObject { + InlineObject::Shape { + kind: crate::para::ShapeKind::TextBox, + w: 144.0, + h: 72.0, + fill: None, + stroke: None, + stroke_width: 0.75, + float: Default::default(), + story, + freeform: None, + effects: Default::default(), + extra: Default::default(), + } +} + +/// Story ids of the boxes in a body paragraph, in order. +fn boxes_in(d: &Document, path: &Path) -> Vec> { + let p = d.para(StoryRef::Body, path).unwrap(); + p.objects.iter().filter_map(|o| if let InlineObject::Shape { story, .. } = o { Some(*story) } else { None }).collect() +} + +#[test] +fn pasted_text_box_gets_its_own_story() { + let mut d = doc(&["ab", "cd"]); + let id = text_box(&mut d, &Pos::body(0, 1), "Inside"); + let f = d.copy_range(&Pos::body(0, 0), &Pos::body(0, 5)); + assert_eq!(f.parts.keys().copied().collect::>(), vec![id]); + // Edit the original after copying: the paste keeps the copied text. + d.insert_text(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 6 }, " later", &CharProps::default()).unwrap(); + d.insert_fragment(&Pos::body(1, 2), &f).unwrap(); + let pasted = boxes_in(&d, &Path::top(1)); + let [Some(new)] = pasted[..] else { panic!("{pasted:?}") }; + assert_ne!(new, id); + assert_eq!(d.parts.get(&new).map(|p| p.kind), Some(PartKind::TextBox)); + assert_eq!(d.plain_text(StoryRef::Part(new)), "Inside"); + assert_eq!(d.plain_text(StoryRef::Part(id)), "Inside later"); + // Editing the copy leaves the original alone; a second paste is a third story. + d.insert_text(&Pos { story: StoryRef::Part(new), path: Path::top(0), off: 0 }, "Copy: ", &CharProps::default()).unwrap(); + assert_eq!(d.plain_text(StoryRef::Part(id)), "Inside later"); + d.insert_fragment(&Pos::body(1, 0), &f).unwrap(); + let again = boxes_in(&d, &Path::top(1)); + assert_eq!(again.len(), 2); + assert!(again.iter().all(|s| s.is_some_and(|s| s != id)) && again[0] != again[1], "{again:?}"); + // Multi-paragraph pastes too. + let f2 = d.copy_range(&Pos::body(0, 0), &Pos::body(1, 1)); + let before = d.parts.len(); + d.insert_fragment(&Pos::body(1, 0), &f2).unwrap(); + assert_eq!(d.parts.len(), before + 1); +} + +#[test] +fn text_box_pasted_into_another_document_brings_its_text() { + let mut src = doc(&["x"]); + let id = text_box(&mut src, &Pos::body(0, 1), "From the source"); + let f = src.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3)); + // The target already has a header with the same part id. + let mut dst = doc(&["target"]); + let hdr = dst.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Header text", CharProps::default()))]); + assert_eq!(hdr, id); + dst.insert_fragment(&Pos::body(0, 6), &f).unwrap(); + let [Some(new)] = boxes_in(&dst, &Path::top(0))[..] else { panic!() }; + assert_ne!(new, hdr); + assert_eq!(dst.plain_text(StoryRef::Part(new)), "From the source"); + assert_eq!(dst.plain_text(StoryRef::Part(hdr)), "Header text"); + // The whole body of a document, as Insert › Text from File uses it. + let mut dst2 = doc(&["t"]); + dst2.insert_fragment(&Pos::body(0, 1), &src.body_fragment()).unwrap(); + let [Some(n2)] = boxes_in(&dst2, &Path::top(0))[..] else { panic!() }; + assert_eq!(dst2.plain_text(StoryRef::Part(n2)), "From the source"); +} + +#[test] +fn text_box_in_a_copied_table_gets_its_own_story() { + let mut d = doc(&["before", "after"]); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(1, 1, 200.0))).unwrap(); + let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }; + let id = text_box(&mut d, &cell, "In a cell"); + let f = d.copy_range(&Pos::body(0, 0), &Pos::body(2, 5)); + assert!(f.parts.contains_key(&id)); + d.insert_fragment(&Pos::body(2, 5), &f).unwrap(); + let ids: Vec = d + .para_paths(StoryRef::Body) + .iter() + .filter_map(|p| d.para(StoryRef::Body, p)) + .flat_map(|p| p.objects.iter()) + .filter_map(|o| if let InlineObject::Shape { story, .. } = o { *story } else { None }) + .collect(); + assert_eq!(ids.len(), 2, "{ids:?}"); + assert_ne!(ids[0], ids[1]); + assert!(ids.iter().all(|i| d.plain_text(StoryRef::Part(*i)) == "In a cell")); +} + +#[test] +fn fragment_without_parts_never_shows_an_unrelated_story() { + let mut d = doc(&["x"]); + let hdr = d.add_part(PartKind::Header, Vec::new()); + let own = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Mine", CharProps::default()))]); + let mut p = Paragraph::new(); + p.insert_object(0, box_shape(Some(hdr)), &CharProps::default()).unwrap(); + p.insert_object(0, box_shape(Some(own)), &CharProps::default()).unwrap(); + p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap(); + let f = Fragment { blocks: vec![Block::Para(p)], ..Default::default() }; + d.insert_fragment(&Pos::body(0, 1), &f).unwrap(); + let got = boxes_in(&d, &Path::top(0)); + assert_eq!(got.len(), 3); + assert_eq!(got[0], None, "missing story"); + let Some(copy) = got[1] else { panic!("{got:?}") }; + assert_ne!(copy, own); + assert_eq!(d.plain_text(StoryRef::Part(copy)), "Mine"); + assert_eq!(got[2], None, "a header isn't a text box"); +} + +#[test] +fn self_nested_text_box_paste_is_bounded() { + let mut d = doc(&["x"]); + let id = text_box(&mut d, &Pos::body(0, 1), "loop"); + // The box contains a box showing itself. + d.insert_object(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }, box_shape(Some(id)), &CharProps::default()).unwrap(); + let f = d.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3)); + let before = d.parts.len(); + d.insert_fragment(&Pos::body(0, 0), &f).unwrap(); + let added = d.parts.len() - before; + // The copy's inner box would show the story being copied: a cycle, so it gets none. + assert_eq!(added, 1); +} + +/// A fragment whose body is one paragraph of `refs` text boxes all showing story 1. +fn fan_out(story: Vec>, refs: usize) -> Fragment { + let mut p = Paragraph::new(); + for _ in 0..refs { + p.insert_object(0, box_shape(Some(1)), &CharProps::default()).unwrap(); + } + let mut parts = BTreeMap::new(); + parts.insert(1, crate::Part { kind: PartKind::TextBox, blocks: story, body: Default::default() }); + Fragment { blocks: vec![Block::Para(p)], parts } +} + +#[test] +fn pasted_boxes_share_the_blocks_they_need_not_copy() { + let big = para_block(Paragraph::with_text(&"x".repeat(100_000), CharProps::default())); + let f = fan_out(vec![big.clone()], 3); + let mut d = doc(&["x"]); + d.insert_fragment(&Pos::body(0, 1), &f).unwrap(); + let got = boxes_in(&d, &Path::top(0)); + assert_eq!(got.len(), 3); + let ids: Vec = got.into_iter().flatten().collect(); + assert_eq!(ids.len(), 3, "each box has a story"); + assert!(ids.windows(2).all(|w| w[0] != w[1]), "stories of their own: {ids:?}"); + for id in ids { + let b = d.parts[&id].blocks.first().unwrap(); + assert!(Arc::ptr_eq(b, &big), "no text box inside: shared, not copied"); + } +} + +#[test] +fn a_box_over_the_budget_leaves_later_smaller_boxes_their_stories() { + // Review of #3874: once one story didn't fit, every later box in the paste was left + // empty, however small its story. Only the box that doesn't fit is. + // (Story 1 holds a text box, so each box showing it needs a real copy.) + let mut bp = Paragraph::with_text(&"x".repeat(100_000), CharProps::default()); + bp.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap(); + let big = para_block(bp); + let small = para_block(Paragraph::with_text("small", CharProps::default())); + let mut p = Paragraph::new(); + for story in [2, 1, 2, 1] { + p.insert_object(0, box_shape(Some(story)), &CharProps::default()).unwrap(); + } + let mut parts = BTreeMap::new(); + parts.insert(1, crate::Part { kind: PartKind::TextBox, blocks: vec![big], body: Default::default() }); + parts.insert(2, crate::Part { kind: PartKind::TextBox, blocks: vec![small], body: Default::default() }); + let mut blocks = vec![Block::Para(p)]; + let mut d = doc(&["x"]); + let mut budget = BoxBudget { bytes: 50_000, ..BoxBudget::new() }; + d.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget); + let Some(Block::Para(p)) = blocks.first() else { panic!() }; + let got: Vec> = p.objects.iter().map(|o| if let InlineObject::Shape { story, .. } = o { *story } else { None }).collect(); + assert_eq!(got.iter().map(Option::is_some).collect::>(), [false, true, false, true], "{got:?}"); + assert_eq!(budget.skipped, 2); + assert_eq!(budget.costs.len(), 2, "each story costed once"); +} + +#[test] +fn many_boxes_sharing_one_story_paste_with_their_text() { + // Review of #3874: a document converted from PDF has thousands of boxes showing story + // data they share. 64 boxes x a 1 MiB block is 64 MiB counted per box (over the 32 MiB + // cap) but the block is shared, not copied, so every box keeps its text. + let big = para_block(Paragraph::with_text(&"x".repeat(1 << 20), CharProps::default())); + let f = fan_out(vec![big.clone()], 64); + let mut d = doc(&["x"]); + let (_, warned) = crate::test_log::warnings(|| d.insert_fragment(&Pos::body(0, 1), &f).unwrap()); + assert!(!warned.iter().any(|w| w.contains("text boxes are left empty")), "{warned:?}"); + let ids: Vec = boxes_in(&d, &Path::top(0)).into_iter().flatten().collect(); + assert_eq!(ids.len(), 64, "every box keeps a story"); + for id in ids { + let b = d.parts[&id].blocks.first().unwrap(); + assert!(Arc::ptr_eq(b, &big), "shared, so not charged per box"); + assert_eq!(d.parts[&id].blocks.len(), 1); + } +} + +#[test] +fn pasted_box_copies_are_capped_in_bytes() { + // Story 1 is a 1 MiB paragraph that holds a text box itself, so each box showing it needs + // its own copy; 64 of them used to copy 64 MiB. + let mut p = Paragraph::with_text(&"x".repeat(1 << 20), CharProps::default()); + p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap(); + let f = fan_out(vec![para_block(p)], 64); + let mut d = doc(&["x"]); + let before = d.parts.len(); + let (_, warned) = crate::test_log::warnings(|| d.insert_fragment(&Pos::body(0, 1), &f).unwrap()); + let added = d.parts.len() - before; + // The boxes left empty are said to be, once for the paste (review of #3874). + let empty: Vec<_> = warned.iter().filter(|w| w.contains("text boxes are left empty")).collect(); + assert_eq!(empty.len(), 1, "{warned:?}"); + assert!(empty[0].starts_with(&format!("paste: {} text boxes", 64 - added)), "{empty:?}"); + let copied: usize = d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { p.text.len() } else { 0 }).sum(); + assert!((1..64).contains(&added), "{added}"); + assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}"); + // The boxes past the cap keep no story rather than a shared one. + let got = boxes_in(&d, &Path::top(0)); + assert_eq!(got.iter().flatten().count(), added); +} + +#[test] +fn pasted_box_copies_count_the_formatting_they_copy() { + // A story holding a text box and a run whose font name is 1 MiB: each copy clones the + // formatting too, so 64 copies used to copy 64 MiB of it past the 32 MiB cap. + let props = CharProps { font: Some("f".repeat(1 << 20)), ..CharProps::default() }; + let mut p = Paragraph::with_text("x", props); + p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap(); + let f = fan_out(vec![para_block(p)], 64); + let mut d = doc(&["x"]); + let before = d.parts.len(); + d.insert_fragment(&Pos::body(0, 1), &f).unwrap(); + let added = d.parts.len() - before; + let font = |p: &Paragraph| p.runs.iter().map(|r| r.props.font.as_ref().map_or(0, String::len)).sum::(); + let copied: usize = d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { font(p) } else { 0 }).sum(); + assert!((1..64).contains(&added), "{added}"); + assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}"); +} + +#[test] +fn pasted_box_copies_count_the_objects_they_copy() { + // #3614 review: a story holding a text box and a 1 MiB kept-for-round-trip object costs + // the object's XML too, not only its slot: 64 copies used to copy 64 MiB of it. + let mut p = Paragraph::with_text("x", CharProps::default()); + p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap(); + let opaque = InlineObject::Opaque { format: "docx".into(), xml: "x".repeat(1 << 20), text: String::new() }; + p.insert_object(0, opaque, &CharProps::default()).unwrap(); + let f = fan_out(vec![para_block(p)], 64); + let mut d = doc(&["x"]); + let before = d.parts.len(); + d.insert_fragment(&Pos::body(0, 1), &f).unwrap(); + let added = d.parts.len() - before; + let xml = |o: &InlineObject| if let InlineObject::Opaque { xml, .. } = o { xml.len() } else { 0 }; + let copied: usize = + d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { p.objects.iter().map(xml).sum() } else { 0 }).sum(); + assert!((1..64).contains(&added), "{added}"); + assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}"); + // Nested group members and equations count too. + let member = |obj| crate::para::GroupChild { x: 0.0, y: 0.0, obj }; + let deep = InlineObject::Opaque { format: String::new(), xml: "y".repeat(5000), text: String::new() }; + let inner = InlineObject::Group { w: 1.0, h: 1.0, float: Default::default(), ch_w: 1.0, ch_h: 1.0, children: vec![member(deep)], canvas: None }; + let outer = InlineObject::Group { w: 1.0, h: 1.0, float: Default::default(), ch_w: 1.0, ch_h: 1.0, children: vec![member(inner)], canvas: None }; + assert!(object_cost(&outer) >= 5000); + let eq = InlineObject::Equation { + linear: String::new(), + display: false, + math: crate::math::Math { nodes: crate::math::parse_linear(&"z".repeat(5000)), ..Default::default() }, + }; + assert!(object_cost(&eq) >= 5000); +} + +#[test] +fn tables_in_paths() { + let mut d = doc(&["before", "after"]); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(2, 2, 200.0))).unwrap(); + let paths = d.para_paths(StoryRef::Body); + assert_eq!(paths.len(), 6); + assert_eq!(paths[1], Path(vec![1, 0, 0, 0])); + let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 0, 1, 0]), off: 0 }; + let e = d.insert_text(&cell, "x", &CharProps::default()).unwrap(); + assert_eq!(e.off, 1); + assert_eq!(d.next_para(StoryRef::Body, &Path(vec![1, 0, 1, 0])), Some(Path(vec![1, 1, 0, 0]))); + assert!(d.plain_text(StoryRef::Body).contains("\tx")); + // Delete from before the table into a cell: text cleared, table kept (partially covered). + d.delete_range(&Pos::body(0, 2), &cell).unwrap(); + assert_eq!(d.para(StoryRef::Body, &Path::top(0)).unwrap().text, "be"); + assert!(d.container(StoryRef::Body, &Path(vec![1, 0, 0, 0])).is_some()); + assert_eq!(Path(vec![1, 0, 1, 0]).cell(), Some((Path::top(1), 0, 1))); +} + +fn para(text: &str) -> Block { + Block::Para(Paragraph::with_text(text, CharProps::default())) +} + +#[test] +fn appended_pieces_are_each_followed_by_one_paragraph_mark() { + let mut spike = Fragment::default(); + spike.append_piece(Fragment { blocks: vec![para("a")], ..Default::default() }); + spike.append_piece(Fragment { blocks: vec![para("b")], ..Default::default() }); + assert_eq!(spike.plain_text(), "a\nb\n"); + // A piece that already ends with its paragraph mark gets no second one. + let mut spike = Fragment::default(); + spike.append_piece(Fragment { blocks: vec![para("a"), para("")], ..Default::default() }); + assert_eq!(spike.plain_text(), "a\n"); + spike.append_piece(Fragment { blocks: vec![para("b"), para("c")], ..Default::default() }); + assert_eq!(spike.plain_text(), "a\nb\nc\n"); +} + +#[test] +fn appended_pieces_keep_their_own_text_boxes() { + // Two documents give their first text box the same id. + let mut one = doc(&["x"]); + let id1 = text_box(&mut one, &Pos::body(0, 1), "One"); + let mut two = doc(&["y"]); + let id2 = text_box(&mut two, &Pos::body(0, 1), "Two"); + assert_eq!(id1, id2); + let mut spike = Fragment::default(); + spike.append_piece(one.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3))); + spike.append_piece(two.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3))); + let mut dst = doc(&[""]); + dst.insert_fragment(&Pos::body(0, 0), &spike).unwrap(); + let texts: Vec = [0, 1].iter().flat_map(|i| boxes_in(&dst, &Path::top(*i))).map(|b| dst.plain_text(StoryRef::Part(b.unwrap()))).collect(); + assert_eq!(texts, ["One", "Two"]); +} + +#[test] +fn bad_positions_error() { + let mut d = doc(&["é"]); + assert!(d.insert_text(&Pos::body(0, 1), "x", &CharProps::default()).is_err()); + assert!(d.insert_text(&Pos::body(5, 0), "x", &CharProps::default()).is_err()); + assert!(d.split_paragraph(&Pos::body(0, 99)).is_err()); + let c = d.clamp(&Pos::body(9, 9)); + assert_eq!(c, Pos::body(0, 2)); + assert!(d.delete_range(&Pos { story: StoryRef::Part(3), path: Path::top(0), off: 0 }, &Pos::body(0, 0)).is_err()); +} + +// delete_range over whole tables (#1844), in the body, a header and a footnote. + +/// A document whose story (the body, or a header or footnote part) holds `lines`. +fn story_doc(kind: Option, lines: &[&str]) -> (Document, StoryRef) { + match kind { + None => (doc(lines), StoryRef::Body), + Some(k) => { + let mut d = doc(&["body"]); + let blocks = lines.iter().map(|l| para_block(Paragraph::with_text(l, CharProps::default()))).collect(); + let id = d.add_part(k, blocks); + (d, StoryRef::Part(id)) + } + } +} + +const STORIES: [Option; 3] = [None, Some(PartKind::Header), Some(PartKind::Footnote)]; + +fn at(story: StoryRef, path: &[u32], off: usize) -> Pos { + Pos { story, path: Path(path.to_vec()), off } +} + +/// Insert an `rows`×`cols` table at `path` whose cells hold `texts`, row by row. +fn put_table(d: &mut Document, story: StoryRef, path: &[u32], rows: usize, cols: usize, texts: &[&str]) { + d.insert_block(story, &Path(path.to_vec()), Block::Table(Table::new(rows, cols, 200.0))).unwrap(); + for (k, t) in texts.iter().enumerate() { + let mut p = path.to_vec(); + p.extend([(k / cols) as u32, (k % cols) as u32, 0]); + d.insert_text(&at(story, &p, 0), t, &CharProps::default()).unwrap(); + } +} + +/// The story's top-level blocks: a paragraph's text, or `[table]`. +fn shape(d: &Document, story: StoryRef) -> Vec { + d.story(story).unwrap().iter().map(|b| b.as_para().map_or("[table]".to_string(), |p| p.text.clone())).collect() +} + +fn cell_text(d: &Document, story: StoryRef, table: &[u32], r: u32, c: u32) -> String { + let mut p = table.to_vec(); + p.extend([r, c, 0]); + d.para(story, &Path(p)).map(|p| p.text.clone()).unwrap_or_else(|| "".into()) +} + +#[test] +fn a_range_ending_at_the_end_of_a_cell_that_isnt_the_last_keeps_the_table() { + for kind in STORIES { + for (r, c) in [(0, 1), (1, 0)] { + let (mut d, st) = story_doc(kind, &["before", "after"]); + put_table(&mut d, st, &[1], 2, 2, &["a", "b", "c", "d"]); + d.delete_range(&at(st, &[0], 0), &at(st, &[1, r, c, 0], 1)).unwrap(); + assert_eq!(shape(&d, st), ["", "[table]", "after"], "{kind:?} ({r},{c})"); + assert_eq!(cell_text(&d, st, &[1], 1, 1), "d", "{kind:?} ({r},{c})"); + } + } +} + +#[test] +fn a_range_from_the_start_of_one_table_into_the_middle_of_the_next_removes_only_the_first() { + for kind in STORIES { + let (mut d, st) = story_doc(kind, &["x", "mid", "y"]); + put_table(&mut d, st, &[2], 2, 2, &["e", "f", "g", "h"]); + put_table(&mut d, st, &[1], 2, 2, &["a", "b", "c", "d"]); + assert_eq!(shape(&d, st), ["x", "[table]", "mid", "[table]", "y"]); + let to = d.delete_range(&at(st, &[1, 0, 0, 0], 0), &at(st, &[3, 0, 1, 0], 1)).unwrap(); + assert_eq!(shape(&d, st), ["x", "", "[table]", "y"], "{kind:?}"); + assert_eq!([cell_text(&d, st, &[2], 0, 0), cell_text(&d, st, &[2], 0, 1), cell_text(&d, st, &[2], 1, 0)], ["", "", "g"], "{kind:?}"); + assert!(d.para(st, &to.path).is_some(), "{kind:?}: {to:?}"); + } +} + +#[test] +fn a_range_from_a_cell_paragraph_to_the_end_of_a_table_nested_in_that_cell_removes_only_the_nested_table() { + for kind in STORIES { + let (mut d, st) = story_doc(kind, &["before", "after"]); + put_table(&mut d, st, &[1], 1, 1, &["p"]); + put_table(&mut d, st, &[1, 0, 0, 1], 1, 1, &["n"]); + d.insert_block(st, &Path(vec![1, 0, 0, 2]), Block::Para(Paragraph::with_text("q", CharProps::default()))).unwrap(); + let to = d.delete_range(&at(st, &[1, 0, 0, 0], 1), &at(st, &[1, 0, 0, 1, 0, 0, 0], 1)).unwrap(); + assert_eq!(shape(&d, st), ["before", "[table]", "after"], "{kind:?}"); + let cell = d.container(st, &Path(vec![1, 0, 0, 0])).unwrap(); + let cell: Vec = cell.iter().map(|b| b.as_para().map_or("[table]".to_string(), |p| p.text.clone())).collect(); + assert_eq!(cell, ["pq"], "{kind:?}"); + assert_eq!(to, at(st, &[1, 0, 0, 0], 1), "{kind:?}"); + } +} + +#[test] +fn two_whole_tables_at_the_end_of_a_story_leave_one_empty_paragraph() { + for kind in STORIES { + for trailing in [true, false] { + let (mut d, st) = story_doc(kind, &[""]); + put_table(&mut d, st, &[0], 1, 2, &["a", "b"]); + put_table(&mut d, st, &[1], 2, 1, &["c", "d"]); + if !trailing { + // A story that ends in a table (a hostile file). + d.story_mut(st).unwrap().pop(); + } + let to = d.delete_range(&at(st, &[0, 0, 0, 0], 0), &at(st, &[1, 1, 0, 0], 1)).unwrap(); + assert_eq!(shape(&d, st), [""], "{kind:?} trailing {trailing}"); + assert_eq!(to, at(st, &[0], 0), "{kind:?} trailing {trailing}"); + } + } +} + +/// Content controls pair their markers within one block list (a story, or a cell), so a +/// marker in a table never has its partner outside it: a control inside a table removed whole +/// goes with it, and one the range only enters keeps both markers (the control is emptied). +#[test] +fn removing_a_whole_table_keeps_a_control_the_range_only_partly_covers() { + use crate::control::ContentControl; + let start = || InlineObject::ControlStart { control: Box::new(ContentControl::default()) }; + for kind in STORIES { + let (mut d, st) = story_doc(kind, &["before", "after"]); + put_table(&mut d, st, &[1], 1, 1, &["c"]); + // One control around "efo" in "before", one around the cell's text. + d.insert_object(&at(st, &[0], 4), InlineObject::ControlEnd, &CharProps::default()).unwrap(); + let in_before = d.insert_object(&at(st, &[0], 1), start(), &CharProps::default()).unwrap(); + d.insert_object(&at(st, &[1, 0, 0, 0], 1), InlineObject::ControlEnd, &CharProps::default()).unwrap(); + d.insert_object(&at(st, &[1, 0, 0, 0], 0), start(), &CharProps::default()).unwrap(); + assert_eq!(d.control_ranges(st).len(), 2, "setup"); + let end = d.para(st, &Path(vec![1, 0, 0, 0])).unwrap().len(); + // From inside the first control to the end of the table. + d.delete_range(&Pos { off: in_before.off + 1, ..in_before }, &at(st, &[1, 0, 0, 0], end)).unwrap(); + assert_eq!(shape(&d, st), ["b\u{FFFC}e\u{FFFC}after"], "{kind:?}: the table goes, the paragraphs join"); + let ranges = d.control_ranges(st); + assert_eq!(ranges.len(), 1, "{kind:?}: {ranges:?}"); + assert!(ranges.iter().all(|r| r.start.path == Path::top(0) && r.end.path == Path::top(0)), "{kind:?}"); + let objects = d.para(st, &Path::top(0)).map_or(0, |p| p.objects.len()); + assert_eq!(objects, 2, "{kind:?}: no orphan markers left"); + } +} + +#[test] +fn a_table_nested_deeper_than_the_paragraph_walk_is_never_removed_whole() { + // The deepest nesting allowed, each table in the first cell of the one around it, ahead of + // that cell's paragraph: the innermost paragraphs come first in the document but the + // paragraph walk stops short. + const { assert!(MAX_TABLE_DEPTH > crate::PARA_WALK_DEPTH) }; + let mut t = Table::new(1, 1, 200.0); + if let Some(cell) = t.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) { + cell.blocks = vec![para_block(Paragraph::with_text("deep", CharProps::default()))]; + } + for _ in 1..MAX_TABLE_DEPTH { + let mut outer = Table::new(1, 1, 200.0); + if let Some(cell) = outer.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) { + cell.blocks.insert(0, Arc::new(Block::Table(t))); + } + t = outer; + } + let mut d = doc(&["before", "after"]); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap(); + let first = d.para_paths(StoryRef::Body).into_iter().find(|p| p.0.first() == Some(&1)).unwrap(); + d.delete_range(&Pos { story: StoryRef::Body, path: first, off: 0 }, &Pos::body(2, 0)).unwrap(); + assert!(d.body.get(1).is_some_and(|b| b.as_table().is_some()), "a table with paragraphs the walk never saw was removed"); +} +#[test] +fn adopting_a_document_renumbers_what_it_refers_to() { + use crate::para::NoteKind; + use crate::{Comment, Revision, RevisionKind}; + // Ours already has a footnote, a comment, a list and a revision with the ids the other's use. + let mut ours = doc(&["ours"]); + let our_note = ours.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("our note", CharProps::default()))]); + ours.comments.insert(0, Comment { part: our_note, ..Comment::default() }); + let our_list = Arc::make_mut(&mut ours.numbering).add_list(crate::numbering::ListKind::Bullet); + ours.revisions.push(Revision { kind: RevisionKind::Insert, ..Revision::default() }); + + let mut theirs = doc(&["theirs"]); + let note = theirs.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("their note", CharProps::default()))]); + let said = theirs.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("their comment", CharProps::default()))]); + theirs.comments.insert(0, Comment { author: "Them".into(), part: said, durable_id: Some("0A0B0C0D".into()), ..Comment::default() }); + let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Bullet); + assert_eq!(list, our_list); + theirs.revisions.push(Revision { kind: RevisionKind::Delete, ..Revision::default() }); + let mut style = crate::styles::Style { id: "TheirList".into(), name: "Their List".into(), ..Default::default() }; + style.para.numbering = Some(crate::props::NumRef { num: list, level: 0 }); + Arc::make_mut(&mut theirs.styles).upsert(style); + let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + p.props.numbering = Some(crate::props::NumRef { num: list, level: 0 }); + p.runs[0].props.del = Some(0); + // An insertion naming a revision it doesn't have. + p.runs[0].props.ins = Some(7); + let props = p.props_of_char(0).clone(); + p.insert_object(0, InlineObject::NoteRef { kind: NoteKind::Footnote, id: note, custom: String::new() }, &props).unwrap(); + p.insert_object(0, InlineObject::CommentStart { id: 0 }, &props).unwrap(); + + let frag = ours.adopt(theirs); + let Some(Block::Para(p)) = frag.blocks.first() else { panic!() }; + let new_list = p.props.numbering.unwrap().num; + assert_ne!(new_list, our_list); + assert!(ours.numbering.num(new_list).is_some()); + assert_eq!(ours.styles.get("TheirList").and_then(|s| s.para.numbering).map(|n| n.num), Some(new_list)); + // Their deletion is their revision, after ours. + let del = p.runs.iter().find_map(|r| r.props.del).unwrap(); + assert_eq!(ours.revisions.get(del as usize).map(|r| r.kind), Some(RevisionKind::Delete)); + assert!(p.runs.iter().all(|r| r.props.ins.is_none())); + // Their note and comment come along under ids of their own. + let (note_id, comment_id) = p.objects.iter().fold((None, None), |(n, c), o| match o { + InlineObject::NoteRef { id, .. } => (Some(*id), c), + InlineObject::CommentStart { id } => (n, Some(*id)), + _ => (n, c), + }); + let text = |id: u32| { + ours.parts + .get(&id) + .map(|p| p.blocks.iter().map(|b| if let Block::Para(p) = &**b { p.text.clone() } else { String::new() }).collect::()) + }; + assert_eq!(text(note_id.unwrap()).as_deref(), Some("their note")); + let comment = ours.comments.get(&comment_id.unwrap()).unwrap(); + assert_eq!((comment.author.as_str(), text(comment.part).as_deref()), ("Them", Some("their comment"))); + // Its durable id is the other document's: the writer gives the copy one of its own. + assert_eq!(comment.durable_id, None); + assert_eq!(text(our_note).as_deref(), Some("our note")); +} + +#[test] +fn a_kept_character_style_leaves_what_it_doesnt_set_to_the_paragraph_style() { + use crate::styles::{Style, StyleKind, StyleSheet}; + let mut ours = doc(&["ours"]); + let mut theirs = doc(&["theirs"]); + theirs.styles = Arc::new(StyleSheet::empty()); + Arc::make_mut(&mut theirs.styles).default_chr = CharProps { size: Some(9.0), ..Default::default() }; + let big = CharProps { size: Some(20.0), ..Default::default() }; + Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Big".into(), name: "Big".into(), chr: big, ..Default::default() }); + Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Link".into(), name: "Link".into(), kind: StyleKind::Character, ..Default::default() }); + let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + p.props.style = Some("Big".into()); + p.runs[0].props.style = Some("Link".into()); + + let frag = ours.adopt_with(theirs, true); + let Some(Block::Para(p)) = frag.blocks.first() else { panic!() }; + assert_eq!(ours.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props).size, 20.0); +} + +#[test] +fn kept_styles_renamed_on_adoption_are_renamed_in_list_levels_too() { + use crate::styles::Style; + let mut ours = doc(&["ours"]); + Arc::make_mut(&mut ours.styles).upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() }); + let mut theirs = doc(&["theirs"]); + Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() }); + let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Numbered); + let abs = theirs.numbering.nums.iter().find(|n| n.id == list).map(|n| n.abstract_id).unwrap(); + let a = Arc::make_mut(&mut theirs.numbering).abstracts.iter_mut().find(|a| a.id == abs).unwrap(); + a.levels[0].style = Some("Heading1".into()); + let level = a.levels[0].clone(); + Arc::make_mut(&mut theirs.numbering).nums.iter_mut().find(|n| n.id == list).unwrap().level_overrides.push((1, level)); + + let before = ours.numbering.abstracts.len(); + ours.adopt_with(theirs, true); + let copied = &ours.numbering.abstracts[before..]; + let styles: Vec<_> = copied + .iter() + .flat_map(|a| a.levels.iter()) + .chain(ours.numbering.nums.iter().flat_map(|n| n.level_overrides.iter().map(|(_, l)| l))) + .filter_map(|l| l.style.clone()) + .collect(); + assert_eq!(styles.len(), 2, "{styles:?}"); + for st in &styles { + assert_ne!(st, "Heading1"); + assert!(ours.styles.get(st).is_some(), "{st}"); + } +} + +#[test] +fn an_adopted_section_breaks_tracked_change_names_its_revision() { + use crate::{Revision, RevisionKind, SectionProps}; + let mut ours = doc(&["ours"]); + ours.revisions.push(Revision { kind: RevisionKind::Insert, ..Revision::default() }); + let mut theirs = doc(&["theirs"]); + theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() }); + let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + p.section = Some(Box::new(SectionProps { fmt_change: crate::props::PropChange::boxed(0, SectionProps::default()), ..SectionProps::default() })); + + let frag = ours.adopt(theirs); + let Some(Block::Para(p)) = frag.blocks.first() else { panic!() }; + let rev = p.section.as_ref().and_then(|s| s.fmt_change.as_ref()).map(|c| c.rev); + assert_eq!(rev.and_then(|r| ours.revisions.get(r as usize)).map(|r| r.kind), Some(RevisionKind::Format)); +} + +#[test] +fn an_adopted_tracked_format_change_keeps_its_old_styles_and_list() { + use crate::props::{NumRef, ParaProps, PropChange}; + use crate::{Revision, RevisionKind}; + for keep in [false, true] { + let mut ours = doc(&["ours"]); + let our_list = Arc::make_mut(&mut ours.numbering).add_list(crate::numbering::ListKind::Bullet); + let mut theirs = doc(&["theirs"]); + theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() }); + let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Bullet); + assert_eq!(list, our_list); + let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + // Live and old props name the same styles (ours has them too) and their list. + p.props.style = Some("Quote".into()); + p.props.numbering = Some(NumRef { num: list, level: 0 }); + let old = ParaProps { style: Some("Quote".into()), numbering: Some(NumRef { num: list, level: 0 }), ..ParaProps::default() }; + p.props.fmt_change = PropChange::boxed(0, old); + p.runs[0].props.style = Some("Strong".into()); + p.runs[0].props.fmt_change = PropChange::boxed(0, CharProps { style: Some("Strong".into()), ..CharProps::default() }); + + let frag = ours.adopt_with(theirs, keep); + let Some(Block::Para(p)) = frag.blocks.first() else { panic!() }; + let old = &p.props.fmt_change.as_ref().unwrap().old; + assert_eq!((&old.style, old.numbering), (&p.props.style, p.props.numbering), "keep_styles {keep}"); + assert_ne!(old.numbering.map(|n| n.num), Some(our_list)); + let run = &p.runs[0].props; + assert_eq!(run.fmt_change.as_ref().unwrap().old.style, run.style, "keep_styles {keep}"); + } +} + +#[test] +fn adopted_styles_never_become_our_defaults() { + use crate::styles::{Style, StyleKind}; + for keep in [false, true] { + let mut ours = doc(&["ours"]); + let mut theirs = doc(&["theirs"]); + // A localized default character style, and one whose id we have too. + Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() }); + Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() }); + ours.adopt_with(theirs, keep); + assert!(ours.styles.styles.iter().all(|s| !s.default), "keep_styles {keep}"); + } +} + +#[test] +fn adopted_permission_ranges_keep_ids_of_their_own() { + let perm = |id, editor: &str| InlineObject::PermStart { id, group: String::new(), editor: editor.into() }; + let mut ours = doc(&["ours"]); + ours.insert_object(&Pos::body(0, 4), InlineObject::PermEnd { id: 0 }, &CharProps::default()).unwrap(); + ours.insert_object(&Pos::body(0, 0), perm(0, "us"), &CharProps::default()).unwrap(); + // Theirs: a closed range and one left open, numbered from 0 like ours. + let mut theirs = doc(&["theirs"]); + theirs.insert_object(&Pos::body(0, 6), perm(1, "them"), &CharProps::default()).unwrap(); + theirs.insert_object(&Pos::body(0, 6), InlineObject::PermEnd { id: 0 }, &CharProps::default()).unwrap(); + theirs.insert_object(&Pos::body(0, 0), perm(0, "them"), &CharProps::default()).unwrap(); + + let frag = ours.adopt(theirs); + let Some(Block::Para(p)) = frag.blocks.first() else { panic!() }; + let ids: Vec = p + .objects + .iter() + .filter_map(|o| match o { + InlineObject::PermStart { id, .. } | InlineObject::PermEnd { id } => Some(*id), + _ => None, + }) + .collect(); + assert_eq!(ids.len(), 3); + assert!(!ids.contains(&0), "{ids:?}"); + assert_eq!(ids[0], ids[1], "a range's start and end still pair: {ids:?}"); + assert_ne!(ids[1], ids[2]); +} + +#[test] +fn an_adopted_notes_text_box_gets_a_story_of_its_own() { + use crate::para::NoteKind; + // Ours has a text box under the id their note's text box has. + let mut ours = doc(&["ours"]); + ours.add_part(PartKind::Footnote, Vec::new()); + let our_box = text_box(&mut ours, &Pos::body(0, 0), "our box"); + let mut theirs = doc(&["theirs"]); + let note = theirs.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("note", CharProps::default()))]); + let their_box = theirs.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("their box", CharProps::default()))]); + assert_eq!(their_box, our_box); + theirs + .insert_object(&Pos { story: StoryRef::Part(note), path: Path::top(0), off: 0 }, box_shape(Some(their_box)), &CharProps::default()) + .unwrap(); + theirs + .insert_object(&Pos::body(0, 0), InlineObject::NoteRef { kind: NoteKind::Footnote, id: note, custom: String::new() }, &CharProps::default()) + .unwrap(); + + let blocks = ours.adopt(theirs).blocks; + let Some(Block::Para(p)) = blocks.first() else { panic!() }; + let new_note = p.objects.iter().find_map(|o| if let InlineObject::NoteRef { id, .. } = o { Some(*id) } else { None }).unwrap(); + let story = ours.parts[&new_note] + .blocks + .iter() + .find_map(|b| if let Block::Para(p) = &**b { p.objects.iter().find_map(|o| o.text_boxes().first().copied()) } else { None }) + .unwrap(); + assert_ne!(story, our_box); + let part = &ours.parts[&story]; + assert_eq!(part.kind, PartKind::TextBox); + assert!(matches!(part.blocks.first().map(|b| &**b), Some(Block::Para(p)) if p.text == "their box")); +} diff --git a/crates/wordmodel/src/lib.rs b/crates/wordmodel/src/lib.rs index 59242ec4..9034bd8a 100644 --- a/crates/wordmodel/src/lib.rs +++ b/crates/wordmodel/src/lib.rs @@ -24,6 +24,7 @@ pub mod cluster; pub mod collate; pub mod connector; pub mod control; +pub mod edit; pub mod effects; pub mod eq; pub mod fields; @@ -149,5 +150,7 @@ pub struct Document { pub missing_pictures: MissingPictures, } +#[cfg(test)] +mod test_log; #[cfg(test)] mod tests; diff --git a/crates/wordmodel/src/test_log.rs b/crates/wordmodel/src/test_log.rs new file mode 100644 index 00000000..745ce21f --- /dev/null +++ b/crates/wordmodel/src/test_log.rs @@ -0,0 +1,36 @@ +//! Test helper: the warnings this crate logs, captured per test thread, so a test can check that +//! a limit which leaves something out says so. + +use std::cell::RefCell; + +struct Capture; + +thread_local! { + static LINES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +impl log::Log for Capture { + fn enabled(&self, m: &log::Metadata) -> bool { + m.level() <= log::Level::Warn + } + + fn log(&self, r: &log::Record) { + if self.enabled(r.metadata()) { + LINES.with(|l| l.borrow_mut().push(r.args().to_string())); + } + } + + fn flush(&self) {} +} + +static CAPTURE: Capture = Capture; + +/// `f`'s result and the warnings logged on this thread while it ran. +pub(crate) fn warnings(f: impl FnOnce() -> T) -> (T, Vec) { + // Only this logger is ever installed in a test binary; a second call keeps it. + let _ = log::set_logger(&CAPTURE); + log::set_max_level(log::LevelFilter::Warn); + LINES.with(|l| l.borrow_mut().clear()); + let out = f(); + (out, LINES.with(|l| std::mem::take(&mut *l.borrow_mut()))) +} diff --git a/crates/wordmodel/src/tests.rs b/crates/wordmodel/src/tests.rs index d2924f97..088bd3bd 100644 --- a/crates/wordmodel/src/tests.rs +++ b/crates/wordmodel/src/tests.rs @@ -1,6 +1,8 @@ use super::*; use std::sync::Arc; +mod note_conversion; + #[test] fn new_document_is_valid() { let d = Document::new(); @@ -220,6 +222,93 @@ fn parts_and_lists_after_the_largest_ids_take_free_ones() { assert_eq!((&n.abstracts[..2], &n.nums[..2]), (&lists.abstracts[..], &lists.nums[..])); } +#[test] +fn clone_is_shallow() { + let d = Document::from_text("a\nb"); + let mut e = d.clone(); + assert!(Arc::ptr_eq(&d.body[0], &e.body[0])); + e.insert_text(&Pos::body(1, 0), "x", &CharProps::default()).unwrap(); + assert!(Arc::ptr_eq(&d.body[0], &e.body[0])); + assert!(!Arc::ptr_eq(&d.body[1], &e.body[1])); + assert_eq!(d.plain_text(StoryRef::Body), "a\nb"); +} + +/// [`Document::same_content`] is what tells the engine that a command changed nothing: it +/// must see every change, and it must not call two different documents the same. +#[test] +fn same_content_sees_every_change() { + let d = Document::from_text("Hello\nWorld"); + assert!(d.same_content(&d.clone()), "a copy is the same document"); + // The text changed. + let mut e = d.clone(); + e.insert_text(&Pos::body(0, 0), "x", &CharProps::default()).unwrap(); + assert!(!d.same_content(&e), "the text changed"); + // The formatting changed. + let mut e = d.clone(); + e.format_range(&Pos::body(0, 0), &Pos::body(0, 5), &|c| c.bold = Some(true)).unwrap(); + assert!(!d.same_content(&e), "the formatting changed"); + // The styles changed. + let mut e = d.clone(); + Arc::make_mut(&mut e.styles).get_mut("Normal").unwrap().chr.bold = Some(true); + assert!(!d.same_content(&e), "a style changed"); + // The lists changed. + let mut e = d.clone(); + Arc::make_mut(&mut e.numbering).add_list(ListKind::Numbered); + assert!(!d.same_content(&e), "a list was added"); + // The settings changed. + let mut e = d.clone(); + e.settings.track_changes = true; + assert!(!d.same_content(&e), "a setting changed"); + // The metadata changed. + let mut e = d.clone(); + e.core.title = "Draft".into(); + assert!(!d.same_content(&e), "the title changed"); + // A part changed. + let mut e = d.clone(); + e.add_part(PartKind::Header, vec![para_block(Paragraph::new())]); + assert!(!d.same_content(&e), "a part was added"); + // A picture changed. + let mut e = d.clone(); + e.media.insert("i.png".into(), Arc::new(vec![1, 2, 3])); + assert!(!d.same_content(&e), "a picture was added"); + // A revision was recorded. + let mut e = d.clone(); + e.revisions.push(Revision::new(RevisionKind::Insert, "Mira", "2026-10-23T00:00:00Z")); + assert!(!d.same_content(&e), "a revision was recorded"); + // A comment was added. + let mut e = d.clone(); + e.comments.insert(1, Comment { author: "Mira".into(), ..Default::default() }); + assert!(!d.same_content(&e), "a comment was added"); + // A paragraph that was marked as changed but holds the same content is the same. + let mut e = d.clone(); + e.para_mut(StoryRef::Body, &Path::top(0)).unwrap().touch(); + assert!(d.same_content(&e), "the paragraph only changed its counter"); + // A table copied for an edit: the same until a cell's content changes. + let mut d = Document::from_text("Hello"); + d.body.insert(0, Arc::new(Block::Table(Table::new(2, 2, 200.0)))); + let mut e = d.clone(); + Arc::make_mut(&mut e.body[0]); + assert!(!Arc::ptr_eq(&d.body[0], &e.body[0])); + assert!(d.same_content(&e), "the table was only copied"); + let Block::Table(t) = Arc::make_mut(&mut e.body[0]) else { panic!("a table") }; + t.rows[1].cells[1].blocks[0] = para_block(Paragraph::with_text("x", CharProps::default())); + assert!(!d.same_content(&e), "a cell's text changed"); + // A building block was added. + let mut e = d.clone(); + e.glossary.push(BuildingBlock { name: "Sign-off".into(), ..Default::default() }); + assert!(!d.same_content(&e), "a building block was added"); +} + +#[test] +fn json_round_trip() { + let mut d = Document::from_text("Hello\nWorld"); + d.format_range(&Pos::body(0, 0), &Pos::body(0, 5), &|c| c.bold = Some(true)).unwrap(); + let j = serde_json::to_string(&d).unwrap(); + let back: Document = serde_json::from_str(&j).unwrap(); + assert_eq!(back.plain_text(StoryRef::Body), "Hello\nWorld"); + assert_eq!(back.para(StoryRef::Body, &Path::top(0)).unwrap().runs[0].props.bold, Some(true)); +} + #[test] fn ensure_nonempty_repairs() { let mut d = Document::new(); @@ -229,6 +318,127 @@ fn ensure_nonempty_repairs() { assert!(matches!(d.body.last().map(|b| &**b), Some(Block::Para(_)))); } +#[test] +fn text_box_anchor_finds_the_owning_shape() { + let mut d = Document::from_text("Before after"); + let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Inside", CharProps::default()))]); + let shape = |story| InlineObject::Shape { + kind: para::ShapeKind::TextBox, + w: 144.0, + h: 72.0, + fill: None, + stroke: None, + stroke_width: 0.75, + float: Default::default(), + story, + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + // A plain shape before it doesn't count. + d.insert_object(&Pos::body(0, 0), shape(None), &CharProps::default()).unwrap(); + let end = d.insert_object(&Pos::body(0, 10), shape(Some(id)), &CharProps::default()).unwrap(); + assert_eq!(d.text_box_anchor(id), Some(end)); + assert_eq!(d.text_box_anchor(id + 1), None); +} + +#[test] +fn word_count_including_notes_counts_used_boxes_and_notes_only() { + let mut d = Document::from_text("one two"); + let shape = |story| InlineObject::Shape { + kind: para::ShapeKind::TextBox, + w: 144.0, + h: 72.0, + fill: None, + stroke: None, + stroke_width: 0.75, + float: Default::default(), + story: Some(story), + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))]; + let boxed = d.add_part(PartKind::TextBox, para_of("three four")); + let nested = d.add_part(PartKind::TextBox, para_of("five")); + let note = d.add_part(PartKind::Footnote, para_of("six seven")); + // Not counted: an orphaned box (deleted or cut), a header, a comment. + d.add_part(PartKind::TextBox, para_of("orphan words here")); + d.add_part(PartKind::Header, para_of("header words")); + d.add_part(PartKind::Comment, para_of("comment words")); + d.insert_object(&Pos::body(0, 3), shape(boxed), &CharProps::default()).unwrap(); + d.insert_object(&Pos { story: StoryRef::Part(boxed), path: Path::top(0), off: 0 }, shape(nested), &CharProps::default()).unwrap(); + d.insert_object( + &Pos::body(0, 0), + InlineObject::NoteRef { kind: para::NoteKind::Footnote, id: note, custom: String::new() }, + &CharProps::default(), + ) + .unwrap(); + // A box pointing at a header doesn't make the header count. + let hdr = d.parts.iter().find(|(_, p)| p.kind == PartKind::Header).map(|(k, _)| *k).unwrap(); + d.insert_object(&Pos::body(0, 0), shape(hdr), &CharProps::default()).unwrap(); + assert_eq!(d.word_count(), 2); + assert_eq!(d.word_count_including_notes(), 7); + assert_eq!(d.counted_stories(), vec![StoryRef::Body, StoryRef::Part(note), StoryRef::Part(boxed), StoryRef::Part(nested)]); +} + +#[test] +fn counted_stories_survive_self_nested_boxes() { + let mut d = Document::from_text("a"); + let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("b", CharProps::default()))]); + let shape = InlineObject::Shape { + kind: para::ShapeKind::TextBox, + w: 10.0, + h: 10.0, + fill: None, + stroke: None, + stroke_width: 0.0, + float: Default::default(), + story: Some(id), + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + d.insert_object(&Pos::body(0, 0), shape.clone(), &CharProps::default()).unwrap(); + d.insert_object(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }, shape, &CharProps::default()).unwrap(); + assert_eq!(d.word_count_including_notes(), 2); +} + +#[test] +fn prune_text_boxes_drops_only_unshown_ones() { + let shape = |story| InlineObject::Shape { + kind: para::ShapeKind::TextBox, + w: 50.0, + h: 20.0, + fill: None, + stroke: None, + stroke_width: 0.0, + float: Default::default(), + story: Some(story), + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))]; + let mut d = Document::from_text("body"); + assert_eq!(d.prune_text_boxes(), 0, "nothing to do without text boxes"); + let shown = d.add_part(PartKind::TextBox, para_of("shown")); + let nested = d.add_part(PartKind::TextBox, para_of("inside shown")); + let orphan = d.add_part(PartKind::TextBox, para_of("deleted box")); + let orphan_child = d.add_part(PartKind::TextBox, para_of("inside the deleted box")); + let hdr = d.add_part(PartKind::Header, para_of("header")); + let in_header = d.add_part(PartKind::TextBox, para_of("box in the header")); + d.insert_object(&Pos::body(0, 0), shape(shown), &CharProps::default()).unwrap(); + let at = |id| Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }; + d.insert_object(&at(shown), shape(nested), &CharProps::default()).unwrap(); + d.insert_object(&at(orphan), shape(orphan_child), &CharProps::default()).unwrap(); + d.insert_object(&at(hdr), shape(in_header), &CharProps::default()).unwrap(); + assert_eq!(d.prune_text_boxes(), 2); + let kept: Vec = d.parts.keys().copied().collect(); + assert_eq!(kept, vec![shown, nested, hdr, in_header]); + assert_eq!(d.prune_text_boxes(), 0); +} + /// `blocks` in the only cell of `levels` tables, each nested in the next. fn nested_tables(levels: usize, blocks: Blocks) -> Blocks { (0..levels).fold(blocks, |inner, _| { @@ -258,6 +468,25 @@ fn box_para(story: u32, id: u32) -> Blocks { vec![para_block(p)] } +#[test] +fn adopting_relinks_lists_in_deeply_nested_tables() { + // As #1339: a list paragraph 20 tables down takes the list it is brought in with. + let mut ours = Document::from_text("ours"); + let our_list = Arc::make_mut(&mut ours.numbering).add_list(numbering::ListKind::Bullet); + let mut theirs = Document::from_text("theirs"); + let list = Arc::make_mut(&mut theirs.numbering).add_list(numbering::ListKind::Bullet); + assert_eq!(list, our_list); + let mut p = Paragraph::with_text("item", CharProps::default()); + p.props.numbering = Some(props::NumRef { num: list, level: 0 }); + theirs.body = nested_tables(20, vec![para_block(p)]); + let mut nums = Vec::new(); + for b in &ours.adopt(theirs).blocks { + edit::each_para(b, 0, &mut |p| nums.extend(p.props.numbering.map(|n| n.num))); + } + assert_eq!(nums.len(), 1); + assert_ne!(nums[0], our_list); +} + #[test] fn text_boxes_shown_from_deeply_nested_tables_are_kept() { // #1339: the paragraph walk stopped 16 tables down, so a box shown only from deeper was taken @@ -285,6 +514,59 @@ fn text_boxes_shown_from_deeply_nested_tables_are_kept() { assert_eq!(d.next_shape_id(), 8); } +#[test] +fn a_deeply_nested_table_is_copied_with_its_text_boxes() { + // A copy takes the box 17 tables down along, so pasting it into another document shows its + // text, not the other document's box that happens to have the same id. + let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))]; + let mut d = Document::from_text("body"); + let deep = d.add_part(PartKind::TextBox, para_of("deep box")); + d.body.extend(nested_tables(17, box_para(deep, 0))); + let frag = d.body_fragment(); + assert_eq!(frag.parts.keys().copied().collect::>(), vec![deep]); + let mut e = Document::new(); + let own = e.add_part(PartKind::TextBox, para_of("its own box")); + assert_eq!(own, deep, "the same id in both documents"); + e.body = box_para(own, 0); + let end = e.end_of(StoryRef::Body); + e.insert_fragment(&end, &frag).unwrap(); + let mut shown = Vec::new(); + e.objects_in_reading_order(&mut |o| shown.extend(o.text_box())); + let texts: Vec = shown.iter().map(|id| e.plain_text(StoryRef::Part(*id))).collect(); + assert_eq!(texts, vec!["its own box", "deep box"]); +} + +#[test] +fn notes_in_text_box_follow_nested_boxes_once() { + let shape = |story| InlineObject::Shape { + kind: para::ShapeKind::TextBox, + w: 50.0, + h: 20.0, + fill: None, + stroke: None, + stroke_width: 0.0, + float: Default::default(), + story: Some(story), + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + let note = |id| InlineObject::NoteRef { kind: para::NoteKind::Footnote, id, custom: String::new() }; + let mut d = Document::new(); + let outer = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::new())]); + let inner = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::new())]); + let (n1, n2) = (d.add_part(PartKind::Footnote, Vec::new()), d.add_part(PartKind::Footnote, Vec::new())); + let at = |id| Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }; + // outer: [note n1][box inner][box outer (itself)]; inner: [note n2] + d.insert_object(&at(outer), shape(outer), &CharProps::default()).unwrap(); + d.insert_object(&at(outer), shape(inner), &CharProps::default()).unwrap(); + d.insert_object(&at(outer), note(n1), &CharProps::default()).unwrap(); + d.insert_object(&at(inner), note(n2), &CharProps::default()).unwrap(); + assert_eq!(d.notes_in_text_box(outer), vec![n1, n2]); + assert_eq!(d.notes_in_text_box(inner), vec![n2]); + assert!(d.notes_in_text_box(n1).is_empty(), "not a text box"); +} + fn fs(instr: &str) -> InlineObject { InlineObject::FieldStart { instr: instr.into(), locked: false, dirty: false } } @@ -408,6 +690,20 @@ fn object_walks_are_linear_in_the_paragraph() { assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed()); } +#[test] +fn deleting_across_many_lone_control_markers_is_not_quadratic() { + // A millisecond now; the old delete took 18 s at 1,500 markers and 114 s at 3,000. + let n = 3_000; + let starts: Vec = (0..n).map(|_| InlineObject::ControlStart { control: Box::default() }).collect(); + let mut d = Document::from_text("before\n\nafter"); + d.body[1] = Arc::new(Block::Para(para_of_objects(starts))); + let t = std::time::Instant::now(); + let at = d.delete_range(&Pos::body(0, 3), &Pos::body(2, 2)).unwrap(); + assert_eq!(at, Pos::body(0, 3)); + assert_eq!(d.plain_text(StoryRef::Body), "befter"); + assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed()); +} + #[test] fn control_markers_kept_are_sorted() { // `delete_range` groups the kept markers by paragraph and binary-searches each group, so diff --git a/crates/wordmodel/src/tests/note_conversion.rs b/crates/wordmodel/src/tests/note_conversion.rs new file mode 100644 index 00000000..e134888a --- /dev/null +++ b/crates/wordmodel/src/tests/note_conversion.rs @@ -0,0 +1,128 @@ +use super::*; +use crate::para::{InlineObject, NoteKind, OBJ}; + +fn reference(id: u32, kind: NoteKind) -> InlineObject { + InlineObject::NoteRef { kind, id, custom: "*".into() } +} + +fn styled(len: usize, style: &str) -> Run { + Run { len, props: CharProps { style: Some(style.into()), italic: Some(true), ..Default::default() } } +} + +#[test] +fn conversion_preserves_reference_run_boundaries_and_unmatched_storage() { + for (foot, end, expected) in [ + (true, false, ["FootnoteReference", "EndnoteReference", "FootnoteReference", "EndnoteReference", "EndnoteReference", "FootnoteReference"]), + (false, true, ["FootnoteReference", "FootnoteReference", "FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]), + (true, true, ["FootnoteReference", "EndnoteReference", "FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]), + ] { + let mut d = Document::new(); + let note = |style: &str| { + let mut p = Paragraph::with_text("Original note 漢", CharProps::default()); + p.props.style = Some(style.into()); + p.mark.style = Some(style.into()); + vec![para_block(p)] + }; + let f = d.add_part(PartKind::Footnote, note("FootnoteText")); + let e = d.add_part(PartKind::Endnote, note("EndnoteText")); + let p = Paragraph { + text: format!("é{OBJ}{OBJ}漢{OBJ}{OBJ}"), + objects: vec![ + reference(f, NoteKind::Footnote), + InlineObject::BookmarkStart { name: "kept".into() }, + reference(e, NoteKind::Endnote), + reference(999, NoteKind::Footnote), + reference(f, NoteKind::Footnote), + ], + runs: vec![ + styled(2, "FootnoteReference"), + styled(3, "FootnoteReference"), + styled(3, "FootnoteReference"), + styled(3, "EndnoteReference"), + styled(3, "EndnoteReference"), + styled(3, "FootnoteReference"), + ], + ..Paragraph::new() + }; + let mut table = Table::new(1, 1, 200.0); + table.rows[0].cells[0].blocks = vec![para_block(p.clone())]; + d.body = vec![para_block(p.clone()), Arc::new(Block::Table(table))]; + let h = d.add_part(PartKind::Header, vec![para_block(p.clone())]); + let t = d.add_part(PartKind::TextBox, vec![para_block(p.clone())]); + let before = d.clone(); + assert_eq!(d.convert_notes(foot, end), usize::from(foot) + usize::from(end)); + for (story, path) in [ + (StoryRef::Body, Path::top(0)), + (StoryRef::Body, Path(vec![1, 0, 0, 0])), + (StoryRef::Part(h), Path::top(0)), + (StoryRef::Part(t), Path::top(0)), + ] { + let got = d.para(story, &path).unwrap(); + assert_eq!(got.text, p.text); + assert_eq!(got.runs.iter().map(|r| r.props.style.as_deref().unwrap()).collect::>(), expected); + assert!(got.runs.iter().all(|r| r.props.italic == Some(true))); + assert_eq!(got.objects[1], p.objects[1]); + assert_eq!(got.objects[3], p.objects[3], "a missing note stays unchanged"); + assert_eq!(got.objects[0], reference(f, if foot { NoteKind::Endnote } else { NoteKind::Footnote })); + assert_eq!(got.objects[2], reference(e, if end { NoteKind::Footnote } else { NoteKind::Endnote })); + assert_eq!(got.objects[4], got.objects[0], "surplus object storage retains the existing conversion"); + } + for (id, flipped, from, to) in [(f, foot, "FootnoteText", "EndnoteText"), (e, end, "EndnoteText", "FootnoteText")] { + let got = d.para(StoryRef::Part(id), &Path::top(0)).unwrap(); + assert_eq!(got.text, before.plain_text(StoryRef::Part(id))); + assert_eq!(got.props.style.as_deref(), Some(if flipped { to } else { from })); + assert_eq!(got.mark.style, got.props.style); + } + let mut missing = Paragraph { + text: format!("{OBJ}{OBJ}"), + objects: vec![reference(f, if foot { NoteKind::Endnote } else { NoteKind::Footnote })], + runs: vec![styled(0, "FootnoteReference"), styled(3, "FootnoteReference"), styled(3, "FootnoteReference")], + ..Paragraph::new() + }; + missing.runs.push(styled(usize::MAX, "FootnoteReference")); + d.body = vec![para_block(missing)]; + d.convert_notes(true, true); + let got = d.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!( + got.runs.iter().map(|r| r.props.style.as_deref().unwrap()).collect::>(), + ["FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"] + ); + } +} + +fn dense(n: usize) -> Document { + let mut d = Document::new(); + let id = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Shared original note", CharProps::default()))]); + d.body = vec![para_block(Paragraph { + text: format!("{}{}", OBJ.to_string().repeat(n), "x".repeat(n)), + objects: (0..n).map(|_| reference(id, NoteKind::Footnote)).collect(), + runs: (0..n).map(|_| styled(OBJ.len_utf8(), "FootnoteReference")).chain((0..n).map(|_| styled(1, "FootnoteReference"))).collect(), + ..Paragraph::new() + })]; + d +} + +#[test] +fn conversion_work_scales_with_reference_and_formatting_runs() { + let (small, big) = (dense(2_000), dense(16_000)); + let convert = |input: &Document| { + let mut d = input.clone(); + assert_eq!(d.convert_notes(true, false), 1); + let p = d.para(StoryRef::Body, &Path::top(0)).unwrap(); + let n = p.objects.len(); + assert!(p.runs[..n].iter().all(|r| r.props.style.as_deref() == Some("EndnoteReference"))); + assert!(p.runs[n..].iter().all(|r| r.props.style.as_deref() == Some("FootnoteReference"))); + std::hint::black_box(d); + }; + convert(&small); + convert(&big); + // The characters and runs the conversion walks (`crate::work`), not a timing: about 8x for 8x + // the references and runs when they are walked together, about 64x when each run rescans the + // references. + let s1 = crate::work::count(|| convert(&small)); + let s8 = crate::work::count(|| convert(&big)); + let ratio = s8 as f64 / s1.max(1) as f64; + eprintln!("note conversion steps: {s1} -> {s8}, {ratio:.1}x for 8x references and runs"); + assert!(s1 > 0, "no steps counted, so the walk this test watches no longer counts them (crate::work)"); + assert!(ratio <= 12.0, "8x the references and runs cost {ratio:.1}x the steps ({s1} -> {s8})"); +}