diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index f29c2061c..c2186a9a2 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1562,3 +1562,89 @@ pub fn add_text_replacement( })?; Ok(s) } + +/// The Eraser (Draw tools): remove the parts of drawing `(page, index)` within `radius` points +/// of `path`, splitting strokes where they are cut. A drawing with nothing left is deleted. +/// Returns whether anything was erased. +pub fn erase_ink(doc: &mut Document, page: usize, index: usize, path: &[[f64; 2]], radius: f64, meta: &Meta) -> Result { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + if d.name(b"Subtype") != Some(b"Ink") { + return Err(AnnotError::Invalid("only drawings can be erased".into())); + } + if path.is_empty() || !path.iter().all(|p| finite(p)) || !radius.is_finite() || radius <= 0.0 { + return Err(AnnotError::Invalid("invalid eraser path".into())); + } + let strokes: Vec> = d + .get(b"InkList") + .map(|l| doc.resolve(l)) + .and_then(|l| l.as_array().cloned()) + .unwrap_or_default() + .iter() + .map(|s| { + let v: Vec = doc.resolve(s).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default(); + v.chunks_exact(2).map(|p| [p[0], p[1]]).collect() + }) + .collect(); + // Distance from a point to the eraser's path (segments, or a single point). + let near = |p: [f64; 2]| -> bool { + if path.len() == 1 { + return (p[0] - path[0][0]).hypot(p[1] - path[0][1]) <= radius; + } + path.windows(2).any(|w| { + let (a, b) = (w[0], w[1]); + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let len2 = dx * dx + dy * dy; + let t = if len2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 }; + (p[0] - (a[0] + t * dx)).hypot(p[1] - (a[1] + t * dy)) <= radius + }) + }; + let mut kept: Vec> = Vec::new(); + let mut erased = false; + for s in &strokes { + // Densify so long segments are cut where the eraser crosses them. + let mut pts: Vec<[f64; 2]> = Vec::new(); + for w in s.windows(2) { + let n = ((w[1][0] - w[0][0]).hypot(w[1][1] - w[0][1]) / (radius / 2.0)).ceil().clamp(1.0, 2000.0) as usize; + for k in 0..n { + let t = k as f64 / n as f64; + pts.push([w[0][0] + (w[1][0] - w[0][0]) * t, w[0][1] + (w[1][1] - w[0][1]) * t]); + } + } + if let Some(last) = s.last() { + pts.push(*last); + } + let mut run: Vec<[f64; 2]> = Vec::new(); + for p in pts { + if near(p) { + erased = true; + if run.len() >= 2 { + kept.push(std::mem::take(&mut run)); + } + run.clear(); + } else { + run.push(p); + } + } + if run.len() >= 2 { + kept.push(run); + } + } + if !erased { + return Ok(false); + } + if kept.is_empty() { + delete_annotation(doc, page, index)?; + return Ok(true); + } + let width = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0); + let rect = grow(bounds(kept.iter().flatten().copied()).unwrap_or_default(), width / 2.0 + 1.0); + doc.update_dict(r, |d| { + d.set(b"InkList".to_vec(), Object::Array(kept.iter().map(|s| num_array(&s.concat())).collect())); + d.set(b"Rect".to_vec(), num_array(&rect)); + touch(d, meta); + })?; + set_appearance(doc, r)?; + Ok(true) +} diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index c495cfb1c..1396b84b0 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -560,3 +560,22 @@ fn files_attach_as_comments() { .is_err() ); } + +#[test] +fn the_eraser_cuts_strokes_and_removes_empty_drawings() { + let mut doc = fixture(); + let line = |y: f64| (0..=10).map(|k| [100.0 + k as f64 * 20.0, y]).collect::>(); + let shape = Shape::Ink { strokes: vec![line(500.0), line(400.0)] }; + let i = add_annotation(&mut doc, &new(0, shape), &meta("i")).unwrap(); + // A vertical swipe through the middle of both lines. + assert!(erase_ink(&mut doc, 0, i, &[[200.0, 550.0], [200.0, 350.0]], 5.0, &meta("e")).unwrap()); + let d = &list(&doc, 0)[i]; + let strokes = d.get(b"InkList").unwrap().as_array().unwrap().len(); + assert_eq!(strokes, 4, "each line cut in two"); + assert!(!erase_ink(&mut doc, 0, i, &[[600.0, 100.0]], 5.0, &meta("e")).unwrap(), "nothing under the eraser"); + // Rubbing out everything deletes the drawing. + let all: Vec<[f64; 2]> = (0..=20).map(|k| [100.0 + k as f64 * 10.0, 500.0]).chain((0..=20).map(|k| [100.0 + k as f64 * 10.0, 400.0])).collect(); + erase_ink(&mut doc, 0, i, &all[..21], 8.0, &meta("e")).unwrap(); + erase_ink(&mut doc, 0, i, &all[21..], 8.0, &meta("e")).unwrap(); + assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Ink"))); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 9e9b7eb50..6c4778e54 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -188,6 +188,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("comment.caret", "Insert text", None, None, Annotate, "text-cursor-input"), c("comment.replace", "Replace text", None, None, Annotate, "replace"), c("comment.attach", "Attach a file", None, None, Annotate, "paperclip"), + c("comment.eraser", "Erase drawings", None, None, Annotate, "eraser"), c("form.fields", "Form fields panel", VIEW, None, Document, "list"), c("form.clear", "Clear form", EDIT, None, FillForms, "eraser"), c("comment.flatten", "Flatten comments", None, None, HasComments, "layers"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0cfd50d97..7ae2a7587 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -91,6 +91,7 @@ fn scope_of(edit: &Edit) -> Scope { | Edit::MarkAnnotation { .. } | Edit::LockAnnotation { .. } | Edit::ReplaceText { .. } + | Edit::EraseInk { .. } | Edit::MoveAnnotation { .. } | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } @@ -529,6 +530,13 @@ pub enum Edit { state: ReviewState, author: String, }, + /// The Eraser: rub out the parts of drawing `(page, index)` near `path` (user space). + EraseInk { + page: usize, + index: usize, + path: Vec<[f64; 2]>, + radius: f64, + }, /// Replace Text: strike out `quads` and add a grouped caret holding `text`. ReplaceText { page: usize, @@ -757,6 +765,7 @@ impl Edit { Edit::ReplyToAnnotation { .. } => "Reply".into(), Edit::SetAnnotationStatus { state, .. } => format!("Set status {}", state.name()), Edit::ReplaceText { .. } => "Replace text".into(), + Edit::EraseInk { .. } => "Erase".into(), Edit::MarkAnnotation { marked: true, .. } => "Mark with checkmark".into(), Edit::MarkAnnotation { .. } => "Remove checkmark".into(), Edit::LockAnnotation { locked: true, .. } => "Lock comment".into(), @@ -874,6 +883,7 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), | Edit::SetAnnotationStatus { .. } | Edit::MarkAnnotation { .. } | Edit::ReplaceText { .. } + | Edit::EraseInk { .. } | Edit::LockAnnotation { .. } | Edit::MoveAnnotation { .. } | Edit::ResizeAnnotation { .. } @@ -1041,6 +1051,9 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - Edit::SetAnnotationStatus { page, index, state, author } => { printcraft_annot::set_review_state(doc, *page, *index, *state, author, &cx.meta())?; } + Edit::EraseInk { page, index, path, radius } => { + printcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; + } Edit::ReplaceText { page, quads, text, author, strike, caret } => { printcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &cx.meta())?; } diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 02401d4ed..cb8ddfe5d 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -42,6 +42,7 @@ pub enum CommentTool { Caret, ReplaceText, Attach, + Eraser, } /// The quick-bar flyout groups, in Acrobat's order: Comment ▸, Highlight ▸, Draw ▸. @@ -57,10 +58,12 @@ pub const GROUPS: [&[CommentTool]; 3] = [ CommentTool::PolyLine, CommentTool::Polygon, CommentTool::Cloud, + CommentTool::Eraser, ], ]; -pub const ALL: [CommentTool; 17] = [ +pub const ALL: [CommentTool; 18] = [ + CommentTool::Eraser, CommentTool::ReplaceText, CommentTool::Attach, CommentTool::Polygon, @@ -100,6 +103,7 @@ impl CommentTool { Self::Caret => "comment.caret", Self::ReplaceText => "comment.replace", Self::Attach => "comment.attach", + Self::Eraser => "comment.eraser", } } @@ -126,6 +130,7 @@ impl CommentTool { Self::Caret => "Insert text", Self::ReplaceText => "Replace text", Self::Attach => "Attach file", + Self::Eraser => "Eraser", } } @@ -148,6 +153,7 @@ impl CommentTool { Self::Caret => "text-cursor-input", Self::ReplaceText => "replace", Self::Attach => "paperclip", + Self::Eraser => "eraser", } } @@ -166,7 +172,7 @@ impl CommentTool { /// Tools that draw with a drag gesture. pub fn draws(self) -> bool { - matches!(self, Self::Ink | Self::Line | Self::Arrow | Self::Rectangle | Self::Oval) + matches!(self, Self::Ink | Self::Line | Self::Arrow | Self::Rectangle | Self::Oval | Self::Eraser) } /// Tools that place points one click at a time (double-click or Enter finishes). @@ -198,6 +204,7 @@ impl CommentTool { Self::Callout => Shape::Callout { rect: [0.0; 4], knee: [0.0; 2], point: [0.0; 2], font_size: 10.0 }, Self::Caret => Shape::Caret { rect: [0.0; 4] }, Self::ReplaceText => Shape::TextMarkup { kind: Markup::StrikeOut, quads: Vec::new() }, + Self::Eraser => Shape::Ink { strokes: Vec::new() }, Self::Attach => Shape::Attachment { at: [0.0; 2], icon: printcraft_engine::AttachIcon::PushPin, file: String::new(), data: Vec::new() }, } } @@ -535,7 +542,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, { let p = clamp_to(page_rect, p); let u = cx.to_user(p); - if *tool == CommentTool::Ink { + if matches!(*tool, CommentTool::Ink | CommentTool::Eraser) { let last = points.last().map(|l| cx.to_screen(*l)).unwrap_or(p); if last.distance(p) >= 1.5 { points.push(u); @@ -550,7 +557,9 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, && page == cx.page { cv.gesture = None; - if let Some(shape) = drawn_shape(tool, &points) { + if tool == CommentTool::Eraser { + view.pending_edit = erase(cx, &points); + } else if let Some(shape) = drawn_shape(tool, &points) { view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); } } @@ -885,6 +894,9 @@ fn paint_gesture(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) { CommentTool::Ink => { painter.add(egui::Shape::line(pts, stroke)); } + CommentTool::Eraser => { + painter.add(egui::Shape::line(pts, Stroke::new(12.0, Color32::from_gray(128).gamma_multiply(0.35)))); + } CommentTool::Line | CommentTool::Arrow if pts.len() == 2 => { painter.line_segment([pts[0], pts[1]], stroke); if *tool == CommentTool::Arrow { @@ -942,6 +954,29 @@ fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option { } } +/// Eraser: rub out drawings along `path` (user space); one undo step for all of them. +fn erase(cx: &PageCx<'_>, path: &[[f64; 2]]) -> Option { + let zoom = (cx.xf.rect.width() / cx.xf.pw.max(1.0)) as f64; + let radius = (6.0 / zoom).max(1.0); + let b = path.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p[0]), r[1].min(p[1]), r[2].max(p[0]), r[3].max(p[1])]); + let mut hits: Vec = cx + .comments() + .filter(|a| a.subtype == "Ink" && !a.locked) + .filter(|a| { + let r = a.rect; + (r[0] as f64) <= b[2] + radius && (r[2] as f64) >= b[0] - radius && (r[1] as f64) <= b[3] + radius && (r[3] as f64) >= b[1] - radius + }) + .map(|a| a.index) + .collect(); + if hits.is_empty() { + return None; + } + // Later indices first: deleting an emptied drawing shifts the ones after it. + hits.sort_unstable_by(|a, b| b.cmp(a)); + let edits = hits.into_iter().map(|index| Edit::EraseInk { page: cx.page, index, path: path.to_vec(), radius }).collect(); + Some(Edit::Batch { label: "Erase".into(), edits }) +} + fn new_comment(cx: &PageCx<'_>, tool: CommentTool, shape: Shape, contents: String) -> Edit { Edit::AddAnnotation(NewAnnotation { page: cx.page, shape, style: cx.prefs.style(tool), contents, author: cx.prefs.author.clone() }) } diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 977b90198..c4a224ec0 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -501,3 +501,20 @@ fn attaching_a_file_as_a_comment() { assert!(doc.info.attachments.iter().any(|a| a.name == "notes.txt"), "listed in the Attachments panel"); assert_eq!(s.quick_tool, QuickTool::Select); } + +#[test] +fn erasing_part_of_a_drawing() { + let mut h = harness(|app| app.set_option("quick", "ink").unwrap()); + drag_pt(&mut h, (40.0, 60.0), (260.0, 60.0)); + assert_eq!(comments(&h).len(), 1); + h.state_mut().set_option("quick", "eraser").unwrap(); + drag_pt(&mut h, (150.0, 90.0), (150.0, 30.0)); + let c = comments(&h); + assert_eq!(c.len(), 1); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.can_undo(), Some("Erase")); + // Rubbing along the rest removes the drawing. + drag_pt(&mut h, (40.0, 60.0), (260.0, 60.0)); + assert!(comments(&h).is_empty(), "{:?}", comments(&h)); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index dbcc9e3c7..f5ea8bf89 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3587,11 +3587,11 @@ title = "Eraser for ink strokes" area = "E" tier = "P1" milestone = "M5" -status = "planned" -commands = [] +status = "shipped" +commands = ["comment.eraser"] tools = [] -evidence = [] -notes = "" +evidence = ["crates/annot/src/tests.rs::the_eraser_cuts_strokes_and_removes_empty_drawings", "crates/ui-egui/tests/comments.rs::erasing_part_of_a_drawing"] +notes = "cuts strokes where the eraser passes; an emptied drawing is deleted; one undo step" [[feature]] id = "comment.line"