diff --git a/ROADMAP.md b/ROADMAP.md index 95be72290d..fe208d3caf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 7b3446cfcf..6d910194e3 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -2256,6 +2256,8 @@ pub struct Summary { pub in_reply_to: Option, /// 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, @@ -2351,6 +2353,7 @@ pub fn summaries(doc: &Document) -> Vec { 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"), diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index bd583aeaed..538b45e334 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -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 = 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"))); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cd9ec5b855..e64bbcb612 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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_annot::summaries(doc) + let mut list: Vec = 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 Vec &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 diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 44ac16c345..1d865b0bce 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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); diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 0490390163..bb22b00a88 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -421,10 +421,14 @@ pub struct Annotation { /// `/NM` of this annotation, and of the one it replies to (`/IRT`). pub name: Option, pub in_reply_to: Option, - /// 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> = + 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 { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index cc1e4af2e3..954823ef09 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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::{ diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0c4104fe99..c458857063 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5738,6 +5738,7 @@ mod tests { name: None, in_reply_to: None, parent: None, + grouped: false, rect: [0.0; 4], color: None, index: 0, diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index ede5522fa5..dc157b30d9 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -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 = 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) }; diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 8abb4720bd..13e728c5ed 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -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| { diff --git a/crates/ui-egui/tests/perf.rs b/crates/ui-egui/tests/perf.rs index 319edeffc8..b2ccc081c7 100644 --- a/crates/ui-egui/tests/perf.rs +++ b/crates/ui-egui/tests/perf.rs @@ -94,7 +94,7 @@ fn pdf(objs: Vec) -> Vec { 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 { let annots: Vec = (0..n).map(|i| format!("{} 0 R", i + 4)).collect(); let mut objs = vec![ diff --git a/docs/roadmap.md b/docs/roadmap.md index bb542aefc7..cddf47679b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -168,7 +168,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
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
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:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
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.
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.
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:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
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.
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.
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 |