XFDF keeps a callout's leader, text-box inset, intent and arrowhead (#819) (#854)

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
MarekSpodBiedry 2026-10-10 21:38:27 +02:00 committed by GitHub
parent 4b74f655de
commit f6caba43b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 69 additions and 2 deletions

View File

@ -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).

View File

@ -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::<Vec<_>>().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::<Vec<_>>().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" => {

View File

@ -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}");
}