fix(xfdf): FDF comment export excludes source pages by identity, not by /Type (#6036)

This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-09 18:18:59 +08:00
1 parent ce1cd80477
commit 3a1a2272fe
2 files changed
+51 -2

No files matched your search

+14 -2
View File
@@ -42,13 +42,23 @@ struct FdfCopy<'a> {
objects: Vec<Object>,
numbers: HashMap<ObjRef, u32>,
pending: Vec<(ObjRef, usize)>,
/// The leaf pages of the source document, by identity: whatever their `/Type` says (or does not).
source_pages: HashSet<ObjRef>,
nodes: usize,
bytes: usize,
}
impl<'a> FdfCopy<'a> {
fn new(doc: &'a Document) -> Self {
Self { doc, objects: Vec::new(), numbers: HashMap::new(), pending: Vec::new(), nodes: MAX_FDF_OBJECTS, bytes: MAX_FDF_BYTES }
Self {
doc,
objects: Vec::new(),
numbers: HashMap::new(),
pending: Vec::new(),
source_pages: HashSet::new(),
nodes: MAX_FDF_OBJECTS,
bytes: MAX_FDF_BYTES,
}
}
fn charge(&mut self, bytes: usize) -> Result<(), DataError> {
@@ -181,7 +191,7 @@ impl<'a> FdfCopy<'a> {
while let Some((source, index)) = self.pending.get(cursor).copied() {
// ISO 32000-2 §7.3.10: an absent or stale reference denotes null. Keep reader
// errors distinct so damaged objects cannot be silently lost during export.
let value = if doc.generation(source.num) != source.generation {
let value = if doc.generation(source.num) != source.generation || self.source_pages.contains(&source) {
std::sync::Arc::new(Object::Null)
} else {
match doc.try_get(source.num) {
@@ -250,6 +260,8 @@ pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> R
// Keep the page/array owners once. Handles retain positions, never annotation
// bodies: the document cache can evict and reparse even a repeated source reference.
let pages = pdfcraft_model::pages(doc);
// Page discovery accepts a leaf with no /Type: the export keeps these out by reference too.
copy.source_pages = pages.iter().map(|p| p.obj).collect();
let mut arrays = Vec::new();
let mut picked: Vec<(Option<ObjRef>, usize, usize)> = Vec::new();
for (pi, page) in pages.iter().enumerate() {
+37
View File
@@ -1706,6 +1706,43 @@ fn xml_form_import_preserves_text_around_comments_and_instructions() {
}
}
/// Page discovery accepts a leaf without /Type, so a comment's nested reference to one must not
/// carry the page or its content into the export.
#[test]
fn fdf_export_nulls_a_nested_link_to_a_page_without_a_type() {
let source = {
let mut doc = Document::new_empty();
let root = doc.root().unwrap();
let tree = doc.get(root).as_dict().unwrap().reference(b"Pages").unwrap();
let contents = doc.add(pdfcraft_cos::Stream::from_raw(Dict::new(), b"% private-page-content\n".repeat(4_096)));
let mut page = Dict::new();
page.set(b"Parent".to_vec(), Object::Ref(tree));
page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));
page.set(b"Contents".to_vec(), Object::Ref(contents));
let page = doc.add(page);
doc.update_dict(tree, |d| {
d.set(b"Type".to_vec(), Object::name("Pages"));
d.set(b"Count".to_vec(), Object::Int(1));
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
})
.unwrap();
let mut target = Dict::new();
target.set(b"Label".to_vec(), PdfString::text("kept"));
target.set(b"Target".to_vec(), Object::Ref(page));
fdf_note_with_data(&mut doc, Object::Array(vec![Object::Dict(target)]));
write_full(&doc, &SaveOptions::default()).unwrap()
};
let source = Document::open(Arc::new(source)).unwrap();
assert_eq!(pdfcraft_model::pages(&source).len(), 1);
let bytes = export_fdf(&source, true, false, "source.pdf").unwrap();
assert!(bytes.len() < 4_096, "the untyped page's content must stay in the PDF ({} bytes)", bytes.len());
let fdf = Document::open(Arc::new(bytes)).unwrap();
let data = exported_note_data(&fdf);
let target = data.as_array().unwrap()[0].as_dict().unwrap();
assert_eq!(target.get(b"Label").unwrap().as_string().unwrap().to_text(), "kept");
assert_eq!(*fdf.resolve(target.get(b"Target").unwrap()), Object::Null);
}
#[test]
fn fdf_export_nulls_nested_page_links_without_copying_the_source_page_tree() {
for indirect_type in [false, true] {