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:
alehelandClaude Opus 5.5 committed 2026-10-23 17:30:08 +02:00
1 parent c2fd321ce3
commit 21814f60f5
12 files changed
+158 -47

No files matched your search

+1 -1
View File
@@ -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.
+3
View File
@@ -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"),
+13 -15
View File
@@ -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")));
+17 -6
View File
@@ -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
+8 -3
View File
@@ -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);
+54 -4
View File
@@ -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> {
+1 -1
View File
@@ -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::{
+1
View File
@@ -5738,6 +5738,7 @@ mod tests {
name: None,
in_reply_to: None,
parent: None,
grouped: false,
rect: [0.0; 4],
color: None,
index: 0,
+15 -12
View File
@@ -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) };
+43 -3
View File
@@ -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| {
+1 -1
View File
@@ -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
View File
@@ -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 |