From 2bd6d1357da4129be429127c000966eaa8ffa5e2 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:48:26 +0800 Subject: [PATCH] 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 --- crates/edit/src/flatten.rs | 10 +++++++-- crates/edit/src/tests.rs | 40 ++++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 ++-- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index 8202c18d83..3ac5468057 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -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), } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 7930a7eade..299356b293 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -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 = 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 = (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 { + 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"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f05387aa21..245d439af9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"