diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index 1be77abef..ac90dda11 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -309,42 +309,59 @@ fn prune_fields(doc: &mut Document, widgets: &HashSet, on_page: Option<& let af_ref = af.as_ref(); let mut af_dict = doc.resolve(&af).as_dict().cloned().unwrap_or_default(); let fields = af_dict.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); - // Returns whether the node survives. `known` holds each node's answer once it is visited, so - // a node listed twice is pruned once, and one still being visited (a cycle) survives. + // Returns whether the node survives, and whether that answer depends on the depth cutoff. + // `known` holds each node's answer once it is visited, so a node listed twice is pruned once, + // and one still being visited (a cycle) survives. An answer the cutoff decided records the depth + // it was reached at, and a shallower path visits the node again: at most once per depth. fn keep( doc: &mut Document, r: ObjRef, widgets: &HashSet, on_page: Option<&HashSet>, - known: &mut HashMap, + known: &mut HashMap)>, depth: usize, - ) -> bool { + ) -> (bool, bool) { #[cfg(test)] KEEP_CALLS.with(|c| c.set(c.get().saturating_add(1))); - if widgets.contains(&r) || depth > 64 { - return depth > 64; + if widgets.contains(&r) { + return (false, false); } - if let Some(&survives) = known.get(&r) { - return survives; + if depth > 64 { + return (true, true); } - known.insert(r, true); - let Some(d) = doc.get(r).as_dict().cloned() else { return true }; + match known.get(&r) { + Some(&(survives, None)) => return (survives, false), + Some(&(survives, Some(at))) if depth >= at => return (survives, true), + _ => {} + } + known.insert(r, (true, None)); + let Some(d) = doc.get(r).as_dict().cloned() else { return (true, false) }; let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) else { let survives = on_page.is_none_or(|p| p.contains(&r)); - known.insert(r, survives); - return survives; + known.insert(r, (survives, None)); + return (survives, false); }; - let survivors: Vec = - kids.iter().filter(|k| k.as_ref().is_none_or(|kr| keep(doc, kr, widgets, on_page, known, depth + 1))).cloned().collect(); + let mut cut = false; + let survivors: Vec = kids + .iter() + .filter(|k| { + k.as_ref().is_none_or(|kr| { + let (survives, kid_cut) = keep(doc, kr, widgets, on_page, known, depth + 1); + cut |= kid_cut; + survives + }) + }) + .cloned() + .collect(); if survivors.len() != kids.len() { let _ = doc.update_dict(r, |d| d.set(b"Kids".to_vec(), Object::Array(survivors.clone()))); } - known.insert(r, !survivors.is_empty()); - !survivors.is_empty() + known.insert(r, (!survivors.is_empty(), cut.then_some(depth))); + (!survivors.is_empty(), cut) } let mut known = HashMap::new(); let survivors: Vec = - fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, on_page, &mut known, 0))).cloned().collect(); + fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, on_page, &mut known, 0).0)).cloned().collect(); if survivors.len() == fields.len() && widgets.is_empty() { return Ok(()); // only looked for fields on no page, and there were none } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 69ee2687e..2b3d474bb 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6403,6 +6403,38 @@ fn flattening_prunes_a_field_tree_with_repeated_kids_once_per_node() { assert_eq!(left, 2, "the chain to the hidden widget and the self-listing node stay"); } +/// A field reached first at the end of a long chain has its kids cut off by the depth limit, so +/// they survive there. A shorter path to the same field still looks at its kids: an off-page widget +/// under it is taken out when every field is flattened. +#[test] +fn a_field_reached_again_by_a_shorter_path_is_pruned_there() { + let chain = 64; + let page = stream("", " "); + // 7…: the chain; then the shared field, its off-page widget, and the short path to it. + let shared = 7 + chain; + let fields = format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 7 0 R {} 0 R] >> >>", shared + 2); + let mut objs: Vec = vec![ + fields, + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>".into(), + page, + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (shown) /Rect [10 10 150 40] /F 4 /AP << /N 6 0 R >> >>".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream".into(), + ]; + for i in 0..chain { + objs.push(format!("<< /T (c{i}) /Kids [{} 0 R] >>", 8 + i)); + } + objs.push(format!("<< /T (shared) /Kids [{} 0 R] >>", shared + 1)); + objs.push("<< /Type /Annot /Subtype /Widget /FT /Tx /T (orphan) /Rect [10 50 150 80] /F 4 >>".into()); + objs.push(format!("<< /T (short) /Kids [{shared} 0 R] >>")); + let objs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let mut doc = build(&objs); + assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1); + let shared_ref = pdfcraft_cos::ObjRef::new(u32::try_from(shared).unwrap(), 0); + let kids = doc.resolve(doc.get(shared_ref).as_dict().unwrap().get(b"Kids").unwrap()).as_array().unwrap().len(); + assert_eq!(kids, 0, "the off-page widget is pruned along the short path"); +} + /// A ToUnicode destination is repeated for every glyph that shows its code: a page whose text /// would run past the extraction budget is refused, not built in memory. #[test]