Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor fc51f46c83 Release 0.17.0 2026-11-09 06:29:58 -05:00
11 changed files with 63 additions and 191 deletions
Generated
+28 -28
View File
@@ -1028,7 +1028,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]] [[package]]
name = "deckcraft" name = "deckcraft"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono", "chrono",
@@ -1050,7 +1050,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-anim" name = "deckcraft-anim"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"deckcraft-geom", "deckcraft-geom",
"deckcraft-model", "deckcraft-model",
@@ -1059,7 +1059,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-av1" name = "deckcraft-av1"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"deckcraft-bitstream", "deckcraft-bitstream",
"rayon", "rayon",
@@ -1068,14 +1068,14 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-bitstream" name = "deckcraft-bitstream"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"craft-media", "craft-media",
] ]
[[package]] [[package]]
name = "deckcraft-cli" name = "deckcraft-cli"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"deckcraft-engine", "deckcraft-engine",
"deckcraft-mcp", "deckcraft-mcp",
@@ -1085,14 +1085,14 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-color" name = "deckcraft-color"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"serde", "serde",
] ]
[[package]] [[package]]
name = "deckcraft-engine" name = "deckcraft-engine"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"deckcraft-anim", "deckcraft-anim",
@@ -1118,7 +1118,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-fonts" name = "deckcraft-fonts"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"epaint_default_fonts", "epaint_default_fonts",
"harfrust 0.13.3", "harfrust 0.13.3",
@@ -1130,7 +1130,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-format" name = "deckcraft-format"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"deckcraft-model", "deckcraft-model",
"serde", "serde",
@@ -1141,7 +1141,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-geom" name = "deckcraft-geom"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"kurbo", "kurbo",
"serde", "serde",
@@ -1149,7 +1149,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-h264" name = "deckcraft-h264"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"deckcraft-bitstream", "deckcraft-bitstream",
"rayon", "rayon",
@@ -1158,7 +1158,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-hevc" name = "deckcraft-hevc"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"deckcraft-bitstream", "deckcraft-bitstream",
"rayon", "rayon",
@@ -1167,21 +1167,21 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-isobmff" name = "deckcraft-isobmff"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"craft-media", "craft-media",
] ]
[[package]] [[package]]
name = "deckcraft-matroska" name = "deckcraft-matroska"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"craft-media", "craft-media",
] ]
[[package]] [[package]]
name = "deckcraft-mcp" name = "deckcraft-mcp"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"deckcraft-engine", "deckcraft-engine",
"deckcraft-render", "deckcraft-render",
@@ -1191,7 +1191,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-media" name = "deckcraft-media"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"deckcraft-av1", "deckcraft-av1",
"deckcraft-h264", "deckcraft-h264",
@@ -1209,7 +1209,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-metafile" name = "deckcraft-metafile"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"image", "image",
"kurbo", "kurbo",
@@ -1218,7 +1218,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-model" name = "deckcraft-model"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"deckcraft-color", "deckcraft-color",
"deckcraft-geom", "deckcraft-geom",
@@ -1230,15 +1230,15 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-ogg" name = "deckcraft-ogg"
version = "0.15.0" version = "0.17.0"
[[package]] [[package]]
name = "deckcraft-opus" name = "deckcraft-opus"
version = "0.15.0" version = "0.17.0"
[[package]] [[package]]
name = "deckcraft-pdf" name = "deckcraft-pdf"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"craft-pdf", "craft-pdf",
"deckcraft-fonts", "deckcraft-fonts",
@@ -1256,7 +1256,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-pptx" name = "deckcraft-pptx"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"deckcraft-color", "deckcraft-color",
"deckcraft-engine", "deckcraft-engine",
@@ -1272,7 +1272,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-render" name = "deckcraft-render"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"deckcraft-anim", "deckcraft-anim",
"deckcraft-color", "deckcraft-color",
@@ -1291,7 +1291,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-text" name = "deckcraft-text"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"deckcraft-color", "deckcraft-color",
@@ -1305,7 +1305,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-ui-egui" name = "deckcraft-ui-egui"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"craft-ui", "craft-ui",
"craft-update", "craft-update",
@@ -1331,7 +1331,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-vp9" name = "deckcraft-vp9"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"deckcraft-bitstream", "deckcraft-bitstream",
"rayon", "rayon",
@@ -1340,7 +1340,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-web" name = "deckcraft-web"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"craft-web-api", "craft-web-api",
"craft-web-host", "craft-web-host",
@@ -6325,7 +6325,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]] [[package]]
name = "xtask" name = "xtask"
version = "0.15.0" version = "0.17.0"
dependencies = [ dependencies = [
"serde_json", "serde_json",
] ]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"] default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
[workspace.package] [workspace.package]
version = "0.15.0" version = "0.17.0"
edition = "2024" edition = "2024"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
rust-version = "1.95" rust-version = "1.95"
+1 -1
View File
@@ -104,7 +104,7 @@ pub(super) fn freeform(s: &mut Session, p: &Value) -> Result<Value> {
a.iter() a.iter()
.filter_map(|v| Some(deckcraft_geom::Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))) .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()) .filter(|q| q.x.is_finite() && q.y.is_finite())
.take(crate::tools::MAX_GESTURE_POINTS) .take(20_000)
.collect() .collect()
}) })
.unwrap_or_default(); .unwrap_or_default();
+14 -26
View File
@@ -85,20 +85,14 @@ impl AutomationRoots {
/// May the file or folder at `path` be read (or written)? `Err` says why not. /// May the file or folder at `path` be read (or written)? `Err` says why not.
pub fn check(&self, path: &str, access: Access) -> Result<(), String> { pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
self.check_path(Path::new(path), access)
}
/// [`Self::check`] for a path that may not be UTF-8: the checked path is the one opened.
pub fn check_path(&self, path: &Path, access: Access) -> Result<(), String> {
let path_text = path.display();
let roots = match access { let roots = match access {
Access::Read => &self.read, Access::Read => &self.read,
Access::Write => &self.write, Access::Write => &self.write,
}; };
if roots.is_empty() { if roots.is_empty() {
return match access { return match access {
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path_text}")), Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path_text}")), Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
}; };
} }
let resolved = resolve(path)?; let resolved = resolve(path)?;
@@ -107,8 +101,8 @@ impl AutomationRoots {
} }
let roots = roots.iter().map(|root| root.shown.display().to_string()).collect::<Vec<_>>().join(", "); let roots = roots.iter().map(|root| root.shown.display().to_string()).collect::<Vec<_>>().join(", ");
match access { match access {
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path_text}")), Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path}")),
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path_text}")), Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path}")),
} }
} }
} }
@@ -197,23 +191,17 @@ pub fn check_write(path: &str) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check(path, Access::Write)) current().map_or(Ok(()), |r| r.check(path, Access::Write))
} }
/// May `path` be written here? Always, without roots in force. For a path that may not be UTF-8.
pub fn check_write_path(path: &Path) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check_path(path, Access::Write))
}
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest /// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
/// existing folder's links followed and the rest appended. /// existing folder's links followed and the rest appended.
fn resolve(path: &Path) -> Result<PathBuf, String> { fn resolve(path: &str) -> Result<PathBuf, String> {
if path.as_os_str().is_empty() { if path.is_empty() {
return Err("automation path rejected: path is empty".into()); return Err("automation path rejected: path is empty".into());
} }
let shown = path.display();
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows. // Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) { if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
return Err(format!("automation path rejected: reserved device names are not allowed: {shown}")); return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));
} }
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))?; let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
// The names below the nearest existing folder, last first. // The names below the nearest existing folder, last first.
let mut missing: Vec<OsString> = vec![]; let mut missing: Vec<OsString> = vec![];
let mut at = absolute.as_path(); let mut at = absolute.as_path();
@@ -224,11 +212,11 @@ fn resolve(path: &Path) -> Result<PathBuf, String> {
return Ok(found); return Ok(found);
} }
Err(e) => match std::fs::symlink_metadata(at) { Err(e) => match std::fs::symlink_metadata(at) {
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {shown}")), Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")),
// There, yet its real path can't be had (no permission): nothing to compare. // There, yet its real path can't be had (no permission): nothing to compare.
Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")), Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")), Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
}, },
} }
let parent = at.parent(); let parent = at.parent();
@@ -238,12 +226,12 @@ fn resolve(path: &Path) -> Result<PathBuf, String> {
at = parent; at = parent;
} }
(Some(Component::ParentDir), _) => { (Some(Component::ParentDir), _) => {
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}")); return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}"));
} }
// Down to the root (or drive) and it doesn't exist either. // Down to the root (or drive) and it doesn't exist either.
_ => { _ => {
let e = std::io::Error::from(std::io::ErrorKind::NotFound); let e = std::io::Error::from(std::io::ErrorKind::NotFound);
return Err(format!("automation path rejected: cannot resolve {shown}: {e}")); return Err(format!("automation path rejected: cannot resolve {path}: {e}"));
} }
} }
} }
+7 -11
View File
@@ -1,8 +1,6 @@
//! Fonts a presentation uses that this machine doesn't have, so opening it can say so (its text //! 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`). //! draws in a stand-in; see `FontDb::document_face`).
use std::collections::HashSet;
use deckcraft_fonts::FontDb; use deckcraft_fonts::FontDb;
use deckcraft_model::resolve::{self, Ctx}; use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::text::{Bullet, TextBody}; use deckcraft_model::text::{Bullet, TextBody};
@@ -14,13 +12,8 @@ use deckcraft_model::{Presentation, Shape, ShapeKind};
/// font to load and are left for the round trip. /// font to load and are left for the round trip.
pub fn install_embedded_fonts(p: &Presentation) { pub fn install_embedded_fonts(p: &Presentation) {
let db = FontDb::global(); 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 font in &p.embedded_fonts {
for id in font.faces().into_iter().flatten() { for id in font.faces().into_iter().flatten() {
if !seen.insert(id) {
continue;
}
let Some(item) = p.media(id) else { continue }; let Some(item) = p.media(id) else { continue };
let Some(sfnt) = deckcraft_fonts::embedded_sfnt(&item.data) else { continue }; let Some(sfnt) = deckcraft_fonts::embedded_sfnt(&item.data) else { continue };
db.add_font(sfnt.to_vec()); db.add_font(sfnt.to_vec());
@@ -34,8 +27,12 @@ pub fn install_embedded_fonts(p: &Presentation) {
/// and can't be installed, so they aren't reported. /// and can't be installed, so they aren't reported.
pub fn missing_fonts(p: &Presentation) -> Vec<String> { pub fn missing_fonts(p: &Presentation) -> Vec<String> {
let db = FontDb::global(); let db = FontDb::global();
// used_fonts is already free of case-insensitive duplicates. let mut out: Vec<String> = Vec::new();
let mut out: Vec<String> = used_fonts(p).into_iter().filter(|f| !f.starts_with('.') && !db.has_family(f)).collect(); 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);
}
}
out.sort_by_key(|f| f.to_lowercase()); out.sort_by_key(|f| f.to_lowercase());
out out
} }
@@ -43,10 +40,9 @@ pub fn missing_fonts(p: &Presentation) -> Vec<String> {
/// The families the slides' text runs and bullets are drawn in. /// The families the slides' text runs and bullets are drawn in.
pub fn used_fonts(p: &Presentation) -> Vec<String> { pub fn used_fonts(p: &Presentation) -> Vec<String> {
let mut out: Vec<String> = Vec::new(); let mut out: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut add = |f: String| { let mut add = |f: String| {
let f = f.trim().to_string(); let f = f.trim().to_string();
if !f.is_empty() && seen.insert(f.to_ascii_lowercase()) { if !f.is_empty() && !out.iter().any(|o| o.eq_ignore_ascii_case(&f)) {
out.push(f); out.push(f);
} }
}; };
+3 -36
View File
@@ -304,12 +304,7 @@ impl Dictionary {
/// `w` made of dictionary parts allowed in compounds (`COMPOUNDFLAG`, or the begin, middle and /// `w` made of dictionary parts allowed in compounds (`COMPOUNDFLAG`, or the begin, middle and
/// end flags), each at least `COMPOUNDMIN` characters long. /// end flags), each at least `COMPOUNDMIN` characters long.
fn compounded(&self, w: &[char], index: usize, failed: &mut HashSet<(usize, usize)>) -> bool { fn compounded(&self, w: &[char], index: 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() { if self.compound.is_none() && self.compound_end.is_none() {
return false; return false;
} }
@@ -327,11 +322,10 @@ impl Dictionary {
if [self.compound, self.compound_end].into_iter().flatten().any(|f| self.compound_part(rest, f, true)) { if [self.compound, self.compound_end].into_iter().flatten().any(|f| self.compound_part(rest, f, true)) {
return true; return true;
} }
if self.compounded(rest, index + 1, failed) { if self.compounded(rest, index + 1) {
return true; return true;
} }
} }
failed.insert((w.len(), index));
false false
} }
@@ -358,7 +352,7 @@ impl Dictionary {
return true; return true;
} }
} }
self.compounded(&w, 0, &mut HashSet::new()) self.compounded(&w, 0)
} }
/// Is `word` spelt correctly? Capitalised and upper-case forms of a lower-case word pass. /// Is `word` spelt correctly? Capitalised and upper-case forms of a lower-case word pass.
@@ -674,8 +668,6 @@ pub fn add_user_word(word: &str) -> std::io::Result<()> {
return Ok(()); return Ok(());
} }
let path = user_dictionary_path().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no folder for the user dictionary"))?; let path = user_dictionary_path().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no folder for the user dictionary"))?;
// A confined automation client may write only inside its write roots, this file included.
crate::file_access::check_write_path(&path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
if let Some(d) = path.parent() { if let Some(d) = path.parent() {
std::fs::create_dir_all(d)?; std::fs::create_dir_all(d)?;
} }
@@ -939,17 +931,6 @@ 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] #[test]
fn runs_give_words_their_language() { fn runs_give_words_their_language() {
use deckcraft_model::{Paragraph, Run, RunProps}; use deckcraft_model::{Paragraph, Run, RunProps};
@@ -989,20 +970,6 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
assert_eq!(w, vec!["check", "done"]); assert_eq!(w, vec!["check", "done"]);
} }
#[test]
fn a_confined_client_cannot_write_the_user_dictionary_outside_its_roots() {
let root = std::env::temp_dir().join(format!("deckcraft-dict-root-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let roots = crate::file_access::AutomationRoots::new(std::slice::from_ref(&root), std::slice::from_ref(&root)).unwrap();
// The user dictionary is a file of its own outside `root`, not whatever HOME points at.
let outside = std::env::temp_dir().join(format!("deckcraft-dict-outside-{}", std::process::id()));
set_user_dictionary_path(outside.join("user.dic"));
let r = crate::file_access::confine(roots.as_ref(), || add_user_word("zqxconfinedword"));
assert_eq!(r.map_err(|e| e.kind()), Err(std::io::ErrorKind::PermissionDenied));
assert!(!outside.exists(), "the refused write created nothing");
let _ = std::fs::remove_dir_all(&root);
}
#[test] #[test]
fn a_long_paragraph_with_a_huge_language_is_checked_in_linear_work() { fn a_long_paragraph_with_a_huge_language_is_checked_in_linear_work() {
use deckcraft_model::{Paragraph, Run, RunProps}; use deckcraft_model::{Paragraph, Run, RunProps};
-53
View File
@@ -5433,38 +5433,6 @@ 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]]); 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<Vec<f32>> { fn ink_pressures(s: &Session) -> Vec<Vec<f32>> {
let st = s.doc().unwrap(); let st = s.doc().unwrap();
let Some(sh) = st.shapes().iter().rev().find(|sh| matches!(sh.kind, deckcraft_model::ShapeKind::Ink { .. })) else { let Some(sh) = st.shapes().iter().rev().find(|sh| matches!(sh.kind, deckcraft_model::ShapeKind::Ink { .. })) else {
@@ -6902,27 +6870,6 @@ fn master_body_placeholder_format_keeps_per_level_formatting() {
assert_eq!(shown(&s), vec![Some(28.0), Some(24.0)]); 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] #[test]
fn slide_master_view_rejects_invalid_indices() { fn slide_master_view_rejects_invalid_indices() {
let mut s = Session::with_new(); let mut s = Session::with_new();
-3
View File
@@ -17,7 +17,6 @@ mod geometry;
mod hit; mod hit;
mod pointer; mod pointer;
pub(crate) use drag::MAX_GESTURE_POINTS;
pub use hit::{line_xfrm, resize_box, table_rows, text_pos}; pub use hit::{line_xfrm, resize_box, table_rows, text_pos};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -276,8 +275,6 @@ pub struct ToolState {
pub path_preview: Vec<Point>, pub path_preview: Vec<Point>,
pub path_smooth: bool, pub path_smooth: bool,
pub path_closing: 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. /// The shape in Edit Points: its vertices are handles instead of the box's.
pub edit_points: Option<ShapeId>, pub edit_points: Option<ShapeId>,
/// The vertex (path, index) whose control handles show in Edit Points. /// The vertex (path, index) whose control handles show in Edit Points.
+2 -6
View File
@@ -4,12 +4,8 @@ use super::{Drag, Hit, Point, Result, Session, ShapeId, ShapeKind, TextSel, Tool
impl Session { impl Session {
pub(super) fn double_click(&mut self, p: Point, tol: f64) -> Result<Value> { pub(super) fn double_click(&mut self, p: Point, tol: f64) -> Result<Value> {
if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() { if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() {
// The double-click's own click added a duplicate vertex (none at the point cap, where // The double-click's own click added a duplicate vertex.
// the existing vertices are all kept). while points.len() > 2 && points.last().zip(points.get(points.len() - 2)).is_some_and(|(a, b)| (*a - *b).hypot() <= tol) {
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(); points.pop();
} }
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
+6 -22
View File
@@ -12,16 +12,9 @@ struct MoveDrag {
enter_text: Option<(ShapeId, Point)>, 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 { impl Session {
/// Update the active drag interaction using the pointer position and modifiers. /// Update the active drag interaction using the pointer position and modifiers.
pub(super) fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> { pub(super) fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
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) }; let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) };
match drag { match drag {
Drag::Guide { index } => { Drag::Guide { index } => {
@@ -188,7 +181,7 @@ impl Session {
} }
Drag::Freeform { mode, mut points, pressed } => { Drag::Freeform { mode, mut points, pressed } => {
// Dragging scribbles (except for curves, which only take clicked points). // Dragging scribbles (except for curves, which only take clicked points).
if pressed && mode != "curve" && points.len() < MAX_GESTURE_POINTS && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) { if pressed && mode != "curve" && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) {
points.push(p); points.push(p);
} }
let mut v = points.clone(); let mut v = points.clone();
@@ -198,24 +191,15 @@ impl Session {
self.tool.path_preview = v; self.tool.path_preview = v;
self.tool.drag = Some(Drag::Freeform { mode, points, pressed }); self.tool.drag = Some(Drag::Freeform { mode, points, pressed });
} }
// Handled above. Drag::Ink { mut points } => {
Drag::Ink { .. } => {} points.push(self.ink_sample(p));
self.tool.ink_preview = points.clone();
self.tool.drag = Some(Drag::Ink { points });
}
} }
Ok(Value::Null) 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<Value> { fn drag_move(&mut self, drag: MoveDrag, p: Point, mods: Mods, tol: f64) -> Result<Value> {
let MoveDrag { ids, start, origs, moved, duplicate, enter_text } = drag; let MoveDrag { ids, start, origs, moved, duplicate, enter_text } = drag;
let mut d = p - start; let mut d = p - start;
-3
View File
@@ -228,10 +228,7 @@ impl Session {
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
return self.finish_freeform(true); return self.finish_freeform(true);
} }
self.tool.press_added_vertex = points.len() < super::MAX_GESTURE_POINTS;
if self.tool.press_added_vertex {
points.push(p); points.push(p);
}
self.tool.path_preview = points.clone(); self.tool.path_preview = points.clone();
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: true }); self.tool.drag = Some(Drag::Freeform { mode, points, pressed: true });
return Ok(Value::Null); return Ok(Value::Null);