mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 13:24:35 +00:00
fix(comments): show stray and nested replies, leave Replace Text out of the count
Follow-up to threading by /IRT reference, from review: - An /IRT that reaches no listed comment (the annotation itself, a loop, a pop-up, an object no page lists) left the annotation a reply shown nowhere, though still counted. `link_replies` now settles the links in both readers (inspect on open, the engine's list after an edit): a reply keeps its parent only when the chain reaches a comment within 64 steps, and anything else stays a comment of its own. - A reply to a reply was never shown in the Comments panel (only comment_list followed the chain, #806), but the new header line counted it. The panel now files every text reply under the comment its thread starts from (`thread_tops`); a status or checkmark stays with the annotation it was set on. - A Replace Text caret (/RT /Group) is grouped with its strikeout, not a reply to it, so the header no longer counts it (new `grouped` field on both readers). - The engine summary and comment_list gather replies once instead of rescanning every annotation per reply (quadratic on large reviews). - Inspect takes the first listing in page order instead of a min over an unordered map. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
12 files changed
+158
-47
No files matched your search
+1
-1
@@ -199,7 +199,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged.
|
||||
- **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged.
|
||||
- **2026-10-21 (M3, #3808):** Page thumbnails in the Inspector can be resized independently from the document and Organize, with a slider, buttons and `thumbnail-zoom` control option. Selection, page following and reload are covered by regressions. Estimates unchanged.
|
||||
- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies (28, as Acrobat says, for a file it used to call 44) with the replies in small text under it ("16 replies", in every catalogue), and a reply's thread line runs from the avatar above it to its own. UI, engine and automation regressions cover it. Estimates unchanged.
|
||||
- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies (28, as Acrobat says, for a file it used to call 44) with the replies in small text under it ("16 replies", in every catalogue), and a reply's thread line runs from the avatar above it to its own. A reply to a reply shows in its thread in the panel too (it was listed nowhere), an `/IRT` that leads to no comment (itself, a loop, a pop-up, an unlisted object) leaves its annotation a comment of its own instead of hiding it, and a Replace Text caret isn't counted as a reply. UI, engine and automation regressions cover it. Estimates unchanged.
|
||||
- **2026-10-22 (M2, #3107 / PR #3438):** Inline-image parsing falls back to the existing EI heuristic when missing declared samples, headers or terminators exceed the content-window budget, instead of waiting for an impossible allocation. Exact-length parsing for available samples and ordinary large streamed images is retained. Estimates unchanged.
|
||||
- **2026-10-21 (M14, Brazilian Portuguese):** Completed the `pt-br` catalog against the current 2,674-key union, including signature validation and accessibility messages; no build or compilation run. Localization remains partial because help and unpredictable engine/OS error details are not translated.
|
||||
- **2026-10-21 (M14, #3557):** Preferences: the OK button stays below the scrolling settings instead of being the last thing in them, so it is in reach in a short window.
|
||||
|
||||
@@ -2256,6 +2256,8 @@ pub struct Summary {
|
||||
pub in_reply_to: Option<String>,
|
||||
/// The page and `/Annots` position `/IRT` points at, by reference (`/NM` need not be unique).
|
||||
pub parent: Option<(usize, usize)>,
|
||||
/// `/RT /Group`: grouped with the `/IRT` annotation (Replace Text's caret), not a reply to it.
|
||||
pub grouped: bool,
|
||||
pub rect: [f32; 4],
|
||||
pub color: Option<[f32; 3]>,
|
||||
pub state: Option<String>,
|
||||
@@ -2351,6 +2353,7 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
|
||||
name: text_value(doc, d, b"NM"),
|
||||
in_reply_to,
|
||||
parent,
|
||||
grouped: d.name(b"RT") == Some(b"Group"),
|
||||
rect,
|
||||
color,
|
||||
state: text_value(doc, d, b"State"),
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
//! 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 std::collections::HashMap;
|
||||
|
||||
use pdfcraft_engine::{
|
||||
Edit, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort,
|
||||
};
|
||||
@@ -198,26 +200,22 @@ impl Automation {
|
||||
}
|
||||
v
|
||||
};
|
||||
// Every reply under the comment its thread starts from, at any depth, in document order:
|
||||
// a reply to a reply keeps its `/IRT` on the direct parent, so following one level would
|
||||
// drop it. Parents are found by reference, as `/NM` need not be unique.
|
||||
let tops = pdfcraft_render::thread_tops(&all);
|
||||
let mut threads: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new();
|
||||
for r in &all {
|
||||
if let Some(&top) = tops.get(&(r.page, r.index)) {
|
||||
threads.entry(top).or_default().push(r);
|
||||
}
|
||||
}
|
||||
let roots: Vec<Value> = all
|
||||
.iter()
|
||||
.filter(|c| !c.is_reply() && only.is_none_or(|p| c.page == p))
|
||||
.map(|c| {
|
||||
let mut v = view(c);
|
||||
// Every reply under this comment, at any depth, in document order. A reply to a
|
||||
// reply keeps its `/IRT` on the direct parent, so following one level would drop it.
|
||||
// Parents are found by reference: `/NM` need not be unique.
|
||||
let descends_from = |r: &Annotation, root: &Annotation| -> bool {
|
||||
let mut parent = r.parent;
|
||||
for _ in 0..64 {
|
||||
let Some(at) = parent else { return false };
|
||||
if at == (root.page, root.index) {
|
||||
return true;
|
||||
}
|
||||
parent = all.iter().find(|x| (x.page, x.index) == at).and_then(|x| x.parent);
|
||||
}
|
||||
false
|
||||
};
|
||||
let thread: Vec<&Annotation> = all.iter().filter(|r| r.is_reply() && descends_from(r, c)).collect();
|
||||
let thread = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]);
|
||||
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")));
|
||||
|
||||
@@ -2890,7 +2890,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
|
||||
/// The comment list, read from the object graph (as `inspect` lists comments).
|
||||
fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_render::Annotation> {
|
||||
pdfcraft_annot::summaries(doc)
|
||||
let mut list: Vec<pdfcraft_render::Annotation> = pdfcraft_annot::summaries(doc)
|
||||
.into_iter()
|
||||
.map(|s| pdfcraft_render::Annotation {
|
||||
page: s.page,
|
||||
@@ -2901,6 +2901,7 @@ fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_render::Annotation
|
||||
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,
|
||||
@@ -2911,7 +2912,9 @@ fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_render::Annotation
|
||||
fill_sign: s.fill_sign,
|
||||
points: s.points,
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
pdfcraft_render::link_replies(&mut list);
|
||||
list
|
||||
}
|
||||
|
||||
/// Seconds to add to UTC for local time (0 where unknown).
|
||||
@@ -5329,21 +5332,29 @@ pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str {
|
||||
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_reply() && a.state.is_none()).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, all: &[pdfcraft_render::Annotation], parent: &pdfcraft_render::Annotation, depth: usize) {
|
||||
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 all.iter().filter(|r| r.state.is_none() && r.replies_to(parent)) {
|
||||
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, all, r, depth + 1);
|
||||
write_replies(out, children, r, depth + 1);
|
||||
}
|
||||
}
|
||||
let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new();
|
||||
@@ -5382,7 +5393,7 @@ pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: Su
|
||||
if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) {
|
||||
let _ = writeln!(out, "{c}");
|
||||
}
|
||||
write_replies(&mut out, all, a, 0);
|
||||
write_replies(&mut out, &children, a, 0);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
|
||||
@@ -3420,18 +3420,20 @@ fn comments_lock_take_checkmarks_hide_and_summarize() {
|
||||
}
|
||||
|
||||
/// Replies thread by their `/IRT` reference, not `/NM`: PyMuPDF names the first comment on every
|
||||
/// page `fitz-A0`, which used to file every reply under page 1's comment. Both readers (inspect on
|
||||
/// open, and the comment list the engine refreshes after an edit) must agree.
|
||||
/// page `fitz-A0`, which used to file every reply under page 1's comment. An `/IRT` to the
|
||||
/// annotation itself leaves it a comment of its own. Both readers (inspect on open, and the
|
||||
/// comment list the engine refreshes after an edit) must agree.
|
||||
#[test]
|
||||
fn replies_thread_by_reference_when_names_repeat_across_pages() {
|
||||
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 4 0 R] /Count 2 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 8 0 R] >> endobj
|
||||
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [6 0 R 7 0 R] >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Alpha) /Contents (COVER) >> endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Beta) /Contents (SECOND) >> endobj
|
||||
7 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (ANSWER) >> endobj
|
||||
8 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (fitz-A1) /IRT 8 0 R /T (Delta) /Contents (SELF) >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
@@ -3440,9 +3442,12 @@ trailer << /Root 1 0 R >>
|
||||
let all = &s.get(id).unwrap().info.annotations;
|
||||
let answer = all.iter().find(|a| a.contents.as_deref() == Some("ANSWER")).unwrap();
|
||||
assert_eq!(answer.parent, Some((1, 0)), "{answer:?}");
|
||||
let looped = all.iter().find(|a| a.contents.as_deref() == Some("SELF")).unwrap();
|
||||
assert!(!looped.is_reply(), "{looped:?}");
|
||||
let text = comment_summary("fitz.pdf", all, SummarySort::Page);
|
||||
let (cover, second) = text.split_once("SECOND").unwrap();
|
||||
assert!(!cover.contains("ANSWER"), "page 1's comment has no replies: {text}");
|
||||
assert!(cover.contains("Number: 2 Author: Delta"), "the self-reply is a comment of its own: {text}");
|
||||
assert_eq!(second.matches(" Author:").count(), replies, "{text}");
|
||||
};
|
||||
check(&s, 1);
|
||||
|
||||
@@ -421,10 +421,14 @@ pub struct Annotation {
|
||||
/// `/NM` of this annotation, and of the one it replies to (`/IRT`).
|
||||
pub name: Option<String>,
|
||||
pub in_reply_to: Option<String>,
|
||||
/// The page and `/Annots` position of the annotation `/IRT` points at, found by object
|
||||
/// The page and `/Annots` position of the comment `/IRT` points at, found by object
|
||||
/// reference. Threads follow this rather than `/NM`, which need not be unique: PyMuPDF
|
||||
/// names the first comment on every page `fitz-A0`.
|
||||
/// names the first comment on every page `fitz-A0`. `None` when `/IRT` leads to no listed
|
||||
/// comment ([`link_replies`]).
|
||||
pub parent: Option<(usize, usize)>,
|
||||
/// `/RT /Group`: grouped with the annotation `/IRT` points at (Replace Text's caret with its
|
||||
/// strikeout) rather than a reply to it.
|
||||
pub grouped: bool,
|
||||
/// Rect in PDF user space: [x0, y0, x1, y1].
|
||||
pub rect: [f32; 4],
|
||||
pub color: Option<[f32; 3]>,
|
||||
@@ -452,9 +456,9 @@ impl Annotation {
|
||||
matches!(self.state.as_deref(), Some("Marked" | "Unmarked"))
|
||||
}
|
||||
|
||||
/// A reply (or status reply) to another annotation rather than a comment of its own.
|
||||
/// A reply (or status reply) to another comment rather than a comment of its own.
|
||||
pub fn is_reply(&self) -> bool {
|
||||
self.in_reply_to.is_some() || self.parent.is_some()
|
||||
self.parent.is_some()
|
||||
}
|
||||
|
||||
/// Whether this annotation's `/IRT` points at `other`.
|
||||
@@ -463,6 +467,50 @@ impl Annotation {
|
||||
}
|
||||
}
|
||||
|
||||
/// Settle the reply links of a document's annotations: a reply keeps its `parent` only when
|
||||
/// following the links reaches a comment of its own within 64 steps. An `/IRT` to an unlisted
|
||||
/// object, a pop-up, the annotation itself or a loop leaves it a comment of its own, so it stays
|
||||
/// in view instead of hanging off nothing. Both comment readers (inspect, and the engine's list
|
||||
/// after an edit) run this.
|
||||
pub fn link_replies(annotations: &mut [Annotation]) {
|
||||
let at: HashMap<(usize, usize), usize> =
|
||||
annotations.iter().enumerate().filter(|(_, a)| a.subtype != "Popup").map(|(i, a)| ((a.page, a.index), i)).collect();
|
||||
let links: Vec<Option<usize>> =
|
||||
annotations.iter().enumerate().map(|(i, a)| a.parent.and_then(|p| at.get(&p).copied()).filter(|&j| j != i)).collect();
|
||||
for (i, a) in annotations.iter_mut().enumerate() {
|
||||
let mut up = links.get(i).copied().flatten();
|
||||
let mut settled = false;
|
||||
for _ in 0..64 {
|
||||
let Some(j) = up else { break };
|
||||
up = links.get(j).copied().flatten();
|
||||
if up.is_none() {
|
||||
settled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !settled {
|
||||
a.parent = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The comment each reply's thread starts from, by the reply's page and `/Annots` position: a
|
||||
/// reply to a reply files under that comment too. Expects links [`link_replies`] settled.
|
||||
pub fn thread_tops(annotations: &[Annotation]) -> HashMap<(usize, usize), (usize, usize)> {
|
||||
let parents: HashMap<(usize, usize), (usize, usize)> = annotations.iter().filter_map(|a| Some(((a.page, a.index), a.parent?))).collect();
|
||||
parents
|
||||
.iter()
|
||||
.map(|(&reply, &first)| {
|
||||
let mut top = first;
|
||||
for _ in 0..64 {
|
||||
let Some(&up) = parents.get(&top) else { break };
|
||||
top = up;
|
||||
}
|
||||
(reply, top)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FieldKind {
|
||||
Text,
|
||||
@@ -1370,6 +1418,7 @@ impl<'a> Inspector<'a> {
|
||||
name: self.text(d, b"NM"),
|
||||
in_reply_to,
|
||||
parent,
|
||||
grouped: self.name(d, b"RT").as_deref() == Some("Group"),
|
||||
rect,
|
||||
color,
|
||||
index,
|
||||
@@ -1388,6 +1437,7 @@ impl<'a> Inspector<'a> {
|
||||
info.annotations.push(annotation);
|
||||
}
|
||||
info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3])));
|
||||
link_replies(&mut info.annotations);
|
||||
}
|
||||
|
||||
fn link(&self, page: usize, index: usize, d: &Dictionary, text_budget: &mut usize) -> Option<Link> {
|
||||
|
||||
@@ -18,7 +18,7 @@ pub mod transition;
|
||||
|
||||
pub use inspect::{
|
||||
Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo,
|
||||
PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, pretty_date,
|
||||
PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, link_replies, pretty_date, thread_tops,
|
||||
};
|
||||
pub use pixels::Pixels;
|
||||
pub use raster::{
|
||||
|
||||
@@ -5738,6 +5738,7 @@ mod tests {
|
||||
name: None,
|
||||
in_reply_to: None,
|
||||
parent: None,
|
||||
grouped: false,
|
||||
rect: [0.0; 4],
|
||||
color: None,
|
||||
index: 0,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! The Comments panel (Acrobat's right-hand comment list; 11-visual-spec §3, audit `a15-*`).
|
||||
//!
|
||||
//! Header "Comments N" with search; an "Add a comment" box that posts a sticky note on the
|
||||
//! current page; cards grouped by page. Selecting a card selects the comment on the page and
|
||||
//! opens its reply box and "…" menu (Edit, Set status, Delete). Status replies show as a badge.
|
||||
//! Header "Comments N" (comments, as Acrobat counts them) with "N replies" under it and search;
|
||||
//! an "Add a comment" box that posts a sticky note on the current page; cards grouped by page.
|
||||
//! Selecting a card selects the comment on the page and opens its reply box and "…" menu (Edit,
|
||||
//! Set status, Delete). Status replies show as a badge.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -93,9 +94,10 @@ pub fn count(info: &DocInfo) -> usize {
|
||||
info.annotations.iter().filter(|a| !a.is_reply() && a.state.is_none()).count()
|
||||
}
|
||||
|
||||
/// The replies under the header's count (not status changes or checkmarks).
|
||||
/// The replies under the header's count, at any depth: not status changes, checkmarks or a
|
||||
/// Replace Text caret (grouped with its strikeout, not a reply to it).
|
||||
pub fn reply_count(info: &DocInfo) -> usize {
|
||||
info.annotations.iter().filter(|a| a.is_reply() && a.state.is_none()).count()
|
||||
info.annotations.iter().filter(|a| a.is_reply() && a.state.is_none() && !a.grouped).count()
|
||||
}
|
||||
|
||||
fn matches(a: &Annotation, q: &str) -> bool {
|
||||
@@ -166,12 +168,14 @@ pub(crate) fn show(
|
||||
let mut replies: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new();
|
||||
let mut page_counts: HashMap<usize, usize> = HashMap::new();
|
||||
// Replies find their parent by reference (`/NM` need not be unique) and keep source order.
|
||||
// Page badges count roots before filtering.
|
||||
// A reply at any depth joins the thread of the comment it starts from, as comment_list lists
|
||||
// it; a status or checkmark stays with the annotation it was set on. Page badges count roots
|
||||
// before filtering.
|
||||
let tops = pdfcraft_render::thread_tops(&info.annotations);
|
||||
for a in &info.annotations {
|
||||
if a.is_reply() {
|
||||
if let Some(parent) = a.parent {
|
||||
replies.entry(parent).or_default().push(a);
|
||||
}
|
||||
if let Some(parent) = a.parent {
|
||||
let key = if a.state.is_none() { tops.get(&(a.page, a.index)).copied().unwrap_or(parent) } else { parent };
|
||||
replies.entry(key).or_default().push(a);
|
||||
} else {
|
||||
let count = page_counts.entry(a.page).or_default();
|
||||
*count = count.saturating_add(1);
|
||||
@@ -514,8 +518,7 @@ fn card(
|
||||
edit
|
||||
}
|
||||
|
||||
/// A 26 pt round badge: a type glyph (comments) or initials (replies).
|
||||
/// A round avatar; returns where it was drawn.
|
||||
/// A 26 pt round badge: a type glyph (comments) or initials (replies). Returns where it was drawn.
|
||||
fn avatar(ui: &mut egui::Ui, t: &Tokens, icon: Option<&str>, text: Option<&str>, color: Color32) -> egui::Rect {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::hover());
|
||||
let bg = if t.dark() { Color32::from_gray(0x55) } else { Color32::from_gray(0xD8) };
|
||||
|
||||
@@ -1086,8 +1086,8 @@ fn the_panel_filters_and_sorts() {
|
||||
}
|
||||
|
||||
// Contributor-original PDF. Two roots deliberately share /NM; the replies (on another page,
|
||||
// with dates opposite to source order) point at the first by reference. An unnamed root has
|
||||
// no thread.
|
||||
// with dates opposite to source order) point at the first by reference. Nothing replies to the
|
||||
// unnamed root.
|
||||
const REVIEW_FIXTURE: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj
|
||||
@@ -1153,7 +1153,7 @@ fn the_panel_preserves_threads_source_order_and_unfiltered_page_counts() {
|
||||
h.state_mut().views[0].comments.hidden_checks.clear();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Unnamed root");
|
||||
assert_eq!(h.query_all_by_label("Needle first reply").count(), 0, "an unnamed root has no replies");
|
||||
assert_eq!(h.query_all_by_label("Needle first reply").count(), 0, "nothing replies to the unnamed root");
|
||||
}
|
||||
|
||||
// Contributor-original PDF, shaped like PyMuPDF output: every page's first comment is named
|
||||
@@ -1205,6 +1205,46 @@ const SINGLE_COMMENT: &[u8] = b"%PDF-1.7
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
// Contributor-original PDF. Page 1: an unnamed comment with a reply and a reply to that reply,
|
||||
// and a Replace Text strikeout with its grouped caret. Page 2: an `/IRT` to the annotation
|
||||
// itself, to an annotation no page lists (object 11) and to a pop-up.
|
||||
const THREAD_EDGES: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >> endobj
|
||||
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [10 0 R 12 0 R 13 0 R 14 0 R] >> endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Alpha) /Contents (Unnamed parent) >> endobj
|
||||
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /IRT 5 0 R /T (Beta) /Contents (Direct reply) >> endobj
|
||||
7 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /IRT 6 0 R /T (Gamma) /Contents (Reply to the reply) >> endobj
|
||||
8 0 obj << /Type /Annot /Subtype /StrikeOut /Rect [60 100 160 112] /IT /StrikeOutTextEdit /T (Alpha) /Contents (Struck words) >> endobj
|
||||
9 0 obj << /Type /Annot /Subtype /Caret /Rect [158 96 166 104] /IRT 8 0 R /RT /Group /T (Alpha) /Contents (Replacement words) >> endobj
|
||||
10 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /IRT 10 0 R /T (Delta) /Contents (Points at itself) >> endobj
|
||||
11 0 obj << /Type /Annot /Subtype /Text /Rect [20 120 40 140] /T (Nobody) /Contents (Listed on no page) >> endobj
|
||||
12 0 obj << /Type /Annot /Subtype /Text /Rect [20 120 40 140] /IRT 11 0 R /T (Delta) /Contents (Points at an unlisted note) >> endobj
|
||||
13 0 obj << /Type /Annot /Subtype /Popup /Rect [120 100 280 180] >> endobj
|
||||
14 0 obj << /Type /Annot /Subtype /Text /Rect [20 80 40 100] /IRT 13 0 R /T (Delta) /Contents (Points at a pop-up) >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
#[test]
|
||||
fn replies_that_lead_nowhere_stay_comments_and_deeper_replies_join_their_thread() {
|
||||
let h = harness_with_bytes(THREAD_EDGES.to_vec(), |app| app.set_option("panel", "comments").unwrap());
|
||||
// An `/IRT` that reaches no listed comment leaves its annotation a comment of its own,
|
||||
// rather than a reply shown nowhere.
|
||||
for orphan in ["Points at itself", "Points at an unlisted note", "Points at a pop-up"] {
|
||||
assert!(h.get_by_label(orphan).rect().top() > h.get_by_label("Page 2").rect().top(), "{orphan}");
|
||||
}
|
||||
// A reply to a reply joins the thread of the comment it starts from, also when that comment
|
||||
// has no /NM.
|
||||
let (parent, direct, deeper) = (h.get_by_label("Unnamed parent"), h.get_by_label("Direct reply"), h.get_by_label("Reply to the reply"));
|
||||
assert!(parent.rect().top() < direct.rect().top() && direct.rect().top() < deeper.rect().top());
|
||||
assert!(deeper.rect().top() < h.get_by_label("Struck words").rect().top(), "under its own thread");
|
||||
// Comments: the unnamed parent, the strikeout and the three on page 2. Replies: the two
|
||||
// replies, not the Replace Text caret grouped with its strikeout.
|
||||
h.get_by_label("5");
|
||||
h.get_by_label("2 replies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_reveals_edits_and_reopens_a_late_thread_without_changing_its_replies() {
|
||||
let mut h = harness_with_bytes(REVIEW_FIXTURE.to_vec(), |app| {
|
||||
|
||||
@@ -94,7 +94,7 @@ fn pdf(objs: Vec<String>) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Named roots exercise thread lookups even when none of the comments has replies.
|
||||
/// Named roots, none with replies: the panel still looks up every root's thread.
|
||||
fn named_comments(n: usize) -> Vec<u8> {
|
||||
let annots: Vec<String> = (0..n).map(|i| format!("{} 0 R", i + 4)).collect();
|
||||
let mut objs = vec![
|
||||
|
||||
+1
-1
@@ -168,7 +168,7 @@ total matches the parity estimate.
|
||||
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. |
|
||||
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web |
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: the Comments panel groups replies by name and counts all page roots once per frame (#2669, item 5); stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles; Ctrl+Enter submission for highlight comment text and replies, with Shift+Enter newlines (reconciled with upstream main for PR #1457 on 2026-10-14). Missing: custom stamps, replace-text proposals, summary layouts with the page |
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: replies thread by their `/IRT` reference rather than the parent's `/NM`, so files that repeat names per page (PyMuPDF) thread correctly, and the Comments header counts comments without their replies, as Acrobat does, with the reply count under it; the Comments panel groups replies and counts all page roots once per frame (#2669, item 5); stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles; Ctrl+Enter submission for highlight comment text and replies, with Shift+Enter newlines (reconciled with upstream main for PR #1457 on 2026-10-14). Missing: custom stamps, replace-text proposals, summary layouts with the page |
|
||||
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. |
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links; nested FDF export uses lazy array owners and annotation positions, with retained entries budgeted before cloning under copied-graph limits and source `/Page` and `/Pages` dictionaries replaced by null at every copy depth); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page |
|
||||
|
||||
Reference in new issue
Block a user