diff --git a/ROADMAP.md b/ROADMAP.md index 1fb7459f49..e2671ea80d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -527,6 +527,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732. - **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off. - **2026-10-14 (M2, #623):** A page whose content was cut at the renderer's limits no longer looks complete: a notice names it (and how many), `ui.state` lists the affected pages under `active.cut_pages`, and `text_extract` marks its text `truncated`, since the cut part's text can't be found or copied. The renderer already reported the cut; nothing showed it. +- **2026-10-14 (M5, comment pop-ups; reworked 2026-11-01):** A double-click on a comment opens its pop-up window on the page: a title bar with the comment's kind, author and date; its text and its replies' typed in place, the replies as panels each inside the one it answers, a Reply button under each; the window keeps its `/Rect` size on screen as the page zooms, scrolls its thread, drags by its title bar, resizes by its corner and closes with ×, each an undo step saved in the file's `/Popup` so other viewers agree. A faint wedge in the comment's colour joins the comment to its open window on roll-over or selection. `comment_popup` does the same headlessly and `comment_list` reports the pop-up. - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. - **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged. - **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged. diff --git a/crates/annot/README.md b/crates/annot/README.md index 6ab3736ed2..3c258a2ab7 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -27,7 +27,9 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`, comment another program drew with a font that has those characters, whose appearance is kept rather than redrawn with `?` (new text for it is taken). - **Edits:** reply (`/IRT`), set review status (a `/State` reply, as Acrobat's "Set status" - does), change text, move, resize, restyle, delete (with pop-up and replies). + does), change text, move, resize, restyle, delete (with pop-up and replies), and open, close + or move the pop-up window (`set_popup`: `/Popup` `/Open` and `/Rect`, created beside a comment + that has none; a note's own `/Open` follows). `summaries` report each comment's pop-up. ## API sketch diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index 4c5fe044c4..99c3264006 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -43,6 +43,8 @@ pub struct Summary { pub points: Vec<[f32; 2]>, /// A callout's text box (`/Rect` inset by `/RD`), in user space; `None` for other comments. pub text_box: Option<[f32; 4]>, + /// The comment's pop-up window (`/Popup`), when it has one. + pub popup: Option, } impl Summary { @@ -54,6 +56,49 @@ impl Summary { } } +/// A comment's pop-up window (ISO 32000-2 §12.5.6.14): where it sits on the page and whether it +/// is shown open. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PopupInfo { + /// `/Rect` of the `/Popup` annotation, in user space. + pub rect: [f32; 4], + /// `/Open` of the `/Popup` annotation. + pub open: bool, +} + +/// The pop-up the comment `annot` (the object `own`, when it is one of its own) names in +/// `/Popup`, when it is the comment's: a pop-up annotation whose `/Parent`, if it has one, is a +/// reference to the comment. A `/Popup` naming another annotation, or a pop-up of another +/// comment, isn't. ISO 32000-2:2020 §12.5.6.14, Table 186: `/Parent` is "(Optional; shall be +/// an indirect reference)". +pub(crate) fn own_popup(doc: &Document, own: Option, annot: &Dict) -> Option { + let pr = annot.reference(b"Popup")?; + let is_own = doc.get(pr).as_dict().is_some_and(|pd| { + pd.name(b"Subtype") == Some(b"Popup") + && pd + .get(b"Parent") + .is_none_or(|parent| parent.as_ref().is_some_and(|parent| own.is_some_and(|own| final_ref(doc, parent) == final_ref(doc, own)))) + }); + is_own.then_some(pr) +} + +/// The comment's own pop-up (see [`own_popup`]), as [`PopupInfo`]: `None` when it has none or it +/// is unreadable. +pub(crate) fn popup_info(doc: &Document, own: Option, annot: &Dict) -> Option { + // Another annotation's rectangle and open state aren't this comment's (review on #2137). + let r = own_popup(doc, own, annot)?; + let p = doc.get(r); + let p = p.as_dict()?; + let rect: Vec = doc.resolve(p.get(b"Rect")?).as_array()?.iter().filter_map(|v| doc.resolve(v).as_f64().map(|x| x as f32)).collect(); + // Four numbers that are finite as `f32` (a huge one becomes infinite), corners in any order. + let [x0, y0, x1, y1] = rect[..] else { return None }; + if !rect.iter().all(|v| v.is_finite()) { + return None; + } + let open = p.get(b"Open").map(|o| doc.resolve(o)).is_some_and(|o| matches!(&*o, Object::Bool(true))); + Some(PopupInfo { rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)], open }) +} + pub(crate) fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option { let o = doc.resolve(d.get(key)?); let s = match &*o { @@ -205,6 +250,7 @@ pub fn summaries(doc: &Document) -> Vec { no_rotate: drawn_upright(doc, d), points, text_box, + popup: popup_info(doc, entry.as_ref().map(|r| final_ref(doc, r)), d), }); } } diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 386327278c..c58e70be88 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -31,7 +31,7 @@ mod types; use create::num_array; pub use create::{CALLOUT_KNEE_LINE, Side, add_annotation, callout_attach, callout_knee, callout_knee_on, callout_side, orient_image_stamp}; pub use draw::{AppearanceCache, set_appearance, with_missing_appearances, with_missing_appearances_cached}; -pub use info::{Props, Summary, is_comment_subtype, is_group_subordinate, props, set_info, summaries}; +pub use info::{PopupInfo, Props, Summary, is_comment_subtype, is_group_subordinate, props, set_info, summaries}; #[cfg(test)] use lookup::{ANNOTS_ENTRIES_COPIED, PageWalk, annot_dict, annot_ref}; use lookup::{annots, page_ref}; @@ -42,8 +42,8 @@ pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations}; pub(crate) use modify::n; pub use modify::{ add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, release_members, - set_border_color, set_border_shape, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect, - set_review_state, set_style, with_replies_and_popups, + set_border_color, set_border_shape, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_popup, + set_rect, set_review_state, set_style, with_replies_and_popups, }; pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model}; pub use types::{ diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 88ed052083..a262db39c4 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -3,8 +3,8 @@ use super::create::{ ink_bounds, normalize, num_array, on_side, rect_for, rgb, }; use super::draw::{draw_appearance, redraw_or_restore, set_appearance}; -use super::info::{is_group_subordinate, line_endings_of, text_value}; -use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots}; +use super::info::{is_comment_subtype, is_group_subordinate, line_endings_of, own_popup, popup_info, text_value}; +use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, is_fill_sign, page_ref, set_annots}; use super::text_style::{self, TextStyle}; use super::types::{ BorderShape, FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, @@ -438,6 +438,83 @@ pub(crate) fn unlocked(doc: &Document, r: ObjRef) -> Result<(), AnnotError> { if flags(doc, r) & FLAG_LOCKED != 0 { Err(AnnotError::Invalid("the comment is locked".into())) } else { Ok(()) } } +/// Open, close or move a comment's pop-up window (Acrobat: double-click a comment to open it, +/// drag its title bar to move it). `rect` is the pop-up's new `/Rect` in user space; `open` its +/// `/Open`. A comment without a `/Popup` gets one, placed to the right of the comment as new +/// notes get theirs. A text box, a Fill & Sign mark or a reply has no pop-up of its own (a reply +/// shows in its parent's). The lock does not apply: the window's place and state are not the +/// comment. The modification date is left alone: nothing of the comment changed. +pub fn set_popup(doc: &mut Document, page: usize, index: usize, rect: Option<[f64; 4]>, open: Option, meta: &Meta) -> Result<(), AnnotError> { + let (p, r) = annot_ref(doc, page, index)?; + let d = annot_dict(doc, r); + let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + if !is_comment_subtype(&subtype) || subtype == "FreeText" || is_fill_sign(doc, &d) || d.contains(b"IRT") { + return Err(AnnotError::Invalid(format!("{subtype} comments have no pop-up of their own"))); + } + let rect = match rect { + Some(rect) => { + let rect = normalize(rect); + if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return Err(AnnotError::Invalid("invalid pop-up rectangle".into())); + } + Some(rect) + } + None => None, + }; + let _ = meta; + // Where a new pop-up goes: to the right of the comment, as new notes' do. + let own: Vec = d + .get(b"Rect") + .map(|o| doc.resolve(o)) + .and_then(|o| o.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|v| doc.resolve(v).as_f64()) + .collect(); + let beside = match own[..] { + [_, _, x1, y1] if finite(&own) => [x1 + 10.0, y1 - 120.0, x1 + 210.0, y1], + _ => [0.0, 0.0, 200.0, 120.0], + }; + // Only a pop-up that is this comment's: `/Popup` naming another annotation (or a pop-up + // whose `/Parent` is another comment) would move or open that one, so it gets its own. + match own_popup(doc, Some(r), &d) { + Some(pr) => { + // One without a `/Rect` of four numbers reads as no pop-up (`summaries`), so it gets + // the new pop-up's place: opening it then shows it. + let rect = rect.or_else(|| popup_info(doc, Some(r), &d).is_none().then_some(beside)); + doc.update_dict(pr, |p| { + if let Some(rect) = rect { + p.set(b"Rect".to_vec(), num_array(&rect)); + } + if let Some(open) = open { + p.set(b"Open".to_vec(), Object::Bool(open)); + } + })? + } + None => { + let mut pd = Dict::new(); + pd.set(b"Type".to_vec(), Object::name("Annot")); + pd.set(b"Subtype".to_vec(), Object::name("Popup")); + pd.set(b"Rect".to_vec(), num_array(&rect.unwrap_or(beside))); + pd.set(b"Parent".to_vec(), Object::Ref(r)); + pd.set(b"Open".to_vec(), Object::Bool(open.unwrap_or(false))); + pd.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + let pref = doc.add(Object::Dict(pd)); + doc.update_dict(r, |d| d.set(b"Popup".to_vec(), Object::Ref(pref)))?; + let mut list = annots(doc, p); + list.push(Object::Ref(pref)); + set_annots(doc, p, list)?; + } + } + // A note's own `/Open` says the same thing to viewers that read it there (§12.5.6.4). + if let Some(open) = open + && (subtype == "Text" || d.contains(b"Open")) + { + doc.update_dict(r, |d| d.set(b"Open".to_vec(), Object::Bool(open)))?; + } + Ok(()) +} + /// Lock or unlock a comment (the Locked flag). A locked comment can't be moved, resized, /// restyled or deleted; its text and replies stay editable, as in Acrobat. pub fn set_locked(doc: &mut Document, page: usize, index: usize, locked: bool) -> Result<(), AnnotError> { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 3140354d0e..2e52ed9260 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -4562,6 +4562,163 @@ fn a_hostile_callout_line_is_never_rewritten_into_another_shape() { assert_eq!(assert_callout_line(&list(&doc, 0)[c], 6, "indirect"), [150.0, 400.0, 260.0, 620.0, 300.0, 620.0]); } +/// A pop-up is opened, closed and moved through `set_popup` (#comment.popups): a note's own +/// pop-up follows, a comment without one gets one beside it, and the comment itself (its text, +/// its date) is left alone. Summaries report the pop-up. Text boxes, Fill & Sign marks and +/// replies have none of their own; a bad rectangle is refused. +#[test] +fn pop_ups_open_close_and_move_and_are_reported() { + let mut doc = fixture(); + let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [20.0, 280.0], icon: NoteIcon::Comment }), &meta("n1")).unwrap(); + let before = summaries(&doc).into_iter().find(|s| s.index == note).unwrap(); + assert_eq!(before.popup.map(|p| p.open), Some(false), "new notes have a closed pop-up"); + set_popup(&mut doc, 0, note, None, Some(true), &meta("n1")).unwrap(); + let after = summaries(&doc).into_iter().find(|s| s.index == note).unwrap(); + assert_eq!(after.popup.map(|p| p.open), Some(true)); + assert_eq!(after.popup.map(|p| p.rect), before.popup.map(|p| p.rect), "opening does not move it"); + assert_eq!((after.modified, after.contents.as_deref()), (before.modified, Some("hello")), "the comment is untouched"); + let (_, r) = annot_ref(&mut doc, 0, note).unwrap(); + assert_eq!(annot_dict(&doc, r).get(b"Open"), Some(&Object::Bool(true)), "the note's own /Open follows"); + set_popup(&mut doc, 0, note, Some([150.0, 100.0, 290.0, 200.0]), Some(false), &meta("n1")).unwrap(); + let moved = summaries(&doc).into_iter().find(|s| s.index == note).unwrap(); + assert_eq!(moved.popup, Some(PopupInfo { rect: [150.0, 100.0, 290.0, 200.0], open: false })); + assert_eq!(annot_dict(&doc, r).get(b"Open"), Some(&Object::Bool(false))); + + // A highlight is created without a pop-up: the first open gives it one, beside it. + let mark = add_annotation( + &mut doc, + &new(0, Shape::TextMarkup { kind: Markup::Highlight, quads: vec![[20.0, 200.0, 120.0, 200.0, 20.0, 190.0, 120.0, 190.0]] }), + &meta("h1"), + ) + .unwrap(); + assert_eq!(summaries(&doc).into_iter().find(|s| s.index == mark).unwrap().popup, None); + set_popup(&mut doc, 0, mark, None, Some(true), &meta("h1")).unwrap(); + let given = summaries(&doc).into_iter().find(|s| s.index == mark).unwrap().popup.expect("a pop-up now"); + assert!(given.open); + assert!((given.rect[0] - 130.0).abs() < 0.01 && (given.rect[2] - 330.0).abs() < 0.01, "beside the mark: {:?}", given.rect); + + // #2137 review: a /Popup naming another annotation (here the note) isn't this comment's: the + // note is left alone and the mark gets a pop-up of its own. + let (_, mr) = annot_ref(&mut doc, 0, mark).unwrap(); + doc.update_dict(mr, |m| m.set(b"Popup".to_vec(), Object::Ref(r))).unwrap(); + let note_before = annot_dict(&doc, r); + set_popup(&mut doc, 0, mark, Some([10.0, 10.0, 110.0, 60.0]), Some(true), &meta("h1")).unwrap(); + assert_eq!(annot_dict(&doc, r), note_before, "the note isn't moved or opened"); + let own = annot_dict(&doc, mr).reference(b"Popup").expect("a pop-up of its own"); + assert_ne!(own, r); + assert_eq!(doc.get(own).as_dict().and_then(|p| p.reference(b"Parent")), Some(mr)); + // Its own pop-up, with /Parent naming it, is the one moved next time. + set_popup(&mut doc, 0, mark, Some([12.0, 10.0, 112.0, 60.0]), None, &meta("h1")).unwrap(); + assert_eq!(annot_dict(&doc, mr).reference(b"Popup"), Some(own), "the same pop-up"); + + // #2137 review: nor is it reported as the comment's (see the helper). + foreign_pop_ups_are_not_the_marks(&mut doc, r, mark, mr); + + // None of their own. + let text_box = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [10.0, 10.0, 110.0, 40.0], font_size: 12.0 }), &meta("t1")).unwrap(); + assert!(matches!(set_popup(&mut doc, 0, text_box, None, Some(true), &meta("t1")), Err(AnnotError::Invalid(_)))); + let reply = add_reply(&mut doc, 0, note, "Agreed", "Ada", &meta("r1")).unwrap(); + assert!(matches!(set_popup(&mut doc, 0, reply, None, Some(true), &meta("r1")), Err(AnnotError::Invalid(_)))); + assert!(matches!(set_popup(&mut doc, 0, note, Some([0.0, 0.0, 0.5, 0.5]), None, &meta("n1")), Err(AnnotError::Invalid(_)))); + assert!(matches!(set_popup(&mut doc, 0, note, Some([f64::NAN, 0.0, 10.0, 10.0]), None, &meta("n1")), Err(AnnotError::Invalid(_)))); + assert!(matches!(set_popup(&mut doc, 0, 99, None, Some(true), &meta("n1")), Err(AnnotError::NoSuchAnnotation { .. }))); + + // #2137 review: /Annots may list the note through an object that is only a reference to it. + // Its pop-up's /Parent names the note itself, so the pop-up is still the note's. + let p = page_ref(&doc, 0).unwrap(); + let via = doc.add(Object::Ref(r)); + let list: Vec = annots(&doc, p).into_iter().map(|o| if o.as_ref() == Some(r) { Object::Ref(via) } else { o }).collect(); + set_annots(&mut doc, p, list).unwrap(); + let listed = summaries(&doc).into_iter().find(|s| s.index == note).unwrap(); + assert_eq!(listed.popup, Some(PopupInfo { rect: [150.0, 100.0, 290.0, 200.0], open: false }), "listed through {via:?}"); +} + +/// #2137 review: a pop-up that is not the mark's own is neither reported as its pop-up nor +/// changed through it (part of `pop_ups_open_close_and_move_and_are_reported`). +fn foreign_pop_ups_are_not_the_marks(doc: &mut Document, note: ObjRef, mark: usize, mr: ObjRef) { + // #2137 review: nor is it reported as the comment's. A /Popup naming the note, or the note's + // own pop-up (its /Parent is the note), gives the mark no pop-up in the summaries. + let popup_of_mark = |doc: &Document| summaries(doc).into_iter().find(|s| s.index == mark).unwrap().popup; + let notes_popup = annot_dict(doc, note).reference(b"Popup").expect("the note's pop-up"); + for foreign in [note, notes_popup] { + doc.update_dict(mr, |m| m.set(b"Popup".to_vec(), Object::Ref(foreign))).unwrap(); + assert_eq!(popup_of_mark(doc), None, "{foreign:?} isn't the mark's"); + } + // A pop-up whose /Parent isn't a reference (it must be one) isn't the mark's either: it is + // neither reported nor changed, and the mark gets one of its own. + let mut bad = Dict::new(); + bad.set(b"Type".to_vec(), Object::name("Annot")); + bad.set(b"Subtype".to_vec(), Object::name("Popup")); + bad.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 50.0, 50.0].iter().map(|v| Object::Real(*v)).collect())); + bad.set(b"Parent".to_vec(), Object::Dict(Dict::new())); + let bad = doc.add(Object::Dict(bad)); + doc.update_dict(mr, |m| m.set(b"Popup".to_vec(), Object::Ref(bad))).unwrap(); + assert_eq!(popup_of_mark(doc), None, "a direct /Parent"); + let bad_before = doc.get(bad).as_dict().cloned(); + set_popup(doc, 0, mark, None, Some(true), &meta("h1")).unwrap(); + assert_eq!(doc.get(bad).as_dict().cloned(), bad_before, "left alone"); + assert_ne!(annot_dict(doc, mr).reference(b"Popup"), Some(bad)); + // A pop-up with no /Parent at all is taken as the comment's. + let mut orphan = Dict::new(); + orphan.set(b"Type".to_vec(), Object::name("Annot")); + orphan.set(b"Subtype".to_vec(), Object::name("Popup")); + orphan.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 50.0, 50.0].iter().map(|v| Object::Real(*v)).collect())); + let orphan = doc.add(Object::Dict(orphan)); + doc.update_dict(mr, |m| m.set(b"Popup".to_vec(), Object::Ref(orphan))).unwrap(); + assert_eq!(popup_of_mark(doc), Some(PopupInfo { rect: [0.0, 0.0, 50.0, 50.0], open: false }), "no /Parent"); +} + +/// #2137 review: a comment's own pop-up without a `/Rect` of four numbers reads as none; opening +/// it gives it the place a new pop-up gets, so the window shows again. +#[test] +fn opening_a_pop_up_without_a_rect_gives_it_one() { + let mut doc = fixture(); + let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [20.0, 280.0], icon: NoteIcon::Comment }), &meta("n1")).unwrap(); + let (_, r) = annot_ref(&mut doc, 0, note).unwrap(); + let pr = annot_dict(&doc, r).reference(b"Popup").expect("new notes have a pop-up"); + doc.update_dict(pr, |p| { + p.remove(b"Rect"); + }) + .unwrap(); + assert_eq!(summaries(&doc).into_iter().find(|s| s.index == note).unwrap().popup, None, "no /Rect reads as no pop-up"); + set_popup(&mut doc, 0, note, None, Some(true), &meta("n1")).unwrap(); + let popup = summaries(&doc).into_iter().find(|s| s.index == note).unwrap().popup.expect("the pop-up reads again"); + assert!(popup.open); + assert!(popup.rect[2] - popup.rect[0] >= 100.0 && popup.rect[3] - popup.rect[1] >= 100.0, "a window's size: {:?}", popup.rect); + assert_eq!(annot_dict(&doc, r).reference(b"Popup"), Some(pr), "the same pop-up, not a new one"); +} + +/// #2137 review: a pop-up rectangle with its corners reversed is read, and saved, with them in +/// order (ISO 32000-2 §7.9.5: a reader normalises a rectangle). One whose numbers aren't finite +/// as `f32` (a file's huge number) reads as no pop-up, and opening it gives it a new place. +#[test] +fn a_pop_up_rect_with_reversed_corners_is_put_in_order_and_a_huge_one_is_none() { + let mut doc = fixture(); + let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [20.0, 280.0], icon: NoteIcon::Comment }), &meta("n1")).unwrap(); + let (_, r) = annot_ref(&mut doc, 0, note).unwrap(); + let pr = annot_dict(&doc, r).reference(b"Popup").expect("new notes have a pop-up"); + let popup = |doc: &Document| summaries(doc).into_iter().find(|s| s.index == note).unwrap().popup; + let set_rect = |doc: &mut Document, rect: [f64; 4]| doc.update_dict(pr, |p| p.set(b"Rect".to_vec(), num_array(&rect))).unwrap(); + + // Reversed in the file: read in order. + set_rect(&mut doc, [290.0, 200.0, 150.0, 100.0]); + assert_eq!(popup(&doc), Some(PopupInfo { rect: [150.0, 100.0, 290.0, 200.0], open: false })); + // Reversed through `set_popup`: saved in order. + set_popup(&mut doc, 0, note, Some([300.0, 220.0, 160.0, 120.0]), None, &meta("n1")).unwrap(); + let saved: Vec = + doc.get(pr).as_dict().and_then(|p| p.get(b"Rect")).and_then(Object::as_array).unwrap().iter().filter_map(Object::as_f64).collect(); + assert_eq!(saved, [160.0, 120.0, 300.0, 220.0]); + + // A number too large for `f32` (or infinite): no pop-up, and opening it gives it a place. + for huge in [1e300, f64::INFINITY] { + set_rect(&mut doc, [0.0, 0.0, huge, 100.0]); + assert_eq!(popup(&doc), None, "{huge}"); + } + set_popup(&mut doc, 0, note, None, Some(true), &meta("n1")).unwrap(); + let given = popup(&doc).expect("the pop-up reads again"); + assert!(given.open && given.rect.iter().all(|v| v.is_finite()), "{given:?}"); +} + #[test] fn freehand_highlight_is_compatible_ink_with_marker_blending() { let shape = Shape::FreehandHighlight { strokes: vec![vec![[20.0, 100.0], [120.0, 100.0]]] }; diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 474e2c3d24..22aef7bf20 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -206,6 +206,8 @@ impl Automation { "rect": crate::view_rect(&doc.info, c.page, c.shown_rect(doc.info.pages.get(c.page).map_or(0, |p| p.rotation))), "color": c.color.map(hex), "group": group, + // Unchanged on a page the viewer could not read, as `rect` is. + "popup": c.popup.map(|w| json!({ "rect": crate::view_rect(&doc.info, c.page, w.rect), "open": w.open })), }); // A text box's or typed text's oval or circle (a rectangle is not reported). if c.subtype == "FreeText" @@ -710,6 +712,17 @@ impl Automation { self.apply(a, Edit::MarkAnnotation { page, index, marked, author }) } + pub(crate) fn comment_popup(&mut self, a: &Args) -> Result { + let (page, index) = self.comment_target(a)?; + let info = self.doc(a)?.info.pages[page].clone(); + let open = a.opt_bool("open")?; + let rect = a.nums::<4>("rect")?.map(|r| rect_to_user(&info, r)); + if open.is_none() && rect.is_none() { + return Err(ToolError::InvalidArgs("nothing to change: pass open and/or rect".into())); + } + self.apply(a, Edit::SetAnnotationPopup { page, index, rect, open }) + } + pub(crate) fn comment_lock(&mut self, a: &Args) -> Result { let (page, index) = self.comment_target(a)?; let locked = a.opt_bool("locked")?.unwrap_or(true); diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 43eacc7f95..0e36c61039 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -873,6 +873,7 @@ impl Automation { "sign_trust" => self.sign_trust(&a)?, "comment_mark" => self.comment_mark(&a)?, "comment_lock" => self.comment_lock(&a)?, + "comment_popup" => self.comment_popup(&a)?, "comments_hide" => self.comments_hide(&a)?, "layer_set" => self.layer_set(&a)?, "view_reading_colors" => self.view_reading_colors(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index fdfd9c3c27..3e1eeecfe9 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1072,6 +1072,11 @@ pub fn tools() -> Vec { t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.") .ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])), t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies, or one reply (its id, or page and index, from comment_list) with its pop-up and the replies to it; the comment and its other replies stay. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])), + t("comment_popup", "Open, close or move a comment's pop-up", "Open or close a comment's pop-up window (open), or move it (rect, in page view coordinates as comment_list reports). A comment without a pop-up gets one beside it. Text boxes, Fill & Sign marks and replies have none of their own. Undoable.") + .with(schema(comment_ref(json!({ + "open": { "type": "boolean" }, + "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, + })), &["doc"])), t( "comments_copy", "Copy comments", diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index ed111af420..9975666b1a 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -12472,6 +12472,32 @@ fn a_callouts_parts_are_listed_and_changed_through_the_tools() { assert!(near(&auto["points"][1], &[288.0, 220.0]), "{auto}"); } +/// comment_popup opens, closes and moves a comment's pop-up, comment_list reports it, and each +/// change is one undo step with its own label. +#[test] +fn comment_popups_are_opened_moved_and_listed() { + let dir = workdir("popup"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [30.0, 40.0], "contents": "Look here" })); + let listed = ok(&mut a, "comment_list", json!({ "doc": doc })); + let note = &listed["comments"][0]; + assert_eq!(note["popup"]["open"], json!(false), "{listed}"); + let id = note["id"].as_str().unwrap().to_string(); + let opened = ok(&mut a, "comment_popup", json!({ "doc": doc, "id": id, "open": true })); + assert_eq!(opened["undo"], json!("Open comment pop-up"), "{opened}"); + let listed = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(listed["comments"][0]["popup"]["open"], json!(true)); + let moved = ok(&mut a, "comment_popup", json!({ "doc": doc, "id": id, "rect": [60.0, 60.0, 180.0, 140.0] })); + assert_eq!(moved["undo"], json!("Move comment pop-up")); + let listed = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(listed["comments"][0]["popup"]["rect"], json!([60.0, 60.0, 180.0, 140.0]), "{listed}"); + let closed = ok(&mut a, "comment_popup", json!({ "doc": doc, "id": id, "open": false })); + assert_eq!(closed["undo"], json!("Close comment pop-up")); + assert!(matches!(err(&mut a, "comment_popup", json!({ "doc": doc, "id": id })), ToolError::InvalidArgs(_))); + let _ = std::fs::remove_dir_all(&dir); +} + /// #3005: a table and a small signature drawn inside one transparency-group Form XObject (how /// HTML-to-PDF converters write a block with a translucent picture). Edit a PDF sees one piece /// of artwork and no text; `image_edit ungroup` draws it in place with the same pixels, its diff --git a/crates/engine/src/comments.rs b/crates/engine/src/comments.rs index 5c21f816da..a700e38f17 100644 --- a/crates/engine/src/comments.rs +++ b/crates/engine/src/comments.rs @@ -25,6 +25,7 @@ pub(crate) fn comment_list(doc: &pdfcraft_cos::Document) -> Vec, }, + /// Open, close or move a comment's pop-up window (`rect` in user space; a comment without a + /// pop-up gets one). + SetAnnotationPopup { + page: usize, + index: usize, + rect: Option<[f64; 4]>, + open: Option, + }, /// Resize a rectangle, oval or text box. ResizeAnnotation { page: usize, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 1979c86348..4262769278 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -87,6 +87,9 @@ pub const EDIT_LABELS: &[&str] = &[ "Unlock comment", "Move comment", "Reshape comment", + "Open comment pop-up", + "Close comment pop-up", + "Move comment pop-up", "Resize comment", "Change comment properties", "Fill in {name}", @@ -232,6 +235,9 @@ impl Edit { Edit::LockAnnotation { .. } => Label::of("Unlock comment"), Edit::MoveAnnotation { .. } => Label::of("Move comment"), Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"), + Edit::SetAnnotationPopup { rect: Some(_), .. } => Label::of("Move comment pop-up"), + Edit::SetAnnotationPopup { open: Some(true), .. } => Label::of("Open comment pop-up"), + Edit::SetAnnotationPopup { .. } => Label::of("Close comment pop-up"), Edit::ResizeAnnotation { .. } => Label::of("Resize comment"), Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 624ab4ab3e..cc075a8593 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -62,6 +62,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::LockAnnotation { .. } | Edit::MoveAnnotation { .. } | Edit::ReshapeAnnotation { .. } + | Edit::SetAnnotationPopup { .. } | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } @@ -634,6 +635,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?, Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?, Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?, + Edit::SetAnnotationPopup { page, index, rect, open } => pdfcraft_annot::set_popup(doc, *page, *index, *rect, *open, &cx.meta())?, Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?, Edit::StyleAnnotation { page, index, color, opacity, width, endings } => { pdfcraft_annot::set_style( diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 441fb7d1b5..1d2e22a19b 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -66,6 +66,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::EraseInk { .. } | Edit::MoveAnnotation { .. } | Edit::ReshapeAnnotation { .. } + | Edit::SetAnnotationPopup { .. } | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 3ff8f2ee29..4d3f427ff8 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -37,7 +37,8 @@ pub use core::{check_pages, inspect, pretty_date}; use core::{inspect_structure, spend}; pub use model::{ Annotation, Attachment, AttachmentSource, DestView, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, MAX_GROUP_DEPTH, OutlineItem, - PageInfo, PageTransition, RemoteDestination, ReviewStates, TransitionStyle, Xfa, fill_group_text, group_primaries, link_replies, thread_tops, + PageInfo, PageTransition, Popup, RemoteDestination, ReviewStates, TransitionStyle, Xfa, fill_group_text, group_primaries, link_replies, + thread_tops, }; #[cfg(test)] use navigation::{OUTLINE_DEPTH, OUTLINE_WINDOW}; diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 5a1ee6b07b..1c524c38f3 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -1,6 +1,6 @@ use super::core::{limited_pdf_string, pretty_date, spend}; use super::model::{ - Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, RemoteDestination, fill_group_text, link_replies, + Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, Popup, RemoteDestination, fill_group_text, link_replies, state_or_default, }; use super::navigation::text_string; @@ -209,6 +209,29 @@ impl<'a> Inspector<'a> { Some((lead, text_box)) if subtype == "FreeText" => (lead, Some(text_box)), _ => (points, None), }; + // Only the comment's own pop-up: a pop-up annotation whose /Parent, if any, is a + // reference to this comment (keep in step with `pdfcraft_annot::summaries`). + let own = self.resolved_id(a); + let is_own = |p: &&Dictionary| { + self.name(p, b"Subtype").as_deref() == Some("Popup") + && p.get(b"Parent") + .map_or(true, |parent| parent.as_reference().is_ok() && self.resolved_id(parent).is_some_and(|id| Some(id) == own)) + }; + // A pop-up without a `/Rect` of four numbers finite as `f32` is none, as + // `pdfcraft_annot::summaries` reads it: both lists of the comments agree. Its corners + // may come in any order. + let popup = d.get(b"Popup").ok().and_then(|o| self.dict(o)).filter(is_own).and_then(|p| { + let rect = self.resolve(p.get(b"Rect").ok()?).as_array().ok()?; + let nums: Vec = rect.iter().filter_map(|v| self.resolve(v).as_float().ok()).collect(); + let [x0, y0, x1, y1] = nums[..] else { return None }; + if !nums.iter().all(|v| v.is_finite()) { + return None; + } + Some(Popup { + rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)], + open: p.get(b"Open").ok().and_then(|o| self.resolve(o).as_bool().ok()).unwrap_or(false), + }) + }); let annotation = Annotation { page, subtype, @@ -232,6 +255,7 @@ impl<'a> Inspector<'a> { no_rotate: d.get(b"F").is_ok_and(|f| draw_flags(self.resolve(f)) & 16 != 0), points, text_box, + popup, }; kept = kept.saturating_add(retained(&annotation)); if kept > MAX_ANNOTATION_BYTES { diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index b955201b8f..2ed99ced5e 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -350,6 +350,8 @@ pub struct Annotation { pub points: Vec<[f32; 2]>, /// A callout's text box (`/Rect` inset by `/RD`), in user space; `None` for other comments. pub text_box: Option<[f32; 4]>, + /// The comment's pop-up window (`/Popup`): where it sits and whether it is open. + pub popup: Option, } /// How far [`group_primaries`] follows `/IRT` from a group subordinate to its primary. @@ -412,6 +414,15 @@ pub fn fill_group_text(all: &mut [Annotation]) { } } +/// A comment's pop-up window (ISO 32000-2 §12.5.6.14). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Popup { + /// `/Rect` of the `/Popup` annotation, in user space. + pub rect: [f32; 4], + /// `/Open`: shown open. + pub open: bool, +} + impl Annotation { /// A checkmark reply (`/StateModel /Marked`): "Marked" or "Unmarked" rather than a review status. pub fn is_mark(&self) -> bool { diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index abc763f433..9ef34ebe56 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -305,6 +305,7 @@ fn no_rotate_comments_are_shown_upright_from_their_corner_and_back() { no_rotate: true, points: Vec::new(), text_box: None, + popup: None, }; let rect = note.rect.map(f64::from); for rotation in [0u16, 90, 180, 270, 45] { @@ -2000,6 +2001,39 @@ fn carriage_returns_in_comment_text_become_line_breaks() { assert_eq!(info.annotations[0].contents.as_deref(), Some("one\n\ntwo\nthree")); } +/// A comment's pop-up (`/Popup`) is read with its place and state; one without reads none. +#[test] +fn a_comments_pop_up_is_read_with_its_place_and_state() { + let bytes = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Annots [4 0 R 5 0 R 6 0 R] >> endobj\n4 0 obj << /Type /Annot /Subtype /Text /Rect [20 260 40 280] /Contents (Hi) /Popup 5 0 R /Open true >> endobj\n5 0 obj << /Type /Annot /Subtype /Popup /Rect [190 160 50 280] /Parent 4 0 R /Open true >> endobj\n6 0 obj << /Type /Annot /Subtype /Square /Rect [10 10 60 60] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"; + let info = inspect(Arc::new(bytes.to_vec()), None).expect("opens"); + let note = info.annotations.iter().find(|a| a.subtype == "Text").expect("the note"); + assert_eq!(note.popup, Some(Popup { rect: [50.0, 160.0, 190.0, 280.0], open: true }), "normalised, and open"); + let square = info.annotations.iter().find(|a| a.subtype == "Square").expect("the square"); + assert_eq!(square.popup, None); + assert!(info.annotations.iter().all(|a| a.subtype != "Popup"), "the pop-up is not a comment of its own"); + + // #2137 review: a /Popup naming another annotation, a pop-up of another comment, or one + // whose /Parent isn't a reference isn't this comment's pop-up; one without /Parent is. + for (target, extra, own) in [ + ("6 0 R", "", false), + ("7 0 R", "7 0 obj << /Type /Annot /Subtype /Popup /Rect [0 0 50 50] /Parent 6 0 R >> endobj\n", false), + ("7 0 R", "7 0 obj << /Type /Annot /Subtype /Popup /Rect [0 0 50 50] /Parent << >> >> endobj\n", false), + ("7 0 R", "7 0 obj << /Type /Annot /Subtype /Popup /Rect [0 0 50 50] >> endobj\n", true), + ] { + let text = String::from_utf8_lossy(bytes).replace("/Popup 5 0 R", &format!("/Popup {target}")).replace("trailer", &format!("{extra}trailer")); + let info = inspect(Arc::new(text.into_bytes()), None).expect("opens"); + let note = info.annotations.iter().find(|a| a.subtype == "Text").expect("the note"); + assert_eq!(note.popup.is_some(), own, "/Popup {target} {extra}"); + } + + // #2137 review: /Annots may list the note through an object that is only a reference to + // it; its pop-up's /Parent names the note itself, and is still its own. + let text = String::from_utf8_lossy(bytes).replace("/Annots [4 0 R", "/Annots [8 0 R").replace("trailer", "8 0 obj 4 0 R endobj\ntrailer"); + let info = inspect(Arc::new(text.into_bytes()), None).expect("opens"); + let note = info.annotations.iter().find(|a| a.subtype == "Text").expect("the note"); + assert_eq!(note.popup, Some(Popup { rect: [50.0, 160.0, 190.0, 280.0], open: true }), "listed through 8 0 R"); +} + #[test] fn dates_are_prettified() { assert_eq!(pretty_date("D:20260930104512-04'00'"), "2026-09-30 10:45"); @@ -2171,6 +2205,34 @@ mod group_tests { } } +/// #2137 review: a pop-up without a `/Rect` of four numbers reads as no pop-up, as +/// `pdfcraft_annot::summaries` reads it, so the two lists of comments agree. +#[test] +fn a_pop_up_without_a_four_number_rect_is_none() { + let bytes = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Annots [4 0 R 5 0 R] >> endobj\n4 0 obj << /Type /Annot /Subtype /Text /Rect [20 260 40 280] /Contents (Hi) /Popup 5 0 R >> endobj\n5 0 obj << /Type /Annot /Subtype /Popup RECT /Parent 4 0 R /Open true >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"; + for (rect, read) in [("", false), ("/Rect [0 0 50]", false), ("/Rect (box)", false), ("/Rect [0 0 50 /x]", false), ("/Rect [0 0 50 60]", true)] { + let info = inspect(Arc::new(bytes.replace("RECT", rect).into_bytes()), None).expect("opens"); + let note = info.annotations.iter().find(|a| a.subtype == "Text").expect("the note"); + assert_eq!(note.popup.is_some(), read, "{rect:?}"); + } +} + +/// #2137 review: a pop-up `/Rect` with its corners reversed reads with them in order (ISO +/// 32000-2 §7.9.5), and one with a number too large for `f32` reads as no pop-up, as +/// `pdfcraft_annot::summaries` reads both. +#[test] +fn a_pop_up_rect_with_reversed_corners_is_put_in_order_and_a_huge_one_is_none() { + let bytes = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Annots [4 0 R 5 0 R] >> endobj\n4 0 obj << /Type /Annot /Subtype /Text /Rect [20 260 40 280] /Contents (Hi) /Popup 5 0 R >> endobj\n5 0 obj << /Type /Annot /Subtype /Popup /Rect RECT /Parent 4 0 R /Open true >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"; + let popup = |rect: &str| { + let info = inspect(Arc::new(bytes.replace("RECT", rect).into_bytes()), None).expect("opens"); + info.annotations.iter().find(|a| a.subtype == "Text").expect("the note").popup.map(|p| p.rect) + }; + assert_eq!(popup("[150 200 50 100]"), Some([50.0, 100.0, 150.0, 200.0]), "reversed"); + let huge = format!("1{}", "0".repeat(40)); + assert_eq!(popup(&format!("[0 0 {huge} 100]")), None, "a number past f32"); + assert_eq!(popup(&format!("[0 0 {huge}.0 100]")), None, "a real past f32"); +} + /// Inspection keeps every object it reads, converted, for as long as it runs, and nothing it /// reports comes from `/PieceInfo`. InDesign writes kilobytes of that private data into every /// page, and a 1,412-page book held over a gigabyte of it (#5873). It is left out of what diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 142ebd480b..87deacccd6 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -19,8 +19,8 @@ pub mod transition; pub use inspect::{ Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, MAX_GROUP_DEPTH, - OutlineItem, PageInfo, PageTransition, RemoteDestination, ReviewStates, TransitionStyle, Xfa, attachment_data, check_pages, fill_group_text, - group_primaries, inspect, link_replies, pretty_date, thread_tops, + OutlineItem, PageInfo, PageTransition, Popup, RemoteDestination, ReviewStates, TransitionStyle, Xfa, attachment_data, check_pages, + fill_group_text, group_primaries, inspect, link_replies, pretty_date, thread_tops, }; pub use pixels::Pixels; pub use raster::{ diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 390bf12d04..47719a86b1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2716,7 +2716,8 @@ fn scroll_direction(dir: i8, dy: f32, resized: bool) -> i8 { /// The hover tip, find bar, size hint and the in-place editors drawn over the pages. Whether the /// text editor was finished. fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, primary_down: bool, hover_text: Option<(Pos2, String)>) -> bool { - let PageEnv { added, author, avail, can_modify, doc, form, info, prefs, t, today, tool, units, unobstructed, .. } = *env; + let PageEnv { added, allowed, author, avail, can_modify, comments_hidden, doc, form, info, prefs, t, today, tool, units, unobstructed, .. } = + *env; if let Some((pos, text)) = hover_text { // Click-through, as a tooltip is: the pages lose the pointer under anything that isn't. let tip = egui::Area::new(egui::Id::unique("canvas-hover")).order(egui::Order::Tooltip).interactable(false); @@ -2735,6 +2736,14 @@ fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, prim if let Some(e) = comments::composer(ui.ctx(), view, info, prefs) { view.pending_edit = Some(e); } + if let Some(e) = comments::popups(ui.ctx(), view, info, prefs, comments_hidden, allowed) { + // A click on the composer's Post also takes the focus from a pop-up's box, saving what + // was typed there: both edits land in this frame, so both are kept, in one batch. + view.pending_edit = Some(match view.pending_edit.take() { + Some(first) => Edit::Batch { label: "Edit comment".into(), edits: vec![first, e] }, + None => e, + }); + } if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) { view.pending_edit = Some(e); } diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index f559b05a89..4667c77a39 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -164,7 +164,7 @@ fn edges_are_primary_finite_in_order_and_capped() { } /// The page modules: the per-page chain in `canvas::document_area` and what it calls. -const PAGE_MODULES: [&str; 37] = [ +const PAGE_MODULES: [&str; 38] = [ "canvas.rs", "canvas/page_input.rs", "canvas/area.rs", @@ -182,6 +182,7 @@ const PAGE_MODULES: [&str; 37] = [ "tools/comments/input.rs", "tools/comments/menu.rs", "tools/comments/paint.rs", + "tools/comments/popups.rs", "tools/comments/state.rs", "tools/forms_ui.rs", "tools/prepare.rs", @@ -241,13 +242,14 @@ const RAW_READS: [&str; 21] = [ /// Functions in the page modules that are not page input and read egui's pointer state /// themselves: (file, function, why). -const NOT_PAGE_INPUT: [(&str, &str, &str); 10] = [ +const NOT_PAGE_INPUT: [(&str, &str, &str); 11] = [ ("canvas/area.rs", "zoom_anchor", "a pinch zooms the whole view, and is read before the pages' widget exists"), ("canvas/area.rs", "drawing_edge_pointer", "scrolls the view at its edges, read before the pages' widget exists"), ("canvas/area.rs", "glide_interrupted", "a press anywhere stops the touchpad's glide"), ("canvas/area.rs", "presentation_click", "full screen's click to turn the page, read before the pages' widget exists"), ("canvas/bars.rs", "page_size_hint", "the page size hint sits in the view's corner, not on a page"), ("canvas/organize.rs", "organize_grid", "Organize Pages shows thumbnails, not pages"), + ("tools/comments/popups.rs", "popups", "the comment pop-up windows are Areas of their own over the page"), ("tools/forms_ui.rs", "overlay", "the field editor is an Area of its own over the page"), ("tools/content_ui.rs", "editor", "the added-text editor is an Area of its own over the page"), ("tools/edit_text_ui.rs", "overlay", "the paragraph editor is an Area of its own over the page"), @@ -292,10 +294,15 @@ const NOT_PAGE_DRAGS: [(&str, &str, &str); 3] = [ const CURSOR_WRITES: [&str; 4] = ["set_cursor_icon(", ".cursor_icon = ", "on_hover_cursor(", "on_hover_and_drag_cursor("]; /// Functions in the page modules that set a cursor other than the pages': (file, function, why). -const NOT_PAGE_CURSOR: [(&str, &str, &str); 3] = [ +const NOT_PAGE_CURSOR: [(&str, &str, &str); 4] = [ ("tools/fill_sign/scan.rs", "scan_tab", "the signature dialog's Scan tab"), ("tools/edit_text_ui.rs", "overlay", "the paragraph editor is an Area of its own over the page, drawn after the pages' cursor is set"), ("tools/calc_order_ui.rs", "panel", "the Calculation order panel, whose rows are dragged to reorder them"), + ( + "tools/comments/popups.rs", + "popups", + "the comment pop-up windows are Areas of their own over the page; their title bars are dragged to move them", + ), ]; /// Whether `line` uses `name`, and not a longer name that ends in it (a field, after its `.`, is diff --git a/crates/ui-egui/src/canvas/xform.rs b/crates/ui-egui/src/canvas/xform.rs index 0f6249c8a8..c74876032b 100644 --- a/crates/ui-egui/src/canvas/xform.rs +++ b/crates/ui-egui/src/canvas/xform.rs @@ -20,6 +20,11 @@ impl DocView { pub fn page_xform(&self, page: usize) -> Option { self.screen_xforms.iter().find(|(p, _)| *p == page).map(|(_, xf)| *xf) } + + /// Where the pages are on screen this frame, with their transforms. + pub(crate) fn page_xforms(&self) -> Vec<(usize, PageXform)> { + self.screen_xforms.iter().map(|(p, xf)| (*p, *xf)).collect() + } } /// `r` moved so its corner lies on a whole physical pixel, so that a raster drawn from there diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 6e862eab4e..9df2421b5f 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -168,6 +168,15 @@ impl PdfCraftApp { } _ => Vec::new(), }; + // The comments the pop-ups hold unsaved text or a drag for, to see afterwards whether + // their places still name them (pages may move, come or go). + let popups_before: Vec = match (self.views.get(i), self.session.get(id)) { + (Some(v), Some(d)) => { + let keys = v.comments.popup_keys(); + d.info.annotations.iter().filter(|a| keys.contains(&(a.page, a.index))).cloned().collect() + } + _ => Vec::new(), + }; match self.session.apply(id, edit.clone()) { Ok(()) => { let Some(doc) = self.session.get(id) else { return true }; @@ -210,8 +219,7 @@ impl PdfCraftApp { view.forms.unbaked.extend(unbaked); } } else if let Some(page) = one_page { - // Comment and content edits change one page: keep every other raster. - view.page_changed(page); + one_page_changed(view, &edit, page); } else if let Some((changed_pages, unbaked)) = form_edit { view.forms.unbaked.extend(unbaked); for page in changed_pages { @@ -223,6 +231,10 @@ impl PdfCraftApp { if matches!(edit, Edit::AddAnnotation(_)) { view.comments.comment_added(); } + // A deletion moves the pop-ups' state with its comments (`annotation_deleted`). + if !matches!(edit, Edit::DeleteAnnotation { .. }) { + view.comments.popups_after_edit(&popups_before, &info.annotations); + } // Keep the pages the user acted on selected, where they now are. select_after_edit(view, &edit, info, &annots_before, self.quick_tool); if let (Some((target, ambiguous_before)), Edit::EditPageImage { page, .. }) = (target_arrange_image, &edit) { @@ -1008,6 +1020,15 @@ fn content_page(edit: &Edit) -> Option { } } +/// A comment or content edit changes one page, `page`: keep every other raster. Opening, closing +/// or moving a pop-up changes none: the window is drawn over the page, and a /Popup annotation +/// paints nothing in the raster (ISO 32000-2 §12.5.6.14). +fn one_page_changed(view: &mut crate::DocView, edit: &Edit, page: usize) { + if !matches!(edit, Edit::SetAnnotationPopup { .. }) { + view.page_changed(page); + } +} + fn comment_page(edit: &Edit) -> Option { match edit { Edit::AddAnnotation(a) => Some(a.page), @@ -1017,6 +1038,7 @@ fn comment_page(edit: &Edit) -> Option { | Edit::SetAnnotationStatus { page, .. } | Edit::MoveAnnotation { page, .. } | Edit::ReshapeAnnotation { page, .. } + | Edit::SetAnnotationPopup { page, .. } | Edit::ResizeAnnotation { page, .. } | Edit::StyleAnnotation { page, .. } | Edit::FillAnnotation { page, .. } diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index 7594350b0b..b9b6b1b9cc 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -26,6 +26,7 @@ mod geometry; mod input; mod menu; mod paint; +mod popups; mod state; pub(crate) use composer::{composer, submit_key}; @@ -45,6 +46,7 @@ use menu::{RECENT_CUSTOM, same_colour}; pub(crate) use paint::paint_page; #[cfg(test)] use paint::{changing_points, drawn_shape, edited_in_view}; +pub(crate) use popups::popups; #[cfg(test)] use state::after_delete; use state::login_name; diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 5f44fb4df1..cfaa4967ce 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -3,6 +3,7 @@ use super::geometry::{ reshapable, reshape_for, resizable, resized, snapped_caret, }; use super::paint::{closes_at, drawn_shape, edit_anchor, erase, moves_box_only, new_comment}; +use super::popups::has_popup; use super::state::{Composer, ComposerKind, Gesture, PageCx}; use super::{Annotation, CommentPrefs, CommentTool, CommentView, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2}; use crate::page_cursor::{Layer, Subject}; @@ -207,6 +208,8 @@ pub(crate) fn describe(cx: &PageCx<'_>, view: &mut DocView) { // tip is that of the one drawn on top, among those with something to say (#3620). let top = cx .comments() + // An open pop-up already shows the comment: no tooltip over it. + .filter(|a| !a.popup.is_some_and(|w| w.open)) .filter(|a| cx.screen_rects(a).any(|r| r.contains(p))) .filter_map(|a| hover_tip(a, cx.prefs).map(|text| (a.index, text))) .max_by_key(|(index, _)| *index); @@ -333,12 +336,20 @@ fn select_input( None => {} } } - if resp.double_clicked() + if (resp.double_clicked() || resp.triple_clicked()) && on_page && cx.allowed && let Some((a, _)) = owner { cv.select_only((cx.page, a.index)); + // A double-click opens the comment's pop-up window, as in Acrobat; a text box, whose + // text is the comment itself, is edited in place. + if has_popup(a) { + if !a.popup.is_some_and(|p| p.open) { + view.pending_edit = Some(Edit::SetAnnotationPopup { page: cx.page, index: a.index, rect: None, open: Some(true) }); + } + return true; + } cv.composer = Some(Composer { page: cx.page, at: edit_anchor(cx.info, a), diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index 79b451ed8e..8cdcb03c81 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -1,4 +1,5 @@ use super::geometry::{HANDLES, handle_pos, held, is_callout, is_markup, reaches_points, reshapable, reshape_for, resizable}; +use super::popups::paint_connectors; use super::state::{CommentView, Gesture, PageCx, SETTLE_SECS}; use super::{ Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape, @@ -58,6 +59,8 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> for r in cx.screen_rects(a) { painter.rect_stroke(r.expand(2.0), CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Middle); } + // Selected text markup is linked to its open pop-up too. + paint_connectors(painter, cx, view, pointer); return paint_gesture(painter, cx, view); } if view.signature_drag.contains(page, index) && matches!(cv.gesture, Some(Gesture::Move { page: p, index: i, .. }) if (p, i) == (page, index)) @@ -82,6 +85,7 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> return paint_points(painter, cx, view, a, pointer, shift); } } + paint_connectors(painter, cx, view, pointer); paint_gesture(painter, cx, view); } diff --git a/crates/ui-egui/src/tools/comments/popups.rs b/crates/ui-egui/src/tools/comments/popups.rs new file mode 100644 index 0000000000..839cdcd9c7 --- /dev/null +++ b/crates/ui-egui/src/tools/comments/popups.rs @@ -0,0 +1,558 @@ +use super::state::PageCx; +use super::{Annotation, Color32, CommentPrefs, CornerRadius, DocInfo, DocView, Edit, PagePointer, PageXform, Pos2, QuickTool, Rect, pos2, theme}; + +/// A comment with a pop-up window of its own: not a text box, a Fill & Sign mark, a reply or a +/// status (replies are found by `/IRT` reference, [`Annotation::is_comment`]). +pub(super) fn has_popup(a: &Annotation) -> bool { + a.subtype != "FreeText" && !a.fill_sign && a.is_comment() +} + +/// The connector from a comment to its open pop-up, in the comment's colour, while the pointer is +/// on either or the comment is selected (Acrobat's "lines connecting comment markups to their +/// pop-ups on mouse rollover"). It is a faint wedge: a point on the comment where it is +/// nearest the window, widening to a base on the window's side that faces +/// the comment ([`wedge_base`]). +pub(super) fn paint_connectors(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView, pointer: Option) { + let cv = &view.comments; + for ((page, index), window) in &cv.popup_rects { + if *page != cx.page { + continue; + } + let Some(a) = cx.get(*index) else { continue }; + if !a.popup.is_some_and(|p| p.open) { + continue; + } + let rects: Vec = cx.screen_rects(a).collect(); + let on_mark = pointer.is_some_and(|p| rects.iter().any(|r| r.expand(3.0).contains(p))); + if !(on_mark || cv.is_selected((*page, *index)) || cv.popup_hovered == Some((*page, *index))) { + continue; + } + let Some(mark) = rects.first() else { continue }; + let from = nearest_on(*mark, window.center()); + let to = nearest_on(*window, from); + if from.distance(to) < 4.0 { + continue; + } + let color = a + .color + .map_or(Color32::from_rgb(0xDC, 0x26, 0x26), |c| Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8)); + let faded = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), WEDGE_ALPHA); + let [b0, b1] = wedge_base(*window, from, to); + let mut wedge = egui::Mesh::default(); + for p in [from, b0, b1] { + wedge.colored_vertex(p, faded); + } + wedge.add_triangle(0, 1, 2); + painter.add(egui::Shape::mesh(wedge)); + } +} + +/// How opaque a connector's wedge is (of 255). +const WEDGE_ALPHA: u8 = 90; + +/// The two ends of a connector's base on `window`: centred on `to` (the point of the window +/// nearest the comment) along the side that faces `from`, as wide as a small part of the window +/// and kept on that side. At a corner, the side is the one the comment lies further beyond. +fn wedge_base(window: Rect, from: Pos2, to: Pos2) -> [Pos2; 2] { + let half = (window.width().min(window.height()) * 0.08).clamp(5.0, 18.0); + // `half` along [lo, hi] around `c`, moved in to stay on the side (a side shorter than the + // base gives all of it). + let spread = |c: f32, lo: f32, hi: f32| { + let h = half.min((hi - lo).max(0.0) / 2.0); + let c = c.clamp(lo + h, hi - h); + (c - h, c + h) + }; + let on_horizontal = (to.y - window.top()).abs() < 0.5 || (to.y - window.bottom()).abs() < 0.5; + let on_vertical = (to.x - window.left()).abs() < 0.5 || (to.x - window.right()).abs() < 0.5; + let beyond_x = (from.x - to.x).abs() / window.width().max(1.0); + let beyond_y = (from.y - to.y).abs() / window.height().max(1.0); + if on_horizontal && !(on_vertical && beyond_x > beyond_y) { + let (a, b) = spread(to.x, window.left(), window.right()); + [pos2(a, to.y), pos2(b, to.y)] + } else { + let (a, b) = spread(to.y, window.top(), window.bottom()); + [pos2(to.x, a), pos2(to.x, b)] + } +} + +/// The point of `r`'s boundary nearest `p` (`p` inside `r` is pushed to the nearer vertical edge). +fn nearest_on(r: Rect, p: Pos2) -> Pos2 { + let x = p.x.clamp(r.left(), r.right()); + let y = p.y.clamp(r.top(), r.bottom()); + if r.left() < x && x < r.right() && r.top() < y && y < r.bottom() { + pos2(if (p.x - r.left()).abs() <= (p.x - r.right()).abs() { r.left() } else { r.right() }, y) + } else { + pos2(x, y) + } +} + +/// A pop-up window's size on screen, in points, whatever the zoom (the readers keep theirs the +/// same as the page zooms), kept within these bounds whatever size the file gives it. +const MIN_WIDTH: f32 = 200.0; +const MAX_WIDTH: f32 = 800.0; +const MIN_HEIGHT: f32 = 120.0; +const MAX_HEIGHT: f32 = 800.0; +/// The side of the grip at a window's lower right corner that resizes it. +const RESIZE_GRIP: f32 = 10.0; +/// How far a reply's panel sits in from the panel around it. +const NEST: i8 = 6; +/// Past this depth a reply's panel stops nesting and sits under the one before it. +const MAX_NESTING: usize = 8; + +/// `replies` (all filed under comment `top`) in thread order, each with its depth: a reply to +/// `top` is 1, and each reply comes right after the one it answers, followed by its own replies. +/// One whose parent isn't among them (a status reply, say) answers `top`. A cycle of `/IRT` +/// references shows each reply once. +fn thread<'a>(top: (usize, usize), replies: &[&'a Annotation]) -> Vec<(&'a Annotation, usize)> { + use std::collections::{HashMap, HashSet}; + let known: HashSet<(usize, usize)> = replies.iter().map(|r| (r.page, r.index)).collect(); + let mut answered: HashMap<(usize, usize), Vec<&'a Annotation>> = HashMap::new(); + for r in replies { + let parent = r.parent.filter(|p| known.contains(p) && *p != (r.page, r.index)).unwrap_or(top); + answered.entry(parent).or_default().push(r); + } + let mut out = Vec::with_capacity(replies.len()); + let mut seen = HashSet::new(); + // Depth first, answers in document order: the stack holds them reversed. + let below = |key: (usize, usize), depth: usize| answered.get(&key).into_iter().flatten().rev().map(move |r| (*r, depth)); + let mut stack: Vec<(&Annotation, usize)> = below(top, 1).collect(); + let mut next = 0; + loop { + while let Some((r, depth)) = stack.pop() { + if !seen.insert((r.page, r.index)) { + continue; + } + out.push((r, depth)); + stack.extend(below((r.page, r.index), depth.saturating_add(1))); + } + // Replies only a cycle reaches: shown from their first, as if they answered `top`. The + // search resumes where it stopped, so the whole of it is one pass. + while next < replies.len() && replies.get(next).is_some_and(|r| seen.contains(&(r.page, r.index))) { + next += 1; + } + match replies.get(next) { + Some(r) => stack.push((*r, 1)), + None => break, + } + } + out +} + +/// A thread as nested panels: each reply ([`thread`] order) and the positions of the replies +/// drawn inside its panel; then the positions of the replies to the comment itself. Nesting stops +/// at [`MAX_NESTING`], so drawing it recurses no deeper than that. +fn nest<'a>(order: &[(&'a Annotation, usize)]) -> (Vec<(&'a Annotation, Vec)>, Vec) { + let mut nodes: Vec<(&Annotation, Vec)> = Vec::with_capacity(order.len()); + let mut roots = Vec::new(); + // The panels open at each depth above the reply being placed. + let mut open: Vec = Vec::new(); + for (i, (r, depth)) in order.iter().enumerate() { + let depth = (*depth).clamp(1, MAX_NESTING); + open.truncate(depth - 1); + match open.last().and_then(|p| nodes.get_mut(*p)) { + Some((_, kids)) => kids.push(i), + None => roots.push(i), + } + nodes.push((r, Vec::new())); + open.push(i); + } + (nodes, roots) +} + +/// A pop-up window's colours: the comment's colour, lightened for the body and the title bar, +/// and ink that reads on them. Fixed, not from the app's theme: the window is the comment's, as +/// it looks on paper. +#[derive(Clone, Copy)] +struct Palette { + edge: Color32, + title: Color32, + title_ink: Color32, + body: Color32, + panel_edge: Color32, + ink: Color32, + faint: Color32, +} + +fn palette(color: Color32) -> Palette { + // `color` mixed into white, `f` of the way. + let mix = |f: f32| { + let c = |v: u8| (255.0 + (f32::from(v) - 255.0) * f).round().clamp(0.0, 255.0) as u8; + Color32::from_rgb(c(color.r()), c(color.g()), c(color.b())) + }; + let title = mix(0.8); + let light = |c: Color32| 0.299 * f32::from(c.r()) + 0.587 * f32::from(c.g()) + 0.114 * f32::from(c.b()) > 150.0; + Palette { + edge: color, + title, + title_ink: if light(title) { Color32::from_gray(20) } else { Color32::WHITE }, + body: mix(0.35), + panel_edge: mix(0.7), + ink: Color32::from_gray(20), + faint: Color32::from_gray(80), + } +} + +/// What a pop-up window's parts share as they draw. +struct Parts<'v> { + allowed: bool, + author: &'v str, + pal: Palette, + cv: &'v mut super::state::CommentView, + edits: &'v mut Vec, +} + +/// One comment's or reply's text in its box, typed in place (saved when the box loses focus); +/// read-only when comments can't be changed or it is locked. +fn text_box(ui: &mut egui::Ui, parts: &mut Parts<'_>, a: &Annotation, name: &str) { + let key = (a.page, a.index); + if parts.allowed && !a.locked { + let typed = parts.cv.popup_text.as_ref().filter(|(p, i, _)| (*p, *i) == key).map(|(_, _, s)| s.clone()); + let mut text = typed.unwrap_or_else(|| a.contents.clone().unwrap_or_default()); + let field = ui + .add( + egui::TextEdit::multiline(&mut text) + .desired_rows(1) + .desired_width(f32::INFINITY) + .font(theme::regular(12.5)) + .text_color(parts.pal.ink) + .background_color(Color32::WHITE) + .margin(egui::Margin::symmetric(4, 2)) + .id_salt(("popup-text", a.page, a.index)), + ) + .accessible_name(name); + if field.changed() { + parts.cv.popup_text = Some((a.page, a.index, text.clone())); + } + if field.lost_focus() + && let Some((_, _, s)) = parts.cv.popup_text.take_if(|(p, i, _)| (*p, *i) == key) + && s.trim_end() != a.contents.as_deref().unwrap_or_default() + { + parts.edits.push(Edit::SetAnnotationContents { page: a.page, index: a.index, text: s.trim_end().to_string() }); + } + } else { + egui::Frame::NONE.fill(Color32::WHITE).inner_margin(egui::Margin::symmetric(4, 2)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.label(egui::RichText::new(a.contents.as_deref().unwrap_or_default()).font(theme::regular(12.5)).color(parts.pal.ink)); + }); + } +} + +/// The row under a comment's or reply's text: a Reply button at its end (the start is kept for +/// more of a reply's controls), and the box to type that reply in once it is pressed (Post, or +/// Ctrl/⌘+Enter, posts it; Enter starts a new line, as in the readers' pop-ups). +fn footer(ui: &mut egui::Ui, parts: &mut Parts<'_>, a: &Annotation) { + let key = (a.page, a.index); + // Small buttons, as the readers' footers have. + ui.spacing_mut().button_padding = egui::vec2(6.0, 1.0); + let mut opened = false; + if parts.allowed { + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let label = egui::RichText::new(tl!("Reply")).font(theme::regular(11.0)).color(parts.pal.ink); + let button = egui::Button::new(label) + .fill(Color32::WHITE.gamma_multiply(0.6)) + .stroke(egui::Stroke::new(1.0, parts.pal.panel_edge)) + .min_size(egui::vec2(0.0, 16.0)); + if ui.add(button).clicked() { + parts.cv.popup_reply = Some((a.page, a.index, String::new())); + opened = true; + } + }); + }); + } + let Some(mut reply) = parts.cv.popup_reply.as_ref().filter(|(p, i, _)| (*p, *i) == key).map(|(_, _, s)| s.clone()) else { return }; + let field = ui.add( + egui::TextEdit::multiline(&mut reply) + .desired_rows(1) + .desired_width(f32::INFINITY) + .hint_text(tl!("Add a reply")) + .font(theme::regular(12.5)) + .text_color(parts.pal.ink) + .background_color(Color32::WHITE) + .margin(egui::Margin::symmetric(4, 2)) + .id_salt(("popup-reply", a.page, a.index)), + ); + if opened { + field.request_focus(); + } + if field.changed() { + parts.cv.popup_reply = Some((a.page, a.index, reply.clone())); + } + let enter = super::submit_key(ui, &field, false); + let ok = !reply.trim().is_empty(); + let (post, cancel) = ui + .horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let small = |s: &str| egui::RichText::new(s).font(theme::regular(11.0)); + let post = ui.add_enabled(ok, egui::Button::new(small(tl!("Post"))).min_size(egui::vec2(0.0, 16.0))).clicked(); + let cancel = ui.add(egui::Button::new(small(tl!("Cancel"))).min_size(egui::vec2(0.0, 16.0))).clicked(); + (post, cancel) + }) + .inner + }) + .inner; + if (post || enter) && ok { + parts.cv.popup_reply = None; + parts.edits.push(Edit::ReplyToAnnotation { page: a.page, index: a.index, text: reply.trim().to_string(), author: parts.author.to_string() }); + } else if cancel { + parts.cv.popup_reply = None; + } +} + +/// A reply's panel: who wrote it and when, its text and footer, and the panels of its own +/// replies inside it. +fn reply_panel(ui: &mut egui::Ui, parts: &mut Parts<'_>, nodes: &[(&Annotation, Vec)], at: usize) { + let Some((r, kids)) = nodes.get(at) else { return }; + let pal = parts.pal; + egui::Frame::NONE.stroke(egui::Stroke::new(1.0, pal.panel_edge)).corner_radius(CornerRadius::same(3)).inner_margin(egui::Margin::same(4)).show( + ui, + |ui| { + ui.set_width(ui.available_width()); + ui.spacing_mut().item_spacing.y = 3.0; + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if let Some(m) = &r.modified { + ui.label(egui::RichText::new(m).font(theme::regular(10.5)).color(pal.faint)); + } + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + let who = r.author.as_deref().unwrap_or("?"); + ui.add(egui::Label::new(egui::RichText::new(who).font(theme::semibold(11.5)).color(pal.ink)).truncate()); + }); + }); + }); + text_box(ui, parts, r, r.author.as_deref().unwrap_or("?")); + footer(ui, parts, r); + for kid in kids { + egui::Frame::NONE.inner_margin(egui::Margin { left: NEST, ..Default::default() }).show(ui, |ui| reply_panel(ui, parts, nodes, *kid)); + } + }, + ); +} + +/// The open pop-up windows of the comments on screen, laid out as the readers' are: a title bar +/// with the comment's kind and author, its date and ×; the comment's text, editable in place; its +/// replies as panels, each inside the one it answers; and a Reply button under each, which opens +/// a box to answer it. The window keeps its size on screen as the page zooms, and its text and +/// replies scroll inside it. The title bar drags the window and its lower right corner resizes +/// it, both saved with the comment; × closes it. Returns the edits to apply (one, or a batch when +/// a click both saves typed text and closes). Hidden comments show none. +pub(crate) fn popups(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs, hidden: bool, allowed: bool) -> Option { + let pointer = ctx.input(|i| i.pointer.hover_pos()); + let mut edits: Vec = Vec::new(); + let mut rects = Vec::new(); + let mut hovered = None; + // The comment each reply files under, as the Comments panel threads them (by reference). + let tops = pdfcraft_render::thread_tops(&info.annotations); + let xforms: Vec<(usize, PageXform)> = view.page_xforms(); + for (page, xf) in xforms { + let shown: Vec = + info.annotations.iter().filter(|a| !hidden && a.page == page && has_popup(a) && a.popup.is_some_and(|p| p.open)).cloned().collect(); + for a in shown { + let Some(p) = a.popup else { continue }; + let key = (page, a.index); + let placed = xf.user_rect(info, page, p.rect); + // Its place follows the page; its size is the pop-up's `/Rect` in points on screen, + // or the size its corner is being dragged to. + // A `/Rect` with numbers that aren't finite gives the least size. + let side = |a: f32, b: f32| Some((b - a).abs()).filter(|v| v.is_finite()).unwrap_or(0.0); + let saved = egui::vec2(side(p.rect[0], p.rect[2]), side(p.rect[1], p.rect[3])); + let clamp = |s: egui::Vec2| egui::vec2(s.x.clamp(MIN_WIDTH, MAX_WIDTH), s.y.clamp(MIN_HEIGHT, MAX_HEIGHT)); + let resizing = view.comments.popup_resize.filter(|(rp, ri, _)| (*rp, *ri) == key).map(|(_, _, size)| size); + let size = clamp(resizing.unwrap_or(saved)); + let dragging = view.comments.popup_drag.filter(|(dp, di, _)| (*dp, *di) == key).map(|(_, _, at)| at); + let pos = dragging.unwrap_or(placed.left_top()); + let id = egui::Id::unique(("comment-popup", view.id.0, page, a.index)); + let color = a + .color + .map_or(Color32::from_rgb(0xFF, 0xD6, 0x5C), |c| Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8)); + let pal = palette(color); + // A screen rectangle on this page in user space, to save as the pop-up's `/Rect`. Only + // converts: the pointer and the redaction look are unused. + let to_user = |r: Rect| { + let cx = PageCx { + page, + xf: &xf, + ptr: &PagePointer::default(), + info, + tool: QuickTool::Select, + prefs: &CommentPrefs::default(), + allowed, + hidden, + redact_mark: ([0.0; 3], 1.0), + }; + cx.rect_to_user(r) + }; + let area = egui::Area::new(id).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { + let frame = egui::Frame::popup(ui.style()).inner_margin(egui::Margin::ZERO).corner_radius(CornerRadius::same(3)).fill(pal.body); + let window = frame.stroke(egui::Stroke::new(1.0, pal.edge)).show(ui, |ui| { + ui.set_width(size.x); + // The whole size, however short its thread. + ui.set_min_height(size.y); + ui.spacing_mut().item_spacing.y = 3.0; + // Title bar: the comment's kind and author, its date and ×, on one line; it + // drags the window. + let bar = egui::Frame::NONE.fill(pal.title).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| { + ui.set_width(size.x - 12.0); + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Closing changes the comment's /Open, which needs comment permission. + if ui.add_enabled_ui(allowed, |ui| crate::icons::button(ui, "x", 14.0, false, tl!("Close"))).inner.clicked() { + edits.push(Edit::SetAnnotationPopup { page, index: a.index, rect: None, open: Some(false) }); + } + if let Some(m) = &a.modified { + ui.label(egui::RichText::new(m).font(theme::regular(10.5)).color(pal.title_ink)); + } + // The kind and author take the rest of the line, cut short to fit. + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + let kind = tl!(crate::comments_panel::kind_label(&a)); + let title = match a.author.as_deref() { + Some(who) => format!("{kind} – {who}"), + None => kind.to_string(), + }; + ui.add(egui::Label::new(egui::RichText::new(title).font(theme::semibold(12.0)).color(pal.title_ink)).truncate()); + }); + }) + }); + }); + // The bar drags the window, except where the × is: a region registered after + // the button would take its clicks. + let bar_rect = bar.response.rect; + let grip = ui.interact( + Rect::from_min_max(bar_rect.min, pos2(bar_rect.right() - 24.0, bar_rect.bottom())), + id.with("grip"), + egui::Sense::drag(), + ); + if grip.hovered() || grip.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + if grip.drag_started_by(egui::PointerButton::Primary) { + view.comments.popup_drag = Some((page, a.index, pos)); + } + if grip.dragged() + && let Some((_, _, at)) = view.comments.popup_drag.as_mut() + { + *at += grip.drag_delta(); + } + if grip.drag_stopped() + && let Some((_, _, at)) = view.comments.popup_drag.take() + && allowed + && at.distance(placed.left_top()) >= 2.0 + { + // The same size on the page, at the new place. Only converts the + // rectangle to user space: the pointer and the redaction look are unused. + let rect = to_user(Rect::from_min_size(at, placed.size())); + edits.push(Edit::SetAnnotationPopup { page, index: a.index, rect: Some(rect), open: None }); + } + // Replies, as the thread reads: text replies only, filed under this + // comment by `/IRT` reference as in the Comments panel, each under the + // one it answers. A review status or a checkmark (`/State`) is a reply too, + // but not one to read here, and a member of the comment's group (`/RT + // /Group`, ISO 32000-2 §12.5.6.2) is part of the comment, not a reply. + let replies: Vec<&Annotation> = info + .annotations + .iter() + .filter(|r| { + r.state.is_none() + && !r.grouped + && r.parent.is_some_and(|parent| tops.get(&(r.page, r.index)).copied().unwrap_or(parent) == key) + }) + .collect(); + // The comment's text. Replace Text as `main` writes it (#5688) keeps the + // replacement on the caret grouped under an empty strikeout: the window shows + // and edits the caret's text, the proposal's. Locking the comment locks that + // text too. + let mut holder = info + .annotations + .iter() + .find(|r| { + r.grouped + && r.parent == Some(key) + && a.contents.as_deref().unwrap_or("").is_empty() + && r.contents.as_deref().is_some_and(|c| !c.is_empty()) + }) + .cloned() + .unwrap_or_else(|| a.clone()); + holder.locked |= a.locked; + let order = thread(key, &replies); + let (nodes, roots) = nest(&order); + let mut parts = Parts { allowed, author: &prefs.author, pal, cv: &mut view.comments, edits: &mut edits }; + // The body keeps the window's height: the text and replies scroll in what the + // title bar leaves, with a bar that shows whenever there is more (egui's show + // only near the pointer), in the ink's colour. + egui::Frame::NONE.inner_margin(egui::Margin::same(4)).show(ui, |ui| { + ui.set_width(size.x - 8.0); + ui.spacing_mut().scroll = egui::style::ScrollStyle { + dormant_handle_opacity: 0.4, + dormant_background_opacity: 0.1, + foreground_color: true, + ..egui::style::ScrollStyle::solid() + }; + ui.visuals_mut().override_text_color = Some(pal.ink); + let body_height = (size.y - bar_rect.height() - 8.0).max(24.0); + let scroll = + egui::ScrollArea::vertical().id_salt(("popup-body", page, a.index)).max_height(body_height).auto_shrink([false, false]); + scroll.show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 3.0; + text_box(ui, &mut parts, &holder, tl!("Add a comment")); + footer(ui, &mut parts, &a); + for root in &roots { + reply_panel(ui, &mut parts, &nodes, *root); + } + }); + }); + }); + // The lower right corner resizes the window; its new size is saved with the + // comment, its upper left corner where it was, when the drag ends. + let corner = window.response.rect.right_bottom(); + let grip = + ui.interact(Rect::from_min_max(corner - egui::vec2(RESIZE_GRIP, RESIZE_GRIP), corner), id.with("resize"), egui::Sense::drag()); + if grip.hovered() || grip.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe); + } + // Two short diagonal strokes mark the grip. + for inset in [3.0, 7.0] { + let stroke = egui::Stroke::new(1.0, pal.faint); + ui.painter().line_segment([corner - egui::vec2(3.0, inset), corner - egui::vec2(inset, 3.0)], stroke); + } + if grip.drag_started_by(egui::PointerButton::Primary) { + view.comments.popup_resize = Some((page, a.index, size)); + } + if grip.dragged() + && let Some((_, _, s)) = view.comments.popup_resize.as_mut() + { + *s += grip.drag_delta(); + } + if grip.drag_stopped() + && let Some((_, _, s)) = view.comments.popup_resize.take() + && allowed + { + let s = clamp(s); + // A press on the grip that ends where it began changes nothing. + // The window's size in points is the `/Rect`'s, its upper left corner kept + // where it is on screen: the rectangle of that size on the page there. A page + // turned a quarter (its `/Rotate` and the view's) shows the `/Rect`'s width + // down the screen. + let zoom = xf.zoom(); + if (s - clamp(saved)).length() >= 2.0 && zoom.is_finite() && zoom > 0.0 { + let turn = info.pages.get(page).map_or(0, |pg| u32::from(pg.rotation)) + u32::from(xf.rot); + let on_page = if turn % 180 == 90 { egui::vec2(s.y, s.x) } else { s } * zoom; + let rect = to_user(Rect::from_min_size(placed.left_top(), on_page)); + edits.push(Edit::SetAnnotationPopup { page, index: a.index, rect: Some(rect), open: None }); + } + } + }); + let shown_at = area.response.rect; + if pointer.is_some_and(|p| shown_at.contains(p)) { + hovered = Some(key); + } + rects.push((key, shown_at)); + } + } + view.comments.popup_rects = rects; + view.comments.popup_hovered = hovered; + match edits.len() { + 0 => None, + 1 => edits.pop(), + _ => Some(Edit::Batch { label: "Edit comment".into(), edits }), + } +} diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index fb699e1988..62d6e9db36 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -161,6 +161,19 @@ pub struct CommentView { pub editing: Option<(usize, usize, String)>, /// The panel should scroll the selected card into view. pub reveal: bool, + /// A pop-up window's title bar being dragged: the comment, and where the window's top-left + /// corner is now on screen. + pub popup_drag: Option<(usize, usize, Pos2)>, + /// A pop-up window's corner being dragged: the comment, and the window's size on screen now. + pub popup_resize: Option<(usize, usize, egui::Vec2)>, + /// Text being typed in a pop-up window: (page, index, text). + pub popup_text: Option<(usize, usize, String)>, + /// A reply being typed in a pop-up window: the comment or reply it answers (page, index), and its text. + pub popup_reply: Option<(usize, usize, String)>, + /// Where each open pop-up was drawn last frame, for the connector lines and hit-testing. + pub popup_rects: Vec<((usize, usize), Rect)>, + /// The pop-up the pointer was over last frame. + pub popup_hovered: Option<(usize, usize)>, /// The "Add a comment" box at the top of the Comments panel. pub add_box: String, /// The Comments panel's search query (`None`: search closed). @@ -332,16 +345,59 @@ impl CommentView { if self.editing.is_none() { self.editor_focus = None; } + // What is typed in a pop-up, and a window being dragged or resized, follow theirs too. + let moved = |k: (usize, usize)| after_delete(before, after, gone, k); + self.popup_text = self.popup_text.take().and_then(|(p, i, text)| moved((p, i)).map(|(p, i)| (p, i, text))); + self.popup_reply = self.popup_reply.take().and_then(|(p, i, text)| moved((p, i)).map(|(p, i)| (p, i, text))); + self.popup_drag = self.popup_drag.and_then(|(p, i, at)| moved((p, i)).map(|(p, i)| (p, i, at))); + self.popup_resize = self.popup_resize.and_then(|(p, i, size)| moved((p, i)).map(|(p, i)| (p, i, size))); } /// Undo or Redo just ran. Either may have put annotations back or taken them away, on any /// page, so a (page, index) held here may now name another one. The selection and the inline /// editor close, its unsaved text with it, rather than Save writing that text over whatever - /// took its place. + /// took its place; so do the pop-ups' unsaved text and drags. pub(crate) fn history_stepped(&mut self) { self.clear_selection(); self.editing = None; self.editor_focus = None; + self.popup_text = None; + self.popup_reply = None; + self.popup_drag = None; + self.popup_resize = None; + } + + /// The comments the pop-ups hold unsaved text, a reply or a drag for. + pub(crate) fn popup_keys(&self) -> Vec<(usize, usize)> { + let text = self.popup_text.as_ref().map(|(p, i, _)| (*p, *i)); + let reply = self.popup_reply.as_ref().map(|(p, i, _)| (*p, *i)); + let drag = self.popup_drag.map(|(p, i, _)| (p, i)); + let resize = self.popup_resize.map(|(p, i, _)| (p, i)); + [text, reply, drag, resize].into_iter().flatten().collect() + } + + /// An edit other than a deletion just ran: `before` are the comments at [`Self::popup_keys`] + /// until then, `after` the document's annotations now. When pages moved, came or went, a pop-up's (page, index) + /// may now name another comment: what was typed in that pop-up and not saved, and a window + /// being dragged or resized, are dropped rather than written to whatever took its place. + pub(crate) fn popups_after_edit(&mut self, before: &[Annotation], after: &[Annotation]) { + // The same comment: its kind, author and `/NM`, at that place before and after. + let who = |list: &[Annotation], k: (usize, usize)| { + list.iter().find(|a| (a.page, a.index) == k).map(|a| (a.subtype.clone(), a.author.clone(), a.name.clone())) + }; + let kept = |p: usize, i: usize| who(before, (p, i)).is_some_and(|w| who(after, (p, i)) == Some(w)); + if self.popup_text.as_ref().is_some_and(|(p, i, _)| !kept(*p, *i)) { + self.popup_text = None; + } + if self.popup_reply.as_ref().is_some_and(|(p, i, _)| !kept(*p, *i)) { + self.popup_reply = None; + } + if self.popup_drag.is_some_and(|(p, i, _)| !kept(p, i)) { + self.popup_drag = None; + } + if self.popup_resize.is_some_and(|(p, i, _)| !kept(p, i)) { + self.popup_resize = None; + } } } diff --git a/crates/ui-egui/src/tools/comments/tests.rs b/crates/ui-egui/src/tools/comments/tests.rs index 991957c468..d6a0bee369 100644 --- a/crates/ui-egui/src/tools/comments/tests.rs +++ b/crates/ui-egui/src/tools/comments/tests.rs @@ -87,6 +87,7 @@ fn an_upright_note_on_a_turned_page_is_in_view_where_it_is_drawn() { no_rotate, points: Vec::new(), text_box: None, + popup: None, }; let page = PageInfo { width: 140.0, height: 200.0, label: String::new(), crop: [0.0, 0.0, 200.0, 140.0], rotation: 90 }; let xf = PageXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(140.0, 200.0)), rot: 0, pw: 140.0, ph: 200.0 }; @@ -133,6 +134,7 @@ fn listed(page: usize, index: usize, contents: &str, parent: Option<(usize, usiz no_rotate: false, points: Vec::new(), text_box: None, + popup: None, } } @@ -223,6 +225,7 @@ fn annotation(subtype: &str, contents: Option<&str>, fill_sign: bool) -> Annotat no_rotate: false, points: Vec::new(), text_box: None, + popup: None, } } @@ -428,6 +431,7 @@ fn context_menu_colour_submenu_keeps_its_picker_open_and_a_swatch_closes_the_men fill_sign: false, points: Vec::new(), text_box: None, + popup: None, no_rotate: false, }], ..Default::default() diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 6ef2d4e4bd..4dfd738409 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3745,6 +3745,624 @@ fn a_callouts_arrow_knee_and_text_box_are_dragged_on_their_own() { assert!(near_pt(callout(&h).points[0], 40.0, 30.0)); } +/// #2137 review: a pop-up is edited under comment permission, not change permission: in a +/// document whose security allows comments but not changes, an open pop-up still takes typing +/// and closes. +#[test] +fn a_pop_up_is_editable_where_comments_are_allowed_but_changes_are_not() { + let plain = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [20 150 40 170] /T (Ada) /Popup 5 0 R >> endobj +5 0 obj << /Type /Annot /Subtype /Popup /Parent 4 0 R /Rect [60 60 260 160] /Open true >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(plain.to_vec())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + // Everything but bit 4, modifying the document. + permissions: -1 ^ 8, + encrypt_metadata: true, + seed: [4; 32], + }) + .unwrap(); + let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + let mut h = harness(move |app| { + app.open_bytes("restricted.pdf", None, bytes).expect("opens"); + // Only the restricted document, so it is the first view. + app.close_tab(0); + }); + h.run_steps(6); + let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the open pop-up shows"); + field(&h, "Add a comment"); + let close = h.query_all_by_label("Close").find(|n| window.contains(n.rect().center())).expect("the pop-up's ×"); + close.click(); + h.run_steps(4); + let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note"); + assert_eq!(note.popup.map(|p| p.open), Some(false), "closed"); +} + +/// #2137 review: a pop-up lists the comment's text replies only, as the Comments panel does. A +/// review status (`/StateModel /Review`) and a checkmark (`/StateModel /Marked`, the private +/// reply Mark writes) are replies too, but not ones to read in the thread. +#[test] +fn a_pop_up_lists_text_replies_but_not_status_or_checkmark_replies() { + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [20 150 40 170] /T (Ada) /NM (note-1) /Contents (Please check) /Popup 5 0 R >> endobj +5 0 obj << /Type /Annot /Subtype /Popup /Parent 4 0 R /Rect [60 40 260 160] /Open true >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [20 150 40 170] /F 32 /T (Bob) /NM (reply-1) /IRT 4 0 R /Contents (Agreed with this) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [20 150 40 170] /F 32 /T (Bob) /NM (reply-2) /IRT 4 0 R /State /Accepted /StateModel /Review /Contents (Accepted set by Bob) >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [20 150 40 170] /F 32 /T (Ada) /NM (reply-3) /IRT 4 0 R /State /Marked /StateModel /Marked /Contents (Marked set by Ada) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut h = harness(move |app| { + app.open_bytes("thread.pdf", None, pdf.to_vec()).expect("opens"); + app.close_tab(0); + }); + h.run_steps(6); + let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the open pop-up shows"); + let in_window = |text: &str| h.query_all_by(|n| shows_text(n, text)).any(|n| window.contains(n.rect().center())); + assert!(in_window("Agreed with this"), "the text reply"); + assert!(!in_window("Accepted set by Bob"), "not the review status"); + assert!(!in_window("Marked set by Ada"), "not the checkmark"); +} + +/// A Replace Text proposal: a strikeout and a caret grouped by `/IRT` and `/RT /Group`, with the +/// pop-up `open`. `caret_first` writes the caret as the group's primary, holding the replacement, +/// with the strikeout grouped under it; otherwise the strikeout is the primary and the +/// replacement is on the caret grouped under it, as PdfCraft used to write it. +fn replace_text_pdf(caret_first: bool, open: bool) -> Vec { + let primary = if caret_first { 4 } else { 5 }; + let strike = format!( + "<< /Type /Annot /Subtype /StrikeOut /Rect [40 100 140 120] /QuadPoints [40 120 140 120 40 100 140 100] /C [1 0 0] /T (Ada) /IT /StrikeOutTextEdit{} >>", + if caret_first { " /IRT 4 0 R /RT /Group" } else { " /Popup 6 0 R" } + ); + let caret = format!( + "<< /Type /Annot /Subtype /Caret /Rect [135 95 145 105] /C [0 0 1] /T (Bob) /Contents (slow red){} >>", + if caret_first { " /IT /Replace /Popup 6 0 R" } else { " /IRT 5 0 R /RT /Group" } + ); + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 4 0 R 6 0 R] >> endobj +4 0 obj {caret} endobj +5 0 obj {strike} endobj +6 0 obj << /Type /Annot /Subtype /Popup /Parent {primary} 0 R /Rect [160 20 290 150] /Open {open} >> endobj +trailer << /Root 1 0 R >> +%%EOF", + ) + .into_bytes() +} + +/// A Replace Text proposal's window shows the replacement as the comment's text, and the other +/// member of the group is part of the comment, not a reply panel (ISO 32000-2 §12.5.6.2: a group's +/// members are one unit). Both layouts: the caret as the primary, and the strikeout as the primary +/// with the replacement on the grouped caret, as `main` writes it (#5688). +#[test] +fn a_replace_text_window_shows_the_replacement_as_its_text_not_as_a_reply() { + for caret_first in [true, false] { + let pdf = replace_text_pdf(caret_first, true); + let mut h = harness(move |app| { + app.open_bytes("replace.pdf", None, pdf).expect("opens"); + app.close_tab(0); + }); + h.run_steps(6); + let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the open pop-up shows"); + let boxes: Vec = h + .query_all_by(|n| n.role() == Role::MultilineTextInput) + .filter(|n| window.contains(n.rect().center())) + .map(|n| n.value().unwrap_or_default()) + .collect(); + assert_eq!(boxes, ["slow red"], "caret first: {caret_first}: one box, the replacement"); + } +} + +/// A locked Replace Text proposal, as `main` writes it (the strikeout primary and locked, the +/// replacement on the grouped caret), shows the replacement in its window read-only: locking the +/// comment locks the text the window shows for it. +#[test] +fn a_locked_replace_text_window_shows_the_replacement_read_only() { + let pdf = String::from_utf8(replace_text_pdf(false, true)) + .unwrap() + .replace("/IT /StrikeOutTextEdit /Popup", "/IT /StrikeOutTextEdit /F 128 /Popup") + .into_bytes(); + let mut h = harness(move |app| { + app.open_bytes("replace.pdf", None, pdf).expect("opens"); + app.close_tab(0); + }); + h.run_steps(6); + let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the open pop-up shows"); + let inside = |n: &egui_kittest::Node<'_>| window.contains(n.rect().center()); + let boxes = h.query_all_by(|n| n.role() == Role::MultilineTextInput).filter(inside).count(); + assert_eq!(boxes, 0, "no box to type in while the comment is locked"); + assert!(h.query_all_by_label("slow red").any(|n| inside(&n)), "the replacement is shown"); +} + +/// A double-click on a Replace Text proposal opens the group's one window, the primary's, in +/// either layout. (A double-click on the member grouped under it opens the same +/// window once members are picked on the page; main hit-tests only the primary.) +#[test] +fn a_double_click_on_a_replace_text_opens_its_one_window() { + for (caret_first, on_caret) in [(true, true), (false, false)] { + let pdf = replace_text_pdf(caret_first, false); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("replace.pdf", None, pdf.clone()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app + }); + h.run_steps(4); + crate::support::settle(&mut h); + h.run_steps(45); + let target = if on_caret { (140.0, 98.0) } else { (60.0, 110.0) }; + let p = at(&h, target.0, target.1); + h.hover_at(p); + h.run_steps(1); + for _ in 0..2 { + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(1); + } + h.run_steps(4); + let primary_subtype = if caret_first { "Caret" } else { "StrikeOut" }; + let c = comments(&h); + let primary = c.iter().find(|a| a.subtype == primary_subtype).expect("the primary"); + assert_eq!(primary.popup.map(|w| w.open), Some(true), "caret first: {caret_first}, on the caret: {on_caret}: the group's window opened"); + assert_eq!(h.state().views[0].comments.popup_rects.len(), 1, "one window"); + } +} + +/// A double-click on a sticky note opens its pop-up window on the page, where its text is read +/// and edited; the title bar drags it (saved as the pop-up's place, one undo step); × closes it +/// (comment.popups). +#[test] +fn a_double_click_opens_a_notes_pop_up_which_drags_and_closes() { + // Frames a sixtieth apart, so two clicks land within egui's double-click time. + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("author", "Tester").unwrap(); + app.set_option("quick", "note").unwrap(); + app + }); + h.run_steps(4); + crate::support::settle(&mut h); + click_pt(&mut h, (60.0, 160.0)); + field(&h, "Add a comment").type_text("Please check"); + h.run_steps(2); + h.get_by_label("Post").click(); + h.run_steps(3); + let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note"); + assert_eq!(note.popup.map(|p| p.open), Some(false)); + assert!(h.state().views[0].comments.popup_rects.is_empty(), "closed pop-ups are not shown"); + // Well after the Post click, so egui does not count the next clicks with it. + h.run_steps(45); + + // Double-click the icon. + let icon = at(&h, (note.rect[0] + note.rect[2]) / 2.0, (note.rect[1] + note.rect[3]) / 2.0); + h.hover_at(icon); + h.run_steps(1); + for _ in 0..2 { + h.drag_at(icon); + h.run_steps(1); + h.drop_at(icon); + h.run_steps(1); + } + h.run_steps(4); + let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note"); + assert_eq!(note.popup.map(|p| p.open), Some(true), "the pop-up opened"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Open comment pop-up")); + let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is on screen"); + // Its title names the kind of comment and its author. + h.get_by_label("Note – Tester"); + + // Drag the title bar 80 px right and 40 px down: the pop-up's place on the page moves. + let before = note.popup.unwrap().rect; + let bar = pos2(window.left() + 30.0, window.top() + 12.0); + drag(&mut h, bar, bar + egui::vec2(80.0, 40.0)); + let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note"); + let after = note.popup.unwrap().rect; + // 80 × 40 screen pixels is about 20 × 10 pt at this zoom. + assert!(after[0] > before[0] + 10.0 && after[1] < before[1] - 5.0, "moved right and down on the page: {before:?} -> {after:?}"); + assert!(((after[2] - after[0]) - (before[2] - before[0])).abs() < 0.5, "the same width"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move comment pop-up")); + + // Close it. + h.get_by_label("Close").click(); + h.run_steps(4); + let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note"); + assert_eq!(note.popup.map(|p| p.open), Some(false)); + assert!(h.state().views[0].comments.popup_rects.is_empty()); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Close comment pop-up")); +} + +// Contributor-original PDF: two notes sharing one /NM (as PyMuPDF names them), both with an open +// pop-up; the second has a reply by reference and a reply to that reply, which has no /NM. +const POPUPS_SHARING_A_NAME: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /NM (fitz-A0) /T (Alpha) /Contents (First note) /Popup 5 0 R /Open true >> endobj +5 0 obj << /Type /Annot /Subtype /Popup /Rect [60 110 160 190] /Parent 4 0 R /Open true >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [20 40 40 60] /NM (fitz-A0) /T (Beta) /Contents (Second note) /Popup 7 0 R /Open true >> endobj +7 0 obj << /Type /Annot /Subtype /Popup /Rect [180 -250 290 90] /Parent 6 0 R /Open true >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [20 40 40 60] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (Reply by reference) >> endobj +9 0 obj << /Type /Annot /Subtype /Text /Rect [20 40 40 60] /IRT 8 0 R /T (Delta) /Contents (Reply to the reply) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// A pop-up reads its comment's thread by `/IRT` reference, as the Comments panel does: the +/// replies show once, in the second note's window only, at any depth, though both notes share +/// one `/NM`. +#[test] +fn a_pop_up_lists_its_replies_by_reference_when_names_repeat() { + let h = harness_with_bytes(POPUPS_SHARING_A_NAME.to_vec(), |_| {}); + let windows = h.state().views[0].comments.popup_rects.clone(); + assert_eq!(windows.len(), 2, "both notes' pop-ups are open: {windows:?}"); + let (_, second) = windows.iter().find(|(key, _)| *key == (0, 2)).copied().expect("the second note's pop-up"); + for text in ["Reply by reference", "Reply to the reply"] { + // The Comments panel lists the thread too; count only what the windows show. + let shown: Vec = + h.query_all_by(|n| shows_text(n, text)).map(|n| n.rect().center()).filter(|c| windows.iter().any(|(_, w)| w.contains(*c))).collect(); + assert_eq!(shown.len(), 1, "{text:?} shows in one window: {shown:?}"); + assert!(shown.iter().all(|c| second.contains(*c)), "{text:?} is in the second note's window"); + } +} + +// Contributor-original PDF: one blue note with its pop-up open beside it. +const BLUE_NOTE_WITH_OPEN_POP_UP: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /C [0 0 1] /T (Alpha) /Contents (A note) /Popup 5 0 R /Open true >> endobj +5 0 obj << /Type /Annot /Subtype /Popup /Rect [120 20 260 120] /Parent 4 0 R /Open true >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// What the connectors of comments in colour `color` painted in the last frame: each wedge +/// (its point, the two ends of its base, its colour: `color`, faded). +fn connectors(h: &Harness<'static, PdfCraftApp>, color: egui::Color32) -> Vec<(Pos2, [Pos2; 2], egui::Color32)> { + fn walk(shape: &egui::Shape, color: egui::Color32, out: &mut Vec<(Pos2, [Pos2; 2], egui::Color32)>) { + match shape { + egui::Shape::Mesh(m) => { + if let [tip, b0, b1] = m.vertices.as_slice() + && tip.color.a() > 0 + && tip.color.a() < 255 + && tip.color.to_opaque() == color + { + out.push((tip.pos, [b0.pos, b1.pos], tip.color)); + } + } + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, color, out)), + _ => {} + } + } + let mut out = Vec::new(); + for clipped in &h.output().shapes { + walk(&clipped.shape, color, &mut out); + } + out +} + +/// A comment's connector (comment.connector-lines): a faint wedge in the comment's colour from +/// the edge of the comment nearest its open pop-up to a base on the window's edge around the +/// point nearest the comment; shown +/// while the pointer is on the comment or on the window or the comment is selected, and not +/// otherwise. +#[test] +fn a_connector_line_joins_a_comment_to_its_open_pop_up_while_the_pointer_is_on_either() { + let mut h = harness_with_bytes(BLUE_NOTE_WITH_OPEN_POP_UP.to_vec(), |_| {}); + let blue = egui::Color32::from_rgb(0, 0, 255); + let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open"); + let mark = egui::Rect::from_two_pos(at(&h, 20.0, 180.0), at(&h, 40.0, 160.0)); + let on_edge = |r: egui::Rect, p: Pos2| { + r.expand(1.0).contains(p) && [p.x - r.left(), r.right() - p.x, p.y - r.top(), r.bottom() - p.y].iter().any(|d| d.abs() < 1.0) + }; + + // Away from both: no connector. + h.hover_at(at(&h, 280.0, 190.0)); + h.run_steps(2); + assert!(connectors(&h, blue).is_empty(), "no connector with the pointer elsewhere"); + + // On the note or on its window: one wedge, from the note's edge to the window's. + for (what, pointer) in [("the note", mark.center()), ("the window", window.center())] { + h.hover_at(pointer); + h.run_steps(2); + let found = connectors(&h, blue); + assert_eq!(found.len(), 1, "one connector with the pointer on {what}: {found:?}"); + let (tip, [b0, b1], base) = found[0]; + assert!(on_edge(mark, tip), "its point is on the note's edge {mark:?}: {tip:?}"); + // The nearest point of the note: its lower right corner. + assert!(tip.distance(mark.right_bottom()) < 1.0, "{tip:?}"); + assert!(on_edge(window, b0) && on_edge(window, b1), "its base is on the window's edge {window:?}: {b0:?} {b1:?}"); + // The base lies along one side, around the window's upper left corner, and is wider than a line. + assert!((b0.x - b1.x).abs() < 0.5 || (b0.y - b1.y).abs() < 0.5, "along one side: {b0:?} {b1:?}"); + assert!(b0.distance(b1) >= 10.0, "a base, not a line: {b0:?} {b1:?}"); + assert!(b0.distance(window.left_top()).min(b1.distance(window.left_top())) < 1.0, "at the corner: {b0:?} {b1:?}"); + // The wedge is faint, in the comment's hue. + assert!(base.a() < 255 && base.a() > 0 && base.to_opaque() == blue, "the wedge is faint blue: {base:?}"); + } + + // Selected, the note keeps its connector with the pointer elsewhere. + h.state_mut().views[0].comments.selected = Some((0, 0)); + h.hover_at(at(&h, 280.0, 190.0)); + h.run_steps(2); + assert_eq!(connectors(&h, blue).len(), 1, "a selected comment shows its connector"); +} + +/// A note with an open pop-up and a thread of replies: "Second" and "Third" answer the note, +/// "Second, first answer" answers "Second" (`/IRT` to a reply). +const NOTE_WITH_A_THREAD: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /C [1 0.85 0.2] /T (Alpha) /Contents (First) /Popup 5 0 R /Open true >> endobj +5 0 obj << /Type /Annot /Subtype /Popup /Rect [60 -250 280 150] /Parent 4 0 R /Open true >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Beta) /Contents (Second) /IRT 4 0 R /F 30 >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Beta) /Contents (Second, first answer) /IRT 6 0 R /F 30 >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Beta) /Contents (Third) /IRT 4 0 R /F 30 >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// A pop-up window (comment.popup-windows) is laid out as the readers' are: a title bar with the +/// comment's kind and author; the comment's text; its replies as panels in thread order, an +/// answer to a reply inside that reply's panel; and a Reply button under each, which opens a box +/// that posts an answer to that comment or reply. +#[test] +fn a_pop_up_threads_its_replies_and_takes_a_new_one() { + let mut h = harness_with_bytes(NOTE_WITH_A_THREAD.to_vec(), |_| {}); + let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open"); + h.get_by_label("Note – Alpha"); + // Each reply's text box, inside the window. + let rect = |h: &Harness<'static, PdfCraftApp>, text: &str| { + let found: Vec = h.query_all_by(|n| shows_text(n, text)).map(|n| n.rect()).filter(|r| window.contains_rect(*r)).collect(); + assert_eq!(found.len(), 1, "{text:?} once in the window: {found:?}"); + found[0] + }; + let (second, answer, third) = (rect(&h, "Second"), rect(&h, "Second, first answer"), rect(&h, "Third")); + assert!(second.bottom() < answer.top() && answer.bottom() < third.top(), "thread order: {second:?} {answer:?} {third:?}"); + assert!( + answer.left() > second.left() + 4.0 && answer.right() < second.right() - 4.0, + "the answer sits inside its reply's panel: {second:?} {answer:?}" + ); + assert!((third.left() - second.left()).abs() < 1.0, "replies to the note line up: {second:?} {third:?}"); + // The Reply buttons, top to bottom: the note's, then each reply's. + let replies = |h: &Harness<'static, PdfCraftApp>| { + let mut b: Vec = + h.query_all_by(|n| n.role() == Role::Button && n.label().as_deref() == Some("Reply")).map(|n| n.rect()).collect(); + b.sort_by(|x, y| x.top().total_cmp(&y.top())); + b + }; + assert_eq!(replies(&h).len(), 4, "one under the note and each reply"); + + // The note's Reply: Enter starts a new line in the box, as in the readers' pop-ups; Post posts. + let before = comments(&h).len(); + let at = replies(&h)[0].center(); + click(&mut h, at); + field(&h, "Add a reply").type_text("Fourth"); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert_eq!(comments(&h).len(), before, "Enter does not post"); + h.event(egui::Event::Text("line two".into())); + h.run_steps(2); + h.get_by_label("Post").click(); + h.run_steps(3); + let all = comments(&h); + let note = all.iter().find(|a| a.contents.as_deref() == Some("First")).expect("the note"); + let reply = all.iter().find(|a| a.contents.as_deref() == Some("Fourth\nline two")).expect("the reply was added, on two lines"); + assert_eq!(reply.parent, Some((note.page, note.index))); + assert_eq!(reply.author.as_deref(), Some("Tester")); + assert!(h.state().views[0].comments.popup_reply.is_none(), "the box closes"); + + // A reply's own Reply answers that reply; Ctrl+Enter posts too, as in the comment composer. + let second_key = all.iter().find(|a| a.contents.as_deref() == Some("Second")).map(|a| (a.page, a.index)).expect("the reply"); + let at = replies(&h)[1].center(); + click(&mut h, at); + field(&h, "Add a reply").type_text("Fifth"); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::CTRL, egui::Key::Enter); + h.run_steps(3); + let fifth = comments(&h).into_iter().find(|a| a.contents.as_deref() == Some("Fifth")).expect("Ctrl+Enter posts"); + assert_eq!(fifth.parent, Some(second_key), "it answers the reply"); + + // Cancel closes the box without posting. + let before = comments(&h).len(); + let at = replies(&h)[0].center(); + click(&mut h, at); + field(&h, "Add a reply").type_text("Never mind"); + h.run_steps(2); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!(comments(&h).len(), before); + assert!(h.state().views[0].comments.popup_reply.is_none()); +} + +/// A note with an open pop-up and `replies` replies to it. +fn note_with_replies(replies: usize) -> Vec { + let annots: String = (0..replies + 2).map(|i| format!("{} 0 R ", i + 4)).collect(); + let mut pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [{annots}] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Alpha) /Contents (First) /Popup 5 0 R /Open true >> endobj +5 0 obj << /Type /Annot /Subtype /Popup /Rect [60 10 280 150] /Parent 4 0 R /Open true >> endobj +" + ); + for i in 0..replies { + pdf.push_str(&format!( + "{} 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Beta) /Contents (Reply {i}) /IRT 4 0 R /F 30 >> endobj\n", + i + 6 + )); + } + pdf.push_str("trailer << /Root 1 0 R >>\n%%EOF"); + pdf.into_bytes() +} + +/// A pop-up window keeps its height however long its thread grows: the text and replies scroll +/// inside it, as in the readers' pop-ups, rather than the window growing down the screen. +#[test] +fn a_long_thread_scrolls_inside_its_pop_up() { + let height = |replies: usize| { + let h = harness_with_bytes(note_with_replies(replies), |_| {}); + let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open"); + window.height() + }; + let (short, long) = (height(1), height(25)); + assert!((short - long).abs() < 2.0, "the same height with 1 reply and with 25: {short} {long}"); + assert!(long < 600.0, "within the screen: {long}"); +} + +/// Dragging a pop-up window's lower right corner resizes it, and the new size is saved with the +/// comment (the pop-up's `/Rect`) at the same place, in one undo step; the window shows it. +#[test] +fn a_pop_ups_corner_resizes_it() { + let mut h = harness_with_bytes(BLUE_NOTE_WITH_OPEN_POP_UP.to_vec(), |_| {}); + let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open"); + let before = comments(&h).into_iter().find(|a| a.subtype == "Text").and_then(|a| a.popup).expect("its pop-up").rect; + let corner = window.right_bottom() - egui::vec2(3.0, 3.0); + drag(&mut h, corner, corner + egui::vec2(80.0, 60.0)); + h.run_steps(3); + let after = comments(&h).into_iter().find(|a| a.subtype == "Text").and_then(|a| a.popup).expect("its pop-up").rect; + // Wider and taller on the page, its upper left corner where it was. + assert!(after[2] - after[0] > before[2] - before[0] + 5.0, "wider: {before:?} -> {after:?}"); + assert!(after[3] - after[1] > before[3] - before[1] + 5.0, "taller: {before:?} -> {after:?}"); + assert!((after[0] - before[0]).abs() < 0.5 && (after[3] - before[3]).abs() < 0.5, "the same place: {before:?} -> {after:?}"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move comment pop-up")); + let (_, resized) = h.state().views[0].comments.popup_rects.first().copied().expect("still open"); + // The window shows the saved size, in points on screen whatever the zoom. + assert!((resized.width() - (after[2] - after[0])).abs() < 3.0, "as wide as saved: {after:?} {resized:?}"); + assert!((resized.height() - (after[3] - after[1])).abs() < 3.0, "as tall as saved: {after:?} {resized:?}"); + assert!(resized.width() > window.width() + 60.0 && resized.height() > window.height() + 40.0, "bigger on screen: {window:?} -> {resized:?}"); + assert!((resized.left_top() - window.left_top()).length() < 1.0, "in the same place on screen"); +} + +/// A pop-up window's box holding `text` (a comment's or a reply's). +fn shows_text(n: &egui_kittest::kittest::AccessKitNode<'_>, text: &str) -> bool { + n.role() == Role::MultilineTextInput && n.value().as_deref() == Some(text) +} + +/// On a page turned a quarter (View ▸ Rotate View), a resized pop-up window stays where it is on +/// screen and shows the size it was dragged to. +#[test] +fn a_pop_up_resized_on_a_turned_page_stays_in_place() { + let mut h = harness_with_bytes(BLUE_NOTE_WITH_OPEN_POP_UP.to_vec(), |app| app.views[0].rotation = 90); + let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open"); + let corner = window.right_bottom() - egui::vec2(3.0, 3.0); + drag(&mut h, corner, corner + egui::vec2(80.0, 60.0)); + h.run_steps(3); + let (_, resized) = h.state().views[0].comments.popup_rects.first().copied().expect("still open"); + assert!((resized.left_top() - window.left_top()).length() < 1.0, "in the same place on screen: {window:?} -> {resized:?}"); + assert!((resized.width() - window.width() - 80.0).abs() < 3.0, "80 px wider: {window:?} -> {resized:?}"); + // Taller by the drag, less what its text had already stretched it. + assert!(resized.height() > window.height() + 50.0, "taller: {window:?} -> {resized:?}"); +} + +/// #2137 review: the composer's Post, clicked while a pop-up's box holds unsaved text, also +/// saves that text as the box loses the focus. Both land in one frame and both are kept. +#[test] +fn posting_from_the_composer_keeps_a_pop_ups_unsaved_text() { + let mut h = harness_with_bytes(BLUE_NOTE_WITH_OPEN_POP_UP.to_vec(), |app| app.set_option("quick", "note").unwrap()); + // Low on the left, so the composer stays clear of the window. + click_pt(&mut h, (30.0, 40.0)); + // The composer opens with its editor focused. + h.run_steps(2); + h.event(egui::Event::Text("New note".into())); + h.run_steps(2); + let box_at = h.query_all_by(|n| shows_text(n, "A note")).next().expect("the pop-up's box").rect().right_center() - egui::vec2(4.0, 0.0); + click(&mut h, box_at); + h.event(egui::Event::Text(" edited".into())); + h.run_steps(2); + assert!(h.state().views[0].comments.composer.is_some(), "the composer is still open"); + let post = h.get_by_label("Post").rect().center(); + click(&mut h, post); + h.run_steps(3); + let all = comments(&h); + assert!( + all.iter().any(|a| a.contents.as_deref() == Some("New note")), + "the composer's note: {:?}", + all.iter().map(|a| &a.contents).collect::>() + ); + assert!( + all.iter().any(|a| a.contents.as_deref() == Some("A note edited")), + "the pop-up's text: {:?}", + all.iter().map(|a| &a.contents).collect::>() + ); +} + +/// #2137 review: what a pop-up holds for a comment (a reply being typed, unsaved text) follows +/// that comment when a deletion moves it, and is dropped when an undo or a page edit leaves its +/// (page, index) naming another; other edits keep it. +#[test] +fn a_pop_ups_unsaved_work_follows_its_comment_or_is_dropped() { + use pdfcraft_engine::Edit; + let mut h = harness_with_bytes(NOTE_WITH_A_THREAD.to_vec(), |_| {}); + let key = |h: &Harness<'static, PdfCraftApp>, text: &str| { + comments(h).into_iter().find(|a| a.contents.as_deref() == Some(text)).map(|a| (a.page, a.index)).expect(text) + }; + let (second, third) = (key(&h, "Second"), key(&h, "Third")); + h.state_mut().views[0].comments.popup_reply = Some((third.0, third.1, "draft".into())); + + // Another edit, on another comment: the draft stays. + assert!(h.state_mut().apply_edit(Edit::SetAnnotationContents { page: second.0, index: second.1, text: "Second!".into() })); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.popup_reply, Some((third.0, third.1, "draft".into()))); + + // Deleting "Second" (and its answer) moves "Third" up: the draft goes with it. + assert!(h.state_mut().apply_edit(Edit::DeleteAnnotation { page: second.0, index: second.1 })); + h.run_steps(2); + let moved = key(&h, "Third"); + assert_ne!(moved, third, "Third moved up"); + assert_eq!(h.state().views[0].comments.popup_reply, Some((moved.0, moved.1, "draft".into())), "the draft is still Third's"); + + // Undo puts the thread back: the draft is dropped rather than left on another reply. + h.state_mut().undo(); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.popup_reply, None); + + // A page inserted before it: the comment's (page, index) now names nothing of it. + let note = key(&h, "First"); + h.state_mut().views[0].comments.popup_text = Some((note.0, note.1, "unsaved".into())); + assert!(h.state_mut().apply_edit(Edit::InsertBlankPage { at: 0, width: 300.0, height: 200.0 })); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.popup_text, None); +} + +/// #2137 review: text typed into a pop-up and not yet saved is saved when × closes the window: +/// the click takes the focus from the box and closes it in one frame, one undo step for both. +#[test] +fn closing_a_pop_up_saves_its_typed_text() { + let mut h = harness_with_bytes(BLUE_NOTE_WITH_OPEN_POP_UP.to_vec(), |_| {}); + let box_at = h.query_all_by(|n| shows_text(n, "A note")).next().expect("the pop-up's box").rect().right_center() - egui::vec2(4.0, 0.0); + click(&mut h, box_at); + h.event(egui::Event::Text(" for you".into())); + h.run_steps(2); + let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open"); + let close = h.query_all_by_label("Close").map(|n| n.rect().center()).find(|c| window.contains(*c)).expect("the window's ×"); + click(&mut h, close); + h.run_steps(3); + let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note"); + assert_eq!(note.contents.as_deref(), Some("A note for you"), "the typed text is saved"); + assert_eq!(note.popup.map(|p| p.open), Some(false), "and the window closed"); + assert!(h.state().views[0].comments.popup_text.is_none()); + h.state_mut().undo(); + h.run_steps(2); + let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note"); + assert_eq!((note.contents.as_deref(), note.popup.map(|p| p.open)), (Some("A note"), Some(true)), "one undo step for both"); +} + /// `n` pages with one comment each, "Card 1" to "Card n". fn one_comment_per_page(n: usize) -> Vec { let mut objs = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string()]; @@ -4176,7 +4794,25 @@ fn edit_composer_preserves_only_typewriter_whitespace(kind: u8) { }; support::input::double_click(&mut h, point); h.run_steps(3); - let c = h.state().views[0].comments.composer.as_ref().expect("double-click opens the edit composer"); + if kind == 2 { + // A double-click on a sticky note opens its pop-up window, as in Acrobat; its edit + // composer is the page menu's "Edit text…". + let popup_open = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + doc.info.annotations.iter().find(|a| a.page == 1 && a.index == 0).unwrap().popup.is_some_and(|p| p.open) + }; + assert!(h.state().views[0].comments.composer.is_none(), "a note's double-click opens no composer"); + assert!(popup_open(&h), "a note's double-click opens its pop-up"); + h.hover_at(point); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: point, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: point, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + h.get_all_by_label("Edit text…").last().unwrap().click(); + h.run_steps(3); + } + let c = h.state().views[0].comments.composer.as_ref().expect("the edit composer is open"); assert_eq!(c.page, 1); assert_eq!(c.kind, pdfcraft_ui_egui::comments::ComposerKind::Edit(0)); h.input_mut().events.push(egui::Event::Text(" edited\n\n".into())); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 89052dcc96..7842f4c0dd 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4235,11 +4235,11 @@ title = "Open and reposition comment pop-ups" area = "E" tier = "P1" milestone = "M5" -status = "planned" +status = "shipped" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["comment_popup", "comment_list"] +evidence = ["crates/ui-egui/tests/it/comments.rs::a_double_click_opens_a_notes_pop_up_which_drags_and_closes", "crates/ui-egui/tests/it/comments.rs::a_pop_up_threads_its_replies_and_takes_a_new_one", "crates/ui-egui/tests/it/comments.rs::a_long_thread_scrolls_inside_its_pop_up", "crates/ui-egui/tests/it/comments.rs::a_pop_ups_corner_resizes_it", "crates/ui-egui/tests/it/comments.rs::a_pop_up_resized_on_a_turned_page_stays_in_place", "crates/annot/src/tests.rs::pop_ups_open_close_and_move_and_are_reported", "crates/automation/tests/it/automation.rs::comment_popups_are_opened_moved_and_listed", "crates/render/src/inspect/tests.rs::a_comments_pop_up_is_read_with_its_place_and_state"] +notes = "double-click a comment to open its pop-up window on the page (its /Popup /Open and /Rect, so other viewers show it the same), at the size its /Rect gives in points whatever the zoom; a title bar with the comment's kind, author and date; its text typed in place; its replies as panels, each inside the one it answers (/IRT), their text typed in place too; a Reply button under the comment and under each reply opens a box that answers it (Enter starts a new line, Post or Ctrl+Enter posts); the text and replies scroll inside the window; the title bar drags it, its lower right corner resizes it, × closes it; each is an undo step. Not yet: the window's options menu (status, mark, lock, delete from the window), collapsing a reply's answers, resizing from the other corners and sides, Preferences ▸ Commenting pop-up behaviours (open on roll-over, hide when the Comments list opens)" [[feature]] id = "comment.connector-lines" @@ -4247,11 +4247,11 @@ title = "Connector lines between markups and pop-ups" area = "E" tier = "P2" milestone = "M5" -status = "planned" +status = "shipped" commands = [] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/it/comments.rs::a_connector_line_joins_a_comment_to_its_open_pop_up_while_the_pointer_is_on_either"] +notes = "a faint wedge in the comment's colour from the comment's edge nearest its open pop-up, widening to the window's side that faces it, while the pointer is on either or the comment is selected (Acrobat's roll-over lines). Not yet: the preference to turn them off" [[feature]] id = "comment.review-history"