mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 04:28:20 -06:00
M5: Eraser for drawings (Draw flyout): rubs out the parts of ink strokes it passes over, splitting strokes and deleting emptied drawings, in one undo step
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
968d26a41e
commit
e0d954f10a
@@ -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<bool, AnnotError> {
|
||||
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<Vec<[f64; 2]>> = 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<f64> = 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<[f64; 2]>> = 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)
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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")));
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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())?;
|
||||
}
|
||||
|
||||
@@ -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<Shape> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Eraser: rub out drawings along `path` (user space); one undo step for all of them.
|
||||
fn erase(cx: &PageCx<'_>, path: &[[f64; 2]]) -> Option<Edit> {
|
||||
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<usize> = 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() })
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user