diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index 8aa1b0e320..6e08cde48f 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -37,6 +37,28 @@ fn appearance(doc: &Document, d: &Dict) -> Option { } } +/// Whether a widget's field has a value: its `/V`, or the nearest one inherited through `/Parent` +/// (ISO 32000-2 ยง12.7.4.1). An empty string or array is no value. Past the depth limit the answer +/// is yes: a value that wasn't checked must not be removed. +fn has_value(doc: &Document, widget: &Dict) -> bool { + let mut d = widget.clone(); + let mut seen = HashSet::new(); + for _ in 0..64 { + if let Some(v) = d.get(b"V") { + return match &*doc.resolve(v) { + Object::Null => false, + Object::String(s) => !s.to_text().is_empty(), + Object::Array(a) => !a.is_empty(), + _ => true, + }; + } + let Some(parent) = d.reference(b"Parent").filter(|p| seen.insert(*p)) else { return false }; + let Some(next) = doc.get(parent).as_dict().cloned() else { return false }; + d = next; + } + true +} + /// Algorithm 8.1: the matrix that maps the form's bounding box onto the annotation rectangle. fn placement(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option<[f64; 6]> { let bbox = nums(doc, form.get(b"BBox")).filter(|b| b.len() == 4)?; @@ -220,13 +242,14 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R 9 0 R] >>", + &page, + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (no_value) /Rect [10 10 150 40] /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (empty) /V () /Rect [10 50 150 80] /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (drawn) /Rect [10 90 150 120] /F 4 /AP << /N 10 0 R >> >>", + "<< /FT /Tx /T (parent) /V (Ada) /Kids [9 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 130 150 160] /F 4 >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream", + ]); + assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "only the field with an appearance is drawn"); + let doc = reopen(&doc); + let refs = |o: &Object| -> Vec { doc.resolve(o).as_array().unwrap().iter().filter_map(|f| f.as_ref()).map(|r| r.num).collect() }; + let p = &pdfcraft_model::pages(&doc)[0]; + assert_eq!(refs(p.dict.get(b"Annots").unwrap()), [9], "only the widget with an inherited value stays on the page"); + let root = doc.root().unwrap(); + let af = doc.resolve(doc.get(root).as_dict().unwrap().get(b"AcroForm").unwrap()); + assert_eq!(refs(af.as_dict().unwrap().get(b"Fields").unwrap()), [8], "and its field in the form"); +} + +/// #4017: when the walk up the parent fields reaches its limit before finding a value, the widget +/// stays: an unverified value must not be removed. Here `/V` sits on the 64th ancestor. +#[test] +fn flattening_keeps_a_widget_whose_value_is_past_the_parent_limit() { + let page = stream("", " "); + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [69 0 R] >> >>".into(), + "<< /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 /Parent 6 0 R /Rect [10 10 150 40] /F 4 >>".into(), + ]; + objs.extend((6..69).map(|n| format!("<< /Parent {} 0 R /Kids [{} 0 R] >>", n + 1, n - 1))); + objs.push("<< /FT /Tx /T (deep) /V (Ada) /Kids [68 0 R] >>".into()); + let mut doc = build(&objs.iter().map(String::as_str).collect::>()); + flatten(&mut doc, &[0], false, true).unwrap(); + let doc = reopen(&doc); + let annots = pdfcraft_model::pages(&doc)[0].dict.get(b"Annots").map(|a| doc.resolve(a).as_array().map_or(0, Vec::len)); + assert_eq!(annots, Some(1), "the widget stays"); +} + /// #1794: a reply this builder cannot draw stays on the page even when the comment it replies to /// is flattened; the comment's pop-up still goes with it. #[test]