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

Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
MarekSpodBiedryandBrandon Thomas authored and GitHub committed 2026-10-10 15:38:27 -04:00
1 parent 4b74f655de
commit f6caba43b2
3 files changed
+69 -2

No files matched your search

+2 -1
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).
+30 -1
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" => {
+37
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}");
}