XFDF keeps polygon and polyline vertices (#809) (#848)

Export wrote polygon and polyline comments without their points and import
had nowhere to read them from, so an XFDF round trip left the comments with
no /Vertices and no appearance. Export now writes the points as XFDF
<vertices>x,y;x,y;…</vertices>, and import reads them back (whole x,y pairs
only) before the appearance is drawn.

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
DWAA1660 2026-10-10 13:35:25 -06:00 committed by GitHub
parent 259002da97
commit 4b74f655de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 70 additions and 0 deletions

View File

@ -264,6 +264,12 @@ fn xfdf_annots(doc: &Document, out: &mut String) {
}
out.push_str("</inklist>");
}
// Polygon and polyline points (XFDF `<vertices>`: `x,y;x,y;…`).
let vertices = nums_of(doc, d.get(b"Vertices"));
let pts: Vec<String> = vertices.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect();
if !pts.is_empty() {
let _ = write!(out, "<vertices>{}</vertices>", 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));
}
}
_ => {}
}
}

View File

@ -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<f64>)> {
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("<vertices>100,100;200,120;150,220.5</vertices>"), "{xfdf}");
assert!(xfdf.contains("<vertices>50,300;120,380;200,310</vertices>"), "{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#"<?xml version="1.0"?><xfdf xmlns="http://ns.adobe.com/xfdf/"><annots>
<polygon page="0" rect="0,0,50,50" name="odd"><vertices>1,2;3,4;5,6;7</vertices></polygon>
<polygon page="0" rect="0,0,50,50" name="none"><vertices>junk</vertices></polygon>
</annots></xfdf>"#;
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])]);
}