M5: preserve comment identities during partial XFDF and FDF imports (#1144)

Reuse a replaced named comment's object reference so omitted replies and
review statuses remain attached. Keep popup replacement behavior and let
the final duplicate record control its deferred reply relationship.
Resolve indirect names in existing PDF comments so updates match their
original objects instead of creating duplicate parents.

Cover existing-PDF updates, incremental and full saves, subsequent FDF
exchange, page-scoped names, unnamed additions, duplicate records and
indirect names with surviving reply and review-status references.

Co-authored-by: MMexicann <[email protected]>
Co-authored-by: Codex <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-11 18:32:03 -04:00
1 parent 75b113ac5b
commit 9622f29d04
4 files changed
+252 -9

No files matched your search

+2
View File
@@ -148,6 +148,8 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-11 (M14, #307 Plan 3):** Engine open and full refresh use one page/content traversal to populate marks and added content. The independent public scans remain available; the combined scanner borrows direct objects and holds referenced objects through `Arc<Object>` instead of cloning scan-only `/Contents` arrays or `/PCAdded` dictionaries. Differential tests cover an indirect `/PCAdded` dictionary as well as ordering, malformed/direct/indirect/repeated entries and `/UserUnit`. Exact `bbbe600`→final release CLI measurements used 10 alternating pairs: wall-time medians were 183.645→174.440 ms on 10,000 marker-free pages (10/10 wins), 33.587→32.752 ms on 1,500 mixed pages (8/10), and 24.331→20.133 ms on the 100,000-entry array stress fixture (10/10). The stress fixture's maximum-RSS median fell 28,655,616→19,038,208 B; peak-footprint medians were 14,747,136→14,673,408 B. From P0, the follow-up stress run was 20.886→20.201 ms (10/10), with RSS 23,953,408→19,087,360 B and peak footprint 14,706,144→14,697,952 B. `doc_open` stdout hashes matched every pair. The ancillary `organize` binding preserves the prior `prune_legacy` update condition. Estimates unchanged.
- **2026-10-11 (M4, Organize overview):** Opening Organize pages from All tools, Home, the tool palette or `ui.set tool=organize` opens the thumbnail grid directly. Checkboxes toggle individual pages without modifier keys; page controls rotate either way or delete one page, while the toolbar acts on the selection. Portrait and landscape previews retain their aspect ratio. UI and control regressions cover entry, selection, single-page versus batch actions, undo, permissions and retaining the last page. Milestone and effort estimates unchanged.
- **2026-10-11 (M5, partial comment imports):** Replacing an editable named comment through XFDF or FDF (including an indirect name in the source PDF) preserves its object identity, so existing replies and review statuses remain attached after saving, reopening and exporting again. Replaced pop-ups are still removed. Locked comments, redaction marks and their pop-ups stay unchanged, including when their names are indirect; imported redactions remain visible. Synthetic round-trip tests cover both formats, incremental and full saves; milestone and effort estimates unchanged.
- **2026-10-10 (M9, signature graphics):** A visible digital signature can show a graphic where the signer's name is large, as Acrobat's Configure Signature Appearance does: the saved Fill & Sign signature (drawn strokes smoothed as ink, typed outlines in the script font, or an image) or an imported PNG/JPEG. Sign as previews it and remembers the choice for the next signing; `sign_document` takes `graphic` (`image`, `text` or `strokes`). Graphics are checked and capped before drawing (non-finite or unbounded points, mismatched pixel buffers, oversized images are refused with an error the caller can act on); images keep their alpha as a soft mask. `sign.signature-appearance` stays partial (no named saved appearances or PDF-page graphics). Overall estimates unchanged.
- **2026-10-11 (M14, absolute interface sizes):** A chosen Interface size is now absolute, as PhotoCraft's UI Scale is: "100%" is one screen pixel per point whatever scale the display reports, so on a 1080p screen whose system reports 200 % it draws at a true 100 % (#456, reported after the first change). Auto still keeps the display's scale and adds GNOME's text scaling, and its menu entry shows the size it draws at. The menu offers PhotoCraft's sizes (75–300 %).
- **2026-10-11 (automation, #971):** `text_find` reads pages in growing batches (8, doubling to 128) and stops once `limit` is reached, so a bounded search on a long document no longer extracts and caches every page first. Results say `complete`, and a capped one gives `next` (`start_page`, `skip`) to continue. Still open from #971: a byte budget for the automation text cache and per-page warnings for pages whose text can't be extracted.
+19 -7
View File
@@ -794,7 +794,7 @@ fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result<ObjRef, Da
let mut clash = false;
for a in annots {
let existing = a.as_ref().and_then(|r| doc.get(r).as_dict().cloned());
let same = nm.is_some() && existing.as_ref().and_then(|x| x.get(b"NM").and_then(|n| n.as_string().map(|s| s.to_text()))) == nm;
let same = nm.is_some() && existing.as_ref().and_then(|x| x.get(b"NM").and_then(|n| doc.resolve(n).as_string().map(|s| s.to_text()))) == nm;
if same && existing.as_ref().is_some_and(import_protected) {
clash = true;
kept.push(a);
@@ -812,7 +812,16 @@ fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result<ObjRef, Da
let Some(x) = a.as_ref().map(|r| doc.get(r)).and_then(|o| o.as_dict().cloned()) else { return true };
x.name(b"Subtype") != Some(b"Popup".as_slice()) || x.reference(b"Parent").is_none_or(|p| !removed.contains(&p))
});
let r = doc.add(Object::Dict(d));
// Keep the replaced comment's identity: replies and review statuses not included in a
// partial import still point at it through /IRT. Allocating a new object would detach
// those comments and leave their parent outside the page's annotation list.
let r = match removed.first().copied() {
Some(r) => {
doc.set(r, Object::Dict(d));
r
}
None => doc.add(Object::Dict(d)),
};
// Our own appearance where we can draw one; others keep none (viewers draw a default).
let _ = pdfcraft_annot::set_appearance(doc, r);
kept.push(Object::Ref(r));
@@ -831,7 +840,7 @@ fn import_xfdf(doc: &mut Document, text: &str) -> Result<Report, DataError> {
let pages: Vec<ObjRef> = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect();
// Comments: parents first, then replies (whose /IRT needs the parent's reference).
let mut by_name: HashMap<String, ObjRef> = HashMap::new();
let mut later: Vec<(ObjRef, String)> = Vec::new();
let mut later: HashMap<ObjRef, String> = HashMap::new();
if let Some(annots) = root.children().find(|c| c.has_tag_name("annots")) {
for node in annots.children().filter(|c| c.is_element()) {
let tag = node.tag_name().name().to_ascii_lowercase();
@@ -839,11 +848,13 @@ fn import_xfdf(doc: &mut Document, text: &str) -> Result<Report, DataError> {
let Some(page) = node.attribute("page").and_then(|p| p.parse::<usize>().ok()).and_then(|p| pages.get(p).copied()) else { continue };
let Some(d) = annot_from_xml(node, subtype, page) else { continue };
let r = place(doc, page, d)?;
// A later same-name record replaces this comment, including its reply relation.
later.remove(&r);
if let Some(nm) = node.attribute("name") {
by_name.insert(nm.to_string(), r);
}
if let Some(irt) = node.attribute("inreplyto") {
later.push((r, irt.to_string()));
later.insert(r, irt.to_string());
}
// A pop-up window.
if let Some(pop) = node.children().find(|c| c.has_tag_name("popup"))
@@ -1028,7 +1039,7 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result<Report, DataError> {
// Comments: parents first, then replies (whose /IRT needs the parent's new reference).
let pages: Vec<ObjRef> = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect();
let mut placed: HashMap<ObjRef, ObjRef> = HashMap::new();
let mut later: Vec<(ObjRef, Option<ObjRef>, Option<String>)> = Vec::new();
let mut later: HashMap<ObjRef, (Option<ObjRef>, Option<String>)> = HashMap::new();
for a in body.get(b"Annots").map(|a| fdf.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() {
let Some(mut src) = fdf.resolve(&a).as_dict().cloned() else { continue };
// The parent is another comment: link it below rather than copying it in.
@@ -1044,16 +1055,17 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result<Report, DataError> {
d.remove(b"Page");
d.set(b"P".to_vec(), Object::Ref(page));
let r = place(doc, page, d)?;
later.remove(&r);
if let Some(fr) = a.as_ref() {
placed.insert(fr, r);
}
if let Some(irt) = irt {
let parent_name = fdf.resolve(&irt).as_dict().and_then(|p| text_of(&fdf, p, b"NM"));
later.push((r, irt.as_ref(), parent_name));
later.insert(r, (irt.as_ref(), parent_name));
}
report.comments += 1;
}
for (r, parent_obj, parent_name) in later {
for (r, (parent_obj, parent_name)) in later {
// The parent imported from this file, else one already in the document with its name.
let parent = parent_obj.and_then(|p| placed.get(&p).copied()).or_else(|| parent_name.and_then(|nm| find_by_name(doc, &nm)));
if let Some(parent) = parent.filter(|p| *p != r) {
+229 -1
View File
@@ -1,7 +1,7 @@
use std::sync::Arc;
use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, add_annotation, add_reply, summaries};
use pdfcraft_cos::{SaveOptions, write_full};
use pdfcraft_cos::{SaveOptions, write_full, write_incremental};
use pdfcraft_forms::{NewField, add_field, fields, set_value};
use super::*;
@@ -399,6 +399,234 @@ fn reimporting_a_note_replaces_its_popup() {
assert!(refs.contains(&parent), "the pop-up belongs to a note that is still on the page");
}
/// A partial import updates the parent without detaching replies and review statuses that
/// weren't included in the data file. FDF export only carries parents still on a page.
#[test]
fn replacing_a_note_keeps_existing_replies_and_review_status() {
let xfdf = concat!(
r#"<xfdf xmlns="http://ns.adobe.com/xfdf/"><annots>"#,
r#"<text page="0" name="n1" rect="100,500,120,520"><contents>Updated note</contents></text>"#,
r#"</annots></xfdf>"#,
);
let mut incoming = blank();
import(&mut incoming, xfdf.as_bytes()).unwrap();
let mut original = filled();
let index = summaries(&original).iter().find(|s| s.name.as_deref() == Some("n1")).unwrap().index;
pdfcraft_annot::set_review_state(
&mut original,
0,
index,
pdfcraft_annot::ReviewState::Accepted,
"Reviewer",
&Meta { date: None, id: "status".into() },
)
.unwrap();
let original = Arc::new(write_full(&original, &SaveOptions::default()).unwrap());
for data in [xfdf.as_bytes().to_vec(), export_fdf(&incoming, true, false, "notes.pdf")] {
// Start with the parent, reply and review status in the existing PDF revision.
let mut doc = Document::open(original.clone()).unwrap();
let mut want = threads(&doc);
want.sort();
assert_eq!(want.iter().filter(|(_, parent, _)| parent.as_deref() == Some("n1")).count(), 2);
assert_eq!(import(&mut doc, &data).unwrap().comments, 1);
let note = summaries(&doc).into_iter().find(|s| s.name.as_deref() == Some("n1")).unwrap();
assert_eq!(note.contents.as_deref(), Some("Updated note"));
// Check the saved object graph and a subsequent export/import, rather than relying on
// summaries resolving an obsolete parent's name from an object outside /Annots.
for incremental in [true, false] {
let saved = if incremental {
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert!(saved.starts_with(original.as_slice()), "an incremental save preserves the original revision");
saved
} else {
write_full(&doc, &SaveOptions::default()).unwrap()
};
let reopened = Document::open(Arc::new(saved)).unwrap();
let note = summaries(&reopened).into_iter().find(|s| s.name.as_deref() == Some("n1")).unwrap();
assert_eq!(note.contents.as_deref(), Some("Updated note"));
let fdf = export_fdf(&reopened, true, false, "notes.pdf");
let mut dst = blank();
import(&mut dst, &fdf).unwrap();
let mut got = threads(&dst);
got.sort();
assert_eq!(got, want, "partial comment import must preserve reply and status threads (incremental={incremental})");
}
}
}
#[test]
fn replacing_an_indirectly_named_note_preserves_its_identity_and_thread() {
let initial = concat!(
r#"<xfdf><annots><text page="0" name="n1" rect="10,10,30,30"><contents>Original</contents></text>"#,
r#"<text page="0" name="r1" rect="40,10,60,30" inreplyto="n1"><contents>Reply</contents></text>"#,
r#"<text page="0" name="s1" rect="70,10,90,30" inreplyto="n1" state="Accepted" statemodel="Review"/>"#,
r#"</annots></xfdf>"#,
);
let update = br#"<xfdf><annots><text page="0" name="n1" rect="10,10,30,30"><contents>Updated</contents></text></annots></xfdf>"#;
let mut original = blank();
for name in ["Name", "Agree", "Pick"] {
pdfcraft_forms::delete_field(&mut original, name).unwrap();
}
import(&mut original, initial.as_bytes()).unwrap();
let annotation_refs = |doc: &Document| {
let page = pdfcraft_model::pages(doc)[0].obj;
doc.get(page)
.as_dict()
.unwrap()
.get(b"Annots")
.map(|a| doc.resolve(a))
.unwrap()
.as_array()
.unwrap()
.iter()
.filter_map(Object::as_ref)
.collect::<Vec<_>>()
};
let parent = annotation_refs(&original)[0];
let indirect_name = original.add(PdfString::text("n1"));
original.update_dict(parent, |d| d.set(b"NM".to_vec(), Object::Ref(indirect_name))).unwrap();
let original = Arc::new(write_full(&original, &SaveOptions::default()).unwrap());
let mut incoming = blank();
import(&mut incoming, update).unwrap();
for data in [update.to_vec(), export_fdf(&incoming, true, false, "notes.pdf")] {
let mut doc = Document::open(original.clone()).unwrap();
let refs = annotation_refs(&doc);
assert_eq!(refs.len(), 3);
let parent = refs.iter().copied().find(|r| text_of(&doc, doc.get(*r).as_dict().unwrap(), b"NM").as_deref() == Some("n1")).unwrap();
assert!(doc.get(parent).as_dict().unwrap().get(b"NM").unwrap().as_ref().is_some(), "the saved source name is indirect");
let mut want = threads(&doc);
want.sort();
assert_eq!(import(&mut doc, &data).unwrap().comments, 1);
let updated = annotation_refs(&doc);
assert_eq!(updated.len(), 3, "a valid indirect name must replace its comment instead of duplicating it");
assert!(updated.contains(&parent), "replacement preserves the source object's identity");
assert_eq!(text_of(&doc, doc.get(parent).as_dict().unwrap(), b"Contents").as_deref(), Some("Updated"));
for child in refs.into_iter().filter(|r| *r != parent) {
assert_eq!(doc.get(child).as_dict().unwrap().reference(b"IRT"), Some(parent));
}
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert!(saved.starts_with(original.as_slice()));
let reopened = Document::open(Arc::new(saved)).unwrap();
let mut got = threads(&reopened);
got.sort();
assert_eq!(got, want);
assert_eq!(summaries(&reopened).iter().find(|s| s.name.as_deref() == Some("n1")).unwrap().contents.as_deref(), Some("Updated"));
}
}
#[test]
fn replacement_matches_named_comments_only_on_their_page() {
let xfdf = concat!(
r#"<xfdf><annots>"#,
r#"<text page="0" name="same" rect="10,10,30,30"><contents>First page</contents></text>"#,
r#"<text page="1" name="same" rect="10,10,30,30"><contents>Second page</contents></text>"#,
r#"<text page="0" rect="40,40,60,60"><contents>Unnamed</contents></text>"#,
r#"</annots></xfdf>"#,
);
let mut doc = blank();
import(&mut doc, xfdf.as_bytes()).unwrap();
import(&mut doc, xfdf.as_bytes()).unwrap();
let notes = summaries(&doc);
assert_eq!(notes.len(), 4, "named comments are replaced, unnamed comments are added");
for (page, text) in [(0, "First page"), (1, "Second page")] {
let note = notes.iter().find(|s| s.page == page && s.name.as_deref() == Some("same")).unwrap();
assert_eq!(note.contents.as_deref(), Some(text));
}
assert_eq!(notes.iter().filter(|s| s.name.is_none()).count(), 2);
}
#[test]
fn the_last_same_named_record_controls_its_reply_parent() {
let mut incoming = blank();
let page = pdfcraft_model::pages(&incoming)[0].obj;
let mut refs = Vec::new();
for name in ["parent", "duplicate", "duplicate"] {
let mut d = Dict::new();
d.set(b"Subtype".to_vec(), Object::name("Text"));
d.set(b"NM".to_vec(), PdfString::text(name));
d.set(b"Rect".to_vec(), arr(&[10.0, 10.0, 30.0, 30.0]));
if refs.len() == 1 {
d.set(b"IRT".to_vec(), Object::Ref(refs[0]));
}
refs.push(incoming.add(d));
}
incoming.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(refs.into_iter().map(Object::Ref).collect()))).unwrap();
for data in [export_xfdf(&incoming, true, false, "notes.pdf").into_bytes(), export_fdf(&incoming, true, false, "notes.pdf")] {
let mut doc = blank();
import(&mut doc, &data).unwrap();
let notes = summaries(&doc);
assert_eq!(notes.len(), 2);
let last = notes.iter().find(|s| s.name.as_deref() == Some("duplicate")).unwrap();
assert_eq!(last.in_reply_to, None, "a replaced record's deferred /IRT must not attach the final standalone comment");
}
}
#[test]
fn indirectly_named_protected_comments_and_popups_are_not_replaced() {
let xfdf = br#"<xfdf><annots><redact page="0" name="n1" rect="0,0,1,1" flags="invisible,hidden,noview,print"><contents>Updated</contents></redact></annots></xfdf>"#;
let fdf = b"%FDF-1.2\n1 0 obj\n<< /FDF << /Annots [<< /Subtype /Redact /NM (n1) /Page 0 /Rect [0 0 1 1] /F 39 /Contents (Updated) >>] >> >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n";
let annotation_refs = |doc: &Document| {
pdfcraft_model::pages(doc)[0]
.dict
.get(b"Annots")
.map(|a| doc.resolve(a))
.unwrap()
.as_array()
.unwrap()
.iter()
.filter_map(Object::as_ref)
.collect::<Vec<_>>()
};
for (subtype, flags) in [("Text", LOCKED), ("Redact", 0)] {
let mut original = blank();
let page = pdfcraft_model::pages(&original)[0].obj;
let name = original.add(PdfString::text("n1"));
let mut protected = Dict::new();
protected.set(b"Type".to_vec(), Object::name("Annot"));
protected.set(b"Subtype".to_vec(), Object::name(subtype));
protected.set(b"NM".to_vec(), Object::Ref(name));
protected.set(b"Rect".to_vec(), arr(&[10.0, 10.0, 30.0, 30.0]));
protected.set(b"F".to_vec(), Object::Int(flags));
protected.set(b"Contents".to_vec(), PdfString::text("Original"));
protected.set(b"CustomData".to_vec(), PdfString::text("preserved"));
let parent = original.add(protected);
let mut popup = Dict::new();
popup.set(b"Subtype".to_vec(), Object::name("Popup"));
popup.set(b"Rect".to_vec(), arr(&[40.0, 10.0, 140.0, 60.0]));
popup.set(b"Parent".to_vec(), Object::Ref(parent));
let popup = original.add(popup);
original.update_dict(parent, |d| d.set(b"Popup".to_vec(), Object::Ref(popup))).unwrap();
let mut annots = annotation_refs(&original);
annots.extend([parent, popup]);
original.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(annots.into_iter().map(Object::Ref).collect()))).unwrap();
let original = Arc::new(write_full(&original, &SaveOptions::default()).unwrap());
for data in [xfdf.as_slice(), fdf.as_slice()] {
let mut doc = Document::open(original.clone()).unwrap();
let before = annotation_refs(&doc);
let parent = before.iter().copied().find(|r| text_of(&doc, doc.get(*r).as_dict().unwrap(), b"NM").as_deref() == Some("n1")).unwrap();
let protected = doc.get(parent);
let popup_ref = protected.as_dict().unwrap().reference(b"Popup").unwrap();
let popup = doc.get(popup_ref);
assert!(protected.as_dict().unwrap().get(b"NM").unwrap().as_ref().is_some());
assert_eq!(import(&mut doc, data).unwrap().comments, 1);
let after = annotation_refs(&doc);
assert_eq!(after.len(), before.len() + 1);
assert!(after.contains(&parent) && after.contains(&popup_ref));
assert_eq!(doc.get(parent), protected, "the protected {subtype} must not change, including its unknown data");
assert_eq!(doc.get(popup_ref), popup, "the protected comment's popup must not change");
let added = after.into_iter().find(|r| !before.contains(r)).unwrap();
let added = doc.get(added);
let added = added.as_dict().unwrap();
assert!(added.get(b"NM").is_none(), "the imported comment must relinquish the protected name");
assert_eq!(added.int(b"F"), Some(4), "an imported redaction stays printable with Invisible/Hidden/NoView removed");
let reopened = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap();
assert_eq!(reopened.get(parent), protected);
assert_eq!(reopened.get(popup_ref), popup);
}
}
}
#[test]
fn imported_redact_marks_cannot_replace_a_locked_mark_or_hide_themselves() {
let mut doc = blank();
+2 -1
View File
@@ -79,7 +79,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 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. Missing: page transitions, recovery on the web |
| 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`. |
| 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; flatten; Fill & Sign; 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. 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; 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. Missing: custom stamps, replace-text proposals, summary layouts with the page |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
@@ -143,6 +143,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| 2026-10-11 | minor | M4: direct Organize overview, checkbox selection and individual page actions. Estimates unchanged. |
| 2026-10-11 | minor | Synced with closed issues: encrypted output opens in Acrobat (#774, #836), so gap 1 left rank 0 and convert and share (32–55 h, the gap's 8–15 h removed); organize, XFDF and multi-object move fixes (#817, #821, #809, #819, #844); the #816 panic and the #799–#821 batch fixed; rank 1 names web printing (Windows shipped, #756). Stage and readiness numbers not re-measured |
| 2026-10-11 | minor | M6: respect existing form-font encodings; retain source fonts on appearance fallback. Estimates unchanged. |
| 2026-10-11 | minor | M5: partial XFDF/FDF imports preserve existing replies and review statuses through save and export. Estimates unchanged. |
| 2026-10-11 | minor | M6: keep accepted form values when Tab reopens the same field before Save. Estimates unchanged. |
| 2026-10-11 | minor | M13: reject malformed CLI --root options before dispatch. Estimates unchanged. |
| 2026-10-11 | minor | M14: Traditional Chinese accessibility statuses, script-rule meaning and Taiwan form/table terminology; added a missing table-row explanation and catalog regressions. Estimates unchanged. |