mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 20:18:06 -06:00
refactor(engine): move the comment list and summary to comments.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
748b20abb9
commit
71dccdda50
@@ -0,0 +1,163 @@
|
||||
//! 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,
|
||||
})
|
||||
.collect();
|
||||
pdfcraft_render::link_replies(&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 callouts, clouds and typewriter text apart by `/IT`.
|
||||
pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str {
|
||||
match a.intent.as_deref() {
|
||||
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.
|
||||
fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize) {
|
||||
use std::fmt::Write;
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for r in children.get(&(parent.page, parent.index)).into_iter().flatten() {
|
||||
let indent = " ".repeat(depth + 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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
+3
-162
@@ -20,6 +20,7 @@ pub mod camera_clean;
|
||||
pub mod camera_edges;
|
||||
pub mod catalog;
|
||||
pub mod commands;
|
||||
mod comments;
|
||||
pub mod compare;
|
||||
mod copy_image;
|
||||
pub mod dates;
|
||||
@@ -187,6 +188,8 @@ fn record_link_scan() {
|
||||
});
|
||||
}
|
||||
|
||||
use comments::comment_list;
|
||||
pub use comments::{SummarySort, comment_kind, comment_summary, comment_type_name};
|
||||
pub use document::{Document, TextNotice, display_bytes_for, render_password_for};
|
||||
use document::{display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices};
|
||||
pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit};
|
||||
@@ -349,36 +352,6 @@ pub struct RecoverySnapshot {
|
||||
pub encrypted: bool,
|
||||
}
|
||||
|
||||
/// The comment list, read from the object graph (as `inspect` lists comments).
|
||||
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,
|
||||
})
|
||||
.collect();
|
||||
pdfcraft_render::link_replies(&mut list);
|
||||
list
|
||||
}
|
||||
|
||||
/// Seconds to add to UTC for local time (0 where unknown).
|
||||
fn local_utc_offset() -> i64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -538,138 +511,6 @@ pub struct OptimizeReport {
|
||||
pub discarded: Vec<(Hidden, usize)>,
|
||||
}
|
||||
|
||||
/// 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 callouts, clouds and typewriter text apart by `/IT`.
|
||||
pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str {
|
||||
match a.intent.as_deref() {
|
||||
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.
|
||||
fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize) {
|
||||
use std::fmt::Write;
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for r in children.get(&(parent.page, parent.index)).into_iter().flatten() {
|
||||
let indent = " ".repeat(depth + 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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user