M5: batch Comments panel thread and page lookups (#2669)

This commit is contained in:
Ahmad Mujahid
2026-10-16 12:03:40 +04:00
parent f0c9901c39
commit 4f3f2fe1d2
5 changed files with 208 additions and 11 deletions
+3
View File
@@ -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.
+16 -8
View File
@@ -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<usize, usize> = 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);
}
}
+130 -1
View File
@@ -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<u8>, 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() {
+55 -1
View File
@@ -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<u8> {
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<String>) -> Vec<u8> {
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<u8> {
out
}
/// Named roots exercise thread lookups even when none of the comments has replies.
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![
"<< /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() {
+4 -1
View File
@@ -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:<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: 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: 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:<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); 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 |
@@ -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. |