Refs #169: no form bar for push-button-only documents; Screen is not a comment (#615)

LaTeX `animate` PDFs store each frame as a read-only push button and drive
them from a Screen annotation. PdfCraft showed "This document contains N
interactive form fields." because the bar counted any field, and listed
the Screen annotation in the Comments panel because only Link, Widget and
Popup were filtered out.

- The form-fields bar now needs at least one field that isn't a push
  button (something to fill in).
- Non-markup annotations (ISO 32000-2 §12.5.6: Screen, Movie, RichMedia,
  3D, PrinterMark, TrapNet, Watermark) are no longer comments, both when
  a document is inspected and when the comment list is rebuilt after an
  edit (new pdfcraft_annot::is_comment_subtype).

Playing the animation itself (page actions, timers) is out of scope here.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-09 17:49:54 -04:00 committed by GitHub
parent 90bb2f14ad
commit 275cbcf323
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 106 additions and 4 deletions

View File

@ -1793,7 +1793,14 @@ fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
(!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<Summary> {
let mut out = Vec::new();
let Ok(pages) = page_refs(doc) else { return out };
@ -1802,7 +1809,7 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
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<f32> {

View File

@ -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}");
}
}

View File

@ -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)));

View File

@ -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())]),

View File

@ -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<u8> {
let refs: Vec<String> = (0..annots.len()).map(|i| format!("{} 0 R", i + 6)).collect();
let fields: Vec<String> = annots.iter().enumerate().filter(|(_, a)| a.contains("/Widget")).map(|(i, _)| format!("{} 0 R", i + 6)).collect();
let mut objs: Vec<String> = 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<u8>) -> 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");
}