From c3fb184e9d2fb41758c220b50ee8e1b26c7e3b08 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 10 Nov 2026 17:54:22 -0500 Subject: [PATCH 1/3] engine: index font names and embedded faces instead of rescanning (#1749) Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com> --- crates/engine/src/fonts.rs | 18 +++++++++++------- crates/engine/src/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index e208f72c7..3d99d3f57 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -1,6 +1,8 @@ //! Fonts a presentation uses that this machine doesn't have, so opening it can say so (its text //! draws in a stand-in; see `FontDb::document_face`). +use std::collections::HashSet; + use deckcraft_fonts::FontDb; use deckcraft_model::resolve::{self, Ctx}; use deckcraft_model::text::{Bullet, TextBody}; @@ -12,8 +14,13 @@ use deckcraft_model::{Presentation, Shape, ShapeKind}; /// font to load and are left for the round trip. pub fn install_embedded_fonts(p: &Presentation) { let db = FontDb::global(); + // Faces can name one media item many times; each is copied and enumerated once. + let mut seen = HashSet::new(); for font in &p.embedded_fonts { for id in font.faces().into_iter().flatten() { + if !seen.insert(id) { + continue; + } let Some(item) = p.media(id) else { continue }; let Some(sfnt) = deckcraft_fonts::embedded_sfnt(&item.data) else { continue }; db.add_font(sfnt.to_vec()); @@ -27,12 +34,8 @@ pub fn install_embedded_fonts(p: &Presentation) { /// and can't be installed, so they aren't reported. pub fn missing_fonts(p: &Presentation) -> Vec { let db = FontDb::global(); - let mut out: Vec = Vec::new(); - for family in used_fonts(p) { - if !family.starts_with('.') && !db.has_family(&family) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) { - out.push(family); - } - } + // used_fonts is already free of case-insensitive duplicates. + let mut out: Vec = used_fonts(p).into_iter().filter(|f| !f.starts_with('.') && !db.has_family(f)).collect(); out.sort_by_key(|f| f.to_lowercase()); out } @@ -40,9 +43,10 @@ pub fn missing_fonts(p: &Presentation) -> Vec { /// The families the slides' text runs and bullets are drawn in. pub fn used_fonts(p: &Presentation) -> Vec { let mut out: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); let mut add = |f: String| { let f = f.trim().to_string(); - if !f.is_empty() && !out.iter().any(|o| o.eq_ignore_ascii_case(&f)) { + if !f.is_empty() && seen.insert(f.to_ascii_lowercase()) { out.push(f); } }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5d2310328..1c9434f0e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6870,6 +6870,27 @@ fn master_body_placeholder_format_keeps_per_level_formatting() { assert_eq!(shown(&s), vec![Some(28.0), Some(24.0)]); } +#[test] +fn many_distinct_fonts_are_listed_once_each_without_a_quadratic_scan() { + // 30,000 runs in 15,000 families, each written twice in different case. + let mut p = deckcraft_model::Presentation::default(); + let slide = std::sync::Arc::make_mut(&mut p.slides[0]); + let mut body = deckcraft_model::text::TextBody::from_text("x"); + let run = body.paragraphs[0].runs[0].clone(); + body.paragraphs[0].runs = (0..30_000) + .map(|i| { + let mut r = run.clone(); + let name = format!("Qqq Font {}", i / 2); + r.props.font = Some(if i % 2 == 0 { name } else { name.to_uppercase() }); + r + }) + .collect(); + slide.shapes[0].text = Some(body); + let used = crate::fonts::used_fonts(&p); + assert_eq!(used.iter().filter(|f| f.starts_with("Qqq Font ")).count(), 15_000); + assert_eq!(crate::fonts::missing_fonts(&p).iter().filter(|f| f.to_ascii_lowercase().starts_with("qqq font ")).count(), 15_000); +} + #[test] fn slide_master_view_rejects_invalid_indices() { let mut s = Session::with_new(); From 1e9c392f675eb3fd3feaea6362fa914592c42084 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 10 Nov 2026 17:54:53 -0500 Subject: [PATCH 2/3] Spelling: remember failed compound tails (#1835) * Spelling: remember failed compound tails * Apply review fixes - crates/engine/src/spell.rs * Apply review fixes - crates/engine/src/spell.rs --------- Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com> --- crates/engine/src/spell.rs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/spell.rs b/crates/engine/src/spell.rs index 0eeaa6757..ee76ff89c 100644 --- a/crates/engine/src/spell.rs +++ b/crates/engine/src/spell.rs @@ -304,7 +304,12 @@ impl Dictionary { /// `w` made of dictionary parts allowed in compounds (`COMPOUNDFLAG`, or the begin, middle and /// end flags), each at least `COMPOUNDMIN` characters long. - fn compounded(&self, w: &[char], index: usize) -> bool { + fn compounded(&self, w: &[char], index: usize, failed: &mut HashSet<(usize, usize)>) -> bool { + // `w` is always a tail of the checked word, so its length names it: a (tail, part index) + // that failed once fails again however the earlier parts were split. + if failed.contains(&(w.len(), index)) { + return false; + } if self.compound.is_none() && self.compound_end.is_none() { return false; } @@ -322,10 +327,11 @@ impl Dictionary { if [self.compound, self.compound_end].into_iter().flatten().any(|f| self.compound_part(rest, f, true)) { return true; } - if self.compounded(rest, index + 1) { + if self.compounded(rest, index + 1, failed) { return true; } } + failed.insert((w.len(), index)); false } @@ -352,7 +358,7 @@ impl Dictionary { return true; } } - self.compounded(&w, 0) + self.compounded(&w, 0, &mut HashSet::new()) } /// Is `word` spelt correctly? Capitalised and upper-case forms of a lower-case word pass. @@ -931,6 +937,17 @@ SFX D Y 1\nSFX D 0 ed/S .\n"; } } + #[test] + fn overlapping_compound_roots_with_a_failing_ending_stay_cheap() { + // Roots a..aaaaaaaa overlap, so many splits of "aaa…ab" reach the same (tail, part) state; unmemoized that is millions of searches. + let d = Dictionary::parse( + b"COMPOUNDFLAG X\nCOMPOUNDMIN 1\n".as_slice(), + b"8\na/X\naa/X\naaa/X\naaaa/X\naaaaa/X\naaaaaa/X\naaaaaaa/X\naaaaaaaa/X\n".as_slice(), + ); + assert!(!d.check(&format!("{}b", "a".repeat(99)))); + assert!(d.check(&"a".repeat(20))); + } + #[test] fn runs_give_words_their_language() { use deckcraft_model::{Paragraph, Run, RunProps}; From 54846750d495baabc064bd6e1f27779399188ced Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 10 Nov 2026 18:42:24 -0500 Subject: [PATCH 3/3] Ink: extend a stroke in place and cap a gesture's samples (#1770) * engine: extend an ink stroke in place and cap a gesture's samples * engine: move the ink extension out of drag * address review: close scribbles at the release point; keep vertices when a capped press adds none * Apply review fixes - crates/engine/src/tools/pointer.rs * raise ink gesture cap to 100000 points --------- Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com> --- crates/engine/src/cmd/shape/freeform.rs | 2 +- crates/engine/src/tests.rs | 32 +++++++++++++++++++++++++ crates/engine/src/tools.rs | 3 +++ crates/engine/src/tools/click.rs | 8 +++++-- crates/engine/src/tools/drag.rs | 28 +++++++++++++++++----- crates/engine/src/tools/pointer.rs | 5 +++- 6 files changed, 68 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/shape/freeform.rs b/crates/engine/src/cmd/shape/freeform.rs index 5fd299c9e..f6886c75f 100644 --- a/crates/engine/src/cmd/shape/freeform.rs +++ b/crates/engine/src/cmd/shape/freeform.rs @@ -104,7 +104,7 @@ pub(super) fn freeform(s: &mut Session, p: &Value) -> Result { a.iter() .filter_map(|v| Some(deckcraft_geom::Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))) .filter(|q| q.x.is_finite() && q.y.is_finite()) - .take(20_000) + .take(crate::tools::MAX_GESTURE_POINTS) .collect() }) .unwrap_or_default(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1c9434f0e..8b1841675 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5433,6 +5433,38 @@ fn pen_pressure_is_kept_and_a_mouse_draws_at_full_width() { assert_eq!(got, vec![vec![0.2, 1.0, 0.4], vec![1.0, 1.0]]); } +#[test] +fn a_long_ink_stroke_is_extended_in_place_and_capped() { + let mut s = session(); + s.set_tool(tools::ToolKind::Ink { mode: "pen".into(), color: deckcraft_model::Rgba::rgb(20, 20, 20), width: 4.0 }); + let ev = |kind, x: f64| tools::PointerEvent { kind, x, y: 80.0, mods: Mods::default(), tol: 3.0, pressure: Some(0.5) }; + s.pointer(ev(PointerKind::Down, 0.0)).unwrap(); + let n = tools::MAX_GESTURE_POINTS + 5_000; + for i in 1..n { + s.pointer(ev(PointerKind::Drag, (i % 500) as f64)).unwrap(); + } + assert_eq!(s.tool.ink_preview.len(), tools::MAX_GESTURE_POINTS); + s.pointer(ev(PointerKind::Up, 0.0)).unwrap(); + let kept: usize = ink_pressures(&s).iter().map(Vec::len).sum(); + assert!((2..=tools::MAX_GESTURE_POINTS).contains(&kept), "{kept}"); +} + +#[test] +fn double_clicking_a_capped_freeform_keeps_its_vertices() { + let mut s = session(); + let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 3.0, pressure: None }; + s.set_tool(tools::ToolKind::Shape { preset: "freeform".into() }); + s.pointer(ev(tools::PointerKind::Down, 100.0, 100.0)).unwrap(); + // Densely clicked vertices, each within the double-click tolerance of the last. + for i in 0..tools::MAX_GESTURE_POINTS + 10 { + s.pointer(ev(tools::PointerKind::Down, 300.0 + i as f64 * 0.001, 100.0)).unwrap(); + } + s.pointer(ev(tools::PointerKind::DoubleClick, 300.0, 100.0)).unwrap(); + let st = s.doc().unwrap(); + let deckcraft_model::Geom::Custom { paths } = &st.shapes().last().unwrap().geom else { panic!("no freeform") }; + assert_eq!(paths[0].d.matches(" L ").count(), tools::MAX_GESTURE_POINTS - 1); +} + fn ink_pressures(s: &Session) -> Vec> { let st = s.doc().unwrap(); let Some(sh) = st.shapes().iter().rev().find(|sh| matches!(sh.kind, deckcraft_model::ShapeKind::Ink { .. })) else { diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 82f53dfc3..2b7f78da9 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -17,6 +17,7 @@ mod geometry; mod hit; mod pointer; +pub(crate) use drag::MAX_GESTURE_POINTS; pub use hit::{line_xfrm, resize_box, table_rows, text_pos}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -275,6 +276,8 @@ pub struct ToolState { pub path_preview: Vec, pub path_smooth: bool, pub path_closing: bool, + /// Whether the last freeform press added a vertex (it adds none at the point cap). + pub press_added_vertex: bool, /// The shape in Edit Points: its vertices are handles instead of the box's. pub edit_points: Option, /// The vertex (path, index) whose control handles show in Edit Points. diff --git a/crates/engine/src/tools/click.rs b/crates/engine/src/tools/click.rs index 802eb8c5e..10bc03d98 100644 --- a/crates/engine/src/tools/click.rs +++ b/crates/engine/src/tools/click.rs @@ -4,8 +4,12 @@ use super::{Drag, Hit, Point, Result, Session, ShapeId, ShapeKind, TextSel, Tool impl Session { pub(super) fn double_click(&mut self, p: Point, tol: f64) -> Result { if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() { - // The double-click's own click added a duplicate vertex. - while points.len() > 2 && points.last().zip(points.get(points.len() - 2)).is_some_and(|(a, b)| (*a - *b).hypot() <= tol) { + // The double-click's own click added a duplicate vertex (none at the point cap, where + // the existing vertices are all kept). + while self.tool.press_added_vertex + && points.len() > 2 + && points.last().zip(points.get(points.len() - 2)).is_some_and(|(a, b)| (*a - *b).hypot() <= tol) + { points.pop(); } self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs index a1abcb93c..41ade3266 100644 --- a/crates/engine/src/tools/drag.rs +++ b/crates/engine/src/tools/drag.rs @@ -12,9 +12,16 @@ struct MoveDrag { enter_text: Option<(ShapeId, Point)>, } +/// Samples one ink or freeform gesture keeps; later pointer moves extend it no further. +pub(crate) const MAX_GESTURE_POINTS: usize = 100_000; + impl Session { /// Update the active drag interaction using the pointer position and modifiers. pub(super) fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result { + if matches!(self.tool.drag, Some(Drag::Ink { .. })) { + self.extend_ink(p); + return Ok(Value::Null); + } let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) }; match drag { Drag::Guide { index } => { @@ -181,7 +188,7 @@ impl Session { } Drag::Freeform { mode, mut points, pressed } => { // Dragging scribbles (except for curves, which only take clicked points). - if pressed && mode != "curve" && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) { + if pressed && mode != "curve" && points.len() < MAX_GESTURE_POINTS && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) { points.push(p); } let mut v = points.clone(); @@ -191,15 +198,24 @@ impl Session { self.tool.path_preview = v; self.tool.drag = Some(Drag::Freeform { mode, points, pressed }); } - Drag::Ink { mut points } => { - points.push(self.ink_sample(p)); - self.tool.ink_preview = points.clone(); - self.tool.drag = Some(Drag::Ink { points }); - } + // Handled above. + Drag::Ink { .. } => {} } Ok(Value::Null) } + /// Ink grows by one sample per move: extend it (and its preview) in place rather than + /// copying the whole stroke twice per event, up to [`MAX_GESTURE_POINTS`]. + fn extend_ink(&mut self, p: Point) { + let sample = self.ink_sample(p); + if let Some(Drag::Ink { points }) = self.tool.drag.as_mut() + && points.len() < MAX_GESTURE_POINTS + { + points.push(sample); + self.tool.ink_preview.push(sample); + } + } + fn drag_move(&mut self, drag: MoveDrag, p: Point, mods: Mods, tol: f64) -> Result { let MoveDrag { ids, start, origs, moved, duplicate, enter_text } = drag; let mut d = p - start; diff --git a/crates/engine/src/tools/pointer.rs b/crates/engine/src/tools/pointer.rs index d20ae9315..e289e5c70 100644 --- a/crates/engine/src/tools/pointer.rs +++ b/crates/engine/src/tools/pointer.rs @@ -228,7 +228,10 @@ impl Session { self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); return self.finish_freeform(true); } - points.push(p); + self.tool.press_added_vertex = points.len() < super::MAX_GESTURE_POINTS; + if self.tool.press_added_vertex { + points.push(p); + } self.tool.path_preview = points.clone(); self.tool.drag = Some(Drag::Freeform { mode, points, pressed: true }); return Ok(Value::Null);