M5: flattening comments leaves 3D, multimedia and print-production annotations (#3087) (#3089)

doc_flatten with comments chose every non-widget annotation except Link
and Popup, so a comments-only flatten baked in and deleted 3D, Screen,
Movie, RichMedia, PrinterMark, TrapNet and Watermark annotations with
their data (a 3D annotation's U3D model and default view). The comments
branch now uses pdfcraft_annot::is_comment_subtype, the classifier the
comment list uses; widgets follow `fields` only.


Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mtlcl410
2026-10-18 09:48:26 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f0c275669a
commit 2bd6d1357d
3 changed files with 50 additions and 4 deletions
+8 -2
View File
@@ -63,9 +63,15 @@ enum Which {
fn selected(doc: &Document, d: &Dict, which: Which) -> bool {
let subtype = d.name(b"Subtype").unwrap_or_default();
match which {
// Comments are what the comment list calls comments: links, pop-ups and the non-markup
// multimedia and print-production annotations (3D, Screen, Movie, RichMedia,
// PrinterMark, TrapNet, Watermark) are neither comments nor fields, and stay (#3087).
Which::Comments { comments, fields } => {
let widget = subtype == b"Widget";
!matches!(subtype, b"Link" | b"Popup") && (!widget || fields) && (widget || comments)
if subtype == b"Widget" {
fields
} else {
comments && pdfcraft_annot::is_comment_subtype(&String::from_utf8_lossy(subtype))
}
}
Which::FillSign => !matches!(subtype, b"Link" | b"Popup" | b"Widget") && pdfcraft_annot::is_fill_sign(doc, d),
}
+40
View File
@@ -4498,3 +4498,43 @@ fn removing_marks_strips_wrappers_that_closed_open_state() {
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Watermark).unwrap(), 1);
assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]);
}
/// #3087: flattening comments leaves alone what isn't a comment: links, and the non-markup
/// multimedia and print-production annotations (3D, Screen, Movie, RichMedia, PrinterMark,
/// TrapNet, Watermark), which it used to bake into the page and delete with their data.
/// Flattening fields only leaves them alone too; real comments are still flattened.
#[test]
fn flattening_comments_keeps_annotations_that_are_not_comments() {
let kept = ["Widget", "Link", "3D", "Screen", "Movie", "RichMedia", "PrinterMark", "TrapNet", "Watermark"];
let flattened = ["Text", "Square", "FileAttachment"];
let all: Vec<&str> = flattened.iter().chain(kept.iter()).copied().collect();
let form = "<< /Type /XObject /Subtype /Form /BBox [0 0 40 20] /Length 26 >>\nstream\n0 0 1 rg 0 0 40 20 re f\nendstream";
let annots: Vec<String> = all
.iter()
.enumerate()
.map(|(i, s)| {
format!("<< /Type /Annot /Subtype /{s} /Rect [{x} 700 {r} 720] /AP << /N 4 0 R >> /Contents ({s}) >>", x = 20 + 50 * i, r = 60 + 50 * i)
})
.collect();
let refs: Vec<String> = (0..all.len()).map(|i| format!("{} 0 R", 6 + i)).collect();
let page = format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 5 0 R /Annots [{}] >>", refs.join(" "));
let content = stream("", " ");
let mut objs: Vec<&str> = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", &page, form, &content];
objs.extend(annots.iter().map(String::as_str));
let left = |doc: &Document| -> Vec<String> {
let p = &pdfcraft_model::pages(doc)[0];
let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) else { return Vec::new() };
a.as_array()
.unwrap()
.iter()
.filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.name(b"Subtype")).map(|s| String::from_utf8_lossy(s).into_owned()))
.collect()
};
let mut doc = build(&objs);
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), flattened.len());
assert_eq!(left(&reopen(&doc)), kept, "only the comments were flattened");
let mut doc = build(&objs);
assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "the widget");
let without_widget: Vec<&str> = all.iter().copied().filter(|s| *s != "Widget").collect();
assert_eq!(left(&reopen(&doc)), without_widget, "flattening fields touches no annotation that isn't a widget");
}
+2 -2
View File
@@ -4208,8 +4208,8 @@ milestone = "M5"
status = "shipped"
commands = ["comment.flatten"]
tools = ["doc_flatten"]
evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools"]
notes = "appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted"
evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools", "crates/edit/src/tests.rs::flattening_comments_keeps_annotations_that_are_not_comments"]
notes = "appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted; annotations that are not comments (links, 3D, Screen, Movie, RichMedia, PrinterMark, TrapNet, Watermark) are left in place (#3087)"
[[feature]]
id = "comment.migrate"