diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 587cd4466..0cdd8f388 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1355,8 +1355,22 @@ fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> apd.remove(b"D"); apd.remove(b"R"); apd.set(b"N".to_vec(), Object::Ref(ap)); + // A note icon has a fixed size (`NOTE_SIZE`); viewers draw it at the upper-left corner of `/Rect`. + // The appearance's box is mapped onto `/Rect`, so any other `/Rect` (a large one, or one with + // no width, as LaTeX `pdfcomment` writes) would stretch the icon or shrink it to nothing. + let icon_rect = matches!(d.name(b"Subtype"), Some(b"Text" | b"FileAttachment")) + .then(|| d.get(b"Rect")?.as_array()?.iter().map(|o| o.as_f64()).collect::>>()) + .flatten() + .filter(|q| q.len() == 4 && finite(q)) + .map(|q| { + let (x, top) = (q[0].min(q[2]), q[1].max(q[3])); + [x, top - NOTE_SIZE, x + NOTE_SIZE, top] + }); doc.update_dict(r, |d| { d.set(b"AP".to_vec(), Object::Dict(apd)); + if let Some(rect) = icon_rect { + d.set(b"Rect".to_vec(), num_array(&rect)); + } d.remove(b"AS"); pdfcraft_fonts::set_undrawn_mark(d, missing); })?; diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 4a4dfb106..ba15b81a6 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -991,6 +991,29 @@ fn malformed_optional_interior_color_does_not_hide_shape_strokes() { assert!(list(&doc, 0)[0].get(b"AP").is_none(), "display generation leaves the source document unchanged"); } +/// #3175: viewers draw a note without an appearance as a fixed-size icon at the upper-left corner +/// of its `/Rect`. The display copy must not stretch the icon over a large `/Rect` or shrink it +/// to nothing in one with no width. +#[test] +fn a_note_without_an_appearance_is_shown_at_icon_size() { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 600] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + b"<< /Type /Annot /Subtype /Text /Rect [-23 240 480 468] /C [1 0 0] /Open true >>", + b"<< /Type /Annot /Subtype /Text /Rect [310 380 310 392] /Name /Comment /F 4 >>", + b"<< /Type /Annot /Subtype /FileAttachment /Rect [100 50 300 10] >>", + ]); + let display = with_missing_appearances(&doc).expect("the notes get a display appearance"); + let rects = |doc: &Document| -> Vec> { + let rect = |d: &Dict| d.get(b"Rect").and_then(|r| r.as_array()).map(|a| a.iter().filter_map(Object::as_f64).collect::>()); + list(doc, 0).iter().map(|d| rect(d).unwrap_or_default()).collect() + }; + assert_eq!(rects(&display), [vec![-23.0, 448.0, -3.0, 468.0], vec![310.0, 372.0, 330.0, 392.0], vec![100.0, 30.0, 120.0, 50.0]]); + let original = [vec![-23.0, 240.0, 480.0, 468.0], vec![310.0, 380.0, 310.0, 392.0], vec![100.0, 50.0, 300.0, 10.0]]; + assert_eq!(rects(&doc), original, "the source document is unchanged"); +} + #[test] fn every_line_ending_is_drawn() { let filled = [LineEnding::Square, LineEnding::Circle, LineEnding::Diamond, LineEnding::ClosedArrow, LineEnding::RClosedArrow]; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 879ebc6c6..ecddbe587 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -284,6 +284,27 @@ fn flattening_keeps_a_note_whose_appearance_is_missing() { assert!(flat.contains("q 1 0 0 1 10 10 cm /PCFl0 Do Q"), "{flat}"); } +/// #3175: a note without an appearance is drawn as a fixed-size icon at the upper-left corner of +/// its `/Rect`. A rectangle with no width (LaTeX `pdfcomment`) used to scale the icon to nothing +/// while flattening still counted it as drawn and deleted the note. +#[test] +fn flattening_a_note_with_a_zero_width_rect_draws_a_fixed_size_icon() { + 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 6 0 R] >>", + &page, + "<< /Type /Annot /Subtype /Text /Rect [50 60 50 72] /Contents (Zero width) /Name /Comment /F 4 >>", + "<< /Type /Annot /Subtype /Text /Rect [10 20 150 120] /Contents (Large) /F 4 >>", + ]); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 2, "both icons are drawn"); + let doc = reopen(&doc); + let flat = streams(&doc, 0).pop().unwrap(); + assert!(flat.contains("q 1 0 0 1 50 52 cm /PCFl0 Do Q"), "{flat}"); + assert!(flat.contains("q 1 0 0 1 10 100 cm /PCFl1 Do Q"), "{flat}"); +} + /// #1470: a form field whose widget is on no page (left behind by page extraction or combining) /// has nothing to draw, but flattening every form field still takes it out of `/AcroForm /Fields`, /// or other software keeps treating the flattened file as a form. A hidden widget on a page is