diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec0..038baa6ae 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged) + **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -157,6 +159,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-16 (M5, #2669, item 5):** The Comments panel groups replies by name and counts all page roots once per frame. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index c5fbbd0ce..425b9898e 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -4,6 +4,8 @@ //! 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; + use egui::{Align, Color32, CornerRadius, Layout, Sense, Stroke, vec2}; use pdfcraft_engine::{Edit, NoteIcon, ReviewState, Shape}; use pdfcraft_render::{Annotation, DocInfo}; @@ -154,12 +156,18 @@ pub(crate) fn show( return edit; } let query = view.comments.search.clone().filter(|q| !q.trim().is_empty()); - let replies_of = |a: &Annotation| -> Vec<&Annotation> { - match &a.name { - Some(nm) => info.annotations.iter().filter(|r| r.in_reply_to.as_deref() == Some(nm.as_str())).collect(), - None => Vec::new(), + let mut replies: HashMap<&str, Vec<&Annotation>> = HashMap::new(); + let mut page_counts: HashMap = HashMap::new(); + // Keep global name matching and source reply order. Page badges count roots before filtering. + for a in &info.annotations { + if let Some(parent) = a.in_reply_to.as_deref() { + replies.entry(parent).or_default().push(a); + } else { + let count = page_counts.entry(a.page).or_default(); + *count = count.saturating_add(1); } - }; + } + let replies_of = |a: &Annotation| -> &[&Annotation] { a.name.as_deref().and_then(|name| replies.get(name)).map(Vec::as_slice).unwrap_or(&[]) }; let cv = &view.comments; let status_of = |a: &Annotation| replies_of(a).iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()).unwrap_or_else(|| "None".into()); @@ -172,7 +180,7 @@ pub(crate) fn show( .filter(|a| !cv.hidden_authors.contains(&a.author.clone().unwrap_or_default())) .filter(|a| !cv.hidden_statuses.contains(&status_of(a))) .filter(|a| !cv.hidden_colors.contains(&color_key(a))) - .filter(|a| !cv.hidden_checks.contains(&check_label(is_marked(&replies_of(a))).to_string())) + .filter(|a| !cv.hidden_checks.contains(&check_label(is_marked(replies_of(a))).to_string())) .collect(); let sort = cv.sort; match sort { @@ -221,7 +229,7 @@ pub(crate) fn show( } } else if a.page != page { page = a.page; - let n = info.annotations.iter().filter(|x| x.page == page && x.in_reply_to.is_none()).count(); + let n = page_counts.get(&page).copied().unwrap_or_default(); let open = !view.comments.collapsed_pages.contains(&page); ui.add_space(6.0); let clicked = ui @@ -255,7 +263,7 @@ pub(crate) fn show( continue; } let thread = replies_of(a); - if let Some(e) = card(ui, t, a, &thread, view, prefs, allowed, nav) { + if let Some(e) = card(ui, t, a, thread, view, prefs, allowed, nav) { edit = Some(e); } } diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 914bc281b..2a7624e0a 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -24,10 +24,14 @@ trailer << /Root 1 0 R >> %%EOF"; fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { + harness_with_bytes(TEXT_FIXTURE.to_vec(), setup) +} + +fn harness_with_bytes(bytes: Vec, setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); + app.open_bytes("text.pdf", None, bytes).expect("opens"); app.set_option("left", "closed").unwrap(); app.set_option("author", "Tester").unwrap(); setup(&mut app); @@ -735,6 +739,131 @@ fn the_panel_filters_and_sorts() { assert_eq!(h.query_all_by_label("Oval").count(), ovals + 1, "a group header was added"); } +// Contributor-original PDF. Two roots deliberately share /NM. Their replies are on +// another page, with dates opposite to source order. An unnamed root has no thread. +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 +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (shared) /T (Alpha) /Contents (First root) >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [60 140 80 160] /NM (shared) /T (Beta) /Contents (Duplicate root) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [100 140 120 160] /T (Gamma) /Contents (Unnamed root) >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (late) /T (Delta) /Contents (Late root) >> endobj +9 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (first-reply) /IRT 5 0 R /T (Reader) /Contents (Needle first reply) /M (D:20260102000000) >> endobj +10 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 5 0 R /StateModel /Review /State /Rejected /Contents (Old review) /M (D:20260102000000) >> endobj +11 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 5 0 R /StateModel /Marked /State /Marked /M (D:20260102000000) >> endobj +12 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (second-reply) /IRT 5 0 R /T (Reader) /Contents (Second reply) /M (D:20260101000000) >> endobj +13 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 5 0 R /StateModel /Review /State /Accepted /Contents (Status-only token) /M (D:20260101000000) >> endobj +14 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 5 0 R /StateModel /Marked /State /Unmarked /M (D:20260101000000) >> endobj +15 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (late-reply) /IRT 8 0 R /T (Reader) /Contents (Late thread reply) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +#[test] +fn the_panel_preserves_global_threads_source_order_and_unfiltered_page_counts() { + let mut h = harness_with_bytes(REVIEW_FIXTURE.to_vec(), |app| { + app.set_option("panel", "comments").unwrap(); + app.views[0].comments.search = Some("needle".into()); + app.views[0].comments.hidden_authors.push("Beta".into()); + app.views[0].comments.selected = Some((0, 0)); + }); + h.get_by_label("First root"); + h.get_by_label("Page 1"); + h.get_by_label("3"); // All three page roots count, including the two filtered out. + assert_eq!(h.query_all_by_label("Duplicate root").count(), 0); + assert_eq!(h.query_all_by_label("Unnamed root").count(), 0); + assert!( + h.get_by_label("Needle first reply").rect().top() < h.get_by_label("Second reply").rect().top(), + "replies follow source order, not dates" + ); + h.get_by_label("Accepted"); + h.get_by_label("Mark with checkmark"); // The later Unmarked reply wins independently. + assert_eq!(h.query_all_by_label("Status-only token").count(), 0, "status text is searchable but is not a displayed reply"); + + h.state_mut().views[0].comments.hidden_authors.clear(); + h.run_steps(2); + h.get_by_label("Duplicate root"); + assert_eq!(h.query_all_by_label("Needle first reply").count(), 2, "duplicate names share the same global thread"); + h.state_mut().views[0].comments.search = Some("status-only token".into()); + h.state_mut().views[0].comments.hidden_statuses.push("Rejected".into()); + h.state_mut().views[0].comments.hidden_checks.push("Checked".into()); + h.run_steps(2); + h.get_by_label("First root"); + h.get_by_label("Duplicate root"); + + h.state_mut().views[0].comments.hidden_statuses.push("Accepted".into()); + h.run_steps(2); + h.get_by_label("No comments match."); + h.state_mut().views[0].comments.hidden_statuses.clear(); + h.state_mut().views[0].comments.hidden_checks.push("Unchecked".into()); + h.run_steps(2); + h.get_by_label("No comments match."); + + h.state_mut().views[0].comments.search = Some("unnamed root".into()); + 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"); +} + +#[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| { + app.set_option("panel", "comments").unwrap(); + app.views[0].comments.collapsed_pages = vec![0, 1]; + }); + assert_eq!(h.query_all_by_label("Late root").count(), 0); + h.state_mut().views[0].comments.selected = Some((1, 0)); + h.state_mut().views[0].comments.reveal = true; + h.run_steps(4); + h.get_by_label("Late root"); + h.get_by_label("Late thread reply"); + assert_eq!(h.state().views[0].comments.collapsed_pages, vec![0]); + h.get_by_label("More").click(); + h.run_steps(2); + h.query_all_by_label("Edit").last().expect("comment menu edit, after the toolbar tab").click(); + h.run_steps(2); + let editor = h + .query_all_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput && n.placeholder() != Some("Add a reply")) + .next() + .expect("note editor"); + editor.click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("Late root edited exactly".into())); + h.run_steps(2); + h.get_by_label("Save").click(); + h.run_steps(3); + assert_eq!(h.state().views[0].comments.selected, Some((1, 0))); + let before = comments(&h); + let late = before.iter().find(|a| a.name.as_deref() == Some("late")).unwrap(); + assert_eq!(late.contents.as_deref(), Some("Late root edited exactly")); + let id = h.state().views[0].id; + let saved = h.state().session.save_bytes(id).unwrap().as_ref().clone(); + let mut reopened = harness_with_bytes(saved, |app| { + app.set_option("panel", "comments").unwrap(); + app.views[0].comments.search = Some("late thread reply".into()); + }); + h.get_by_label("Late root edited exactly"); + reopened.get_by_label("Late root edited exactly"); + reopened.get_by_label("Late thread reply"); + assert_eq!(reopened.query_all_by_label("First root").count(), 0); + let after = comments(&reopened); + assert_eq!(after.len(), before.len()); + for (original, saved) in before.iter().zip(&after) { + assert_eq!( + (&saved.name, &saved.contents, &saved.in_reply_to, &saved.state), + (&original.name, &original.contents, &original.in_reply_to, &original.state) + ); + } + assert_eq!(after.iter().find(|a| a.name.as_deref() == Some("late-reply")).unwrap().in_reply_to.as_deref(), Some("late")); + reopened.state_mut().views[0].comments.sort = pdfcraft_ui_egui::comments::SortBy::Author; + reopened.run_steps(2); + assert_eq!(reopened.query_all_by_label("Delta").count(), 2, "the author group and its card remain visible after sorting"); + reopened.get_by_label("Late root edited exactly"); +} + /// #1554: a page group's chevron folds that page's cards away and back, and the choice holds. #[test] fn page_groups_collapse_and_expand() { diff --git a/crates/ui-egui/tests/perf.rs b/crates/ui-egui/tests/perf.rs index f30be048f..319edeffc 100644 --- a/crates/ui-egui/tests/perf.rs +++ b/crates/ui-egui/tests/perf.rs @@ -28,7 +28,7 @@ fn overloaded() -> bool { } } -/// The two budget tests take turns: each starts render workers, and measuring one while the other +/// The budget tests take turns: each starts render workers, and measuring one while another /// runs would charge it for the other's work on a small CI machine. static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); @@ -75,6 +75,10 @@ fn big(n: usize) -> Vec { objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); objs.push("<< /Type /Annot /Subtype /Square /Rect [72 72 144 144] /C [1 0 0] /T (Ada) /Contents (Check) >>".into()); } + pdf(objs) +} + +fn pdf(objs: Vec) -> Vec { let mut out = b"%PDF-1.7\n".to_vec(); let mut offs = Vec::new(); for (i, o) in objs.iter().enumerate() { @@ -90,6 +94,56 @@ fn big(n: usize) -> Vec { out } +/// Named roots exercise thread lookups even when none of the comments has replies. +fn named_comments(n: usize) -> Vec { + let annots: Vec = (0..n).map(|i| format!("{} 0 R", i + 4)).collect(); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << >> /Annots [{}] >>", annots.join(" ")), + ]; + for i in 0..n { + objs.push(format!( + "<< /Type /Annot /Subtype /Square /Rect [72 72 144 144] /C [1 0 0] /T (Ada) /NM (root-{i}) /Contents (Review note {i}) >>" + )); + } + pdf(objs) +} + +#[test] +fn collapsed_comments_with_ten_thousand_named_roots_stay_within_the_frame_budget() { + if overloaded() { + return; + } + let _turn = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let bytes = named_comments(10_000); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("review.pdf", None, bytes).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "comments").unwrap(); + app.views[0].comments.collapsed_pages = vec![0]; + app + }); + h.run_steps(4); + { + let state = h.state(); + let info = &state.session.get(state.views[0].id).unwrap().info; + assert_eq!(info.pages.len(), 1); + assert_eq!(info.annotations.len(), 10_000); + for a in &info.annotations { + assert_eq!(a.name.as_deref(), Some(format!("root-{}", a.index).as_str())); + assert!(a.in_reply_to.is_none()); + assert!(a.state.is_none()); + } + assert_eq!(state.views[0].comments.collapsed_pages, [0]); + } + // Folding removes card layout cost, so this measures filtering and thread lookup work. + let avg = best_average("collapsed named comments", 30, |_| h.step()); + assert!(avg < BUDGET, "collapsed comments: average frame {avg:?}"); +} + #[test] fn scrolling_a_500_page_document_stays_within_the_frame_budget() { if overloaded() { diff --git a/docs/roadmap.md b/docs/roadmap.md index 3dfea674f..72a8d08e2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,7 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged) + > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -113,7 +115,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: 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: 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 | | 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); 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 | @@ -179,6 +181,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | M5 #2669, item 5: the Comments panel groups replies and counts all page roots once per frame. Estimates unchanged. | | 2026-10-15 | minor | M9 #1895: generic appearance rewriting deferred; separate validation overlays preserve original artwork, exports and printing. Baked captions remain unchanged (#79 open). Estimates unchanged. | | 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | | 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |