mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-27 11:57:36 -06:00
ROADMAP.md: main's status line kept; both progress-log lines added. docs/target-app-parity.md: main's status line and Tables row kept, #1596 sentence added to the row; log row added.
1421 lines
65 KiB
Rust
1421 lines
65 KiB
Rust
//! 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;
|
||
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<Block>,
|
||
/// 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<u32, Part>,
|
||
}
|
||
|
||
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<u32, u32> = 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<Item = &Block> {
|
||
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<Block>) {
|
||
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;
|
||
|
||
/// Call `f` on every paragraph of a block, in document order, however deep its tables nest. The
|
||
/// walk keeps its own stack instead of recursing, so it is complete: a text box it doesn't reach
|
||
/// is shown nowhere, which pruning relies on (#1339). `_depth` is unused (callers keep passing it).
|
||
pub(crate) fn each_para<'a>(b: &'a Block, _depth: usize, f: &mut dyn FnMut(&'a Paragraph)) {
|
||
// Most blocks are paragraphs, which need no stack.
|
||
if let Block::Para(p) = b {
|
||
return f(p);
|
||
}
|
||
let mut todo = vec![b];
|
||
while let Some(b) = todo.pop() {
|
||
match b {
|
||
Block::Para(p) => f(p),
|
||
Block::Table(t) => {
|
||
// Last first, so they come off the stack in document order.
|
||
let cells = t.rows.iter().rev().flat_map(|r| r.cells.iter().rev());
|
||
todo.extend(cells.flat_map(|c| c.blocks.iter().rev()).map(|cb| &**cb));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// [`each_para`], changing the paragraphs.
|
||
pub(crate) fn each_para_mut(b: &mut Block, _depth: usize, f: &mut dyn FnMut(&mut Paragraph)) {
|
||
if let Block::Para(p) = b {
|
||
return f(p);
|
||
}
|
||
let mut todo = vec![b];
|
||
while let Some(b) = todo.pop() {
|
||
match b {
|
||
Block::Para(p) => f(p),
|
||
Block::Table(t) => {
|
||
let cells = t.rows.iter_mut().rev().flat_map(|r| r.cells.iter_mut().rev());
|
||
todo.extend(cells.flat_map(|c| c.blocks.iter_mut().rev()).map(Arc::make_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<String>| 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<String>, depth: usize) -> usize {
|
||
let fix = |s: &mut Option<String>| {
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Text box story ids of a paragraph's shapes.
|
||
fn box_ids(p: &Paragraph) -> impl Iterator<Item = u32> + '_ {
|
||
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<u32> = 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<String>| {
|
||
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());
|
||
}
|
||
}
|
||
let mut start = 0usize;
|
||
for r in &mut p.runs {
|
||
let end = start.saturating_add(r.len);
|
||
if at.iter().any(|&o| o >= start && 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<String>) -> usize {
|
||
let stories: Vec<crate::StoryRef> =
|
||
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<Pos> {
|
||
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<Pos> {
|
||
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<Pos> {
|
||
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<Block> {
|
||
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()))
|
||
}
|
||
|
||
/// Paragraph paths between `a` and `b` inclusive (same story), document order.
|
||
pub fn paths_between(&self, a: &Pos, b: &Pos) -> Vec<Path> {
|
||
let (a, b) = order(a, b);
|
||
if a.story != b.story {
|
||
return Vec::new();
|
||
}
|
||
// Only the blocks the range reaches are walked (not every paragraph of the story).
|
||
let mut out = Vec::new();
|
||
if let Some(bl) = self.story(a.story) {
|
||
crate::walk_between(bl, &mut Vec::new(), &mut out, 0, (&a.path.0, &b.path.0));
|
||
}
|
||
out
|
||
}
|
||
|
||
/// 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<Pos> {
|
||
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 kept = self.kept_control_markers(&a, &b);
|
||
let kept_in = |path: &Path| -> Vec<usize> { 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());
|
||
// Kept markers of the paragraphs in between move to the join.
|
||
let mut moved: Vec<(InlineObject, CharProps)> = Vec::new();
|
||
for (path, off) in &kept {
|
||
if *path != a.path
|
||
&& *path != b.path
|
||
&& let Some(p) = self.para(a.story, path)
|
||
&& let Some(o) = p.object_at(*off)
|
||
{
|
||
moved.push((o.clone(), p.props_of_char(*off).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);
|
||
}
|
||
tail.props = ParaProps::default();
|
||
// 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 = pa.props.clone();
|
||
pa.append(tail);
|
||
pa.props = keep_props;
|
||
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<Path> {
|
||
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 }
|
||
}
|
||
|
||
/// The whole body as a fragment (with its text boxes), to insert into another document.
|
||
pub fn body_fragment(&self) -> Fragment {
|
||
let blocks: Vec<Block> = self.body.iter().map(|b| (**b).clone()).collect();
|
||
let parts = self.text_box_parts(&blocks);
|
||
Fragment { blocks, parts }
|
||
}
|
||
|
||
/// Copies of the text box stories shapes in `blocks` show, nested boxes included.
|
||
fn text_box_parts(&self, blocks: &[Block]) -> BTreeMap<u32, Part> {
|
||
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<Block> {
|
||
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;
|
||
}
|
||
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<u32, Part>, depth: usize, budget: &mut usize) {
|
||
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.
|
||
fn adopt_text_box(&mut self, old: u32, parts: &BTreeMap<u32, Part>, depth: usize, budget: &mut usize) -> Option<u32> {
|
||
if depth >= MAX_BOX_DEPTH || *budget == 0 {
|
||
return None;
|
||
}
|
||
let src = parts.get(&old).or_else(|| self.parts.get(&old).filter(|p| p.kind == PartKind::TextBox))?;
|
||
let mut blocks: Vec<Block> = src.blocks.iter().map(|b| (**b).clone()).collect();
|
||
// The copy keeps its text direction and alignment, not its link (a copy stands alone).
|
||
let body = crate::TextBody { next: None, ..src.body };
|
||
*budget -= 1;
|
||
self.adopt_text_boxes(&mut blocks, parts, depth + 1, budget);
|
||
let id = self.try_add_part(PartKind::TextBox, blocks.into_iter().map(Arc::new).collect()).ok()?;
|
||
if let Some(p) = self.parts.get_mut(&id) {
|
||
p.body = body;
|
||
}
|
||
Some(id)
|
||
}
|
||
|
||
/// Insert a fragment at `pos`; returns the position after the pasted content.
|
||
pub fn insert_fragment(&mut self, pos: &Pos, frag: &Fragment) -> Result<Pos> {
|
||
// 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 = MAX_PASTED_BOXES;
|
||
self.adopt_text_boxes(&mut blocks, &frag.parts, 0, &mut budget);
|
||
// 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 {
|
||
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<Path> = 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<Block>], 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<u32>) -> 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<Option<u32>> {
|
||
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<_>>(), 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<u32> = 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;
|
||
assert!((1..=MAX_BOX_DEPTH).contains(&added), "{added}");
|
||
}
|
||
|
||
#[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<String> =
|
||
[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<PartKind>, 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<PartKind>; 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<String> {
|
||
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(|| "<none>".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<String> = 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() {
|
||
// 20 tables, each 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.
|
||
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 0..20 {
|
||
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");
|
||
}
|
||
}
|