address review: clear direct annotation actions and count unlisted widget fields

This commit is contained in:
Dylan M. Taylor
2026-11-04 01:42:44 +00:00
parent 26d94c7ad9
commit 2815daee0f
2 changed files with 51 additions and 3 deletions
+14 -3
View File
@@ -486,6 +486,7 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> {
let pages = pdfcraft_model::pages(doc);
let annots: Vec<Dict> = pages.iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| doc.resolve(&a).as_dict().cloned()).collect();
let count_sub = |s: &[u8]| annots.iter().filter(|a| a.name(b"Subtype") == Some(s)).count();
let listed = listed_annotations(doc);
let names = sub(doc, &cat, b"Names").unwrap_or_default();
let owned_keys: Vec<&[u8]> = OWNED.iter().map(|o| o.1).collect();
// A graph nested too deeply to walk counts as holding everything, so the removal runs and
@@ -528,9 +529,10 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> {
+ usize::from(cat.get(b"OpenAction").is_some_and(|a| doc.resolve(a).as_dict().is_some()))
+ owned(Hidden::LinksActionsScripts)
+ annots.iter().filter(|a| primary_action_only(a)).count()
// fields no page lists (a listed widget is counted with the annotations above)
+ field_dicts(doc)
.into_iter()
.filter(|&r| doc.get(r).as_dict().is_some_and(|d| !d.contains(b"Subtype") && primary_action_only(d)))
.filter(|r| !listed.contains(r) && doc.get(*r).as_dict().is_some_and(primary_action_only))
.count()
+ sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t))
}
@@ -601,9 +603,18 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
let mut kept = Vec::new();
for a in list {
let Some(r) = a.as_ref() else {
if !doc.resolve(&a).as_dict().is_some_and(drop_annot) {
kept.push(a);
let mut a = a;
if doc.resolve(&a).as_dict().is_some_and(drop_annot) {
continue;
}
// a direct annotation holds its own actions: removed from the entry kept in the array
if which.contains(&Hidden::LinksActionsScripts)
&& let Object::Dict(d) = &mut a
{
d.remove(b"AA");
d.remove(b"A");
}
kept.push(a);
continue;
};
let d = doc.get(r).as_dict().cloned().unwrap_or_default();
+37
View File
@@ -3922,3 +3922,40 @@ fn screen_and_field_primary_actions_are_removed() {
let saved = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(!saved.windows(14).any(|w| w == b"SCRIPT_PAYLOAD"), "the script is still in the file");
}
/// A direct annotation dictionary in `/Annots` holds its own action: removal clears it there, not
/// only from annotations that are indirect objects.
#[test]
fn removing_actions_clears_a_direct_annotation_action() {
use crate::sanitize::{Hidden, remove_hidden, scan};
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [<< /Type /Annot /Subtype /Screen /Rect [10 10 50 50] /A << /S /JavaScript /JS (app.alert\\(1\\)) >> >>] >>"
.to_vec(),
stream("", b"BT ET"),
];
let mut doc = pdf(objs);
let counts: HashMap<Hidden, usize> = scan(&doc).into_iter().collect();
assert!(counts[&Hidden::LinksActionsScripts] > 0, "{counts:?}");
remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]).unwrap();
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
assert!(!bytes.windows(10).any(|w| w == b"JavaScript"), "the direct annotation's action survived the save");
}
/// A widget field no page lists, with `/Subtype`, still counts for the actions scan when it holds
/// an action, as removal clears it.
#[test]
fn an_unlisted_widget_field_action_is_counted() {
use crate::sanitize::{Hidden, scan};
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(),
stream("", b"BT ET"),
b"<< /Type /Annot /Subtype /Widget /FT /Btn /T (b) /Rect [0 0 10 10] /A << /S /JavaScript /JS (x) >> >>".to_vec(),
];
let doc = pdf(objs);
let counts: HashMap<Hidden, usize> = scan(&doc).into_iter().collect();
assert!(counts[&Hidden::LinksActionsScripts] > 0, "{counts:?}");
}