Forms: say so when a document is an XFA form (#60)
XFA forms aren't read yet, and opening one gave no hint: a dynamic XFA form showed only its "Please wait…" placeholder page, and a static one looked like an ordinary form though Acrobat shows its XFA data in preference to the fields that get filled. - render: DocInfo.xfa (Static / Dynamic) from /AcroForm /XFA; dynamic when /NeedsRendering is true or there are no fields. Exported for doc_info (automation and CLI) as "xfa". - ui: the notice bar explains each case (above the form-fields notice); a static form keeps its Highlight fields button. - Tests: tests/xfa.rs (dynamic with and without /NeedsRendering, static, ordinary forms, a malformed /XFA). Parity notes for form.xfa-static and form.xfa-dynamic (still planned: this explains XFA, it doesn't support it). Reported by @Sudachi-dev. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
93221c6886
commit
ea2ed3d911
|
|
@ -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::<Vec<_>>(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Xfa>,
|
||||
pub pages: Vec<PageInfo>,
|
||||
pub outline: Vec<OutlineItem>,
|
||||
pub annotations: Vec<Annotation>,
|
||||
|
|
@ -49,6 +51,17 @@ pub struct DocInfo {
|
|||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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)) {
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<u8> {
|
||||
let mut objs: Vec<String> = 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<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"></xdp:xdp>\nendstream";
|
||||
|
||||
fn open(bytes: Vec<u8>) -> 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<printcraft_render::Xfa> {
|
||||
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));
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue