Compare commits

..
8 changed files with 88 additions and 85 deletions
-32
View File
@@ -5023,38 +5023,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]]);
}
#[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>> {
let st = s.doc().unwrap();
let Some(sh) = st.shapes().iter().rev().find(|sh| matches!(sh.kind, deckcraft_model::ShapeKind::Ink { .. })) else {
-3
View File
@@ -17,7 +17,6 @@ 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)]
@@ -276,8 +275,6 @@ pub struct ToolState {
pub path_preview: Vec<Point>,
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<ShapeId>,
/// 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 {
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() {
// 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)
{
// 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) {
points.pop();
}
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)>,
}
/// Samples one ink or freeform gesture keeps; later pointer moves extend it no further.
pub(crate) const MAX_GESTURE_POINTS: usize = 20_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<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) };
match drag {
Drag::Guide { index } => {
@@ -188,7 +181,7 @@ impl Session {
}
Drag::Freeform { mode, mut points, pressed } => {
// 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);
}
let mut v = points.clone();
@@ -198,24 +191,15 @@ impl Session {
self.tool.path_preview = v;
self.tool.drag = Some(Drag::Freeform { mode, points, pressed });
}
// Handled above.
Drag::Ink { .. } => {}
Drag::Ink { mut points } => {
points.push(self.ink_sample(p));
self.tool.ink_preview = points.clone();
self.tool.drag = Some(Drag::Ink { points });
}
}
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> {
let MoveDrag { ids, start, origs, moved, duplicate, enter_text } = drag;
let mut d = p - start;
+2 -5
View File
@@ -228,10 +228,7 @@ impl Session {
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
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.drag = Some(Drag::Freeform { mode, points, pressed: true });
return Ok(Value::Null);
@@ -552,7 +549,7 @@ impl Session {
Some(Drag::Freeform { mode, points, .. }) => {
if mode == "scribble" {
// A scribble ending back at its start closes into a filled shape.
let closed = points.len() > 3 && points.first().is_some_and(|a| (*a - p).hypot() <= 6.0);
let closed = points.len() > 3 && points.first().zip(points.last()).is_some_and(|(a, b)| (*a - *b).hypot() <= 6.0);
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
return self.finish_freeform(closed);
}
+20 -17
View File
@@ -663,52 +663,55 @@ fn read_slides(
let pkg = imp.pkg;
let fallback_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
let mut file_slide_ids: HashMap<u32, SlideId> = HashMap::new();
let mut notes_by_target: HashMap<String, TextBody> = HashMap::new();
// A notes or comments part belongs to one slide; one shared by many must not be read (and
// copied) once per slide that names it.
let mut owned_parts: HashSet<String> = HashSet::new();
for (file_id, path, id) in slide_ids {
let Some(doc) = docs.get(&opc::norm(path)) else { continue };
let part = Part::new(pkg, path, doc);
let layout = part.rels.first_of("slideLayout").and_then(|r| layout_by_part.get(&opc::norm(&r.target)).copied()).unwrap_or(fallback_layout);
let mut slide = read_slide(imp, &part, doc, *id, layout);
if let Some(notes) = slide_notes(imp, &part, &mut notes_by_target) {
if let Some(notes) = slide_notes(imp, &part, &mut owned_parts) {
slide.notes = notes;
}
read_slide_comments(pkg, pres_part, &part, &mut slide);
read_slide_comments(pkg, pres_part, &part, &mut slide, &mut owned_parts);
file_slide_ids.insert(*file_id, *id);
p.slides.push(Arc::new(slide));
}
file_slide_ids
}
/// The slide's notes text, read once per notes part.
fn slide_notes(imp: &mut Imp, part: &Part, cache: &mut HashMap<String, TextBody>) -> Option<TextBody> {
/// The slide's notes text. A notes part is the notes of the first slide that names it.
fn slide_notes(imp: &mut Imp, part: &Part, owned: &mut HashSet<String>) -> Option<TextBody> {
let n = part.rels.first_of("notesSlide")?;
let key = opc::norm(&n.target);
if let Some(cached) = cache.get(&key) {
return Some(cached.clone());
if !owned.insert(opc::norm(&n.target)) {
log::warn!("pptx: notes part {} is named by more than one slide; kept on the first", n.target);
return None;
}
let pkg = imp.pkg;
let nd = load(pkg, &n.target)?;
let np = Part::new(pkg, &n.target, &nd);
let notes = read_notes(imp, &np, &nd.root);
cache.insert(key, notes.clone());
Some(notes)
Some(read_notes(imp, &np, &nd.root))
}
/// Comments. A modern part and the legacy part can describe the same threads (PowerPoint
/// writes both): the modern one wins, its flattened legacy copy is skipped.
fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide) {
let mut parts: Vec<(Doc, bool)> = vec![];
/// Each comments part is read once, for the first slide that names it.
fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide, owned: &mut HashSet<String>) {
let mut parts: Vec<(Doc, bool, bool)> = vec![];
for c in part.rels.all_of("comments") {
// An already-owned part is not re-read, but its kind still decides modern precedence.
let fresh = owned.insert(opc::norm(&c.target));
if let Some(cd) = pkg.xml(&c.target) {
// The content type names the modern part; a package that types it only through a
// Default entry instead of an Override is still modern when its root is p188.
let modern = pkg.content_type(&c.target).as_deref() == Some(crate::write::CT_COMMENTS_MODERN) || cd.root.prefix() == "p188";
parts.push((cd, modern));
parts.push((cd, modern, fresh));
}
}
let has_modern = parts.iter().any(|(_, m)| *m);
for (cd, modern) in parts {
if has_modern && !modern {
let has_modern = parts.iter().any(|(_, m, _)| *m);
for (cd, modern, fresh) in parts {
if !fresh || (has_modern && !modern) {
continue;
}
slide.comments.extend(read_comments(pkg, pres_part, &cd.root, modern));
+27
View File
@@ -146,6 +146,33 @@ fn comments_on_several_slides_round_trip() {
assert_eq!(q.slides[2].comments[0].text, "Slide three");
}
/// A slide that names a modern part another slide already owns, plus its own legacy part, must
/// not import the legacy copy: modern precedence holds even when the modern part is not re-read.
#[test]
fn owned_modern_part_still_suppresses_later_legacy() {
let mut p = deck();
let layout = p.slides[0].layout;
let s2 = defaults::new_slide(&mut p, layout).expect("slide");
p.slides.push(Arc::new(s2));
Arc::make_mut(&mut p.slides[1]).comments = vec![Comment { author: "Cara".into(), text: "Slide two".into(), ..Default::default() }];
let bytes = deckcraft_pptx::export(&p).expect("export");
let rels2 = part(&bytes, "ppt/slides/_rels/slide2.xml.rels").replace("modernComment2.xml", "modernComment1.xml");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip");
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
out.start_file(f.name(), zip::write::SimpleFileOptions::default()).expect("start");
if f.name() == "ppt/slides/_rels/slide2.xml.rels" {
std::io::Write::write_all(&mut out, rels2.as_bytes()).expect("write");
} else {
std::io::copy(&mut f, &mut out).expect("copy");
}
}
let q = deckcraft_pptx::import(&out.finish().expect("finish").into_inner()).expect("import");
assert_eq!(q.slides[0].comments.len(), 2);
assert!(q.slides[1].comments.is_empty(), "{:?}", q.slides[1].comments);
}
/// A legacy-only package (what older tools read and write) still imports: 1/8 pt positions,
/// flattened replies.
#[test]
+31
View File
@@ -287,3 +287,34 @@ fn unreadable_slide_is_reported() {
assert_eq!(lost, vec!["ppt/slides/slide2.xml".to_string()]);
assert_eq!(deckcraft_pptx::import(&broken).expect("import").slides.len(), 1);
}
/// Many slides naming one notes part: it is read for the first slide only, not copied onto every
/// slide that names it.
#[test]
fn a_shared_notes_part_is_read_once() {
use std::sync::Arc;
let mut p = defaults::new_presentation(None);
let layout = p.slides[0].layout;
for _ in 0..3 {
let s = defaults::new_slide(&mut p, layout).expect("slide");
p.slides.push(Arc::new(s));
}
for (i, s) in p.slides.iter_mut().enumerate() {
Arc::make_mut(s).notes = deckcraft_model::text::TextBody::from_text(&format!("notes {i}"));
}
let mut es = entries(&deckcraft_pptx::export(&p).expect("export"));
for (n, d) in es.iter_mut() {
if n.starts_with("ppt/slides/_rels/") {
let x = String::from_utf8(d.clone()).expect("utf8");
let x = x
.replace("notesSlide2.xml", "notesSlide1.xml")
.replace("notesSlide3.xml", "notesSlide1.xml")
.replace("notesSlide4.xml", "notesSlide1.xml");
*d = x.into_bytes();
}
}
let q = deckcraft_pptx::import(&rezip(&es)).expect("import");
assert_eq!(q.slides.len(), 4);
assert_eq!(q.slides[0].notes.text(), "notes 0");
assert!(q.slides[1..].iter().all(|s| s.notes.text().is_empty()), "a shared notes part is not copied");
}