diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 6b14dfaa..d0a0ae09 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1793,7 +1793,14 @@ fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option { (!s.is_empty()).then_some(s) } -/// Every comment (not links, form widgets or pop-ups), ordered by page and then top edge. +/// Whether an annotation subtype is a comment. Links, form widgets, pop-ups and the non-markup +/// multimedia and print-production annotations (ISO 32000-2 §12.5.6: Screen, Movie, RichMedia, +/// 3D, PrinterMark, TrapNet, Watermark) are not. +pub fn is_comment_subtype(subtype: &str) -> bool { + !matches!(subtype, "Link" | "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark") +} + +/// Every comment (see [`is_comment_subtype`]), ordered by page and then top edge. pub fn summaries(doc: &Document) -> Vec { let mut out = Vec::new(); let Ok(pages) = page_refs(doc) else { return out }; @@ -1802,7 +1809,7 @@ pub fn summaries(doc: &Document) -> Vec { let obj = doc.resolve(entry); let Some(d) = obj.as_dict() else { continue }; let Some(subtype) = d.name(b"Subtype").map(|s| String::from_utf8_lossy(s).into_owned()) else { continue }; - if matches!(subtype.as_str(), "Link" | "Widget" | "Popup") { + if !is_comment_subtype(&subtype) { continue; } let nums = |k: &[u8]| -> Vec { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 5990e0d0..93cc6497 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -919,3 +919,14 @@ fn redraw_keeps_shared_annotation_appearances_and_drops_stale_alternates() { } } } + +#[test] +fn non_markup_annotations_are_not_comments() { + // #169: a LaTeX `animate` player's Screen annotation showed up in the Comments list. + for s in ["Link", "Widget", "Popup", "Screen", "Movie", "RichMedia", "3D", "PrinterMark", "TrapNet", "Watermark"] { + assert!(!is_comment_subtype(s), "{s}"); + } + for s in ["Text", "FreeText", "Highlight", "Ink", "Stamp", "FileAttachment", "Redact", "Sound"] { + assert!(is_comment_subtype(s), "{s}"); + } +} diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index ada3ae7e..bd77a0f6 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -815,7 +815,11 @@ impl<'a> Inspector<'a> { } continue; } - if matches!(subtype.as_str(), "Widget" | "Popup") { + // Not comments: form widgets, pop-ups and non-markup annotations such as the + // Screen annotation that drives a LaTeX `animate` player (keep in step with + // `pdfcraft_annot::is_comment_subtype`). + if matches!(subtype.as_str(), "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark") + { continue; } let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null))); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e1747b99..1b936a8c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2878,7 +2878,8 @@ fn notices( tl!("XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in.").to_string(), true, )) - } else if !info.fields.is_empty() { + } else if info.fields.iter().any(|f| f.kind != pdfcraft_render::FieldKind::PushButton) { + // Push buttons alone (LaTeX `animate` frames, navigation buttons) leave nothing to fill in. Some(( "text-cursor-input", crate::i18n::fmt(tl!("This document contains {n} interactive form fields."), &[("n", &info.fields.len().to_string())]), diff --git a/crates/ui-egui/tests/animate.rs b/crates/ui-egui/tests/animate.rs new file mode 100644 index 00000000..20008edd --- /dev/null +++ b/crates/ui-egui/tests/animate.rs @@ -0,0 +1,79 @@ +//! LaTeX `animate` PDFs (#169): every field is a push button holding a frame and a Screen +//! annotation drives the player. Nothing there can be filled in, so the form-fields bar stays +//! away, and the Screen annotation is not a comment. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_ui_egui::PdfCraftApp; + +/// A one-page PDF whose page has `/Annots [6 0 R …]` for each of `annots` (objects from 6 on) +/// and whose AcroForm lists the widgets among them. +fn pdf(annots: &[&str]) -> Vec { + let refs: Vec = (0..annots.len()).map(|i| format!("{} 0 R", i + 6)).collect(); + let fields: Vec = annots.iter().enumerate().filter(|(_, a)| a.contains("/Widget")).map(|(i, _)| format!("{} 0 R", i + 6)).collect(); + let mut objs: Vec = vec![ + format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [{}] >> >>", fields.join(" ")), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Annots [{}] >>", refs.join(" ")), + "<< /Length 0 >>\nstream\n\nendstream".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] /Length 0 >>\nstream\n\nendstream".into(), + ]; + objs.extend(annots.iter().map(|o| o.to_string())); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn open(bytes: Vec) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 800.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("animate.pdf", None, bytes).expect("opens"); + app + }); + h.run_steps(4); + h +} + +/// A frame of the player: a read-only push button (`/Ff 65537`), hidden unless `shown`. +fn frame(name: &str, shown: bool) -> String { + let hidden = if shown { 4 } else { 6 }; + format!("<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65537 /T ({name}) /F {hidden} /Rect [50 50 250 150] /P 3 0 R /AP << /N 5 0 R >> >>") +} + +const SCREEN: &str = "<< /Type /Annot /Subtype /Screen /Rect [50 50 250 150] /P 3 0 R \ + /AA << /PO << /S /JavaScript /JS (app.setInterval\\('a0_gotoNext\\(\\)', 500\\);) >> >> >>"; + +#[test] +fn push_buttons_alone_show_no_form_bar_and_a_screen_annotation_is_not_a_comment() { + let (a, b, c) = (frame("0.0", true), frame("0.1", false), frame("0.2", false)); + let h = open(pdf(&[&a, &b, &c, SCREEN])); + { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.info.fields.len(), 3, "the frames are still fields"); + assert!(doc.info.fields.iter().all(|f| f.kind == pdfcraft_render::FieldKind::PushButton)); + assert!(doc.info.annotations.is_empty(), "no comments: {:?}", doc.info.annotations); + } + assert!(h.query_by_label_contains("interactive form fields").is_none(), "nothing to fill in, so no form bar"); + assert!(h.query_by_label("Highlight fields").is_none()); +} + +#[test] +fn a_fillable_field_beside_push_buttons_still_shows_the_form_bar() { + let button = frame("0.0", true); + let text = "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 160 200 180] /P 3 0 R >>"; + let h = open(pdf(&[&button, text, SCREEN])); + h.get_by_label_contains("This document contains 2 interactive form fields."); + h.get_by_label("Highlight fields"); +}