Files
pdfcraft/crates/engine/src/comments.rs
T
bflatastic c638edf9c7 Merge #2137: M5: comment pop-up windows on the page, with connector lines (comment.popups, comment.connector-lines)
# Conflicts:
#	ROADMAP.md
#	crates/annot/src/info.rs
#	crates/annot/src/lib.rs
#	crates/annot/src/modify.rs
#	crates/annot/src/tests.rs
#	crates/automation/src/comments.rs
#	crates/automation/tests/it/automation.rs
#	crates/engine/src/comments.rs
#	crates/render/src/inspect.rs
#	crates/render/src/inspect/annotations.rs
#	crates/render/src/inspect/model.rs
#	crates/render/src/inspect/tests.rs
#	crates/render/src/lib.rs
#	crates/ui-egui/src/tools/comments/input.rs
#	crates/ui-egui/src/tools/comments/paint.rs
#	crates/ui-egui/src/tools/comments/tests.rs
#	crates/ui-egui/tests/it/comments.rs
2026-11-09 18:22:50 +07:00

176 lines
6.8 KiB
Rust

//! The comment list and its summary.
/// The comment list, read from the object graph (as `inspect` lists comments).
pub(crate) fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_render::Annotation> {
let mut list: Vec<pdfcraft_render::Annotation> = pdfcraft_annot::summaries(doc)
.into_iter()
.map(|s| pdfcraft_render::Annotation {
page: s.page,
subtype: s.subtype,
author: s.author,
contents: s.contents,
modified: s.modified.map(|m| pdfcraft_render::pretty_date(&m)),
name: s.name,
in_reply_to: s.in_reply_to,
parent: s.parent,
grouped: s.grouped,
rect: s.rect,
color: s.color,
index: s.index,
state: s.state,
quads: s.quads,
locked: s.locked,
intent: s.intent,
fill_sign: s.fill_sign,
no_rotate: s.no_rotate,
points: s.points,
text_box: s.text_box,
popup: s.popup.map(|p| pdfcraft_render::Popup { rect: p.rect, open: p.open }),
})
.collect();
pdfcraft_render::link_replies(&mut list);
pdfcraft_render::fill_group_text(&mut list);
list
}
/// 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,
}
}
/// [`comment_type_name`], telling replaced text, callouts, clouds and typewriter text apart by `/IT`.
pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str {
match a.intent.as_deref() {
Some("StrikeOutTextEdit" | "Replace") => "Replace Text",
Some("FreehandHighlight") if a.subtype == "Ink" => "Freehand Highlight",
Some("FreeTextCallout") => "Callout",
Some("PolygonCloud") => "Cloud",
Some("FreeTextTypeWriter") => "Typewriter",
_ => comment_type_name(&a.subtype),
}
}
/// 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: &[pdfcraft_render::Annotation], sort: SummarySort) -> String {
use std::fmt::Write;
let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| a.is_comment()).collect();
// Each reply under the annotation its `/IRT` points at, in document order, gathered once.
type Children<'a> = std::collections::HashMap<(usize, usize), Vec<&'a pdfcraft_render::Annotation>>;
let mut children = Children::new();
for r in all.iter().filter(|r| r.state.is_none()) {
if let Some(parent) = r.parent {
children.entry(parent).or_default().push(r);
}
}
// Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the
// direct parent, so following one level would leave the deeper replies out of the summary.
// A group subordinate (`/RT /Group`, ISO 32000-2 §12.5.6.2) is part of the comment, not a
// reply: it is not written, and replies to it are written at its level, as the comment's.
// `depth` bounds the walk; `level` is the indent.
fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize, level: usize) {
use std::fmt::Write;
if depth > 64 {
return;
}
for r in children.get(&(parent.page, parent.index)).into_iter().flatten() {
if r.is_group_member() {
write_replies(out, children, r, depth + 1, level);
continue;
}
let indent = " ".repeat(level + 1);
let _ =
writeln!(out, "{indent}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, "{indent}{line}");
}
write_replies(out, children, r, depth + 1, level + 1);
}
}
let mut numbered: Vec<(usize, &pdfcraft_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_kind(x.1).cmp(comment_kind(y.1))),
}
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_kind(a));
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}");
}
write_replies(&mut out, &children, a, 0, 0);
out.push('\n');
}
out
}