diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index dd05dbb7e..134696665 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -118,6 +118,23 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result = HashSet::new(); let mut gone_inline: HashSet = HashSet::new(); + // A viewer draws many comments from their dictionary even when the file stores no + // appearance (notes, FreeText, ink, stamps…). Build the appearance it would show, so + // flattening keeps a comment that still has ink on screen instead of deleting it. + for entry in &list { + let Some(r) = entry.as_ref() else { continue }; + let d = doc.resolve(entry).as_dict().cloned().unwrap_or_default(); + let subtype = d.name(b"Subtype").unwrap_or_default(); + if subtype == b"Widget" || !selected(doc, &d, which) { + continue; + } + let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + if flags & (HIDDEN | NO_VIEW) != 0 || appearance(doc, &d).is_some() { + continue; + } + // A subtype this build cannot draw only fails here; it is still removed below. + let _ = pdfcraft_annot::set_appearance(doc, r); + } for (k, entry) in list.iter().enumerate() { let obj = doc.resolve(entry); let Some(d) = obj.as_dict() else { continue }; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 54aecc759..03baeac09 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -175,6 +175,26 @@ fn flattening_draws_appearances_into_the_page_and_removes_the_comments() { assert!(marks_present(&doc).is_empty(), "flattened content is not a removable mark"); } +/// #805: a supported note with no stored appearance is still drawn by viewers. Flattening must +/// generate that appearance and bake it into the page, not delete a comment that shows an icon. +#[test] +fn flattening_keeps_a_note_whose_appearance_is_missing() { + let page = stream("", " "); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>", + &page, + "<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (Synthetic note) /Name /Comment /Open false /F 0 >>", + ]); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "the note icon is drawn"); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + assert!(!p.dict.contains(b"Annots"), "the note is gone once its icon is baked in"); + let flat = streams(&doc, 0).pop().unwrap(); + assert!(flat.contains("q 1 0 0 1 10 10 cm /PCFl0 Do Q"), "{flat}"); +} + /// Display space is in points, so on a `/UserUnit 2` page an item's box maps to half as many /// user-space units. Items record the `/UserUnit` they were written for; one without it was /// written when display space was in user-space units, and reads back scaled to points.