diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c6d9f4503..54866374a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -811,10 +811,30 @@ fn clamp_selection(st: &mut DocState) { /// Default for [`History::byte_limit`]. pub const UNDO_BYTE_BUDGET: usize = 256 << 20; +/// A rough size of a text body in memory: its paragraphs and runs. +fn text_cost(t: &deckcraft_model::text::TextBody) -> usize { + t.paragraphs.iter().map(|p| 96 + p.runs.iter().map(|r| 128 + r.text.len()).sum::()).sum() +} + +/// A rough size of comments in memory, replies included. +fn comments_cost(cs: &[deckcraft_model::Comment], depth: usize) -> usize { + let kids = |c: &deckcraft_model::Comment| if depth < 64 { comments_cost(&c.replies, depth + 1) } else { 0 }; + cs.iter().map(|c| 256 + c.author.len() + c.initials.len() + c.text.len() + c.date.len() + kids(c)).sum() +} + +/// A rough size of a slide's own data besides its shapes: notes, comments, tags and kept XML, +/// all copied with the slide when an edit changes it. +fn slide_extra_cost(s: &deckcraft_model::Slide) -> usize { + text_cost(&s.notes) + + comments_cost(&s.comments, 0) + + s.name.len() + + s.tags.iter().map(|(k, v)| 48 + k.len() + v.len()).sum::() + + s.raw_ext.as_ref().map_or(0, String::len) +} + /// A rough size of a shape in memory: the struct, its text and kept XML, its children. fn shape_cost(sh: &deckcraft_model::Shape, depth: usize) -> usize { - let text: usize = - sh.text.as_ref().map_or(0, |t| t.paragraphs.iter().map(|p| 96 + p.runs.iter().map(|r| 128 + r.text.len()).sum::()).sum()); + let text: usize = sh.text.as_ref().map_or(0, text_cost); let kids: usize = if depth < 64 { sh.children().iter().map(|c| shape_cost(c, depth + 1)).sum() } else { 0 }; 768 + text + sh.raw_ext.as_ref().map_or(0, String::len) + kids } @@ -832,7 +852,7 @@ pub fn undo_cost(doc: &Presentation, newer: &Presentation) -> usize { let media: HashSet<*const Vec> = newer.media.iter().map(|m| Arc::as_ptr(&m.data)).collect(); let mut n = 64 * doc.slides.len(); for s in doc.slides.iter().filter(|s| !slides.contains(&Arc::as_ptr(s))) { - n += 512 + shapes_cost(&s.shapes); + n += 512 + shapes_cost(&s.shapes) + slide_extra_cost(s); } for m in doc.masters.iter().filter(|m| !masters.contains(&Arc::as_ptr(m))) { n += 4096 + shapes_cost(&m.shapes) + m.layouts.iter().map(|l| 512 + shapes_cost(&l.shapes)).sum::(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 68de13418..750e06304 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3338,6 +3338,29 @@ fn undo_history_is_capped_by_memory_as_well_as_steps() { assert_eq!(s.doc().unwrap().history.undo.len(), 120); } +/// A slide's notes count toward the undo budget: small edits to a slide with large notes keep a +/// copy of those notes per step, and must not slip past the budget as if the slide were empty. +#[test] +fn undo_history_counts_slide_notes() { + let mut s = Session::with_new(); + let notes = "n".repeat(1 << 20); + s.edit(|doc, _| { + std::sync::Arc::make_mut(&mut doc.slides[0]).notes = deckcraft_model::text::TextBody::from_text(¬es); + Ok(()) + }) + .unwrap(); + s.doc_mut().unwrap().history = History { limit: 500, byte_limit: 8 << 20, ..Default::default() }; + for i in 0..40 { + s.edit(|doc, _| { + std::sync::Arc::make_mut(&mut doc.slides[0]).name = format!("v{i}"); + Ok(()) + }) + .unwrap(); + } + let n = s.doc().unwrap().history.undo.len(); + assert!((1..=8).contains(&n), "{n} steps of 1 MB notes kept under an 8 MB budget"); +} + #[test] fn picture_styles_replace_each_other_in_one_step() { const PNG: &[u8] = &[