From 4f7aa22214667f216cbc951664365fbb0122b656 Mon Sep 17 00:00:00 2001 From: muraa-p <85794781+muraa-p@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:06:56 +0800 Subject: [PATCH] fix(edit): keep a comment whose appearance is missing when flattening (#805) (#838) Viewers draw notes, FreeText, ink and stamps from their dictionary when the file stores no /AP /N. flatten only drew annotations that already had an appearance and then removed the rest, so a supported note disappeared and the saved page came back blank. Build the appearance a viewer would show for every selected, visible, non-widget annotation that has none, then draw it as usual. A subtype this build cannot render still fails to build and is handled exactly as before. The new synthetic test draws the note icon into the page and asserts it is baked in; without the fix flatten reports 0 drawn and the icon is lost. Co-authored-by: Big Pickle --- crates/edit/src/flatten.rs | 17 +++++++++++++++++ crates/edit/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+) 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.