From f6caba43b2140441aa1b2794109e2811249a1065 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:38:27 +0200 Subject: [PATCH] XFDF keeps a callout's leader, text-box inset, intent and arrowhead (#819) (#854) Co-authored-by: Brandon Thomas --- crates/xfdf/README.md | 3 ++- crates/xfdf/src/lib.rs | 31 ++++++++++++++++++++++++++++++- crates/xfdf/src/tests.rs | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/xfdf/README.md b/crates/xfdf/README.md index fc79172f..fd285258 100644 --- a/crates/xfdf/README.md +++ b/crates/xfdf/README.md @@ -12,7 +12,8 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o - **XFDF** (ISO 19444-1): every comment type PdfCraft knows (text, free text, line, square, circle, polygon, polyline, markup, stamp, caret, ink, attachments, sound, redact) with rect, name, author, subject, dates, flags, colours, opacity, width, icon, quads, line ends, ink, - default appearance, pop-ups and reply threads (`inreplyto`); field values nested by name. + default appearance, callouts (`callout`, `fringe`, `intent`, `head`), pop-ups and reply threads + (`inreplyto`); field values nested by name. - **FDF**: fields (with `/Kids` hierarchies) and comments as direct objects. - **Form data**: Acrobat's XML (`xfdf:original` keeps names that aren't XML names), CSV and tab-delimited text (a row of names, a row of values). diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index f1ee4b15..91c3d245 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -2,7 +2,7 @@ //! //! - **XFDF** (ISO 19444-1, Acrobat's "Export all to data file" / "Import comments", and form //! data): comments with their geometry, colours, flags, authors, dates, replies, pop-ups, -//! ink, quadrilaterals and line ends; field values, nested by name. +//! ink, quadrilaterals, line ends and callouts; field values, nested by name. //! - **FDF** (ISO 32000-2 ยง12.7.8): fields and comments in PDF syntax. //! - Form data as **XML**, **CSV** and **tab-delimited text** (Acrobat's Export data formats). //! @@ -244,6 +244,21 @@ fn xfdf_annots(doc: &Document, out: &mut String) { let name = |o: &Object| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned()).unwrap_or_else(|| "None".into()); let _ = write!(attrs, " head=\"{}\" tail=\"{}\"", name(&le[0]), name(&le[1])); } + // A callout: its leader path, text-box inset, intent and (single-name) line ending. + let cl = nums_of(doc, d.get(b"CL")); + if cl.len() == 4 || cl.len() == 6 { + let _ = write!(attrs, " callout=\"{}\"", cl.iter().map(|v| n(*v)).collect::>().join(",")); + let rd = nums_of(doc, d.get(b"RD")); + if rd.len() == 4 { + let _ = write!(attrs, " fringe=\"{}\"", rd.iter().map(|v| n(*v)).collect::>().join(",")); + } + if let Some(it) = d.get(b"IT").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { + let _ = write!(attrs, " intent=\"{}\"", esc(&it)); + } + if let Some(le) = d.get(b"LE").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { + let _ = write!(attrs, " head=\"{}\"", esc(&le)); + } + } if let Some(irt) = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| names.get(&r)) { let _ = write!(attrs, " inreplyto=\"{}\"", esc(irt)); } @@ -607,6 +622,20 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt Object::Array(vec![Object::name(node.attribute("head").unwrap_or("None")), Object::name(node.attribute("tail").unwrap_or("None"))]), ); } + if let Some(cl) = node.attribute("callout").map(csv).filter(|c| c.len() == 4 || c.len() == 6) { + d.set(b"CL".to_vec(), arr(&cl)); + if let Some(rd) = node.attribute("fringe").map(csv).filter(|r| r.len() == 4) { + d.set(b"RD".to_vec(), arr(&rd)); + } + d.set(b"IT".to_vec(), Object::name(node.attribute("intent").unwrap_or("FreeTextCallout"))); + // A callout's /LE is one name, not the head/tail pair of a line. + match node.attribute("head") { + Some(h) => d.set(b"LE".to_vec(), Object::name(h)), + None => { + d.remove(b"LE"); + } + } + } for child in node.children().filter(|c| c.is_element()) { match child.tag_name().name() { "contents" => { diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 022b36c6..33b3a5a8 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -408,3 +408,40 @@ fn xfdf_keeps_polygon_and_polyline_vertices() { 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])]); } + +#[test] +fn xfdf_keeps_callout_leader_inset_and_ending() { + let mut src = blank(); + let shape = Shape::Callout { + rect: [40.0, 300.0, 180.0, 360.0], + knee: [260.0, 330.0], + point: [320.0, 200.0], + font_size: 12.0, + ending: pdfcraft_annot::LineEnding::ClosedArrow, + }; + let meta = Meta { date: Some("D:20261002120000Z".into()), id: "c1".into() }; + add_annotation( + &mut src, + &NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: "leader".into(), author: "Ada".into() }, + &meta, + ) + .unwrap(); + let xfdf = export_xfdf(&src, true, false, "form.pdf"); + let mut dst = blank(); + import(&mut dst, xfdf.as_bytes()).unwrap(); + let callout_keys = |doc: &Document| { + let page = pdfcraft_model::pages(doc).remove(0); + let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); + let d = annots.iter().filter_map(|a| doc.resolve(a).as_dict().cloned()).find(|d| d.name(b"Subtype") == Some(b"FreeText")).unwrap(); + ( + nums_of(doc, d.get(b"Rect")), + nums_of(doc, d.get(b"CL")), + nums_of(doc, d.get(b"RD")), + d.name(b"IT").map(<[u8]>::to_vec), + d.get(b"LE").and_then(|o| o.as_name()).map(<[u8]>::to_vec), + ) + }; + let want = callout_keys(&src); + assert_eq!(want.1.len(), 6, "{want:?}"); + assert_eq!(callout_keys(&dst), want, "{xfdf}"); +}