diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index ba1a5601..94f020b5 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -528,6 +528,8 @@ fn annot_dict(doc: &Document, r: ObjRef) -> Dict { const FLAG_PRINT: i64 = 4; const FLAG_NO_ZOOM: i64 = 8; const FLAG_NO_ROTATE: i64 = 16; +const FLAG_HIDDEN: i64 = 2; +const FLAG_LOCKED: i64 = 128; /// Size of a note icon (points, unscaled by zoom). pub const NOTE_SIZE: f64 = 20.0; @@ -802,6 +804,9 @@ pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Resul let p = page_ref(doc, page)?; let list = annots(doc, p); let target = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?; + if let Some(r) = target.as_ref() { + unlocked(doc, r)?; + } let mut doomed: Vec = target.as_ref().into_iter().collect(); // Replies (`/IRT`) and pop-ups (`/Parent`) of anything doomed, to a fixpoint. loop { @@ -911,12 +916,55 @@ fn reply( Ok(i) } +fn flags(doc: &Document, r: ObjRef) -> i64 { + doc.get(r).as_dict().and_then(|d| d.get(b"F").and_then(|f| doc.resolve(f).as_f64())).unwrap_or(0.0) as i64 +} + +/// Refuse to change a locked comment (Acrobat: Properties ▸ Locked). +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(()) } +} + +/// 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> { + let (_, r) = annot_ref(doc, page, index)?; + let f = if locked { flags(doc, r) | FLAG_LOCKED } else { flags(doc, r) & !FLAG_LOCKED }; + doc.update_dict(r, |d| d.set(b"F".to_vec(), Object::Int(f)))?; + Ok(()) +} + +/// Mark or unmark a comment with a checkmark. Like Acrobat this adds a hidden state reply with +/// `/StateModel /Marked` by `author`; the latest one wins. It's private bookkeeping, not a status. +pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, author: &str, meta: &Meta) -> Result { + let (p, parent) = annot_ref(doc, page, index)?; + let pd = annot_dict(doc, parent); + if pd.name(b"Subtype") == Some(b"Popup") { + return Err(AnnotError::Invalid("pop-ups can't be marked".into())); + } + let state = if marked { "Marked" } else { "Unmarked" }; + let text = format!("{state} set by {}", if author.is_empty() { "unknown" } else { author }); + let mut d = base_dict("Text", [0.0; 4], p, &text, author, meta); + d.set(b"Rect".to_vec(), pd.get(b"Rect").cloned().unwrap_or_else(|| num_array(&[0.0; 4]))); + d.set(b"IRT".to_vec(), Object::Ref(parent)); + d.set(b"F".to_vec(), Object::Int(FLAG_HIDDEN | FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + d.set(b"State".to_vec(), PdfString::text(state)); + d.set(b"StateModel".to_vec(), PdfString::text("Marked")); + let r = doc.add(Object::Dict(d)); + let mut list = annots(doc, p); + list.push(Object::Ref(r)); + let i = list.len() - 1; + set_annots(doc, p, list)?; + Ok(i) +} + /// Move a comment (and its pop-up) by `(dx, dy)` points. The appearance moves with `/Rect`. pub fn move_annotation(doc: &mut Document, page: usize, index: usize, dx: f64, dy: f64, meta: &Meta) -> Result<(), AnnotError> { if !finite(&[dx, dy]) { return Err(AnnotError::Invalid("invalid offset".into())); } let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; let shift = |o: &Object, every: bool| -> Option { let a = o.as_array()?; Some(Object::Array( @@ -957,6 +1005,7 @@ pub fn move_annotation(doc: &mut Document, page: usize, index: usize, dx: f64, d /// Resize a rectangle, oval or text box to `rect`; its appearance is redrawn. pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> { let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; let d = annot_dict(doc, r); let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); if !matches!(subtype.as_str(), "Square" | "Circle" | "FreeText") { @@ -985,6 +1034,7 @@ pub fn set_style( meta: &Meta, ) -> Result<(), AnnotError> { let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; let d = annot_dict(doc, r); let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); // Check before changing anything: a stale appearance would contradict the new style. @@ -1044,6 +1094,7 @@ pub struct Summary { pub color: Option<[f32; 3]>, pub state: Option, pub quads: Vec<[f32; 8]>, + pub locked: bool, } fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option { @@ -1105,6 +1156,7 @@ pub fn summaries(doc: &Document) -> Vec { color, state: text_value(doc, d, b"State"), quads, + locked: d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & FLAG_LOCKED != 0, }); } } @@ -1124,6 +1176,7 @@ pub fn set_info( meta: &Meta, ) -> Result<(), AnnotError> { let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; let is_note = annot_dict(doc, r).name(b"Subtype") == Some(b"Text"); if icon.is_some() && !is_note { return Err(AnnotError::Invalid("only sticky notes have an icon".into())); @@ -1162,6 +1215,8 @@ pub struct Props { pub modified: Option, /// The appearance can be redrawn (so colour, opacity and width can change). pub restylable: bool, + /// The Locked flag. + pub locked: bool, } /// The current properties of the comment at `(page, index)`. @@ -1184,6 +1239,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)), modified: text_value(doc, d, b"M"), restylable: appearance::build(d).is_some(), + locked: d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & FLAG_LOCKED != 0, subtype, }) } diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 50be4a1e..0e0d3aeb 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -3,7 +3,7 @@ //! Geometry follows the automation convention: points from the top-left of the displayed page, //! y down. It is converted to PDF user space (crop box, `/Rotate`) here. -use printcraft_engine::{Edit, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style}; +use printcraft_engine::{Edit, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort}; use printcraft_render::{Annotation, PageInfo}; use serde_json::{Value, json}; @@ -125,8 +125,10 @@ impl Automation { Some(nm) => all.iter().filter(|r| r.in_reply_to.as_deref() == Some(nm.as_str())).collect(), None => Vec::new(), }; - let status = thread.iter().rev().find_map(|r| r.state.clone()); + let status = thread.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()); v["status"] = json!(status); + v["marked"] = json!(thread.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked"))); + v["locked"] = json!(c.locked); v["replies"] = thread.iter().filter(|r| r.state.is_none()).map(|r| view(r)).collect(); v }) @@ -316,6 +318,37 @@ impl Automation { self.apply(a, Edit::SetAnnotationStatus { page, index, state, author }) } + pub(crate) fn comment_mark(&mut self, a: &Args) -> Result { + let (page, index) = self.comment_target(a)?; + let marked = a.opt_bool("marked")?.unwrap_or(true); + let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string(); + self.apply(a, Edit::MarkAnnotation { page, index, marked, author }) + } + + pub(crate) fn comment_lock(&mut self, a: &Args) -> Result { + let (page, index) = self.comment_target(a)?; + let locked = a.opt_bool("locked")?.unwrap_or(true); + self.apply(a, Edit::LockAnnotation { page, index, locked }) + } + + pub(crate) fn comments_hide(&mut self, a: &Args) -> Result { + let id = self.doc(a)?.id; + let hidden = a.opt_bool("hidden")?.unwrap_or(true); + self.session.set_hide_comments(id, hidden); + Ok(json!({ "hidden": hidden })) + } + + pub(crate) fn comments_summarize(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let (id, stem) = (doc.id, std::path::Path::new(&doc.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default()); + let sort = match a.opt_str("sort")? { + Some(s) => SummarySort::parse(s).ok_or_else(|| ToolError::InvalidArgs(format!("unknown sort {s:?}")))?, + None => SummarySort::Page, + }; + let bytes = self.session.summarize_comments(id, sort).map_err(failed)?; + self.deliver(a, &format!("Summary of comments on {stem}.pdf"), bytes) + } + pub(crate) fn comment_edit(&mut self, a: &Args) -> Result { let (page, index) = self.comment_target(a)?; let info = self.doc(a)?.info.pages[page].clone(); diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 7d370b3b..f7f46eb8 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -309,6 +309,10 @@ impl Automation { "comment_add" => self.comment_add(&a)?, "comment_reply" => self.comment_reply(&a)?, "comment_set_status" => self.comment_set_status(&a)?, + "comment_mark" => self.comment_mark(&a)?, + "comment_lock" => self.comment_lock(&a)?, + "comments_hide" => self.comments_hide(&a)?, + "comments_summarize" => self.comments_summarize(&a)?, "comment_edit" => self.comment_edit(&a)?, "comment_delete" => self.comment_delete(&a)?, other => return Err(ToolError::UnknownTool(other.into())), diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 033993c5..43c448d1 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -487,6 +487,23 @@ pub fn tools() -> Vec { comment_ref(json!({ "status": { "type": "string", "enum": ["none", "accepted", "rejected", "cancelled", "completed"] }, "author": { "type": "string" } })), &["doc", "status"], )), + t("comment_mark", "Mark a comment with a checkmark", "Mark or unmark a comment with a checkmark (Acrobat: Mark with checkmark), recorded as a private reply. Undoable.").with(schema( + comment_ref(json!({ "marked": { "type": "boolean", "description": "Default true." }, "author": { "type": "string" } })), + &["doc"], + )), + t("comment_lock", "Lock a comment", "Lock or unlock a comment (Properties ▸ Locked). Locked comments can't be moved, resized, restyled or deleted; their text stays editable. Undoable.").with(schema( + comment_ref(json!({ "locked": { "type": "boolean", "description": "Default true." } })), + &["doc"], + )), + t("comments_hide", "Hide all comments", "Hide or show every comment on the page (fields and links still draw). A view setting: the file doesn't change.") + .cmd("comment.hide_all") + .with(schema(json!({ "doc": doc(), "hidden": { "type": "boolean", "description": "Default true." } }), &["doc"])), + t("comments_summarize", "Summarize comments", "Make a PDF summarising every comment (number, author, type, date, text, replies), sorted by page, author, date or type.") + .cmd("comment.summarize") + .with(schema( + json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }), + &["doc"], + )), t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, rectangle (rectangle/oval/text box) or position (`move` [dx, dy] in points). One undo step.").with(schema( comment_ref(json!({ "contents": { "type": "string" }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index ea73077c..2aa85cfb 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -875,3 +875,34 @@ fn links_through_tools() { assert_eq!(ok(&mut a, "link_list", json!({ "doc": doc }))["count"], 0); assert!(matches!(a.call("link_add", &json!({ "doc": doc, "page": 1, "rect": [0, 0, 50, 20] })), Err(ToolError::InvalidArgs(_)))); } + +#[test] +fn comment_checkmarks_locks_hiding_and_summaries_through_tools() { + let dir = workdir("comment-polish"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let c = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "Sticky", "author": "Ada" })); + let id = c["comment"]["id"].as_str().unwrap().to_string(); + ok(&mut a, "comment_set_status", json!({ "doc": doc, "id": id, "status": "accepted" })); + ok(&mut a, "comment_mark", json!({ "doc": doc, "id": id })); + ok(&mut a, "comment_lock", json!({ "doc": doc, "id": id })); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["count"], 1, "{list}"); + let c = &list["comments"][0]; + assert_eq!((c["status"].as_str(), c["marked"].as_bool(), c["locked"].as_bool()), (Some("Accepted"), Some(true), Some(true))); + assert!(matches!(a.call("comment_delete", &json!({ "doc": doc, "id": id })), Err(ToolError::Failed(_))), "locked"); + ok(&mut a, "comment_mark", json!({ "doc": doc, "id": id, "marked": false })); + ok(&mut a, "comment_lock", json!({ "doc": doc, "id": id, "locked": false })); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!((list["comments"][0]["marked"].as_bool(), list["comments"][0]["locked"].as_bool()), (Some(false), Some(false))); + + assert_eq!(ok(&mut a, "comments_hide", json!({ "doc": doc }))["hidden"], true); + ok(&mut a, "comments_hide", json!({ "doc": doc, "hidden": false })); + + let r = ok(&mut a, "comments_summarize", json!({ "doc": doc, "sort": "author", "out": "summary.pdf" })); + assert!(r["bytes"].as_u64().unwrap() > 500); + let text = ok(&mut a, "doc_open", json!({ "path": "summary.pdf" }))["doc"].as_u64().unwrap(); + let found = ok(&mut a, "text_find", json!({ "doc": text, "query": "Sticky" })); + assert!(found["count"].as_u64().unwrap() >= 1, "{found}"); + assert!(matches!(a.call("comments_summarize", &json!({ "doc": doc, "sort": "colour" })), Err(ToolError::InvalidArgs(_)))); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 91a8ac9a..48ee6de0 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -181,6 +181,8 @@ pub const COMMANDS: &[CommandSpec] = &[ c("comment.import", "Import comments…", None, None, Modification, "file-input"), c("comment.stamp", "Add a stamp", None, None, Annotate, "stamp"), c("comment.export", "Export all comments to data file…", None, None, HasComments, "file-output"), + c("comment.hide_all", "Hide all comments", None, None, Document, "eye-off"), + c("comment.summarize", "Summarize comments", None, None, HasComments, "file-text"), c("form.import_data", "Import form data…", None, None, HasFields, "file-input"), c("form.export_data", "Export form data…", None, None, HasFields, "file-output"), c("form.flatten", "Flatten form fields", None, None, HasFields, "layers"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0c2d9d96..8bc927a6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -81,6 +81,8 @@ fn scope_of(edit: &Edit) -> Scope { | Edit::SetAnnotationContents { .. } | Edit::ReplyToAnnotation { .. } | Edit::SetAnnotationStatus { .. } + | Edit::MarkAnnotation { .. } + | Edit::LockAnnotation { .. } | Edit::MoveAnnotation { .. } | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } @@ -138,6 +140,11 @@ pub struct Document { } impl Document { + /// Whether comments are hidden (Comments ▸ Hide all comments). + pub fn comments_hidden(&self) -> bool { + self.config.hide_comments + } + pub fn can_undo(&self) -> Option<&str> { self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l.as_str()) } @@ -500,6 +507,19 @@ pub enum Edit { state: ReviewState, author: String, }, + /// Acrobat's "Mark with checkmark" (a hidden `/StateModel /Marked` reply by `author`). + MarkAnnotation { + page: usize, + index: usize, + marked: bool, + author: String, + }, + /// Properties ▸ Locked. + LockAnnotation { + page: usize, + index: usize, + locked: bool, + }, MoveAnnotation { page: usize, index: usize, @@ -694,6 +714,10 @@ impl Edit { Edit::SetAnnotationContents { .. } => "Edit comment".into(), Edit::ReplyToAnnotation { .. } => "Reply".into(), Edit::SetAnnotationStatus { state, .. } => format!("Set status {}", state.name()), + Edit::MarkAnnotation { marked: true, .. } => "Mark with checkmark".into(), + Edit::MarkAnnotation { .. } => "Remove checkmark".into(), + Edit::LockAnnotation { locked: true, .. } => "Lock comment".into(), + Edit::LockAnnotation { .. } => "Unlock comment".into(), Edit::MoveAnnotation { .. } => "Move comment".into(), Edit::ResizeAnnotation { .. } => "Resize comment".into(), Edit::StyleAnnotation { .. } | Edit::SetAnnotationInfo { .. } => "Change comment properties".into(), @@ -798,6 +822,8 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), | Edit::SetAnnotationContents { .. } | Edit::ReplyToAnnotation { .. } | Edit::SetAnnotationStatus { .. } + | Edit::MarkAnnotation { .. } + | Edit::LockAnnotation { .. } | Edit::MoveAnnotation { .. } | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } @@ -962,6 +988,10 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - Edit::SetAnnotationStatus { page, index, state, author } => { printcraft_annot::set_review_state(doc, *page, *index, *state, author, &cx.meta())?; } + Edit::MarkAnnotation { page, index, marked, author } => { + printcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?; + } + Edit::LockAnnotation { page, index, locked } => printcraft_annot::set_locked(doc, *page, *index, *locked)?, Edit::MoveAnnotation { page, index, dx, dy } => printcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?, Edit::ResizeAnnotation { page, index, rect } => printcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?, Edit::StyleAnnotation { page, index, color, opacity, width } => { @@ -1105,6 +1135,7 @@ fn comment_list(doc: &printcraft_cos::Document) -> Vec bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if doc.config.hide_comments == hide { + return false; + } + doc.config.hide_comments = hide; + doc.renderer = RenderPool::new(doc.bytes.clone(), render_threads(), doc.config.clone()); + doc.generation += 1; + true + } + + /// Acrobat's Summarize Comments ("Comments only" layout): a new PDF listing every comment + /// with its number, author, type, date, text and replies, grouped by page. + pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result>, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let text = comment_summary(&doc.name, &doc.info.annotations, sort); + self.create_from_text(&format!("Summary of Comments on {}", doc.name), &text) + } + pub fn close(&mut self, id: DocId) { self.docs.retain(|d| d.id != id); } @@ -1757,6 +1809,111 @@ impl Session { } } +/// Order of Summarize Comments. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SummarySort { + #[default] + Page, + Author, + Date, + Type, +} + +impl SummarySort { + pub const ALL: [SummarySort; 4] = [SummarySort::Page, SummarySort::Author, SummarySort::Date, SummarySort::Type]; + pub fn name(self) -> &'static str { + match self { + SummarySort::Page => "Page", + SummarySort::Author => "Author", + SummarySort::Date => "Date", + SummarySort::Type => "Type", + } + } + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|k| k.name().eq_ignore_ascii_case(s)) + } +} + +/// Acrobat's name for a comment type, as comment lists show it. +pub fn comment_type_name(subtype: &str) -> &str { + match subtype { + "Text" => "Sticky Note", + "FreeText" => "Text Box", + "Highlight" => "Highlight", + "Underline" => "Underline", + "StrikeOut" => "Strikethrough", + "Squiggly" => "Squiggly", + "Square" => "Rectangle", + "Circle" => "Oval", + "Line" => "Line", + "Ink" => "Pencil", + "Polygon" => "Polygon", + "PolyLine" => "Polygonal Line", + "Stamp" => "Stamp", + "Caret" => "Inserted Text", + "FileAttachment" => "File Attachment", + "Sound" => "Sound", + "Redact" => "Redaction", + other => other, + } +} + +/// The text of a comment summary: one block per comment (replies indented under it), with a +/// "Page N" heading when sorted by page. Checkmarks and status replies are left out, as are +/// pop-ups; the number is the comment's position on its page. +pub fn comment_summary(name: &str, all: &[printcraft_render::Annotation], sort: SummarySort) -> String { + use std::fmt::Write; + let top: Vec<&printcraft_render::Annotation> = all.iter().filter(|a| a.in_reply_to.is_none() && a.state.is_none()).collect(); + let replies_of = |a: &printcraft_render::Annotation| -> Vec<&printcraft_render::Annotation> { + all.iter().filter(|r| r.state.is_none() && r.in_reply_to.is_some() && r.in_reply_to == a.name && a.name.is_some()).collect() + }; + let mut numbered: Vec<(usize, &printcraft_render::Annotation)> = Vec::new(); + let mut last = usize::MAX; + let mut n = 0; + for a in &top { + if a.page != last { + last = a.page; + n = 0; + } + n += 1; + numbered.push((n, a)); + } + match sort { + SummarySort::Page => {} + SummarySort::Author => numbered.sort_by(|x, y| x.1.author.cmp(&y.1.author)), + SummarySort::Date => numbered.sort_by(|x, y| x.1.modified.cmp(&y.1.modified)), + SummarySort::Type => numbered.sort_by(|x, y| comment_type_name(&x.1.subtype).cmp(comment_type_name(&y.1.subtype))), + } + let mut out = format!("Summary of Comments on {name}\n\n"); + if numbered.is_empty() { + out.push_str("This document has no comments.\n"); + return out; + } + let mut heading = usize::MAX; + for (n, a) in numbered { + if sort == SummarySort::Page && a.page != heading { + heading = a.page; + let _ = writeln!(out, "Page: {}", a.page + 1); + } + let _ = write!(out, "Number: {n} Author: {} Subject: {}", a.author.as_deref().unwrap_or(""), comment_type_name(&a.subtype)); + if sort != SummarySort::Page { + let _ = write!(out, " Page: {}", a.page + 1); + } + let _ = writeln!(out, " Date: {}", a.modified.as_deref().unwrap_or("")); + if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) { + let _ = writeln!(out, "{c}"); + } + for r in replies_of(a) { + let _ = writeln!(out, " Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or("")); + for line in r.contents.as_deref().unwrap_or("").lines() { + let _ = writeln!(out, " {line}"); + } + } + out.push('\n'); + } + out +} + #[cfg(test)] mod tests; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index bf97a9ea..64917ad8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -387,7 +387,7 @@ fn number_pages_shows_in_the_viewer_and_undoes() { /// RGBA of the pixel at PDF point (x, y) on `page`, rendered at 1 px/pt (page height 300). fn pixel(s: &Session, id: DocId, page: usize, x: u32, y: u32) -> [u8; 4] { let doc = s.get(id).unwrap(); - let mut r = printcraft_render::PageRenderer::new(doc.bytes.clone(), printcraft_render::RenderConfig::default()); + let mut r = printcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); let out = r.render(printcraft_render::RenderRequest { page, scale: 1.0, ..Default::default() }); assert!(out.error.is_none(), "{:?}", out.error); let i = (((300 - y) * out.width + x) * 4) as usize; @@ -639,6 +639,8 @@ fn comment_edits_refresh_the_list_exactly_as_a_full_inspection_would() { s.apply(id, add(2, Shape::Note { at: [100.0, 280.0], icon: NoteIcon::Comment }, "Sticky")).unwrap(); s.apply(id, Edit::ReplyToAnnotation { page: 2, index: 0, text: "Reply".into(), author: "Bob".into() }).unwrap(); s.apply(id, Edit::SetAnnotationStatus { page: 2, index: 0, state: ReviewState::Accepted, author: "Bob".into() }).unwrap(); + s.apply(id, Edit::MarkAnnotation { page: 2, index: 0, marked: true, author: "Bob".into() }).unwrap(); + s.apply(id, Edit::LockAnnotation { page: 1, index: 1, locked: true }).unwrap(); s.apply(id, Edit::MoveAnnotation { page: 0, index: 0, dx: 5.0, dy: 5.0 }).unwrap(); s.apply(id, Edit::DeleteAnnotation { page: 1, index: 0 }).unwrap(); s.undo(id).unwrap(); @@ -646,7 +648,8 @@ fn comment_edits_refresh_the_list_exactly_as_a_full_inspection_would() { let full = printcraft_render::inspect(d.bytes.clone(), None).unwrap(); assert_eq!(format!("{:?}", d.info.annotations), format!("{:?}", full.annotations)); assert_eq!(d.info.file_size, full.file_size); - assert_eq!(d.info.annotations.len(), 6); + assert_eq!(d.info.annotations.len(), 7); + assert!(d.info.annotations.iter().any(|a| a.locked)); } #[test] @@ -1003,3 +1006,42 @@ fn links_from_urls_and_link_edits() { assert!(s.get(id).unwrap().links.is_empty()); assert!(s.apply(id, Edit::RemoveLinks { pages: None }).is_err(), "nothing left to remove"); } + +#[test] +fn comments_lock_take_checkmarks_hide_and_summarize() { + let (mut s, id) = session_with(2); + s.apply(id, rect_comment(1, [80.0, 80.0, 120.0, 120.0])).unwrap(); + s.apply(id, Edit::ReplyToAnnotation { page: 1, index: 0, text: "Agreed".into(), author: "Ada".into() }).unwrap(); + s.apply(id, Edit::LockAnnotation { page: 1, index: 0, locked: true }).unwrap(); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Lock comment")); + assert!(s.get(id).unwrap().info.annotations[0].locked); + assert!(s.apply(id, Edit::MoveAnnotation { page: 1, index: 0, dx: 5.0, dy: 0.0 }).is_err(), "locked comments stay put"); + assert!(s.apply(id, Edit::DeleteAnnotation { page: 1, index: 0 }).is_err(), "and can't be deleted"); + s.apply(id, Edit::SetAnnotationContents { page: 1, index: 0, text: "Still editable".into() }).unwrap(); + s.apply(id, Edit::LockAnnotation { page: 1, index: 0, locked: false }).unwrap(); + s.apply(id, Edit::MoveAnnotation { page: 1, index: 0, dx: 5.0, dy: 0.0 }).unwrap(); + + s.apply(id, Edit::MarkAnnotation { page: 1, index: 0, marked: true, author: "Ada".into() }).unwrap(); + let a = &s.get(id).unwrap().info.annotations; + let mark = a.iter().find(|r| r.is_mark()).expect("a checkmark reply"); + assert_eq!(mark.state.as_deref(), Some("Marked")); + + // Hiding comments drops the rectangle but keeps the page. + let px = pixel(&s, id, 1, 105, 100); + assert!(px[0] > 200 && px[1] < 40, "drawn: {px:?}"); + assert!(s.set_hide_comments(id, true)); + assert!(!s.set_hide_comments(id, true)); + assert_eq!(pixel(&s, id, 1, 105, 100), [255, 255, 255, 255]); + assert!(s.set_hide_comments(id, false)); + assert!(pixel(&s, id, 1, 105, 100)[1] < 40); + + let text = comment_summary("doc.pdf", &s.get(id).unwrap().info.annotations, SummarySort::Page); + assert!(text.starts_with("Summary of Comments on doc.pdf")); + assert!(text.contains("Page: 2\nNumber: 1 Author: Reviewer Subject: Rectangle"), "{text}"); + assert!(text.contains("Still editable") && text.contains(" Agreed"), "{text}"); + assert!(!text.contains("Marked"), "checkmarks aren't listed: {text}"); + let pdf = s.summarize_comments(id, SummarySort::Author).unwrap(); + let n = s.open_new("summary.pdf", pdf).unwrap(); + assert!(!s.get(n).unwrap().info.pages.is_empty()); + assert_eq!(comment_summary("x", &[], SummarySort::Page), "Summary of Comments on x\n\nThis document has no comments.\n"); +} diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 979ca91c..9ca5d59d 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -136,6 +136,15 @@ pub struct Annotation { pub state: Option, /// Text markup quadrilaterals (`/QuadPoints`), 8 numbers each. pub quads: Vec<[f32; 8]>, + /// The Locked flag (`/F` bit 8): the comment can't be moved, resized, restyled or deleted. + pub locked: bool, +} + +impl Annotation { + /// A checkmark reply (`/StateModel /Marked`): "Marked" or "Unmarked" rather than a review status. + pub fn is_mark(&self) -> bool { + matches!(self.state.as_deref(), Some("Marked" | "Unmarked")) + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -593,6 +602,7 @@ impl<'a> Inspector<'a> { index, state: self.text(d, b"State"), quads, + locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0, }); } } diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 8586f709..8fab25fd 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -44,11 +44,13 @@ pub struct RenderConfig { pub password: Option>, /// Layer (optional content group) visibility overrides: (object number, generation, visible). pub layers: Arc>, + /// View ▸ Hide all comments: markup annotations aren't drawn (fields and links still are). + pub hide_comments: bool, } impl RenderConfig { fn settings(&self) -> InterpreterSettings { - InterpreterSettings { ocg_overrides: self.layers.clone(), ..InterpreterSettings::default() } + InterpreterSettings { ocg_overrides: self.layers.clone(), hide_comments: self.hide_comments, ..InterpreterSettings::default() } } } @@ -588,6 +590,34 @@ trailer << /Root 1 0 R >> assert_eq!(px(75, 25), vec![255, 255, 255, 255], "NoView annotation must not be drawn"); } + #[test] + fn hiding_comments_keeps_fields() { + 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 100 100] /Annots [4 0 R 6 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (t) /Rect [10 10 30 30] /AP << /N 5 0 R >> >> endobj +5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 24 >> stream +1 0 0 rg 0 0 20 20 re f +endstream endobj +6 0 obj << /Type /Annot /Subtype /Square /F 4 /Rect [60 60 90 90] /AP << /N 7 0 R >> >> endobj +7 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 30 30] /Length 24 >> stream +0 1 0 rg 0 0 30 30 re f +endstream endobj +trailer << /Root 1 0 R >> +%%EOF"; + let render = |hide: bool| { + let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig { hide_comments: hide, ..RenderConfig::default() }); + r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }) + }; + let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); + let shown = render(false); + assert_eq!(px(&shown, 75, 25), vec![0, 255, 0, 255], "the comment is drawn"); + let hidden = render(true); + assert_eq!(px(&hidden, 75, 25), vec![255, 255, 255, 255], "Hide all comments"); + assert_eq!(px(&hidden, 20, 80), vec![255, 0, 0, 255], "fields stay"); + } + #[test] fn inline_pool_renders_in_priority_order() { let pool = RenderPool::new_inline(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default()); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3f8618bc..c27d6449 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -897,6 +897,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { }; let prefs = &app.comment_prefs; let allowed = doc.allows_annotation(); + let comments_hidden = doc.comments_hidden(); let form = doc.form.clone(); let can_fill = doc.allows_form_filling(); let can_crop = doc.allows_assembly(); @@ -1043,7 +1044,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside); // Comments: tools, selection, moving and resizing come before text selection. - let pcx = comments::PageCx { page: i, xf: &xf, info, tool, prefs, allowed }; + let pcx = comments::PageCx { page: i, xf: &xf, info, tool, prefs, allowed, hidden: comments_hidden }; // Form fields take clicks first with the Select tool (as Acrobat fills fields in // every viewing mode); then comments; then text selection. if let QuickTool::Stamp(kind) = tool diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 408ff026..036f9d5d 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -291,6 +291,16 @@ impl PrintCraftApp { self.left_open = true; } "comment.export" => self.export_data_dialog(true, false), + "comment.summarize" => self.dialog = Some(Dialog::SummarizeComments), + "comment.hide_all" => { + if let Some(i) = active { + let id = self.views[i].id; + let hide = !self.session.get(id).is_some_and(|d| d.comments_hidden()); + if self.session.set_hide_comments(id, hide) { + self.views[i].invalidate_content(); + } + } + } "form.export_data" => self.export_data_dialog(false, true), "form.prepare" => { self.left = crate::LeftPanel::Tool("form"); diff --git a/crates/ui-egui/src/comment_props.rs b/crates/ui-egui/src/comment_props.rs index edbf84c0..d7f0b259 100644 --- a/crates/ui-egui/src/comment_props.rs +++ b/crates/ui-egui/src/comment_props.rs @@ -1,6 +1,6 @@ //! Comment Properties (Acrobat: right-click a comment ▸ Properties…; audit "Comment //! properties"): Appearance (colour, opacity, line thickness, note icon), General (author, -//! subject, modified) and Review History (status changes). +//! subject, modified) and Review History (status changes), with Acrobat's Locked box. use egui::{Align, Layout}; use printcraft_engine::{CommentProps, Edit, NoteIcon}; @@ -42,6 +42,10 @@ impl PrintCraftApp { pub fn edits(d: &PropsDraft) -> Vec { let (o, e) = (&d.original, &d.edited); let mut out = Vec::new(); + // A locked comment refuses other changes: unlock first, lock last. + if o.locked && !e.locked { + out.push(Edit::LockAnnotation { page: d.page, index: d.index, locked: false }); + } let color = (e.color != o.color).then_some(e.color).flatten(); let opacity = ((e.opacity - o.opacity).abs() > 1e-6).then_some(e.opacity); let width = (e.width != o.width).then_some(e.width).flatten(); @@ -54,6 +58,9 @@ pub fn edits(d: &PropsDraft) -> Vec { if author.is_some() || subject.is_some() || icon.is_some() { out.push(Edit::SetAnnotationInfo { page: d.page, index: d.index, author, subject, icon }); } + if !o.locked && e.locked { + out.push(Edit::LockAnnotation { page: d.page, index: d.index, locked: true }); + } out } @@ -146,6 +153,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> (b } } ui.add_space(12.0); + ui.checkbox(&mut d.edited.locked, "Locked"); let (mut apply, mut cancel) = (false, false); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if widgets::pill_button(ui, "OK", true).clicked() { diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 2c74e71e..45342497 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -302,6 +302,8 @@ pub(crate) struct PageCx<'a> { pub prefs: &'a CommentPrefs, /// The document allows commenting. pub allowed: bool, + /// Comments are hidden (Hide all comments): nothing to select. + pub hidden: bool, } impl PageCx<'_> { @@ -322,7 +324,7 @@ impl PageCx<'_> { /// Top-level comments on this page, in paint order. fn comments(&self) -> impl Iterator { - self.info.annotations.iter().filter(move |a| a.page == self.page && a.in_reply_to.is_none() && a.subtype != "Popup") + self.info.annotations.iter().filter(move |a| !self.hidden && a.page == self.page && a.in_reply_to.is_none() && a.subtype != "Popup") } /// Where a comment is on screen: one rectangle, or one per marked line for text markup. @@ -702,7 +704,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, // Scrolled away: keep the draft until the page is back. return None; }; - let cx = PageCx { page, xf: &xf, info, tool: QuickTool::Select, prefs, allowed: true }; + let cx = PageCx { page, xf: &xf, info, tool: QuickTool::Select, prefs, allowed: true, hidden: false }; let anchor = cx.to_screen(c.at); let t = Tokens::get(ctx); let mut post = false; @@ -846,8 +848,19 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo } }); }); + let thread: Vec<&printcraft_render::Annotation> = + info.annotations.iter().filter(|r| a.name.is_some() && r.in_reply_to == a.name).collect(); + let marked = crate::comments_panel::is_marked(&thread); + if ui.add_enabled(allowed, egui::Button::new(if marked { "Remove checkmark" } else { "Mark with checkmark" })).clicked() { + action = Some(CanvasAction::Edit(Box::new(Edit::MarkAnnotation { page, index, marked: !marked, author: prefs.author.clone() }))); + ui.close(); + } + if ui.button("Copy text").clicked() { + ui.ctx().copy_text(a.contents.clone().unwrap_or_default()); + ui.close(); + } ui.separator(); - if ui.add_enabled(allowed, egui::Button::new("Delete")).clicked() { + if ui.add_enabled(allowed && !a.locked, egui::Button::new("Delete")).clicked() { view.comments.selected = None; action = Some(CanvasAction::Edit(Box::new(Edit::DeleteAnnotation { page, index }))); ui.close(); diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index 6f97157b..b494f52c 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -59,6 +59,11 @@ fn initials(name: &str) -> String { if s.is_empty() { "?".into() } else { s.to_uppercase() } } +/// The latest checkmark reply in a thread says "Marked". +pub fn is_marked(thread: &[&Annotation]) -> bool { + thread.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked")) +} + /// The number shown in the header: comments and their replies (not status changes). pub fn count(info: &DocInfo) -> usize { info.annotations.iter().filter(|a| a.state.is_none()).count() @@ -134,7 +139,8 @@ pub(crate) fn show( } }; let cv = &view.comments; - let status_of = |a: &Annotation| replies_of(a).iter().rev().find_map(|r| r.state.clone()).unwrap_or_else(|| "None".into()); + let status_of = + |a: &Annotation| replies_of(a).iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()).unwrap_or_else(|| "None".into()); let mut roots: Vec<&Annotation> = info .annotations .iter() @@ -212,28 +218,46 @@ fn card( let key = (a.page, a.index); let selected = view.comments.selected == Some(key); let color = a.color.map(|c| color32(c.map(f64::from))).unwrap_or(t.accent); - let status = thread.iter().rev().find_map(|r| r.state.as_deref()); + let status = thread.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.as_deref()); + let marked = is_marked(thread); let replies: Vec<&&Annotation> = thread.iter().filter(|r| r.state.is_none()).collect(); let editing = view.comments.editing.as_ref().is_some_and(|(p, i, _)| (*p, *i) == key); let fill = if selected { t.hover.gamma_multiply(0.7) } else { Color32::TRANSPARENT }; let mut open_menu = false; + let mut more_rect = None; let frame = egui::Frame::NONE .fill(fill) .corner_radius(CornerRadius::same(6)) .inner_margin(egui::Margin { left: 10, right: 8, top: 10, bottom: 10 }) .show(ui, |ui| { ui.set_width(ui.available_width()); + let mut buttons_left = f32::INFINITY; let head = ui.horizontal(|ui| { avatar(ui, t, Some(subtype_icon(&a.subtype)), None, color); ui.label(egui::RichText::new(a.author.as_deref().unwrap_or("Unknown author")).font(theme::semibold(12.5)).color(t.text)); if let Some(m) = &a.modified { ui.add(egui::Label::new(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint)).truncate()); } - if selected && allowed { - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - open_menu = icons::button(ui, "ellipsis", 22.0, false, "More").clicked(); - }); - } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if selected && allowed { + let more = icons::button(ui, "ellipsis", 22.0, false, "More"); + open_menu = more.clicked(); + more_rect = Some(more.rect); + } + // Acrobat's per-comment checkmark: a toggle on the selected card, a mark otherwise. + let tip = if marked { "Remove checkmark" } else { "Mark with checkmark" }; + if selected && allowed { + if icons::button(ui, "circle-check", 22.0, marked, tip).clicked() { + edit = Some(Edit::MarkAnnotation { page: a.page, index: a.index, marked: !marked, author: prefs.author.clone() }); + } + } else if marked { + ui.add(icons::image("circle-check", 14.0, t.accent)).on_hover_text("Marked"); + } + if a.locked { + ui.add(icons::image("lock", 13.0, t.text_faint)).on_hover_text("Locked"); + } + buttons_left = ui.min_rect().left(); + }); }); egui::Frame::NONE.inner_margin(egui::Margin { left: 34, right: 0, top: 4, bottom: 0 }).show(ui, |ui| { ui.set_width(ui.available_width()); @@ -314,7 +338,10 @@ fn card( }); }); } - head.response.rect + // The header selects the card, except where its buttons are. + let mut r = head.response.rect; + r.max.x = r.max.x.min(buttons_left - 2.0); + r }); let rect = frame.response.rect; if selected { @@ -345,7 +372,9 @@ fn card( if open_menu { egui::Popup::open_id(ui.ctx(), menu_id); } - egui::Popup::new(menu_id, ui.ctx().clone(), egui::PopupAnchor::Pointer, ui.layer_id()) + // Hang the menu from the "…" button (a pointer anchor would follow the mouse to the items). + let anchor = more_rect.map_or(egui::PopupAnchor::PointerFixed, egui::PopupAnchor::ParentRect); + egui::Popup::new(menu_id, ui.ctx().clone(), anchor, ui.layer_id()) .open_memory(None) .kind(egui::PopupKind::Menu) .layout(Layout::top_down_justified(Align::Min)) @@ -363,12 +392,20 @@ fn card( } } }); + if ui.button(if marked { "Remove checkmark" } else { "Mark with checkmark" }).clicked() { + edit = Some(Edit::MarkAnnotation { page: a.page, index: a.index, marked: !marked, author: prefs.author.clone() }); + ui.close(); + } + if ui.button("Copy text").clicked() { + ui.ctx().copy_text(a.contents.clone().unwrap_or_default()); + ui.close(); + } if ui.button("Properties…").clicked() { view.comments.props_request = Some((a.page, a.index)); ui.close(); } ui.separator(); - if ui.button("Delete").clicked() { + if ui.add_enabled(!a.locked, egui::Button::new("Delete")).clicked() { view.comments.selected = None; edit = Some(Edit::DeleteAnnotation { page: a.page, index: a.index }); ui.close(); @@ -401,7 +438,10 @@ fn legible(c: Color32, t: &Tokens) -> Color32 { } /// The Comments panel header's filter and sort controls (Acrobat: filter funnel and "…"). -pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocView) { +/// The header's "…" (sort, hide all, summary, data exchange) and filter menus; returns a +/// command to run. +pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocView, hidden: bool) -> Option<&'static str> { + let mut command = None; let cv = &mut view.comments; let mut types: Vec = info.annotations.iter().filter(|a| a.in_reply_to.is_none()).map(|a| subtype_label(&a.subtype).to_string()).collect(); types.sort(); @@ -411,8 +451,9 @@ pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocV authors.sort(); authors.dedup(); let active = !(cv.hidden_types.is_empty() && cv.hidden_authors.is_empty() && cv.hidden_statuses.is_empty()); - let more = icons::button(ui, "ellipsis", 26.0, false, "Sort comments"); + let more = icons::button(ui, "ellipsis", 26.0, false, "More options"); egui::Popup::menu(&more).show(|ui| { + ui.set_min_width(220.0); ui.label(egui::RichText::new("Sort by").small()); for (s, label) in [(SortBy::Page, "Page"), (SortBy::Author, "Author"), (SortBy::Date, "Date"), (SortBy::Type, "Type"), (SortBy::Color, "Colour")] @@ -422,6 +463,18 @@ pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocV ui.close(); } } + ui.separator(); + for (id, label) in [ + ("comment.hide_all", if hidden { "Show all comments" } else { "Hide all comments" }), + ("comment.summarize", "Create PDF comment summary…"), + ("comment.export", "Export all to data file…"), + ("comment.import", "Import comments…"), + ] { + if ui.button(label).clicked() { + command = Some(id); + ui.close(); + } + } }); let filter = icons::button(ui, "filter", 26.0, active, "Filter comments"); egui::Popup::menu(&filter).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { @@ -456,4 +509,5 @@ pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocV cv.hidden_statuses.clear(); } }); + command } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b2fe8fbf..4251f16e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -42,6 +42,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { let mut redact_now: Option = None; let mut print_go = false; let mut revert_now = false; + let mut summarize_now = false; let mut replace_now = false; let t = Tokens::get(ctx); let mut close = false; @@ -448,6 +449,32 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { }); return; } + Dialog::SummarizeComments => { + ui.label(egui::RichText::new("Summarize Options").font(theme::semibold(18.0))); + ui.add_space(8.0); + ui.label("Choose a layout:"); + let _ = ui.radio(true, "Comments only"); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label("Sort comments by:"); + egui::ComboBox::from_id_salt("summary-sort").selected_text(app.summary_sort.name()).show_ui(ui, |ui| { + for s in printcraft_engine::SummarySort::ALL { + ui.selectable_value(&mut app.summary_sort, s, s.name()); + } + }); + }); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, "Create PDF Comment Summary", true).clicked() { + summarize_now = true; + close = true; + } + if widgets::pill_button(ui, "Cancel", false).clicked() { + close = true; + } + }); + return; + } Dialog::Revert => { let name = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.name.clone()).unwrap_or_default(); ui.label(egui::RichText::new("Revert").font(theme::semibold(18.0))); @@ -760,6 +787,9 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { if revert_now { app.revert_active(); } + if summarize_now { + app.summarize_comments(); + } if extract_now { app.extract_selection(); } diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index d08649b1..bf450ceb 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -341,6 +341,17 @@ impl PrintCraftApp { self.write_files(&named, "Choose a folder for the split files") } + /// Summarize Comments: make the summary and open it as a new document. + pub fn summarize_comments(&mut self) { + let Some((_, id)) = self.active_ids() else { return }; + let stem = + self.session.get(id).map(|d| std::path::Path::new(&d.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default()); + match self.session.summarize_comments(id, self.summary_sort) { + Ok(bytes) => self.open_created(&format!("Summary of comments on {}.pdf", stem.unwrap_or_default()), bytes, "Created a comment summary"), + Err(e) => self.notify(format!("Couldn't summarize comments: {e}")), + } + } + fn open_created(&mut self, name: &str, bytes: Arc>, message: &str) { match self.session.open_new(name, bytes) { Ok(id) => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1afdddce..6c9cab32 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -146,6 +146,8 @@ pub enum Dialog { Sanitize, /// Documents from a session that ended unexpectedly. Recovery, + /// Comments ▸ Summarize comments (options). + SummarizeComments, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -216,6 +218,8 @@ pub struct PrintCraftApp { pub split_draft: SplitDraft, pub extract_draft: ExtractDraft, pub rotate_draft: RotateDraft, + /// Summarize Comments: sort order. + pub summary_sort: printcraft_engine::SummarySort, /// Where autosaves go (`None`: autosave off, e.g. on the web and in tests). pub recovery: Option, /// Entries left by a previous session, offered in the Recovery dialog. @@ -314,6 +318,7 @@ impl PrintCraftApp { split_draft: SplitDraft::default(), extract_draft: ExtractDraft::default(), rotate_draft: RotateDraft::default(), + summary_sort: Default::default(), recovery: None, recoverable: Vec::new(), recovery_keys: Default::default(), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 214e4286..8cfe430c 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -324,6 +324,7 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { let mut attachment_action: Option<(usize, bool)> = None; // (index, open instead of save) let mut bm_action: Option = None; let mut panel_edit: Option = None; + let mut panel_command: Option<&'static str> = None; let mut bm_rename = app.bookmark_rename.clone(); let bm_editable = app.session.get(id).is_some_and(|d| d.allows_assembly() && d.read_only_reason.is_none()); { @@ -362,7 +363,7 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { } // Right to left: close, "…", filter, search (Acrobat's order left to right). if panel == RightPanel::Comments { - crate::comments_panel::header_controls(ui, info, view); + panel_command = crate::comments_panel::header_controls(ui, info, view, doc.comments_hidden()); } if panel == RightPanel::Comments && icons::button(ui, "search", 26.0, view.comments.search.is_some(), "Search comments").clicked() @@ -472,6 +473,9 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { if let Some(e) = panel_edit { app.apply_edit(e); } + if let Some(c) = panel_command { + app.run_command(c); + } if let Some((p, i)) = app.views.get_mut(index).and_then(|v| v.comments.props_request.take()) { app.open_comment_props(p, i); } diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index 26de39a7..240d0642 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -70,7 +70,7 @@ fn every_registered_command_is_implemented() { app.apply_edit(Edit::AddWatermark { pages: vec![0], settings: Watermark { text: "x".into(), ..Watermark::default() }, replace: false }); app.apply_edit(Edit::AddBackground { pages: vec![0], settings: Background { color: [1.0; 3], opacity: 1.0 }, replace: false }); } - if spec.id == "comment.flatten" || spec.id == "comment.export" { + if matches!(spec.id, "comment.flatten" | "comment.export" | "comment.summarize") { use printcraft_engine::{Edit, NewAnnotation, Shape, Style}; let shape = Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] }; app.apply_edit(Edit::AddAnnotation(NewAnnotation { diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index fed42cd4..1e9cbcef 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -265,3 +265,72 @@ fn the_panel_filters_and_sorts() { // Grouped by type: a group header names each type. assert_eq!(h.query_all_by_label("Oval").count(), ovals + 1, "a group header was added"); } + +#[test] +fn comments_take_checkmarks_lock_hide_and_summarize() { + let mut h = harness(|app| { + app.set_option("panel", "comments").unwrap(); + app.set_option("quick", "square").unwrap(); + }); + drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0)); + h.state_mut().set_option("quick", "select").unwrap(); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the new comment is selected"); + // The card's checkmark. + h.get_by_label("Mark with checkmark").click(); + h.run_steps(3); + assert!(comments(&h).iter().any(|a| a.state.as_deref() == Some("Marked"))); + h.get_by_label("Remove checkmark"); + + // "…" ▸ Copy text puts the comment's text on the clipboard. + h.state_mut().apply_edit(printcraft_engine::Edit::SetAnnotationContents { page: 0, index: 0, text: "Copy me".into() }); + h.run_steps(2); + h.get_by_label("More").click(); + h.run_steps(2); + h.get_by_label("Copy text").click(); + let mut copied = false; + for _ in 0..4 { + h.step(); + copied |= h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(t) if t == "Copy me")); + } + assert!(copied, "the text is copied"); + h.run_steps(2); + + // Properties ▸ Locked. + h.state_mut().open_comment_props(0, 0); + h.run_steps(2); + h.get_by_label("Locked").click(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(comments(&h)[0].locked); + h.key_press(egui::Key::Delete); + h.run_steps(3); + assert!(comments(&h).iter().any(|a| a.in_reply_to.is_none()), "a locked comment isn't deleted"); + + // "…" ▸ Hide all comments, then Show all comments. + h.get_by_label("More options").click(); + h.run_steps(2); + h.get_by_label("Hide all comments").click(); + h.run_steps(2); + let s = h.state(); + assert!(s.session.get(s.views[0].id).unwrap().comments_hidden()); + h.get_by_label("More options").click(); + h.run_steps(2); + h.get_by_label("Show all comments").click(); + h.run_steps(2); + let s = h.state(); + assert!(!s.session.get(s.views[0].id).unwrap().comments_hidden()); + + // "…" ▸ Create PDF comment summary. + h.get_by_label("More options").click(); + h.run_steps(2); + h.get_by_label("Create PDF comment summary…").click(); + h.run_steps(2); + h.get_by_label("Summarize Options"); + h.get_by_label("Create PDF Comment Summary").click(); + h.run_steps(3); + let s = h.state(); + assert_eq!(s.views.len(), 2); + assert!(s.session.get(s.views[1].id).unwrap().name.starts_with("Summary of comments on text")); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3e94da52..d4551581 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3767,11 +3767,11 @@ title = "Lock comments" area = "E" tier = "P1" milestone = "M5" -status = "planned" +status = "shipped" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["comment_lock"] +evidence = ["crates/engine/src/tests.rs::comments_lock_take_checkmarks_hide_and_summarize", "crates/ui-egui/tests/comments.rs::comments_take_checkmarks_lock_hide_and_summarize", "crates/automation/tests/automation.rs::comment_checkmarks_locks_hiding_and_summaries_through_tools"] +notes = "Comment Properties ▸ Locked; locked comments refuse move, resize, restyle and delete; text stays editable" [[feature]] id = "comment.default-properties" @@ -3827,11 +3827,11 @@ title = "Mark comments with a checkmark" area = "E" tier = "P1" milestone = "M5" -status = "planned" +status = "shipped" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["comment_mark"] +evidence = ["crates/engine/src/tests.rs::comments_lock_take_checkmarks_hide_and_summarize", "crates/ui-egui/tests/comments.rs::comments_take_checkmarks_lock_hide_and_summarize", "crates/automation/tests/automation.rs::comment_checkmarks_locks_hiding_and_summaries_through_tools"] +notes = "hidden /StateModel /Marked reply; card toggle and context menu" [[feature]] id = "comment.delete-comment" @@ -3863,11 +3863,11 @@ title = "Copy comment text" area = "E" tier = "P1" milestone = "M5" -status = "planned" +status = "shipped" commands = [] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/comments.rs::comments_take_checkmarks_lock_hide_and_summarize"] +notes = "card and canvas context menus" [[feature]] id = "comment.filter-comments" @@ -3911,11 +3911,11 @@ title = "Show or hide all comments and pop-ups" area = "E" tier = "P1" milestone = "M5" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["comment.hide_all"] +tools = ["comments_hide"] +evidence = ["crates/engine/src/tests.rs::comments_lock_take_checkmarks_hide_and_summarize", "crates/ui-egui/tests/comments.rs::comments_take_checkmarks_lock_hide_and_summarize", "crates/automation/tests/automation.rs::comment_checkmarks_locks_hiding_and_summaries_through_tools"] +notes = "hide/show all comments (fields and links still draw); per-type hiding not yet" [[feature]] id = "comment.popups" @@ -4079,11 +4079,11 @@ title = "Summarize comments into a new PDF" area = "E" tier = "P1" milestone = "M5" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["comment.summarize"] +tools = ["comments_summarize"] +evidence = ["crates/engine/src/tests.rs::comments_lock_take_checkmarks_hide_and_summarize", "crates/ui-egui/tests/comments.rs::comments_take_checkmarks_lock_hide_and_summarize", "crates/automation/tests/automation.rs::comment_checkmarks_locks_hiding_and_summaries_through_tools"] +notes = "Comments only layout, sorted by page, author, date or type; the layouts with the document and connector lines not yet" [[feature]] id = "comment.flatten" diff --git a/vendor/README.md b/vendor/README.md index a7f94174..5c7da3d9 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -7,7 +7,7 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001). | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate` | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields` | | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process | `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers` | diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index 0cb5c03a..3229c774 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -17,7 +17,7 @@ use crate::x_object::{ }; use hayro_syntax::content::TypedIter; use hayro_syntax::content::ops::TypedInstruction; -use hayro_syntax::object::dict::keys::{ANNOTS, AP, AS, F, MCID, N, OC, RECT}; +use hayro_syntax::object::dict::keys::{ANNOTS, AP, AS, F, MCID, N, OC, RECT, SUBTYPE}; use hayro_syntax::object::{Array, Dict, Object, Rect, Stream, dict_or_stream}; use hayro_syntax::page::{Page, Resources}; use kurbo::{Affine, Point, Shape}; @@ -101,6 +101,9 @@ pub struct InterpreterSettings { /// Note that this feature is currently not fully implemented yet, so some /// annotations might be missing. pub render_annotations: bool, + /// PrintCraft patch: hide comments (markup annotations) but keep form fields (`/Widget`) + /// and links, for the viewer's "Hide all comments". + pub hide_comments: bool, /// PrintCraft patch: viewer overrides for optional content groups (object number, generation, /// visible), applied on top of the document's default configuration (Layers panel toggles). pub ocg_overrides: Arc>, @@ -122,6 +125,7 @@ impl Default for InterpreterSettings { cmap_resolver: Arc::new(|_| None), warning_sink: Arc::new(|_| {}), render_annotations: true, + hide_comments: false, ocg_overrides: Arc::new(Vec::new()), } } @@ -158,6 +162,12 @@ pub fn interpret_page<'a>( if flags & (2 | 32) != 0 { continue; } + // PrintCraft patch: "Hide all comments" keeps widgets and links. + if context.settings.hide_comments + && !matches!(annot.get::>(SUBTYPE).as_deref(), Some(b"Widget" | b"Link")) + { + continue; + } // PrintCraft patch: `/N` may be a stream or a dictionary of appearance states selected // by `/AS` (checkboxes, radio buttons, …). Upstream only handled the stream form.