diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index d839a052..f1ee4b15 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -264,6 +264,12 @@ fn xfdf_annots(doc: &Document, out: &mut String) { } out.push_str(""); } + // Polygon and polyline points (XFDF ``: `x,y;x,y;…`). + let vertices = nums_of(doc, d.get(b"Vertices")); + let pts: Vec = vertices.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect(); + if !pts.is_empty() { + let _ = write!(out, "{}", pts.join(";")); + } if let Some(pd) = d.get(b"Popup").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) { let r = nums_of(doc, pd.get(b"Rect")); if r.len() == 4 { @@ -617,6 +623,14 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt .collect(); d.set(b"InkList".to_vec(), Object::Array(list)); } + "vertices" => { + // Whole x,y pairs only; a stray trailing number is dropped. + let mut v = csv(child.text().unwrap_or("")); + v.truncate(v.len() - v.len() % 2); + if !v.is_empty() { + d.set(b"Vertices".to_vec(), arr(&v)); + } + } _ => {} } } diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 371884de..022b36c6 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -352,3 +352,59 @@ fn reimporting_a_note_replaces_its_popup() { let parent = doc.get(popups[0]).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); assert!(refs.contains(&parent), "the pop-up belongs to a note that is still on the page"); } + +/// The `/Vertices` of every annotation on the document's pages, by subtype. +fn vertices(doc: &Document) -> Vec<(String, Vec)> { + let mut out = Vec::new(); + for p in pdfcraft_model::pages(doc) { + for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { + let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; + let sub = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + let v = nums_of(doc, d.get(b"Vertices")); + if !v.is_empty() { + out.push((sub, v)); + } + } + } + out +} + +/// storytold/pdfcraft#809: XFDF exchange dropped polygon and polyline vertices, so the +/// imported comments had no shape (and no appearance). +#[test] +fn xfdf_keeps_polygon_and_polyline_vertices() { + let mut src = blank(); + let meta = |id: &str| Meta { date: Some("D:20261002120000Z".into()), id: id.into() }; + let polygon = Shape::Polygon { vertices: vec![[100.0, 100.0], [200.0, 120.0], [150.0, 220.5]], cloud: false }; + let polyline = Shape::PolyLine { + vertices: vec![[50.0, 300.0], [120.0, 380.0], [200.0, 310.0]], + start: pdfcraft_annot::LineEnding::None, + end: pdfcraft_annot::LineEnding::ClosedArrow, + }; + for (page, shape, id) in [(0, polygon, "pg"), (1, polyline, "pl")] { + let new = NewAnnotation { page, style: Style::default_for(&shape), shape, contents: id.into(), author: "Ada".into() }; + add_annotation(&mut src, &new, &meta(id)).unwrap(); + } + let xfdf = export_xfdf(&src, true, false, "shapes.pdf"); + assert!(xfdf.contains("100,100;200,120;150,220.5"), "{xfdf}"); + assert!(xfdf.contains("50,300;120,380;200,310"), "{xfdf}"); + let mut dst = blank(); + let r = import(&mut dst, xfdf.as_bytes()).unwrap(); + assert_eq!(r.comments, 2, "{r:?}"); + assert_eq!(vertices(&dst), vertices(&src)); + // The imported shapes are drawn again. + for p in pdfcraft_model::pages(&dst) { + for a in p.dict.get(b"Annots").map(|a| dst.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { + let d = dst.resolve(&a).as_dict().cloned().unwrap(); + assert!(d.get(b"AP").is_some(), "{d:?}"); + } + } + // Acrobat-style input: a stray trailing number is dropped and only whole points are kept. + let odd = r#" + 1,2;3,4;5,6;7 + junk + "#; + let mut dst = blank(); + import(&mut dst, odd.as_bytes()).unwrap(); + assert_eq!(vertices(&dst), vec![("Polygon".to_string(), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]); +}