M5: Comment polish: Mark with checkmark (hidden /StateModel /Marked reply; card toggle and menus), Locked comments (Properties ▸ Locked; refuse move/resize/restyle/delete), Copy text, Hide/Show all comments (render setting, vendored hayro patch), Summarize comments (Comments only layout, sort by page/author/date/type); comments panel "…" menu gains hide, summary, export, import; card menu anchored to its button; comment_mark/comment_lock/comments_hide/comments_summarize tools (79 tools)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 02:02:14 -04:00
1 parent ed8a002465
commit 859f03d26e
24 files changed
+644 -47

No files matched your search

+56
View File
@@ -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<ObjRef> = 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<usize, AnnotError> {
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<Object> {
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<String>,
pub quads: Vec<[f32; 8]>,
pub locked: bool,
}
fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
@@ -1105,6 +1156,7 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
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<String>,
/// 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<Props> {
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,
})
}
+35 -2
View File
@@ -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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
let (page, index) = self.comment_target(a)?;
let info = self.doc(a)?.info.pages[page].clone();
+4
View File
@@ -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())),
+17
View File
@@ -487,6 +487,23 @@ pub fn tools() -> Vec<ToolDef> {
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" },
+31
View File
@@ -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(_))));
}
+2
View File
@@ -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"),
+157
View File
@@ -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<printcraft_render::Annota
index: s.index,
state: s.state,
quads: s.quads,
locked: s.locked,
})
.collect()
}
@@ -1744,6 +1775,27 @@ impl Session {
true
}
/// Hide or show every comment on the page (Acrobat's Comments ▸ Hide all comments). Form
/// fields and links still draw. Returns whether anything changed.
pub fn set_hide_comments(&mut self, id: DocId, hide: bool) -> 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<Arc<Vec<u8>>, 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<SummarySort> {
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;
+44 -2
View File
@@ -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");
}
+10
View File
@@ -136,6 +136,15 @@ pub struct Annotation {
pub state: Option<String>,
/// 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,
});
}
}
+31 -1
View File
@@ -44,11 +44,13 @@ pub struct RenderConfig {
pub password: Option<Arc<str>>,
/// Layer (optional content group) visibility overrides: (object number, generation, visible).
pub layers: Arc<Vec<(i32, i32, bool)>>,
/// 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());
+2 -1
View File
@@ -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
+10
View File
@@ -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");
+9 -1
View File
@@ -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<Edit> {
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<Edit> {
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() {
+16 -3
View File
@@ -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<Item = &Annotation> {
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();
+66 -12
View File
@@ -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<String> = 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
}
+30
View File
@@ -42,6 +42,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
let mut redact_now: Option<Dialog> = 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();
}
+11
View File
@@ -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<Vec<u8>>, message: &str) {
match self.session.open_new(name, bytes) {
Ok(id) => {
+5
View File
@@ -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<RecoveryStore>,
/// 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(),
+5 -1
View File
@@ -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<BmAction> = None;
let mut panel_edit: Option<printcraft_engine::Edit> = 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);
}
+1 -1
View File
@@ -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 {
+69
View File
@@ -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"));
}
+21 -21
View File
@@ -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"
+1 -1
View File
@@ -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` |
+11 -1
View File
@@ -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<Vec<(i32, i32, bool)>>,
@@ -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::<hayro_syntax::object::Name<'_>>(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.