diff --git a/apps/printcraft-cli/src/main.rs b/apps/printcraft-cli/src/main.rs index c0ff2e4a..f703f676 100644 --- a/apps/printcraft-cli/src/main.rs +++ b/apps/printcraft-cli/src/main.rs @@ -112,6 +112,7 @@ fn info(args: &[String]) -> Result<(), String> { "first_page_pt": info.pages.first().map(|p| [p.width, p.height]), "title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator, "encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript, + "xfa": info.xfa.map(|x| match x { printcraft_render::Xfa::Static => "static", printcraft_render::Xfa::Dynamic => "dynamic" }), "bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(), "links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(), "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(), diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 112a8831..bf16225d 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1482,6 +1482,8 @@ fn info(d: &Document) -> Value { "creator": i.creator, "producer": i.producer, "tagged": i.tagged, "has_javascript": i.has_javascript, + // XFA forms aren't read yet: "static" (fields work, XFA data ignored) or "dynamic" (placeholder pages). + "xfa": i.xfa.map(|x| match x { printcraft_render::Xfa::Static => "static", printcraft_render::Xfa::Dynamic => "dynamic" }), "security": security, "pages": i.pages.iter().enumerate().map(|(n, p)| json!({ "page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation, diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 04351c9c..c1f5b16d 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -38,6 +38,8 @@ pub struct DocInfo { pub encrypted: bool, pub tagged: bool, pub has_javascript: bool, + /// The form is (also) an XFA form, which isn't read yet (`form.xfa-*` in the parity list). + pub xfa: Option, pub pages: Vec, pub outline: Vec, pub annotations: Vec, @@ -49,6 +51,17 @@ pub struct DocInfo { pub warnings: Vec, } +/// What kind of XFA form a document has (`/AcroForm /XFA`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Xfa { + /// The pages and fields are ordinary PDF; the XFA packets describe the same form (and hold + /// its data, which Acrobat shows in preference to the fields). + Static, + /// The form is laid out from the XFA packets at open (`/NeedsRendering`, or no fields): the + /// PDF pages are only a placeholder. + Dynamic, +} + #[derive(Clone, Debug)] pub struct PageInfo { /// Displayed size in points, after `/Rotate` and `/UserUnit`. @@ -341,6 +354,10 @@ impl<'a> Inspector<'a> { } } info.has_javascript |= info.fields.iter().any(|f| f.has_actions); + let needs_rendering = catalog.get(b"NeedsRendering").ok().and_then(|o| self.resolve(o).as_bool().ok()).unwrap_or(false); + if catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)).is_some_and(|form| form.get(b"XFA").is_ok()) { + info.xfa = Some(if needs_rendering || info.fields.is_empty() { Xfa::Dynamic } else { Xfa::Static }); + } self.layers(catalog, &mut info.layers); self.fonts(&mut info.fonts); if let Some(names) = catalog.get(b"Names").ok().and_then(|o| self.dict(o)) { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5e0ec409..4dfc82c7 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -13,8 +13,8 @@ mod raster; pub mod text; pub use inspect::{ - Annotation, Attachment, AttachmentSource, DocInfo, Field, FieldKind, FontInfo, Layer, Link, LinkTarget, OutlineItem, PageInfo, attachment_data, - inspect, pretty_date, + Annotation, Attachment, AttachmentSource, DocInfo, Field, FieldKind, FontInfo, Layer, Link, LinkTarget, OutlineItem, PageInfo, Xfa, + attachment_data, inspect, pretty_date, }; pub use raster::{MAX_PIXELS, MAX_SIDE, PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderedPage, RequestKind, Tile, effective_scale}; pub use text::{PageText, TextGlyph}; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 048f2e6e..ad76eb2a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1842,6 +1842,18 @@ fn notices( let mut open_repairs = false; let msg = if secured { Some(("lock", "This document is secured. Some changes are restricted by its security settings.".to_string(), false)) + } else if info.xfa == Some(printcraft_render::Xfa::Dynamic) { + Some(( + "triangle-alert", + "This is a dynamic XFA form, which PrintCraft can't display yet. What you see is the file's placeholder page.".to_string(), + false, + )) + } else if info.xfa == Some(printcraft_render::Xfa::Static) { + Some(( + "triangle-alert", + "This form also contains XFA data, which PrintCraft doesn't read yet. You can fill its fields, but Acrobat may show the XFA values instead.".to_string(), + true, + )) } else if !info.fields.is_empty() { Some(("text-cursor-input", format!("This document contains {} interactive form fields.", info.fields.len()), true)) } else if repaired { diff --git a/crates/ui-egui/tests/xfa.rs b/crates/ui-egui/tests/xfa.rs new file mode 100644 index 00000000..bdd54520 --- /dev/null +++ b/crates/ui-egui/tests/xfa.rs @@ -0,0 +1,81 @@ +//! XFA forms (#60): not read yet, so the document says so instead of silently showing a +//! placeholder page or fields whose values Acrobat would override. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use printcraft_ui_egui::PrintCraftApp; + +/// A one-page PDF with a proper xref; `acroform` is the catalog's /AcroForm (or empty), `extra` +/// more catalog entries, `objects` extra objects numbered from 5. +fn pdf(acroform: &str, extra: &str, objects: &[&str]) -> Vec { + let mut objs: Vec = vec![ + format!("<< /Type /Catalog /Pages 2 0 R {acroform} {extra} >>"), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + ]; + objs.extend(objects.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 +} + +const XFA_PACKET: &str = "<< /Length 52 >>\nstream\n\nendstream"; + +fn open(bytes: Vec) -> Harness<'static, PrintCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 800.0)).build_eframe(move |_cc| { + let mut app = PrintCraftApp::new(); + app.open_bytes("form.pdf", None, bytes).expect("opens"); + app + }); + h.run_steps(4); + h +} + +fn xfa(h: &Harness<'static, PrintCraftApp>) -> Option { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().info.xfa +} + +#[test] +fn a_dynamic_xfa_form_says_its_page_is_a_placeholder() { + // No fields, the form lives in the XFA packets (an array of name/stream pairs here). + let h = open(pdf("/AcroForm << /Fields [] /XFA [(template) 5 0 R] >>", "/NeedsRendering true", &[XFA_PACKET])); + assert_eq!(xfa(&h), Some(printcraft_render::Xfa::Dynamic)); + h.get_by_label_contains("dynamic XFA form"); + // Without /NeedsRendering, no fields still means nothing to fill but the placeholder. + let h = open(pdf("/AcroForm << /Fields [] /XFA 5 0 R >>", "", &[XFA_PACKET])); + assert_eq!(xfa(&h), Some(printcraft_render::Xfa::Dynamic)); +} + +#[test] +fn a_static_xfa_form_can_be_filled_but_warns_about_its_xfa_data() { + let field = "<< /FT /Tx /T (name) /Rect [20 20 200 40] /Type /Annot /Subtype /Widget /P 3 0 R >>"; + let h = open(pdf("/AcroForm << /Fields [6 0 R] /XFA 5 0 R >>", "", &[XFA_PACKET, field])); + assert_eq!(xfa(&h), Some(printcraft_render::Xfa::Static)); + h.get_by_label_contains("also contains XFA data"); + // The fields can still be highlighted from the notice. + h.get_by_label("Highlight fields"); +} + +#[test] +fn ordinary_forms_and_documents_have_no_xfa_notice() { + let field = "<< /FT /Tx /T (name) /Rect [20 20 200 40] /Type /Annot /Subtype /Widget /P 3 0 R >>"; + let h = open(pdf("/AcroForm << /Fields [5 0 R] >>", "", &[field])); + assert_eq!(xfa(&h), None); + assert!(h.query_by_label_contains("XFA").is_none()); + h.get_by_label_contains("interactive form fields"); + // A malformed /XFA (a number) still counts as XFA; it never crashes the open. + let h = open(pdf("/AcroForm << /Fields [] /XFA 42 >>", "", &[])); + assert_eq!(xfa(&h), Some(printcraft_render::Xfa::Dynamic)); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6dd14d15..3d887d10 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5107,7 +5107,7 @@ status = "planned" commands = [] tools = [] evidence = [] -notes = "" +notes = "not read yet; detected on open (DocInfo.xfa, doc_info \"xfa\") and a notice says the fields can be filled but Acrobat may show the XFA values instead (#60)" [[feature]] id = "form.xfa-dynamic" @@ -5119,7 +5119,7 @@ status = "planned" commands = [] tools = [] evidence = [] -notes = "" +notes = "not rendered yet; detected on open and a notice says the page shown is the file's placeholder (#60)" [[feature]] id = "form.xfa-formcalc"