mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
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:
1 parent
ed8a002465
commit
859f03d26e
24 files changed
+644
-47
No files matched your search
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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(_))));
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Vendored
+1
-1
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user