Merge #2137: M5: comment pop-up windows on the page, with connector lines (comment.popups, comment.connector-lines)

# Conflicts:
#	ROADMAP.md
#	crates/annot/src/info.rs
#	crates/annot/src/lib.rs
#	crates/annot/src/modify.rs
#	crates/annot/src/tests.rs
#	crates/automation/src/comments.rs
#	crates/automation/tests/it/automation.rs
#	crates/engine/src/comments.rs
#	crates/render/src/inspect.rs
#	crates/render/src/inspect/annotations.rs
#	crates/render/src/inspect/model.rs
#	crates/render/src/inspect/tests.rs
#	crates/render/src/lib.rs
#	crates/ui-egui/src/tools/comments/input.rs
#	crates/ui-egui/src/tools/comments/paint.rs
#	crates/ui-egui/src/tools/comments/tests.rs
#	crates/ui-egui/tests/it/comments.rs
This commit is contained in:
bflatastic committed 2026-11-09 18:22:50 +07:00
commit c638edf9c7
32 files changed
+1784 -26

No files matched your search

+1
View File
@@ -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.
+3 -1
View File
@@ -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
+46
View File
@@ -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<PopupInfo>,
}
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<ObjRef>, annot: &Dict) -> Option<ObjRef> {
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<ObjRef>, annot: &Dict) -> Option<PopupInfo> {
// 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<f32> = 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<String> {
let o = doc.resolve(d.get(key)?);
let s = match &*o {
@@ -205,6 +250,7 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
no_rotate: drawn_upright(doc, d),
points,
text_box,
popup: popup_info(doc, entry.as_ref().map(|r| final_ref(doc, r)), d),
});
}
}
+3 -3
View File
@@ -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::{
+79 -2
View File
@@ -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<bool>, 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<f64> = 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> {
+157
View File
@@ -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<Object> = 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<f64> =
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]]] };
+13
View File
@@ -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<Value> {
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<Value> {
let (page, index) = self.comment_target(a)?;
let locked = a.opt_bool("locked")?.unwrap_or(true);
+1
View File
@@ -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)?,
+5
View File
@@ -1072,6 +1072,11 @@ pub fn tools() -> Vec<ToolDef> {
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",
+26
View File
@@ -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
+1
View File
@@ -25,6 +25,7 @@ pub(crate) fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_render:
no_rotate: s.no_rotate,
points: s.points,
text_box: s.text_box,
popup: s.popup.map(|p| pdfcraft_render::Popup { rect: p.rect, open: p.open }),
})
.collect();
pdfcraft_render::link_replies(&mut list);
+8
View File
@@ -243,6 +243,14 @@ pub enum Edit {
index: usize,
points: Vec<[f64; 2]>,
},
/// 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<bool>,
},
/// Resize a rectangle, oval or text box.
ResizeAnnotation {
page: usize,
+6
View File
@@ -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 { .. }
+2
View File
@@ -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(
+1
View File
@@ -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 { .. }
+2 -1
View File
@@ -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};
+25 -1
View File
@@ -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<f32> = 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 {
+11
View File
@@ -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<Popup>,
}
/// 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 {
+62
View File
@@ -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
+2 -2
View File
@@ -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::{
+10 -1
View File
@@ -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);
}
@@ -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
+5
View File
@@ -20,6 +20,11 @@ impl DocView {
pub fn page_xform(&self, page: usize) -> Option<PageXform> {
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
+24 -2
View File
@@ -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<pdfcraft_render::Annotation> = 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<usize> {
}
}
/// 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<usize> {
match edit {
Edit::AddAnnotation(a) => Some(a.page),
@@ -1017,6 +1038,7 @@ fn comment_page(edit: &Edit) -> Option<usize> {
| Edit::SetAnnotationStatus { page, .. }
| Edit::MoveAnnotation { page, .. }
| Edit::ReshapeAnnotation { page, .. }
| Edit::SetAnnotationPopup { page, .. }
| Edit::ResizeAnnotation { page, .. }
| Edit::StyleAnnotation { page, .. }
| Edit::FillAnnotation { page, .. }
+2
View File
@@ -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;
+12 -1
View File
@@ -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),
@@ -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);
}
+558
View File
@@ -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<Pos2>) {
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<Rect> = 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<usize>)>, Vec<usize>) {
let mut nodes: Vec<(&Annotation, Vec<usize>)> = 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<usize> = 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<Edit>,
}
/// 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<usize>)], 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<Edit> {
let pointer = ctx.input(|i| i.pointer.hover_pos());
let mut edits: Vec<Edit> = 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<Annotation> =
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 }),
}
}
+57 -1
View File
@@ -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;
}
}
}
@@ -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()
+637 -1
View File
@@ -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<u8> {
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<String> = 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<Pos2> =
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<egui::Rect> = 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<egui::Rect> =
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<u8> {
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::<Vec<_>>()
);
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::<Vec<_>>()
);
}
/// #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<u8> {
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()));
+7 -7
View File
@@ -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"