diff --git a/Cargo.lock b/Cargo.lock index e9a71c7c..81655fb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3488,6 +3488,7 @@ dependencies = [ "pdfcraft-engine", "pdfcraft-organize", "pdfcraft-render", + "pdfcraft-xfa", "png 0.18.1", "serde_json", ] @@ -3591,6 +3592,7 @@ dependencies = [ "pdfcraft-redact", "pdfcraft-render", "pdfcraft-sign", + "pdfcraft-xfa", "pdfcraft-xfdf", "png 0.18.1", "thiserror 2.0.21", @@ -3771,6 +3773,7 @@ dependencies = [ "pdfcraft-fonts", "pdfcraft-model", "pdfcraft-render", + "pdfcraft-xfa", "png 0.18.1", "pollster 1.0.1", "rfd", @@ -3794,6 +3797,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "pdfcraft-xfa" +version = "0.2.1" +dependencies = [ + "base64 0.22.1", + "pdfcraft-cos", + "pdfcraft-fonts", + "roxmltree", + "thiserror 2.0.21", +] + [[package]] name = "pdfcraft-xfdf" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 76be6e26..6e712569 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ pdfcraft-export = { path = "crates/export", version = "0.2.0" } pdfcraft-edit = { path = "crates/edit", version = "0.2.0" } pdfcraft-create = { path = "crates/create", version = "0.2.0" } pdfcraft-forms = { path = "crates/forms", version = "0.2.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.2.0" } pdfcraft-render = { path = "crates/render", version = "0.2.0" } pdfcraft-engine = { path = "crates/engine", version = "0.2.0" } pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.2.0" } diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 7a8bf8a0..b3e1ccad 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -105,7 +105,18 @@ fn read(path: &str) -> Result>, String> { fn info(args: &[String]) -> Result<(), String> { let path = *positional(args).first().ok_or("info: missing file")?; - let info = inspect(read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?; + let bytes = read(path)?; + let password = flag(args, "--password"); + let info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?; + // Dynamic XFA forms: say what laying the template out gives (what the app shows). + let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) + .then(|| { + let mut s = pdfcraft_engine::Session::new(); + let id = s.open("info.pdf", None, bytes.clone(), password).ok()?; + let d = s.get(id)?; + d.xfa.as_ref().map(|x| serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })) + }) + .flatten(); let json = serde_json::json!({ "file": path, "pdf_version": info.pdf_version, @@ -115,6 +126,7 @@ fn info(args: &[String]) -> Result<(), String> { "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 { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), + "xfa_layout": xfa_layout, "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/Cargo.toml b/crates/automation/Cargo.toml index 1033ab28..556e9da0 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -23,4 +23,5 @@ default = ["mcp"] mcp = ["dep:base64"] [dev-dependencies] +pdfcraft-xfa = { workspace = true } image = { version = "0.25", default-features = false, features = ["jpeg"] } diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 48c8560a..7cc955ed 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1558,8 +1558,12 @@ 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 forms: "static" (the PDF's own fields work; the XFA data is ignored) or "dynamic" + // (laid out from the template by PdfCraft, see xfa_layout; placeholder pages when that failed). "xfa": i.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), + "xfa_layout": d.xfa.as_ref().map(|x| json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })), + // What was rewritten from, or could not be written to, the XFA data. + "xfa_warnings": d.xfa_warnings, "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/automation/tests/automation.rs b/crates/automation/tests/automation.rs index cccaafc1..1a8721dd 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1830,6 +1830,43 @@ fn exporting_to_word_html_and_rtf() { assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "a.xyz" })).is_err()); } +#[test] +fn dynamic_xfa_forms_open_render_fill_and_save_through_tools() { + let dir = workdir("xfa"); + std::fs::write(dir.join("xfa.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(2))).unwrap(); + let mut a = auto(&dir); + let opened = ok(&mut a, "doc_open", json!({ "path": "xfa.pdf" })); + assert_eq!(opened["pages"], 2, "laid out from the template, not the placeholder page"); + let doc = opened["doc"].as_u64().unwrap(); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert_eq!(info["xfa"], "dynamic"); + assert_eq!(info["xfa_layout"]["pages"], 2); + assert_eq!(info["xfa_layout"]["fields"], 11); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let family = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "familyName").expect("familyName"); + assert_eq!((family["type"].as_str(), family["tooltip"].as_str(), family["page"].as_u64()), (Some("text"), Some("Your family name"), Some(1))); + let answer = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "answer").expect("radio group"); + assert_eq!(answer["options"], json!(["Y", "N"])); + // The page renders with the widgets' own appearances: the check box's border is drawn. + let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 36 })).unwrap(); + let Content::Png { data, .. } = &png[0] else { panic!("expected an image") }; + let decoder = png::Decoder::new(std::io::Cursor::new(data.as_slice())); + let mut reader = decoder.read_info().unwrap(); + let mut buf = vec![0; reader.output_buffer_size().unwrap()]; + reader.next_frame(&mut buf).unwrap(); + assert!(buf.iter().filter(|b| **b < 128).count() > 200, "the page is not blank"); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "familyName": "Singh", "agree": true, "answer": "N", "born": "2001-02-03" } })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" })); + ok(&mut a, "doc_close", json!({ "doc": doc })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "out.pdf" })); + assert_eq!(reopened["pages"], 2, "not laid out twice"); + let doc2 = reopened["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc2 })); + let by = |n: &str| fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == n).unwrap()["value"].clone(); + assert_eq!((by("familyName"), by("agree"), by("answer"), by("born")), (json!("Singh"), json!(true), json!("N"), json!("2001-02-03"))); + assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc2 }))["xfa_layout"]["pages"], 2); +} + #[test] fn cut_stack_printing_through_tools() { let dir = workdir("cut-stack"); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 7a60170f..c645330b 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -15,6 +15,7 @@ pdfcraft-cos = { workspace = true } pdfcraft-organize = { workspace = true } pdfcraft-annot = { workspace = true } pdfcraft-forms = { workspace = true } +pdfcraft-xfa = { workspace = true } pdfcraft-edit = { workspace = true } pdfcraft-redact = { workspace = true } pdfcraft-print = { workspace = true } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 386d044d..923b807e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -39,6 +39,7 @@ pub use pdfcraft_forms::{ pub use pdfcraft_a11y as a11y; pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine}; +pub use pdfcraft_xfa::Report as XfaLayout; /// A change to an existing page image. #[derive(Clone, Debug, PartialEq)] @@ -216,6 +217,12 @@ pub struct Document { config: RenderConfig, /// What field scripts printed or asked for (see [`Session::take_js_output`]). js_output: js::JsOutput, + /// Dynamic XFA forms: what laying the template out produced (pages and fields are + /// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves). + pub xfa: Option, + /// XFA forms: what was approximated, rewritten or could not be written to the XFA data + /// (also in `info.warnings`, kept there when the document is re-read). + pub xfa_warnings: Vec, } impl Document { @@ -1699,6 +1706,74 @@ pub struct Session { js_off: bool, } +/// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. +fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { + let report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; + for f in pdfcraft_forms::fields(doc) { + pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?; + } + Ok(report) +} + +/// The form's fields as the XFA data layer wants them. +fn xfa_field_data(doc: &pdfcraft_cos::Document) -> Vec { + use pdfcraft_forms::FieldKind as K; + pdfcraft_forms::fields(doc) + .into_iter() + .map(|f| { + let data = match f.kind { + K::Text | K::Combo | K::List => pdfcraft_xfa::FieldData::Text(f.value.join("\n")), + K::CheckBox => pdfcraft_xfa::FieldData::Check(!f.value.is_empty()), + K::Radio => pdfcraft_xfa::FieldData::Radio(f.value.first().cloned()), + K::PushButton | K::Signature => pdfcraft_xfa::FieldData::None, + }; + pdfcraft_xfa::FieldDatum { obj: f.obj, name: f.name, data } + }) + .collect() +} + +/// Keep the XFA datasets packet in step with the fields after an edit. Returns what could not +/// be written. +fn xfa_sync_datasets(doc: &mut pdfcraft_cos::Document) -> Result, String> { + let data = xfa_field_data(doc); + pdfcraft_xfa::write_datasets(doc, &data).map(|r| r.warnings).map_err(|e| e.to_string()) +} + +/// Most XFA warnings kept per document. +const MAX_XFA_WARNINGS: usize = 50; + +/// Add `new` to `list` (no repeats, at most [`MAX_XFA_WARNINGS`]). +fn note_warnings(list: &mut Vec, new: &[String]) { + for w in new { + if !list.contains(w) && list.len() < MAX_XFA_WARNINGS { + list.push(w.clone()); + } + } +} + +/// Give the fields the values the XFA datasets hold (a form filled in another viewer). Returns +/// the names of the fields that changed. +fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result, String> { + let data = xfa_field_data(doc); + let fields = pdfcraft_forms::fields(doc); + let mut changed = Vec::new(); + for (name, value) in pdfcraft_xfa::read_values(doc, &data) { + let Some(f) = fields.iter().find(|f| f.name == name) else { continue }; + let new = match value { + pdfcraft_xfa::FieldData::Text(t) => (f.value.join("\n") != t).then_some(FieldValue::Text(t)), + pdfcraft_xfa::FieldData::Check(on) => (f.value.is_empty() == on).then_some(FieldValue::Check(on)), + pdfcraft_xfa::FieldData::Radio(sel) => (f.value.first() != sel.as_ref()).then_some(FieldValue::Radio(sel)), + pdfcraft_xfa::FieldData::None => None, + }; + if let Some(v) = new + && pdfcraft_forms::set_value(doc, &name, &v).is_ok() + { + changed.push(name); + } + } + Ok(changed) +} + /// Validate the signature fields of `cos` (written as `bytes`). fn signatures_of(cos: &pdfcraft_cos::Document, bytes: &[u8], trust: &TrustStore, cache: &pdfcraft_sign::DigestCache) -> Arc> { Arc::new(pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache)) @@ -1785,6 +1860,91 @@ impl Session { } Err(e) => return Err(e), }; + // A dynamic XFA form is a shell around an XML template; lay the template out into real + // pages and fields so the rest of the engine works on it. The original bytes stay: the + // laid-out form is one appended revision. + let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + // Write `work` as one more revision and reopen it: what the document then is. + let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc>, DocInfo, pdfcraft_cos::Document), String> { + let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?; + let new_info = inspect(new_bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; + let new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?; + Ok((new_bytes, new_info, new_cos)) + }; + let (bytes, info, cos, xfa) = match (info.xfa, cos) { + (Some(pdfcraft_render::Xfa::Dynamic), Ok(Ok(cos))) if pdfcraft_xfa::existing_layout(&cos).is_none() => { + let mut work = cos.clone(); + let laid_out = guard(|| xfa_layout(&mut work)) + .unwrap_or_else(|m| Err(format!("laying it out failed unexpectedly ({m})"))) + .and_then(|report| rebase(&work).map(|(b, i, c)| (b, i, c, report))); + match laid_out { + Ok((b, i, c, report)) => (b, i, Ok(Ok(c)), Some(report)), + Err(e) => { + let mut info = info; + info.warnings.push(format!("This dynamic XFA form could not be laid out: {e}")); + (bytes, info, Ok(Ok(cos)), None) + } + } + } + // A static XFA form, or a dynamic one laid out earlier: its datasets may hold values + // filled in by another viewer since; give the fields those values. + (Some(_), Ok(Ok(cos))) => { + let report = pdfcraft_xfa::existing_layout(&cos); + let mut work = cos.clone(); + let synced = + guard(|| xfa_values_from_datasets(&mut work)).unwrap_or_else(|m| Err(format!("reading its data failed unexpectedly ({m})"))); + match synced { + Ok(names) if names.is_empty() => (bytes, info, Ok(Ok(cos)), report), + Ok(names) => match rebase(&work) { + Ok((b, mut i, c)) => { + // The fields were rewritten from the XFA data: say so, and which. + let shown: Vec<&str> = names.iter().take(5).map(String::as_str).collect(); + let more = if names.len() > shown.len() { format!(" and {} more", names.len() - shown.len()) } else { String::new() }; + i.warnings.push(format!( + "{} form field value{} were taken from this form's XFA data (filled in by another viewer): {}{more}", + names.len(), + if names.len() == 1 { "" } else { "s" }, + shown.join(", ") + )); + (b, i, Ok(Ok(c)), report) + } + Err(e) => { + let mut info = info; + info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}")); + (bytes, info, Ok(Ok(cos)), report) + } + }, + Err(e) => { + let mut info = info; + info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}")); + (bytes, info, Ok(Ok(cos)), report) + } + } + } + (_, cos) => (bytes, info, cos, None), + }; + // XFA notes survive the document being re-read after edits. + let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; + let id = self.push_document(name, path, bytes, info, cos, render_password, password, xfa)?; + if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { + note_warnings(&mut d.xfa_warnings, &xfa_warnings); + } + Ok(id) + } + + /// The last step of opening: build the document record and register it. + #[allow(clippy::too_many_arguments)] + fn push_document( + &mut self, + name: String, + path: Option, + bytes: Arc>, + info: DocInfo, + cos: Result, Box>, + render_password: Option, + password: Option<&str>, + xfa: Option, + ) -> Result { let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; let renderer = RenderPool::new(bytes.clone(), render_threads(), config.clone()); let (editor, read_only_reason) = match cos { @@ -1825,6 +1985,8 @@ impl Session { editor, config, js_output: Default::default(), + xfa, + xfa_warnings: Vec::new(), }); Ok(id) } @@ -1840,6 +2002,7 @@ impl Session { let js_off = self.js_off; let doc = self.doc_mut(id)?; let name = doc.name.clone(); + let is_xfa = doc.info.xfa.is_some(); let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48)); cx.today = today; let reason = doc.read_only_reason.clone().unwrap_or_default(); @@ -1855,6 +2018,13 @@ impl Session { // `next` is a copy: if the edit fails or crashes, the document is unchanged. guard(|| run_edit(&mut next, &edit, &mut cx)) .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; + // XFA forms keep their values in the datasets packet too, for Adobe's viewers. + let mut xfa_notes = Vec::new(); + if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full) { + xfa_notes = guard(|| xfa_sync_datasets(&mut next)) + .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) + .map_err(EditError::Write)?; + } // Signed documents are only ever saved incrementally: an edit that needs a full rewrite // (applying redactions, changing security, sanitizing) would invalidate the signatures. let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); @@ -1886,6 +2056,9 @@ impl Session { } doc.dirty = true; doc.generation += 1; + note_warnings(&mut doc.xfa_warnings, &xfa_notes); + let notes = doc.xfa_warnings.clone(); + note_warnings(&mut doc.info.warnings, ¬es); if let Some(js) = cx.js { doc.js_output.append(js.output); } @@ -1986,6 +2159,7 @@ impl Session { l.visible = old.visible; } } + note_warnings(&mut info.warnings, &doc.xfa_warnings); doc.info = info; doc.form = Arc::new(pdfcraft_forms::fields(&editor.cos)); doc.marks = pdfcraft_edit::marks_present(&editor.cos); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a0f8a32d..b7e1dd14 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1410,3 +1410,130 @@ fn guard_turns_a_panic_into_an_error() { let n = 3; assert_eq!(guard(|| -> u8 { panic!("page {n} is bad") }), Err("page 3 is bad".to_string())); } + +#[test] +fn dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally() { + let original = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(3))); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, original.clone(), None).expect("opens"); + let doc = s.get(id).unwrap(); + let x = doc.xfa.as_ref().expect("laid out from the template"); + assert_eq!((x.pages, x.fields), (2, 14), "{x:?}"); + assert!(x.warnings.is_empty(), "{:?}", x.warnings); + assert_eq!(doc.info.pages.len(), 2, "the placeholder page is gone"); + assert_eq!(doc.info.xfa, Some(pdfcraft_render::Xfa::Dynamic), "still an XFA form for Adobe's viewers"); + assert!(!doc.dirty, "laying out is not an edit"); + assert!(doc.bytes.starts_with(&original[..]), "the layout is an appended revision; the original bytes stay"); + let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect(); + assert!(names.contains(&"familyName") && names.contains(&"answer") && names.contains(&"what2"), "{names:?}"); + let family = doc.form.iter().find(|f| f.name == "familyName").unwrap(); + assert_eq!((family.tooltip.as_deref(), family.max_len), (Some("Your family name"), Some(30))); + let answer = doc.form.iter().find(|f| f.name == "answer").unwrap(); + assert_eq!(answer.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["Y", "N"]); + assert!(page_texts(&s, id)[0].contains("Sample Form"), "{:?}", page_texts(&s, id)); + // Fill it in and save: one more incremental revision on top. + s.apply(id, Edit::SetFieldValue { name: "familyName".into(), value: FieldValue::Text("Singh".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "answer".into(), value: FieldValue::Radio(Some("N".into())) }).unwrap(); + assert!(s.get(id).unwrap().dirty); + let saved = s.save_bytes(id).unwrap(); + assert!(saved.starts_with(&original[..])); + // The datasets packet carries the values too, for Adobe's viewers. + let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).expect("a datasets packet"); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].head[0].familyName[0]")), Some("Singh")); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].head[0].answer[0]")), Some("N")); + let id2 = s.open("saved.pdf", None, saved, None).unwrap(); + let d2 = s.get(id2).unwrap(); + assert_eq!(d2.info.pages.len(), 2, "a saved form is not laid out a second time"); + assert_eq!(d2.xfa.as_ref().map(|x| (x.pages, x.fields)), Some((2, 14)), "the layout is remembered"); + assert_eq!(d2.form.iter().find(|f| f.name == "familyName").unwrap().value, vec!["Singh".to_string()]); + assert_eq!(d2.form.iter().find(|f| f.name == "answer").unwrap().value, vec!["N".to_string()]); +} + +#[test] +fn an_xfa_form_without_a_template_opens_with_its_placeholder_and_a_warning() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell("")); + let mut s = Session::new(); + let id = s.open("form.pdf", None, bytes, None).expect("opens"); + let doc = s.get(id).unwrap(); + assert!(doc.xfa.is_none()); + assert_eq!(doc.info.pages.len(), 1); + assert!(doc.info.warnings.iter().any(|w| w.contains("could not be laid out") && w.contains("no template")), "{:?}", doc.info.warnings); +} + +#[test] +fn dynamic_xfa_forms_open_with_the_values_and_rows_of_their_data() { + let data = "
Kaur2001-02-03Yabc
"; + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template_with_data(1, data))); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, bytes, None).expect("opens"); + let doc = s.get(id).unwrap(); + let value = |n: &str| doc.form.iter().find(|f| f.name == n).unwrap_or_else(|| panic!("no field {n}")).value.clone(); + assert_eq!(value("familyName"), vec!["Kaur".to_string()]); + assert_eq!(value("born"), vec!["2001-02-03".to_string()]); + assert_eq!(value("answer"), vec!["Y".to_string()]); + assert_eq!(value("what0_3"), vec!["c".to_string()], "three rows from the data: {:?}", doc.form.iter().map(|f| &f.name).collect::>()); + assert!(!doc.dirty); +} + +#[test] +fn static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back() { + let original = Arc::new(pdfcraft_xfa::fixtures::static_shell("Ada1")); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("static.pdf", None, original.clone(), None).expect("opens"); + let doc = s.get(id).unwrap(); + assert_eq!(doc.info.xfa, Some(pdfcraft_render::Xfa::Static)); + assert!(doc.xfa.is_none(), "nothing was laid out"); + assert_eq!(doc.form.iter().find(|f| f.name == "form1[0].page1[0].name[0]").unwrap().value, vec!["Ada".to_string()]); + assert_eq!(doc.form.iter().find(|f| f.name == "form1[0].page1[0].agree[0]").unwrap().value, vec!["1".to_string()]); + assert!(!doc.dirty && doc.bytes.starts_with(&original[..]), "the values are an appended revision"); + // Rewriting the fields from the XFA data is recorded, not silent. + let note = doc.info.warnings.iter().find(|w| w.contains("taken from this form's XFA data")).expect("a warning"); + assert!(note.starts_with("2 form field values") && note.contains("form1[0].page1[0].name[0]"), "{note}"); + s.apply(id, Edit::SetFieldValue { name: "form1[0].page1[0].name[0]".into(), value: FieldValue::Text("Grace".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "form1[0].page1[0].agree[0]".into(), value: FieldValue::Check(false) }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).unwrap(); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].name[0]")), Some("Grace")); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].agree[0]")), Some(""), "unchecked, with no off value"); + // Reopening agrees with itself: nothing to apply, nothing dirty. + let id2 = s.open("again.pdf", None, saved, None).unwrap(); + let d2 = s.get(id2).unwrap(); + assert_eq!(d2.form.iter().find(|f| f.name == "form1[0].page1[0].name[0]").unwrap().value, vec!["Grace".to_string()]); + assert!(!d2.dirty); + assert!(!d2.info.warnings.iter().any(|w| w.contains("taken from this form's XFA data")), "nothing was rewritten"); +} + +#[test] +fn xfa_data_no_field_binds_to_survives_an_edit_and_save() { + // Regression: editing a field replaced the whole data element with the fields' values. + let data = concat!( + "Ada1keep", + "2020unbound" + ); + let original = Arc::new(pdfcraft_xfa::fixtures::static_shell(data)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("static.pdf", None, original, None).expect("opens"); + s.apply(id, Edit::SetFieldValue { name: "form1[0].page1[0].name[0]".into(), value: FieldValue::Text("Grace".into()) }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + let xdp = pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp; + for kept in [ + "keep", + "2020", + "unbound", + ] { + assert!(xdp.contains(kept), "lost {kept:?}: {xdp}"); + } + let data = pdfcraft_xfa::parse_datasets(&xdp).unwrap(); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].name[0]")), Some("Grace")); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].agree[0]")), Some("1")); + // A value that can't be written is reported on the document. + let original = Arc::new(pdfcraft_xfa::fixtures::static_shell("rich")); + let id = s.open("rich.pdf", None, original, None).expect("opens"); + s.apply(id, Edit::SetFieldValue { name: "form1[0].page1[0].name[0]".into(), value: FieldValue::Text("plain".into()) }).unwrap(); + let doc = s.get(id).unwrap(); + assert!(doc.info.warnings.iter().any(|w| w.contains("structured content")), "{:?}", doc.info.warnings); + assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content"))); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 28de6aa2..bc24e779 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -38,6 +38,7 @@ js-sys = "0.3" web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url"] } [dev-dependencies] +pdfcraft-xfa = { workspace = true } pdfcraft-cos = { workspace = true } pdfcraft-model = { workspace = true } egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5b77a6ed..5b3729e4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -935,7 +935,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Opened without the owner password and something is restricted. let secured = doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); let repaired = !doc.repair_log().is_empty(); - match notices(view, info, secured, repaired, crate::sign_ui::banner(&doc.signatures), ui, &t) { + match notices(view, doc, secured, repaired, crate::sign_ui::banner(&doc.signatures), ui, &t) { Some(Notice::Repairs) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Advanced)), Some(Notice::Security) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Security)), Some(Notice::Signatures) => app.right = Some(RightPanel::Signatures), @@ -1905,13 +1905,15 @@ enum Notice { /// security, forms and warnings. fn notices( view: &mut DocView, - info: &DocInfo, + doc: &pdfcraft_engine::Document, secured: bool, repaired: bool, signed: Option<(&str, Color32, &str, String)>, ui: &mut egui::Ui, t: &Tokens, ) -> Option { + let info = &doc.info; + let xfa = doc.xfa.as_ref(); if let Some((icon, color, template, arg)) = signed { let mut open = false; egui::Frame::NONE.fill(t.accent_soft).inner_margin(egui::Margin::symmetric(14, 7)).show(ui, |ui| { @@ -1934,6 +1936,18 @@ fn notices( let mut open_repairs = false; let msg = if secured { Some(("lock", tl!("This document is secured. Some changes are restricted by its security settings.").to_string(), false)) + } else if let Some(x) = xfa { + let lang = crate::i18n::current(); + let pages = crate::i18n::trn(lang, x.pages as u64, "{n} page", "{n} pages"); + let fields = crate::i18n::trn(lang, x.fields as u64, "{n} field", "{n} fields"); + let mut text = + crate::i18n::fmt(tl!("Dynamic XFA form laid out from its template: {pages}, {fields}."), &[("pages", &pages), ("fields", &fields)]); + if !x.warnings.is_empty() { + text.push(' '); + text.push_str(&x.warnings.join("; ")); + text.push('.'); + } + Some(("text-cursor-input", text, true)) } else if info.xfa == Some(pdfcraft_render::Xfa::Dynamic) { Some(( "triangle-alert", @@ -1942,8 +1956,8 @@ fn notices( )) } else if info.xfa == Some(pdfcraft_render::Xfa::Static) { Some(( - "triangle-alert", - tl!("This form also contains XFA data, which PdfCraft doesn't read yet. You can fill its fields, but Acrobat may show the XFA values instead.").to_string(), + "text-cursor-input", + 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() { diff --git a/crates/ui-egui/tests/xfa.rs b/crates/ui-egui/tests/xfa.rs index ad36e09e..12e55f9c 100644 --- a/crates/ui-egui/tests/xfa.rs +++ b/crates/ui-egui/tests/xfa.rs @@ -1,5 +1,6 @@ -//! 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. +//! XFA forms (#60): a dynamic form is laid out from its template and says so; a static one takes +//! its values from the XFA data; one whose template can't be read says that instead of silently +//! showing a placeholder page. use egui_kittest::Harness; use egui_kittest::kittest::Queryable; @@ -47,9 +48,23 @@ fn xfa(h: &Harness<'static, PdfCraftApp>) -> Option { s.session.get(s.views[0].id).unwrap().info.xfa } +#[test] +fn a_dynamic_xfa_form_is_laid_out_and_filled() { + let h = open(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(2))); + assert_eq!(xfa(&h), Some(pdfcraft_render::Xfa::Dynamic)); + h.get_by_label_contains("laid out from its template: 2 pages, 11 fields"); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.info.pages.len(), 2); + assert!(doc.form.iter().any(|f| f.name == "familyName")); + // The notice offers to highlight the fields, like any form. + h.get_by_label("Highlight fields"); +} + #[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). + // No fields, the form lives in the XFA packets (an array of name/stream pairs here), but + // the packets hold no template: the placeholder page stays, with a notice. let h = open(pdf("/AcroForm << /Fields [] /XFA [(template) 5 0 R] >>", "/NeedsRendering true", &[XFA_PACKET])); assert_eq!(xfa(&h), Some(pdfcraft_render::Xfa::Dynamic)); h.get_by_label_contains("dynamic XFA form"); @@ -59,11 +74,18 @@ fn a_dynamic_xfa_form_says_its_page_is_a_placeholder() { } #[test] -fn a_static_xfa_form_can_be_filled_but_warns_about_its_xfa_data() { +fn a_static_xfa_form_is_filled_from_its_data_and_says_so() { 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(pdfcraft_render::Xfa::Static)); - h.get_by_label_contains("also contains XFA data"); + h.get_by_label_contains("XFA data are kept in step"); + // Values saved in the datasets by another viewer show up in the fields. + let h = open(pdfcraft_xfa::fixtures::static_shell("Ada1")); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.form.iter().find(|f| f.name == "form1[0].page1[0].name[0]").unwrap().value, vec!["Ada".to_string()]); + assert_eq!(doc.form.iter().find(|f| f.name == "form1[0].page1[0].agree[0]").unwrap().value, vec!["1".to_string()]); + assert!(!doc.dirty); // The fields can still be highlighted from the notice. h.get_by_label("Highlight fields"); } diff --git a/crates/xfa/Cargo.toml b/crates/xfa/Cargo.toml new file mode 100644 index 00000000..6bb4707d --- /dev/null +++ b/crates/xfa/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "pdfcraft-xfa" +description = "XFA forms: reads a dynamic form's XDP packets and lays its template out as PDF pages and AcroForm fields" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +pdfcraft-cos = { workspace = true } +pdfcraft-fonts = { workspace = true } +roxmltree = "0.20" +base64 = "0.22" +thiserror = { workspace = true } diff --git a/crates/xfa/README.md b/crates/xfa/README.md new file mode 100644 index 00000000..1df66fb8 --- /dev/null +++ b/crates/xfa/README.md @@ -0,0 +1,75 @@ +# pdfcraft-xfa + +XFA forms (XFA 3.3, the template and layout parts). Layer L3; depends on `pdfcraft-cos` and +`pdfcraft-fonts` only. + +A dynamic XFA form is a PDF shell around an XML template (`/AcroForm /XFA`): one placeholder page +("requires Adobe Reader") and `/NeedsRendering true`. Viewers without an XFA engine show the +placeholder. This crate reads the template, lays it out, and writes ordinary pages and AcroForm +fields into the document, so everything else in PdfCraft (rendering, filling, saving, printing, +the automation tools) works on it unchanged. The engine does this when it opens such a form. + +## What it does + +- **Packets** (`read_packets`): the XDP from one stream or the `(name, stream)` array; UTF-8 + and UTF-16; capped at 64 MB. +- **Template** (`parse`): subforms, areas, fields, draws, exclusion groups, page sets with + page areas, content areas and media; measurements in mm, cm, in, pt and px; fonts, paragraphs, + margins, borders (edge order rules), captions with reserves, check-button items, pictures, + rich text (`exData` XHTML: paragraphs, `br`, bold/italic/underline/size spans, embedded fields), + inline JPEG images, lines, rectangles, occurrences, `breakBefore`/`breakAfter`, overflow + leaders, `columnWidths` and `colSpan`. +- **Layout** (`layout`): positioned, `tb`, `lr-tb`/`rl-tb`, `table` and `row` layouts; a + container without a width is as wide as its content; flow across content areas and pages with + page masters chosen by occurrence; a table's header row repeats after a break; presence + `hidden` takes no space, `invisible` takes space; repeating subforms get their initial + instances; fields that compute the page number or count on layout show the numbers. +- **PDF** (`pdf::write_form`): pages with content streams in the standard 14 fonts (Arial and + friends → Helvetica, Courier New → Courier, serif faces → Times; nothing is embedded), JPEG + XObjects, and widgets: text (multi-line, max length, alignment), date (Acrobat's `AFDate` + format and keystroke actions from the picture clause), check boxes and radio groups (from + `exclGroup`), push buttons with Reset / Print / Save As / URI actions recognised from their + scripts. Appearance streams are left empty for the forms layer to generate. Each field keeps + its SOM path in `/PCSom`; the AcroForm gets `/PCXfaLayout` so a saved form is not laid out + twice. The XFA packets stay, so Adobe's viewers keep rendering the form from them. + +- **Data** (`data`): the `datasets` packet. On layout, a field takes its value from the data + node at its SOM path (dates in ISO form, check and radio states by their on values), and a + repeating subform or row gets as many instances as the data has. `write_datasets` merges the + AcroForm fields' values (by `/PCSom`, or by the Designer field names of a static form) into + the existing `xfa:data`: only the bound nodes' text changes and missing nodes are added, so + unbound data, other namespaces, attributes and comments stay byte for byte. Values it can't + write (a node holding structured content, absurd SOM indices, a packet that is neither UTF-8 + nor UTF-16) come back as warnings; UTF-16 packets are written back as UTF-16. `read_values` + goes the other way, so a form filled here shows its values in Adobe's viewers and a form + filled there shows them here. The engine does both: values on open (recorded in the + document's warnings), the packet after every form edit. + +Layout measures each container once per (node, width): width-less containers would otherwise +be measured exponentially often in their nesting. A measurement budget backs this up. + +## API sketch + +```rust +if pdfcraft_xfa::is_dynamic(&doc) { + let report = pdfcraft_xfa::render_into(&mut doc)?; // pages, fields, warnings + for f in pdfcraft_forms::fields(&doc) { pdfcraft_forms::redraw_field(&mut doc, &f.name)?; } +} +let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for tests +``` + +## Deliberately not done (yet) + +- **Data binding** is the default one only: explicit `bind ref` expressions, global binding and + data descriptions are not followed, and no standalone XML or XDP data file is imported or + exported. +- **Scripting.** No FormCalc or XFA JavaScript (`xfa.host`, `xfa.layout`, `instanceManager`); + rows are not added by button, validations and calculations don't run. Buttons map only the + common idioms (reset, print, save as, launchURL). +- **Static XFA forms** (`/NeedsRendering` absent, AcroForm fields present) keep their AcroForm; + only their data is read and written. +- Choice lists are text fields; signature, image, barcode and password fields are left blank; + PNG and GIF images, `keep` constraints, `subformSet` relations, `rl-tb` is mirrored `lr-tb`, + font metrics are the approximate Helvetica ones, and line heights are 1.15 × size. +- Acrobat cannot be an oracle here (clean-room rules): layout follows the specification, and + fidelity has been checked by eye on real government forms, not pixel-compared. diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs new file mode 100644 index 00000000..a1ef847c --- /dev/null +++ b/crates/xfa/src/data.rs @@ -0,0 +1,793 @@ +//! The data model (`datasets` packet, XFA 3.3 part 1, "Data Binding"): reading the values a +//! form was saved with, and writing the values of its fields back, so Adobe's viewers (which +//! lay the form out from the XFA packets) show what was filled in here. +//! +//! Binding is the default "normal" one: a field's data node is named after the field and nested +//! under nodes named after its named ancestor subforms, repeated instances as repeated sibling +//! elements. Explicit `bind ref` expressions are not followed. + +use std::collections::HashMap; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; + +use crate::XfaError; +use crate::packets::{Encoding, encode}; + +const XFA_DATA_NS: &str = "http://www.xfa.org/schema/xfa-data/1.0/"; +/// Most data nodes read. +const MAX_NODES: u32 = 1_000_000; +const MAX_DEPTH: usize = 64; + +/// One element of the data document. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DataNode { + pub name: String, + pub text: String, + pub children: Vec, +} + +/// A path into the data: `(name, index among same-named siblings)` per level. +pub type DataPath = Vec<(String, usize)>; + +impl DataNode { + /// The node at `path` below this one. + pub fn get(&self, path: &[(String, usize)]) -> Option<&DataNode> { + let mut node = self; + for (name, idx) in path { + node = node.children.iter().filter(|c| &c.name == name).nth(*idx)?; + } + Some(node) + } + + /// How many children of the node at `parent` are named `name`. + pub fn count(&self, parent: &[(String, usize)], name: &str) -> usize { + self.get(parent).map_or(0, |n| n.children.iter().filter(|c| c.name == name).count()) + } + + pub fn text_at(&self, path: &[(String, usize)]) -> Option<&str> { + self.get(path).map(|n| n.text.as_str()) + } +} + +fn to_node(n: roxmltree::Node, depth: usize) -> DataNode { + let name = n.tag_name().name().to_string(); + let children: Vec = + if depth < MAX_DEPTH { n.children().filter(|c| c.is_element()).map(|c| to_node(c, depth + 1)).collect() } else { Vec::new() }; + // The text of a leaf is its value; a container's own text is whitespace. + let text = if children.is_empty() { n.children().filter_map(|c| c.text()).collect::() } else { String::new() }; + DataNode { name, text, children } +} + +/// The `xfa:data` element of the datasets packet in `xdp` (or a bare datasets document), as a +/// tree whose root is that element. +pub fn parse_datasets(xdp: &str) -> Option { + let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + let doc = roxmltree::Document::parse_with_options(xdp, opts).ok()?; + let datasets = doc.descendants().find(|n| n.is_element() && n.tag_name().name() == "datasets")?; + let data = datasets.children().find(|n| n.is_element() && n.tag_name().name() == "data")?; + Some(to_node(data, 0)) +} + +/// The data path a SOM expression names: `form[0].#subform[0].name[1]` → `[(form, 0), (name, 1)]`. +/// Unnamed containers (`#subform`) have no data node. +pub fn som_to_path(som: &str) -> DataPath { + som.split('.') + .filter_map(|seg| { + let seg = seg.trim(); + if seg.is_empty() || seg.starts_with('#') || seg.starts_with('$') { + return None; + } + let (name, idx) = match seg.split_once('[') { + Some((n, rest)) => (n, rest.trim_end_matches(']').parse::().unwrap_or(0)), + None => (seg, 0), + }; + Some((name.to_string(), idx.min(100_000))) + }) + .collect() +} + +/// What a field holds, for writing the datasets. +#[derive(Clone, Debug, PartialEq)] +pub enum FieldData { + Text(String), + /// Checked or not. + Check(bool), + /// The selected button's on value, if any. + Radio(Option), + /// Nothing to write (buttons, signatures). + None, +} + +/// One field of the AcroForm, as the engine sees it. +#[derive(Clone, Debug, PartialEq)] +pub struct FieldDatum { + /// The terminal field dictionary (where `/PCSom` and `/PCItems` live). + pub obj: ObjRef, + /// The fully qualified field name (a static form's SOM path). + pub name: String, + pub data: FieldData, +} + +fn text_of(doc: &Document, d: &Dict, key: &[u8]) -> Option { + d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())) +} + +/// The SOM path of a field: its `/PCSom` when PdfCraft generated it, else its name. +fn som_of(doc: &Document, f: &FieldDatum) -> String { + doc.get(f.obj).as_dict().and_then(|d| text_of(doc, d, crate::pdf::SOM_KEY)).unwrap_or_else(|| f.name.clone()) +} + +/// The on and off item values of a check box (`/PCItems`), or the widget's on state. +fn items_of(doc: &Document, f: &FieldDatum) -> (String, String) { + let d = doc.get(f.obj); + let items: Vec = d + .as_dict() + .and_then(|d| d.get(b"PCItems").map(|o| doc.resolve(o))) + .and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| x.as_string().map(|s| s.to_text())).collect())) + .unwrap_or_default(); + let on = items.first().cloned().unwrap_or_else(|| "1".into()); + let off = items.get(1).cloned().unwrap_or_default(); + (on, off) +} + +/// The Acrobat date pattern a field's format action names (`AFDate_FormatEx("yyyy-mm-dd")`). +fn date_pattern_of(doc: &Document, f: &FieldDatum) -> Option { + let d = doc.get(f.obj); + let aa = doc.resolve(d.as_dict()?.get(b"AA")?); + let fa = doc.resolve(aa.as_dict()?.get(b"F")?); + let js = text_of(doc, fa.as_dict()?, b"JS")?; + let i = js.find("AFDate_FormatEx(")?; + let rest = &js[i + "AFDate_FormatEx(".len()..]; + let q = rest.find('"')?; + let body = &rest[q + 1..]; + Some(body[..body.find('"')?].to_string()) +} + +/// The tokens of a date pattern: `yyyy`, `yy`, `mm`, `m`, `dd`, `d`, or a literal character. +fn date_tokens(pattern: &str) -> Vec { + let mut out = Vec::new(); + let chars: Vec = pattern.chars().collect(); + let mut i = 0; + while i < chars.len() { + let c = chars[i].to_ascii_lowercase(); + if "ymd".contains(c) { + let mut j = i; + while j < chars.len() && chars[j].to_ascii_lowercase() == c { + j += 1; + } + out.push(std::iter::repeat_n(c, j - i).collect()); + i = j; + } else { + out.push(chars[i].to_string()); + i += 1; + } + } + out +} + +/// `2001-02-03` shown in `pattern` (`mm/dd/yyyy` → `02/03/2001`). Unknown patterns and +/// non-dates come back as they are. +pub fn iso_to_pattern(iso: &str, pattern: &str) -> String { + let parts: Vec<&str> = iso.trim().splitn(3, '-').collect(); + let [y, m, d] = parts[..] else { return iso.to_string() }; + let (Ok(y), Ok(m), Ok(d)) = (y.parse::(), m.parse::(), d.parse::()) else { return iso.to_string() }; + let mut out = String::new(); + for t in date_tokens(pattern) { + match t.as_str() { + "yyyy" => out.push_str(&format!("{y:04}")), + "yy" => out.push_str(&format!("{:02}", y % 100)), + "mm" => out.push_str(&format!("{m:02}")), + "m" => out.push_str(&m.to_string()), + "dd" => out.push_str(&format!("{d:02}")), + "d" => out.push_str(&d.to_string()), + other => out.push_str(other), + } + } + out +} + +/// A date typed as `pattern` back to `yyyy-mm-dd`. `None` when it doesn't match. +pub fn pattern_to_iso(text: &str, pattern: &str) -> Option { + let text = text.trim(); + let (mut y, mut m, mut d) = (None, None, None); + let mut rest = text; + let tokens = date_tokens(pattern); + for (i, t) in tokens.iter().enumerate() { + match t.as_str() { + "yyyy" | "yy" | "mm" | "m" | "dd" | "d" => { + // Digits up to the next literal (or a fixed width). + let fixed = matches!(t.as_str(), "yyyy" | "yy" | "mm" | "dd"); + let width = if fixed { + t.len() + } else { + rest.chars().take_while(|c| c.is_ascii_digit()).count().min(if t == "d" || t == "m" { 2 } else { 4 }) + }; + let digits: String = rest.chars().take(width).collect(); + if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) { + return None; + } + let v: u32 = digits.parse().ok()?; + rest = &rest[digits.len()..]; + match t.as_str() { + "yyyy" => y = Some(v), + "yy" => y = Some(if v < 50 { 2000 + v } else { 1900 + v }), + "mm" | "m" => m = Some(v), + _ => d = Some(v), + } + } + lit => { + rest = rest.strip_prefix(lit)?; + } + } + let _ = i; + } + if !rest.is_empty() { + return None; + } + let (y, m, d) = (y?, m?, d?); + (1..=12).contains(&m).then(|| format!("{y:04}-{m:02}-{d:02}")).filter(|_| (1..=31).contains(&d)) +} + +fn xml_name(s: &str) -> String { + let mut out: String = s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' { c } else { '_' }).collect(); + if out.is_empty() || !(out.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')) { + out.insert(0, '_'); + } + out +} + +/// `s` as XML character data. Characters XML 1.0 cannot hold are left out; the flag says +/// whether any were. +fn escape(s: &str) -> (String, bool) { + let mut out = String::with_capacity(s.len()); + let mut dropped = false; + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + // Parsers turn a raw carriage return into a line feed; a reference keeps it. + '\r' => out.push_str(" "), + '\u{FFFE}' | '\u{FFFF}' => dropped = true, + c if c.is_control() && c != '\n' && c != '\t' => dropped = true, + c => out.push(c), + } + } + (out, dropped) +} + +/// Most data nodes one write may create (instances skipped by a SOM index count too). +const MAX_NEW_NODES: usize = 20_000; +/// Highest same-name sibling index a write follows. +const MAX_INDEX: usize = 10_000; + +/// A data tree being built for writing. +#[derive(Default)] +struct Build { + name: String, + text: String, + children: Vec, + /// Positions in `children` by name, in order. + by_name: HashMap>, +} + +impl Build { + /// The node at `path` below this one, created as needed. `None` when the path is deeper + /// than [`MAX_DEPTH`], an index is above [`MAX_INDEX`], or creating it would spend more + /// than `budget` new nodes. + fn at(&mut self, path: &[(String, usize)], budget: &mut usize) -> Option<&mut Build> { + if path.len() > MAX_DEPTH { + return None; + } + let mut node = self; + for (name, idx) in path { + if *idx > MAX_INDEX { + return None; + } + let name = xml_name(name); + let have = node.by_name.get(&name).map_or(0, Vec::len); + if *idx >= have { + let need = idx - have + 1; + if need > *budget { + return None; + } + *budget -= need; + for _ in 0..need { + let pos = node.children.len(); + node.by_name.entry(name.clone()).or_default().push(pos); + node.children.push(Build { name: name.clone(), ..Default::default() }); + } + } + let pos = *node.by_name.get(&name)?.get(*idx)?; + node = node.children.get_mut(pos)?; + } + Some(node) + } + + /// The tree as XML. Depth is bounded by [`Build::at`]. + fn write(&self, out: &mut String) { + if self.children.is_empty() { + out.push_str(&format!("<{}>{}", self.name, escape(&self.text).0, self.name)); + return; + } + out.push_str(&format!("<{}>", self.name)); + self.write_children(out); + out.push_str(&format!("", self.name)); + } + + fn write_children(&self, out: &mut String) { + for c in &self.children { + c.write(out); + } + } +} + +/// A value to write: where (the field's SOM path, and the data path it binds to) and what. +struct Want { + som: String, + path: DataPath, + text: String, +} + +fn wanted(doc: &Document, fields: &[FieldDatum]) -> Vec { + let mut out = Vec::new(); + for f in fields { + let som = som_of(doc, f); + let path = som_to_path(&som); + if path.is_empty() { + continue; + } + let text = match &f.data { + FieldData::Text(t) => match date_pattern_of(doc, f) { + Some(p) => pattern_to_iso(t, &p).unwrap_or_else(|| t.clone()), + None => t.clone(), + }, + FieldData::Check(on) => { + let (on_item, off_item) = items_of(doc, f); + if *on { on_item } else { off_item } + } + FieldData::Radio(sel) => sel.clone().unwrap_or_default(), + FieldData::None => continue, + }; + out.push(Want { som, path, text }); + } + out +} + +/// Note that a value could not be written (once per kind of reason, naming a few fields). +fn skipped(warnings: &mut Vec, reason: &str, som: &str) { + const MAX_WARNINGS: usize = 20; + if let Some(w) = warnings.iter_mut().find(|w| w.starts_with(reason)) { + if w.matches(", ").count() < 4 { + w.push_str(&format!(", {som}")); + } else if !w.ends_with('…') { + w.push_str(", …"); + } + } else if warnings.len() < MAX_WARNINGS { + warnings.push(format!("{reason}: {som}")); + } +} + +const TOO_MANY: &str = "XFA data not written for fields whose data path is too deep or repeats too often"; +const STRUCTURED: &str = "XFA data not written for fields whose data node holds structured content"; +const CONFLICT: &str = "XFA data not written for fields bound to the same data node as another field"; +const CONTROL: &str = "Characters XML can't hold were left out of the XFA data of"; + +/// Fresh data for `wants`: the children of an `xfa:data` element. +fn build_children(wants: &[Want], warnings: &mut Vec) -> String { + let mut root = Build::default(); + let mut budget = MAX_NEW_NODES; + for w in wants { + match root.at(&w.path, &mut budget) { + Some(node) => node.text = w.text.clone(), + None => skipped(warnings, TOO_MANY, &w.som), + } + if escape(&w.text).1 { + skipped(warnings, CONTROL, &w.som); + } + } + let mut out = String::new(); + root.write_children(&mut out); + out +} + +/// The `xfa:data` element for `fields`, built afresh. +pub fn build_data(doc: &Document, fields: &[FieldDatum]) -> String { + format!("{}", build_children(&wanted(doc, fields), &mut Vec::new())) +} + +fn fresh_datasets(data_children: &str) -> String { + format!("{data_children}") +} + +/// Where an element's start tag ends (the index of its `>`), skipping quoted attribute values. +fn start_tag_end(src: &str, range: &std::ops::Range) -> Option { + let bytes = src.as_bytes(); + let mut quote = None; + for i in range.clone() { + let b = *bytes.get(i)?; + match (quote, b) { + (Some(q), _) if b == q => quote = None, + (Some(_), _) => {} + (None, b'"' | b'\'') => quote = Some(b), + (None, b'>') => return Some(i), + _ => {} + } + } + None +} + +/// An element's qualified name as written. +fn qname<'s>(src: &'s str, range: &std::ops::Range) -> Option<&'s str> { + let rest = src.get(range.start.checked_add(1)?..range.end)?; + let end = rest.find(|c: char| c.is_whitespace() || c == '/' || c == '>')?; + rest.get(..end).filter(|n| !n.is_empty()) +} + +/// One change to the packet text: replace `start..end` with `text`. +struct Edit { + start: usize, + end: usize, + text: String, +} + +/// The edit that puts `inner` (markup) at the end of `node`'s content. +fn append_edit(src: &str, node: roxmltree::Node, inner: &str) -> Option { + let range = node.range(); + let tag_end = start_tag_end(src, &range)?; + if tag_end.checked_add(1)? == range.end && src.get(..tag_end)?.ends_with('/') { + // `` becomes `inner`. + let name = qname(src, &range)?; + return Some(Edit { start: tag_end.checked_sub(1)?, end: range.end, text: format!(">{inner}") }); + } + let close = range.start + src.get(range.clone())?.rfind(" Option { + let range = node.range(); + let tag_end = start_tag_end(src, &range)?; + if tag_end.checked_add(1)? == range.end && src.get(..tag_end)?.ends_with('/') { + let name = qname(src, &range)?; + return Some(Edit { start: tag_end.checked_sub(1)?, end: range.end, text: format!(">{text}") }); + } + let close = range.start + src.get(range.clone())?.rfind(" tag_end).then(|| Edit { start: tag_end + 1, end: close, text: text.to_string() }) +} + +/// What merging the values into a packet's text came to. +enum Merged { + /// The data already holds every value. + Unchanged, + Changed(String), + /// The text could not be merged into (the reason is in the warnings). + Failed, +} + +/// Where a datasets packet sits: one packet of an array (`Packet`), or inside the whole XDP. +#[derive(Clone, Copy, PartialEq)] +enum Within { + Packet, + Xdp, +} + +const WRAP_OPEN: &str = ""; +const WRAP_CLOSE: &str = ""; + +/// Write `wants` into the data of the datasets packet in `text`, changing only what has to +/// change: values go into the data nodes they bind to, missing nodes are added, and everything +/// else (data no field binds to, other namespaces, attributes, comments, layout) stays as +/// written. +fn merge(text: &str, within: Within, wants: &[Want], warnings: &mut Vec) -> Merged { + let opts = || roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES }; + // A packet of an array may use the prefixes the XDP envelope declares: read it inside + // an element declaring the usual ones. + let wrapped; + let (src, shift, parsed) = match roxmltree::Document::parse_with_options(text, opts()) { + Ok(d) => (text, 0, d), + Err(first) => { + wrapped = format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"); + match (within, roxmltree::Document::parse_with_options(&wrapped, opts())) { + (Within::Packet, Ok(d)) => (wrapped.as_str(), WRAP_OPEN.len(), d), + _ => { + warnings.push(format!("The XFA data could not be read ({first}); the field values were not written to it")); + return Merged::Failed; + } + } + } + }; + let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name; + let datasets = parsed + .descendants() + .find(|n| is(n, "datasets") && n.tag_name().namespace() == Some(XFA_DATA_NS)) + .or_else(|| parsed.descendants().find(|n| is(n, "datasets"))); + let mut edits = Vec::new(); + let mut data = None; + match datasets { + None if within == Within::Xdp => { + let root = parsed.root_element(); + match append_edit(src, root, &fresh_datasets(&build_children(wants, warnings))) { + Some(e) => edits.push(e), + None => { + warnings.push("The XFA packets could not be extended with data; the field values were not written".into()); + return Merged::Failed; + } + } + } + None => { + warnings.push("The XFA datasets packet holds no datasets element; the field values were not written to it".into()); + return Merged::Failed; + } + Some(ds) => match ds.children().find(|n| is(n, "data")) { + Some(d) => data = Some(d), + None => { + let prefix = qname(src, &ds.range()).and_then(|q| q.split_once(':')).map(|(p, _)| format!("{p}:")).unwrap_or_default(); + let element = format!("<{prefix}data>{}", build_children(wants, warnings)); + match append_edit(src, ds, &element) { + Some(e) => edits.push(e), + None => { + warnings.push("The XFA datasets could not be extended with data; the field values were not written".into()); + return Merged::Failed; + } + } + } + }, + } + if let Some(data) = data { + merge_values(src, data, wants, &mut edits, warnings); + } + if edits.is_empty() { + return Merged::Unchanged; + } + // Apply from the end, so earlier positions stay valid; overlapping edits (two fields bound + // to one node) keep the first. + edits.sort_by_key(|e| (e.start, e.end)); + let mut kept: Vec = Vec::with_capacity(edits.len()); + for e in edits { + if kept.last().is_some_and(|k| e.start < k.end) { + if kept.last().is_some_and(|k| k.text != e.text) && !warnings.iter().any(|w| w == CONFLICT) { + warnings.push(CONFLICT.into()); + } + continue; + } + kept.push(e); + } + let mut out = src.to_string(); + for e in kept.iter().rev() { + if out.get(e.start..e.end).is_none() { + warnings.push("The XFA data could not be updated; the field values were not written to it".into()); + return Merged::Failed; + } + out.replace_range(e.start..e.end, &e.text); + } + if shift > 0 { + match out.get(shift..out.len().saturating_sub(WRAP_CLOSE.len())) { + Some(inner) => out = inner.to_string(), + None => return Merged::Failed, + } + } + Merged::Changed(out) +} + +/// The edits that put `wants` into the `data` element. +fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Vec, warnings: &mut Vec) { + // Element children by local name, per element visited (built once each). + let mut index: HashMap>> = HashMap::new(); + let tree = data.document(); + // Nodes to add, per existing element they go into. + let mut added: Vec<(roxmltree::NodeId, Build)> = Vec::new(); + let mut budget = MAX_NEW_NODES; + for w in wants { + let (escaped, dropped) = escape(&w.text); + let mut node = data; + let mut depth = 0; + let mut have = 0; + for (name, idx) in &w.path { + let kids = index.entry(node.id()).or_insert_with(|| { + let mut by: HashMap> = HashMap::new(); + for c in node.children().filter(|c| c.is_element()) { + by.entry(c.tag_name().name().to_string()).or_default().push(c.id()); + } + by + }); + let same = kids.get(name.as_str()); + match same.and_then(|v| v.get(*idx)).and_then(|id| tree.get_node(*id)) { + Some(next) => { + node = next; + depth += 1; + } + None => { + have = same.map_or(0, Vec::len); + break; + } + } + } + if depth == w.path.len() { + // The data node exists: give it the value, unless it already holds it. + let current: String = node.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect(); + if node.children().any(|c| c.is_element()) { + let all: String = node.descendants().filter(|c| c.is_text()).filter_map(|c| c.text()).collect(); + if all.trim() != w.text.trim() { + skipped(warnings, STRUCTURED, &w.som); + } + continue; + } + if current == w.text { + continue; + } + match replace_edit(src, node, &escaped) { + Some(e) => edits.push(e), + None => skipped(warnings, STRUCTURED, &w.som), + } + } else { + // Missing: add the rest of the path below the deepest node that exists. An empty + // value needs no node. + if w.text.is_empty() { + continue; + } + let mut rest: DataPath = w.path.get(depth..).unwrap_or_default().to_vec(); + if let Some(first) = rest.first_mut() { + first.1 = first.1.saturating_sub(have); + } + let pos = match added.iter().position(|(id, _)| *id == node.id()) { + Some(p) => p, + None => { + added.push((node.id(), Build::default())); + added.len() - 1 + } + }; + let built = added.get_mut(pos).and_then(|(_, b)| b.at(&rest, &mut budget)); + match built { + Some(b) => b.text = w.text.clone(), + None => { + skipped(warnings, TOO_MANY, &w.som); + continue; + } + } + } + if dropped { + skipped(warnings, CONTROL, &w.som); + } + } + for (id, build) in added { + let Some(node) = tree.get_node(id) else { continue }; + let mut inner = String::new(); + build.write_children(&mut inner); + match append_edit(src, node, &inner) { + Some(e) => edits.push(e), + None => warnings.push("Some XFA data nodes could not be added; their field values were not written".into()), + } + } +} + +/// What [`write_datasets`] did. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DatasetsWrite { + /// The datasets packet changed (a new revision of it was added to the document). + pub written: bool, + /// Values that could not be written, and why. + pub warnings: Vec, +} + +/// A packet stream's text and encoding; `None` (with a warning) when it can't be read exactly. +fn packet_text(doc: &Document, o: &Object, warnings: &mut Vec) -> Option<(String, Encoding)> { + let s = doc.resolve(o); + let Object::Stream(s) = &*s else { + warnings.push("The XFA data packet is not a stream; the field values were not written to it".into()); + return None; + }; + let bytes = match s.decoded_within(64 << 20) { + Ok(b) => b, + Err(e) => { + warnings.push(format!("The XFA data could not be decoded ({e}); the field values were not written to it")); + return None; + } + }; + let decoded = crate::packets::decode(&bytes); + if decoded.is_none() { + warnings.push("The XFA data is neither UTF-8 nor UTF-16; the field values were not written to it, so it stays as it was".into()); + } + decoded +} + +/// Write the fields' values into the datasets packet, keeping all the data no field binds to. +/// Nothing is written when the document has no XFA entry or the data already holds the values; +/// values that can't be written are reported, never silently dropped. +pub fn write_datasets(doc: &mut Document, fields: &[FieldDatum]) -> Result { + let mut report = DatasetsWrite::default(); + let Some(root) = doc.root() else { return Ok(report) }; + let catalog = doc.get(root); + let Some(acro_obj) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).cloned() else { return Ok(report) }; + let acro_ref = acro_obj.as_ref(); + let Some(mut acro) = doc.resolve(&acro_obj).as_dict().cloned() else { return Ok(report) }; + let Some(xfa) = acro.get(b"XFA").cloned() else { return Ok(report) }; + let wants = wanted(doc, fields); + let warnings = &mut report.warnings; + match &*doc.resolve(&xfa) { + Object::Array(items) => { + let mut items = items.clone(); + let at = (0..items.len()) + .step_by(2) + .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len()); + let packet = match at { + Some(i) => { + let Some(existing) = items.get(i + 1).cloned() else { return Ok(report) }; + let Some((text, encoding)) = packet_text(doc, &existing, warnings) else { return Ok(report) }; + match merge(&text, Within::Packet, &wants, warnings) { + Merged::Changed(t) => Some((i, encode(&t, encoding))), + Merged::Unchanged | Merged::Failed => None, + } + } + None => None, + }; + match (at, packet) { + (Some(i), Some((_, bytes))) => { + let r = doc.add(Object::Stream(Stream::flate(Dict::new(), &bytes))); + if let Some(slot) = items.get_mut(i + 1) { + *slot = Object::Ref(r); + } + } + (Some(_), None) => return Ok(report), + (None, _) => { + // No datasets packet yet: before the postamble, or last. + let text = fresh_datasets(&build_children(&wants, warnings)); + let r = doc.add(Object::Stream(Stream::flate(Dict::new(), text.as_bytes()))); + let pos = items.iter().position(|n| n.as_string().is_some_and(|s| s.to_text() == "postamble")).unwrap_or(items.len()); + items.insert(pos, Object::Ref(r)); + items.insert(pos, Object::String(PdfString::text("datasets"))); + } + } + acro.set(b"XFA".to_vec(), Object::Array(items)); + } + Object::Stream(_) => { + let Some((text, encoding)) = packet_text(doc, &xfa, warnings) else { return Ok(report) }; + let Merged::Changed(new) = merge(&text, Within::Xdp, &wants, warnings) else { return Ok(report) }; + let r = doc.add(Object::Stream(Stream::flate(Dict::new(), &encode(&new, encoding)))); + acro.set(b"XFA".to_vec(), Object::Ref(r)); + } + _ => return Ok(report), + } + match acro_ref { + Some(ar) => doc.set(ar, Object::Dict(acro)), + None => doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(acro)))?, + } + report.written = true; + Ok(report) +} + +/// The values the datasets hold for `fields`: `(field name, what it should hold)`, only for +/// fields the data mentions. +pub fn read_values(doc: &Document, fields: &[FieldDatum]) -> Vec<(String, FieldData)> { + let Ok(Some(p)) = crate::read_packets(doc) else { return Vec::new() }; + let Some(data) = parse_datasets(&p.xdp) else { return Vec::new() }; + let mut out = Vec::new(); + for f in fields { + let path = som_to_path(&som_of(doc, f)); + let Some(text) = data.text_at(&path) else { continue }; + let value = match &f.data { + FieldData::Text(_) => FieldData::Text(match date_pattern_of(doc, f) { + Some(pat) => iso_to_pattern(text, &pat), + None => text.to_string(), + }), + FieldData::Check(_) => { + let (on, _) = items_of(doc, f); + FieldData::Check(is_on(text, &on)) + } + FieldData::Radio(_) => FieldData::Radio((!text.trim().is_empty()).then(|| text.trim().to_string())), + FieldData::None => continue, + }; + out.push((f.name.clone(), value)); + } + out +} + +/// Does a data value mean "checked" for a button whose on value is `on`? +pub fn is_on(text: &str, on: &str) -> bool { + let t = text.trim(); + if t == on { + return true; + } + !matches!(t.to_ascii_lowercase().as_str(), "" | "0" | "off" | "false" | "no") && on.trim().is_empty() +} diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs new file mode 100644 index 00000000..f05f766d --- /dev/null +++ b/crates/xfa/src/fixtures.rs @@ -0,0 +1,127 @@ +//! Synthetic fixtures for tests in this and other crates (never real forms). + +#![doc(hidden)] + +/// A small dynamic form: a letter page master with a page-number draw, a title, a text field +/// with a caption on top, a check box, a radio group beside a question, a date field, a table +/// with a repeated header row and `rows` data rows, and a last section that starts a new page. +pub fn template(rows: usize) -> String { + let mut t = String::from( + r##" + +1.7 + + +"##, + ); + t +} + +/// [`template`] with a datasets packet holding `data` (the children of `xfa:data`). +pub fn template_with_data(rows: usize, data: &str) -> String { + let t = template(rows); + let packet = + format!("{data}\n"); + t.replace("", &packet) +} + +/// A static XFA form: an AcroForm with Designer-style field names (`form1[0].page1[0].name[0]`, +/// a text field, and `…agree[0]`, a check box with on state `1`), plus XFA packets whose +/// datasets hold `data` (the children of `xfa:data`). No `/NeedsRendering`. +pub fn static_shell(data: &str) -> Vec { + let xdp = format!( + "{data}" + ); + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /XFA 5 0 R /Fields [6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >> >> >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Annots [8 0 R 9 0 R] >>".to_vec(), + b"<< /Length 0 >>\nstream\n\nendstream".to_vec(), + format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()).into_bytes(), + b"<< /T (form1[0]) /Kids [7 0 R] >>".to_vec(), + b"<< /T (page1[0]) /Parent 6 0 R /Kids [8 0 R 9 0 R] >>".to_vec(), + b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name[0]) /Parent 7 0 R /Rect [72 700 300 720] /P 3 0 R /DA (/Helv 10 Tf 0 g) /MK << /BC [0 0 0] >> >>".to_vec(), + b"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree[0]) /Parent 7 0 R /Rect [72 660 84 672] /P 3 0 R /V /Off /AS /Off /MK << /BC [0 0 0] >> /AP << /N << /1 10 0 R /Off 10 0 R >> >> >>".to_vec(), + b"<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Length 0 >>\nstream\n\nendstream".to_vec(), + ]; + assemble(objs) +} + +fn assemble(objs: Vec>) -> Vec { + 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", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + 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 +} + +/// A PDF shell around `xdp`: one placeholder page, `/NeedsRendering true`, no fields. +pub fn shell(xdp: &str) -> Vec { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".to_vec(), + b"<< /Length 44 >>\nstream\nBT /F1 12 Tf 72 700 Td (Please wait...) Tj ET\nendstream".to_vec(), + format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()).into_bytes(), + ]; + assemble(objs) +} diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs new file mode 100644 index 00000000..c9db6997 --- /dev/null +++ b/crates/xfa/src/layout.rs @@ -0,0 +1,1260 @@ +//! The layout engine: places a template's subforms, draws and fields on pages (XFA 3.3 part 2, +//! "Layout"). Supports positioned, top-to-bottom, left-to-right and table/row layouts, page +//! masters with content areas, `breakBefore`/`breakAfter`, and overflow leaders (header rows +//! repeated after a page break). Repeating subforms get their initial instances. +//! +//! Output is a list of pages of resolution-independent items; `pdf` turns them into PDF. + +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; +use std::sync::Arc; + +use crate::XfaError; +use crate::data::{DataNode, is_on, iso_to_pattern, som_to_path}; +use crate::model::*; +use crate::text::{self, Block, Face, Span}; + +/// Most pages a form may lay out to. +pub const MAX_PAGES: usize = 500; +/// Most items (text spans, lines, widgets…) across all pages. +const MAX_ITEMS: usize = 400_000; +/// Deepest nesting followed. +const MAX_DEPTH: usize = 64; +/// Most size measurements of one layout. Measurements of containers are memoized, so a real +/// form stays far below this; it bounds the work a hostile template can cause. +const MAX_MEASURES: usize = 2_000_000; +/// Most initial instances of one repeating subform. +const MAX_INSTANCES: usize = 50; +/// Placeholder for the page count in text laid out before the count is known. +const PAGE_COUNT_MARK: char = '\u{E000}'; + +#[derive(Clone, Debug, PartialEq)] +pub enum Item { + Fill { rect: Rect, color: [f64; 3] }, + Line { from: (f64, f64), to: (f64, f64), width: f64, color: [f64; 3], dashed: bool }, + Text(Span), + Image { rect: Rect, data: Arc>, content_type: String }, + Widget(Box), +} + +#[derive(Clone, Debug, PartialEq)] +pub enum WidgetKind { + Text, + /// A date field with its Acrobat format pattern (`yyyy-mm-dd`). + Date(String), + CheckBox { + on: String, + round: bool, + }, + Radio { + group: String, + on: String, + round: bool, + }, + Button { + caption: String, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BorderShape { + None, + Full, + Underline, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct WidgetBorder { + pub color: Option<[f64; 3]>, + pub fill: Option<[f64; 3]>, + pub shape: BorderShape, + pub width: f64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Action { + Reset, + Print, + SaveAs, + Url(String), +} + +/// An interactive field to create as an AcroForm widget. +#[derive(Clone, Debug, PartialEq)] +pub struct Widget { + pub rect: Rect, + pub kind: WidgetKind, + /// The PDF field name (unique in the form, no periods). + pub name: String, + /// The template's scripting object model path, for data round trips. + pub som: String, + pub tooltip: Option, + pub face: Face, + pub h_align: HAlign, + pub multiline: bool, + pub max_chars: Option, + pub read_only: bool, + pub border: WidgetBorder, + pub value: Option, + pub action: Option, + /// Check boxes: the on and off values of the data; radio buttons: the on value. + pub items: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Page { + pub width: f64, + pub height: f64, + pub items: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Form { + pub pages: Vec, + pub fields: usize, + pub warnings: Vec, +} + +/// Inherited text defaults and the SOM path of the enclosing containers. +#[derive(Clone)] +struct Ctx { + font: Font, + para: Para, + som: String, +} + +impl Ctx { + fn child(&self, c: &Common) -> Ctx { + Ctx { font: self.font.apply(&c.font), para: self.para.apply(&c.para), som: self.som.clone() } + } + + fn with_som(&self, c: &Common, index: usize) -> Ctx { + let mut out = self.child(c); + if let Some(n) = &c.name { + if !out.som.is_empty() { + out.som.push('.'); + } + out.som.push_str(&format!("{n}[{index}]")); + } + out + } +} + +/// (address of a subform or area in the template, available width bits, depth). +type MeasureKey = (usize, u64, usize); + +/// A cached size, with the inherited text defaults it was measured with. +struct Measured { + font: Font, + para: Para, + size: (f64, f64), +} + +struct Cursor { + area: usize, + content: usize, + rect: Rect, + y: f64, + /// Something from the flow was placed in this content area. + placed: bool, +} + +struct Layouter<'t> { + tpl: &'t Template, + data: Option<&'t DataNode>, + /// Shared so a page's master items keep their addresses (the measurement cache keys on them). + areas: Rc>, + uses: Vec, + pages: Vec, + cur: Option, + names: HashMap, + /// Field values by `id`, for embedded fields in rich text. + values: HashMap, + warnings: Vec, + warned: HashSet, + fields: usize, + items: usize, + /// Container sizes by (address of the subform or area in the template, available width, + /// depth), with the inherited font and paragraph they were measured with. + measured: RefCell>, + /// Measurements made so far (see [`MAX_MEASURES`]). + measures: Cell, +} + +/// Index of child `i` among the earlier siblings with the same name (the SOM index). +fn sib_index(children: &[Node], i: usize) -> usize { + let name = children.get(i).and_then(|c| c.common().name.as_deref()); + children.iter().take(i).filter(|c| c.common().name.as_deref() == name && name.is_some()).count() +} + +/// Lay a template out, with the values and repeat counts of `data` (the `xfa:data` element). +pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result { + let areas = match &tpl.root.page_set { + Some(ps) if !ps.areas.is_empty() => ps.areas.clone(), + _ => vec![PageArea { + name: None, + width: 612.0, + height: 792.0, + content: vec![Rect::new(36.0, 36.0, 540.0, 720.0)], + items: Vec::new(), + occur_max: None, + }], + }; + let mut values = HashMap::new(); + // One copy of the root for the whole layout: measurements are cached by node address. + let root_node = Node::Subform(Box::new(tpl.root.clone())); + collect_values(std::slice::from_ref(&root_node), &mut values, 0); + let mut l = Layouter { + tpl, + data, + uses: vec![0; areas.len()], + areas: Rc::new(areas), + pages: Vec::new(), + cur: None, + names: HashMap::new(), + values, + warnings: Vec::new(), + warned: HashSet::new(), + fields: 0, + items: 0, + measured: RefCell::new(HashMap::new()), + measures: Cell::new(0), + }; + let ctx = Ctx { font: Font::default(), para: Para::default(), som: String::new() }; + l.new_page()?; + if tpl.root.common.presence.occupies() { + l.flow_subform(&root_node, None, &ctx, 0, 0)?; + } + l.check_measures()?; + let total = l.pages.len(); + let count = total.to_string(); + for p in &mut l.pages { + for it in &mut p.items { + if let Item::Text(s) = it + && s.text.contains(PAGE_COUNT_MARK) + { + s.text = s.text.replace(PAGE_COUNT_MARK, &count); + } + } + } + Ok(Form { pages: l.pages, fields: l.fields, warnings: l.warnings }) +} + +fn collect_values(nodes: &[Node], out: &mut HashMap, depth: usize) { + if depth > MAX_DEPTH { + return; + } + for n in nodes { + match n { + Node::Field(f) => { + if let Some(id) = &f.common.id { + out.insert(id.clone(), f.value.plain().unwrap_or_default()); + } + } + Node::Subform(s) => { + collect_values(&s.children, out, depth + 1); + if let Some(ps) = &s.page_set { + for a in &ps.areas { + collect_values(&a.items, out, depth + 1); + } + } + } + Node::Area(a) => collect_values(&a.children, out, depth + 1), + Node::ExclGroup(_) | Node::Draw(_) => {} + } + } +} + +fn clamp_opt(v: f64, min: Option, max: Option) -> f64 { + let mut v = v; + if let Some(m) = min { + v = v.max(m); + } + if let Some(m) = max + && m > 0.0 + { + v = v.min(m); + } + v.max(0.0) +} + +/// Does `node` flow (may be split across pages) rather than being placed as one block? +fn splittable(node: &Node) -> bool { + matches!(node, Node::Subform(s) if matches!(s.layout, Layout::Tb | Layout::Table) && s.common.h.is_none()) +} + +fn is_row(node: &Node) -> bool { + matches!(node, Node::Subform(s) if s.layout == Layout::Row) +} + +/// The Acrobat date pattern for an XFA picture clause such as `date{YYYY-MM-DD}`. +fn date_pattern(picture: Option<&str>) -> String { + let p = picture.unwrap_or(""); + let inner = p.find('{').and_then(|a| p[a + 1..].find('}').map(|b| &p[a + 1..a + 1 + b])).unwrap_or(p); + let inner = inner.trim(); + if inner.is_empty() || inner.chars().any(|c| !"YMDEJ-/., :".contains(c)) { + return "yyyy-mm-dd".into(); + } + inner.replace('E', "d").to_ascii_lowercase() +} + +fn button_action(scripts: &[Script]) -> Option { + let s = scripts.iter().find(|s| s.activity == "click")?; + let t = &s.text; + if t.contains("resetData") { + Some(Action::Reset) + } else if t.contains("host.print") || t.contains("execMenuItem(\"Print\")") { + Some(Action::Print) + } else if t.contains("SaveAs") || t.contains("execMenuItem(\"Save\")") { + Some(Action::SaveAs) + } else if let Some(i) = t.find("launchURL(") { + let rest = &t[i + "launchURL(".len()..]; + let q = rest.find(['"', '\''])?; + let quote = rest[q..].chars().next()?; + let body = &rest[q + 1..]; + let end = body.find(quote)?; + Some(Action::Url(body[..end].to_string())) + } else { + None + } +} + +/// A PDF field name: no periods (they separate hierarchy levels), no control characters. +fn clean_name(name: &str) -> String { + let s: String = name.chars().filter(|c| !c.is_control()).map(|c| if c == '.' { '_' } else { c }).collect(); + let s = s.trim().to_string(); + if s.is_empty() { "field".into() } else { s } +} + +impl Layouter<'_> { + fn warn_once(&mut self, msg: impl Into) { + let m = msg.into(); + if self.warned.insert(m.clone()) && self.warnings.len() < 100 { + self.warnings.push(m); + } + } + + fn page_no(&self) -> usize { + self.pages.len() + } + + fn embed(&self, id: &str) -> String { + match self.tpl.page_roles.iter().find(|(i, _)| i == id).map(|(_, r)| *r) { + Some(PageRole::Number) => self.page_no().to_string(), + Some(PageRole::Count) => PAGE_COUNT_MARK.to_string(), + None => self.values.get(id).cloned().unwrap_or_default(), + } + } + + /// `TooLarge` once the measurement budget is spent (sizes measured after that are guesses). + fn check_measures(&self) -> Result<(), XfaError> { + if self.measures.get() > MAX_MEASURES { + return Err(XfaError::TooLarge("the form is nested too deeply to lay out".into())); + } + Ok(()) + } + + fn push(&mut self, item: Item) -> Result<(), XfaError> { + self.check_measures()?; + self.items += 1; + if self.items > MAX_ITEMS { + return Err(XfaError::TooLarge("the form has too many objects to lay out".into())); + } + if let Some(p) = self.pages.last_mut() { + p.items.push(item); + } + Ok(()) + } + + fn push_block(&mut self, b: Block) -> Result<(), XfaError> { + for s in b.spans { + if s.face.underline { + let w = s.face.width(&s.text); + let y = s.baseline + s.face.size * 0.1; + self.push(Item::Line { from: (s.x, y), to: (s.x + w, y), width: (s.face.size * 0.06).max(0.3), color: s.face.color, dashed: false })?; + } + if !s.text.trim().is_empty() { + self.push(Item::Text(s))?; + } + } + Ok(()) + } + + // ───────────────────────────────────────────────────────────────────────── pages + + fn new_page(&mut self) -> Result<(), XfaError> { + if self.pages.len() >= MAX_PAGES { + return Err(XfaError::TooLarge(format!("the form lays out to more than {MAX_PAGES} pages"))); + } + let idx = + (0..self.areas.len()).find(|&i| self.areas[i].occur_max.is_none_or(|m| self.uses[i] < m)).unwrap_or(self.areas.len().saturating_sub(1)); + let areas = Rc::clone(&self.areas); + let Some(area) = areas.get(idx) else { + return Err(XfaError::Malformed("the template has no page area".into())); + }; + if let Some(u) = self.uses.get_mut(idx) { + *u += 1; + } + self.pages.push(Page { width: area.width, height: area.height, items: Vec::new() }); + let content = area.content.first().copied().unwrap_or(Rect::new(0.0, 0.0, area.width, area.height)); + self.cur = Some(Cursor { area: idx, content: 0, rect: content, y: content.y, placed: false }); + // Master page content: positioned draws and fields. + let ctx = Ctx { font: Font::default(), para: Para::default(), som: area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_default() }; + let page_rect = Rect::new(0.0, 0.0, area.width, area.height); + for (i, item) in area.items.iter().enumerate() { + self.draw_positioned_child(item, page_rect, &ctx, 1, sib_index(&area.items, i))?; + } + Ok(()) + } + + /// Move to the next content area, or the next page. + fn next_area(&mut self) -> Result<(), XfaError> { + if let Some(c) = &self.cur + && let Some(area) = self.areas.get(c.area) + && let Some(next) = area.content.get(c.content + 1).copied() + { + let i = c.content + 1; + self.cur = Some(Cursor { area: c.area, content: i, rect: next, y: next.y, placed: false }); + return Ok(()); + } + self.new_page() + } + + fn cursor(&self) -> (Rect, f64, bool) { + match &self.cur { + Some(c) => (c.rect, c.y, c.placed), + None => (Rect::new(0.0, 0.0, 612.0, 792.0), 0.0, false), + } + } + + /// Move the cursor down (margins and spacing: the area still counts as empty). + fn advance(&mut self, dy: f64) { + if let Some(c) = &mut self.cur { + c.y += dy.max(0.0); + } + } + + /// Move past something that was placed. + fn place(&mut self, dy: f64) { + if let Some(c) = &mut self.cur { + c.y += dy.max(0.0); + c.placed = true; + } + } + + /// Make room for `h`: stay, or move to the next content area / page. Returns whether a + /// break happened. + fn make_room(&mut self, h: f64) -> Result { + let (rect, y, placed) = self.cursor(); + if y + h <= rect.bottom() + 0.01 || !placed { + return Ok(false); + } + self.next_area()?; + Ok(true) + } + + fn page_has_flow(&self) -> bool { + self.cur.as_ref().is_some_and(|c| c.placed || c.content > 0) + } + + // ───────────────────────────────────────────────────────────────────────── flow + + /// Flow `node` (a subform; anything else is ignored) into the content areas. + fn flow_subform(&mut self, node: &Node, avail: Option<(f64, f64)>, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + if depth > MAX_DEPTH { + return Ok(()); + } + let Node::Subform(sf) = node else { return Ok(()) }; + self.check_measures()?; + if sf.break_before_page && self.page_has_flow() { + self.new_page()?; + } + // Data adds instances of a repeating subform (rows added in another viewer). + let from_data = match (&sf.common.name, self.data) { + (Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&som_to_path(&ctx.som), name), + _ => 0, + }; + let instances = sf.occur.initial.max(from_data).min(sf.occur.max.unwrap_or(usize::MAX)).clamp(1, MAX_INSTANCES); + let ctx_parent = ctx; + for i in 0..instances { + let ctx = ctx_parent.with_som(&sf.common, sib + i); + let (rect, _, _) = self.cursor(); + let (x, w) = avail.unwrap_or((rect.x, rect.w)); + let w = clamp_opt(sf.common.w.unwrap_or(w), sf.common.min_w, sf.common.max_w).min(w.max(1.0)); + if !matches!(sf.layout, Layout::Tb | Layout::Table) || sf.common.h.is_some() { + // Not splittable: one block. + let (_, h) = self.size_of(node, w, ctx_parent, depth); + self.make_room(h)?; + let (_, y, _) = self.cursor(); + self.draw_node(node, Rect::new(x, y, w, h), ctx_parent, depth, sib + i)?; + self.place(h); + continue; + } + let m = sf.common.margin; + self.advance(m.top); + let inner_x = x + m.left; + let inner_w = (w - m.horizontal()).max(1.0); + match sf.layout { + Layout::Table => self.flow_table(sf, inner_x, inner_w, &ctx, depth)?, + _ => { + for (ci, child) in sf.children.iter().enumerate() { + if !child.common().presence.occupies() { + continue; + } + let csib = sib_index(&sf.children, ci); + if let Node::Subform(_) = child + && splittable(child) + { + self.flow_subform(child, Some((inner_x, inner_w)), &ctx, depth + 1, csib)?; + continue; + } + let (cw, ch) = self.size_of(child, inner_w, &ctx, depth + 1); + self.make_room(ch)?; + let (_, y, _) = self.cursor(); + self.draw_node(child, Rect::new(inner_x, y, cw, ch), &ctx, depth + 1, csib)?; + self.place(ch); + } + } + } + self.advance(m.bottom); + } + if sf.break_after_page { + self.new_page()?; + } + Ok(()) + } + + fn columns(&self, sf: &Subform, inner_w: f64) -> Vec { + if !sf.column_widths.is_empty() { + return sf.column_widths.clone(); + } + let cells = sf.children.iter().filter_map(|c| if let Node::Subform(r) = c { Some(r.children.len()) } else { None }).max().unwrap_or(1).max(1); + vec![inner_w / cells as f64; cells] + } + + fn flow_table(&mut self, sf: &Subform, x: f64, inner_w: f64, ctx: &Ctx, depth: usize) -> Result<(), XfaError> { + let cols = self.columns(sf, inner_w); + let leader = + sf.overflow_leader.as_ref().and_then(|name| sf.children.iter().position(|c| c.common().name.as_deref() == Some(name) && is_row(c))); + for (ci, child) in sf.children.iter().enumerate() { + if !child.common().presence.occupies() { + continue; + } + let csib = sib_index(&sf.children, ci); + if let Node::Subform(row) = child + && row.layout == Layout::Row + { + // Rows added as data (in another viewer) repeat the row. + let from_data = match (&row.common.name, self.data) { + (Some(name), Some(d)) if row.occur.max != Some(1) => d.count(&som_to_path(&ctx.som), name), + _ => 0, + }; + let instances = row.occur.initial.max(from_data).min(row.occur.max.unwrap_or(usize::MAX)).clamp(1, MAX_INSTANCES); + for inst in 0..instances { + let h = self.row_height(row, &cols, inner_w, ctx, depth + 1); + if self.make_room(h)? + && let Some(li) = leader + && li != ci + && let Some(Node::Subform(l)) = sf.children.get(li) + { + let lh = self.row_height(l, &cols, inner_w, ctx, depth + 1); + let (_, y, _) = self.cursor(); + self.draw_row(l, &cols, Rect::new(x, y, inner_w, lh), ctx, depth + 1, sib_index(&sf.children, li))?; + self.place(lh); + } + let (_, y, _) = self.cursor(); + self.draw_row(row, &cols, Rect::new(x, y, inner_w, h), ctx, depth + 1, csib + inst)?; + self.place(h); + } + continue; + } + if splittable(child) + && let Node::Subform(_) = child + { + self.flow_subform(child, Some((x, inner_w)), ctx, depth + 1, csib)?; + continue; + } + let (cw, ch) = self.size_of(child, inner_w, ctx, depth + 1); + self.make_room(ch)?; + let (_, y, _) = self.cursor(); + self.draw_node(child, Rect::new(x, y, cw, ch), ctx, depth + 1, csib)?; + self.place(ch); + } + Ok(()) + } + + // ───────────────────────────────────────────────────────────────────────── sizing + + /// The size a node takes when given `avail_w` of width. Containers are measured once per + /// (node, width, depth): measuring a width-less container measures its content twice (for + /// its width, then its height), which would otherwise be exponential in the nesting. + fn size_of(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) { + let c = node.common(); + let spent = self.measures.get().saturating_add(1); + self.measures.set(spent); + if depth > MAX_DEPTH || spent > MAX_MEASURES { + return (c.w.unwrap_or(avail_w), c.h.unwrap_or(0.0)); + } + // Only containers recurse; their addresses are stable for the whole layout (the + // template is borrowed, the root and page areas are held by the layouter). + let key = match node { + Node::Subform(s) => Some((&**s as *const Subform as usize, avail_w.to_bits(), depth)), + Node::Area(a) => Some((&**a as *const Area as usize, avail_w.to_bits(), depth)), + _ => None, + }; + if let Some(key) = key + && let Ok(cache) = self.measured.try_borrow() + && let Some(m) = cache.get(&key) + && m.font == ctx.font + && m.para == ctx.para + { + return m.size; + } + let size = self.measure(node, avail_w, ctx, depth); + if let Some(key) = key + && let Ok(mut cache) = self.measured.try_borrow_mut() + { + cache.insert(key, Measured { font: ctx.font.clone(), para: ctx.para.clone(), size }); + } + size + } + + fn measure(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) { + let c = node.common(); + let ctx = ctx.child(c); + // A container without a width is as wide as its content (a radio group beside its + // question, say); a leaf without one takes what is available. + let w = match c.w { + Some(w) => w, + None => match node { + Node::Subform(_) | Node::ExclGroup(_) | Node::Area(_) => { + let inner = (avail_w - c.margin.horizontal()).max(1.0); + (self.content_width(node, inner, &ctx, depth) + c.margin.horizontal()).min(avail_w) + } + Node::Field(_) | Node::Draw(_) => avail_w, + }, + }; + let w = clamp_opt(w, c.min_w, c.max_w); + let h = match c.h { + Some(h) => h, + None => { + let inner_w = (w - c.margin.horizontal()).max(1.0); + let content = match node { + Node::Draw(d) => self.value_height(&d.value, inner_w, &ctx), + Node::Field(f) => self.field_height(f, inner_w, &ctx), + Node::ExclGroup(g) => self.group_height(g, inner_w, &ctx, depth), + Node::Area(a) => self.positioned_height(&a.children, inner_w, &ctx, depth), + Node::Subform(s) => match s.layout { + Layout::Positioned => self.positioned_height(&s.children, inner_w, &ctx, depth), + Layout::Tb => { + s.children.iter().filter(|k| k.common().presence.occupies()).map(|k| self.size_of(k, inner_w, &ctx, depth + 1).1).sum() + } + Layout::LrTb | Layout::RlTb => self.packed_height(&s.children, inner_w, &ctx, depth), + Layout::Table => { + let cols = self.columns(s, inner_w); + s.children + .iter() + .filter(|k| k.common().presence.occupies()) + .map(|k| match k { + Node::Subform(r) if r.layout == Layout::Row => self.row_height(r, &cols, inner_w, &ctx, depth + 1), + _ => self.size_of(k, inner_w, &ctx, depth + 1).1, + }) + .sum() + } + Layout::Row => self.row_height(s, &self.columns(s, inner_w), inner_w, &ctx, depth + 1), + }, + }; + content + c.margin.vertical() + } + }; + (w, clamp_opt(h, c.min_h, c.max_h)) + } + + /// The natural width of a container's content within `avail_w`. + fn content_width(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> f64 { + let group: Vec; + let (children, layout): (&[Node], Layout) = match node { + Node::Subform(s) => { + if s.layout == Layout::Table && !s.column_widths.is_empty() { + return s.column_widths.iter().sum::().min(avail_w); + } + (&s.children, s.layout) + } + Node::ExclGroup(g) => { + // Fields are leaves: measuring these copies caches nothing. + group = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); + (&group, g.layout) + } + Node::Area(a) => (&a.children, Layout::Positioned), + _ => return avail_w, + }; + let visible = children.iter().filter(|k| k.common().presence.occupies()); + match layout { + Layout::LrTb | Layout::RlTb | Layout::Row => { + let sum: f64 = visible.map(|k| self.size_of(k, avail_w, ctx, depth + 1).0).sum(); + if sum <= avail_w + 0.05 { sum } else { avail_w } + } + Layout::Positioned => { + visible.map(|k| k.common().x.unwrap_or(0.0) + self.size_of(k, avail_w, ctx, depth + 1).0).fold(0.0, f64::max).min(avail_w) + } + Layout::Tb | Layout::Table => visible.map(|k| self.size_of(k, avail_w, ctx, depth + 1).0).fold(0.0, f64::max).min(avail_w), + } + } + + fn value_height(&self, v: &Value, w: f64, ctx: &Ctx) -> f64 { + match v { + Value::Text(t) => text::measure_height(&text::plain(t), w, &ctx.para, &Face::of(&ctx.font), &|id| self.embed(id)), + Value::Rich(r) => text::measure_height(r, w, &ctx.para, &Face::of(&ctx.font), &|id| self.embed(id)), + Value::Image { data, .. } => crate::pdf::jpeg_size(data).map_or(w, |(iw, ih)| if iw > 0 { w * ih as f64 / iw as f64 } else { w }), + Value::Line { .. } | Value::Rectangle(_) | Value::Empty => 0.0, + } + } + + fn caption_reserve(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> Option<(Placement, f64)> { + let cap = f.caption.as_ref()?; + if !cap.presence.occupies() || f.ui == Ui::Button { + return None; + } + if cap.value.is_empty() && cap.reserve.is_none() { + return None; + } + let placement = if cap.placement == Placement::Inline { Placement::Left } else { cap.placement }; + let font = ctx.font.apply(&cap.font); + let para = ctx.para.apply(&cap.para); + let reserve = cap.reserve.unwrap_or_else(|| match placement { + Placement::Top | Placement::Bottom => { + let rich = match &cap.value { + Value::Rich(r) => r.clone(), + other => text::plain(&other.plain().unwrap_or_default()), + }; + text::measure_height(&rich, inner_w, ¶, &Face::of(&font), &|id| self.embed(id)) + } + _ => (Face::of(&font).width(&cap.value.plain().unwrap_or_default()) + 2.0).min(inner_w * 0.6), + }); + Some((placement, reserve)) + } + + fn field_height(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> f64 { + let font = ctx.font.apply(&f.common.font); + let lines = if f.multiline { 3.0 } else { 1.0 }; + let ui = match f.ui { + Ui::CheckButton => f.check_size.unwrap_or(10.0).max(text::line_height(font.size)), + _ => text::line_height(font.size) * lines + 4.0 + f.ui_margin.vertical(), + }; + match self.caption_reserve(f, inner_w, ctx) { + Some((Placement::Top | Placement::Bottom, r)) => ui + r, + Some((_, r)) => { + let cap = f.caption.as_ref().map(|c| { + let rich = match &c.value { + Value::Rich(r) => r.clone(), + other => text::plain(&other.plain().unwrap_or_default()), + }; + text::measure_height(&rich, (inner_w - r).max(1.0), &ctx.para.apply(&c.para), &Face::of(&ctx.font.apply(&c.font)), &|id| { + self.embed(id) + }) + }); + ui.max(cap.unwrap_or(0.0)) + } + None => ui, + } + } + + fn group_height(&self, g: &ExclGroup, inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { + let nodes: Vec = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); + match g.layout { + Layout::Tb => nodes.iter().filter(|k| k.common().presence.occupies()).map(|k| self.size_of(k, inner_w, ctx, depth + 1).1).sum(), + Layout::Positioned => self.positioned_height(&nodes, inner_w, ctx, depth), + _ => self.packed_height(&nodes, inner_w, ctx, depth), + } + } + + fn positioned_height(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { + children + .iter() + .filter(|k| k.common().presence.occupies()) + .map(|k| { + let c = k.common(); + let (_, h) = self.size_of(k, (inner_w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1); + c.y.unwrap_or(0.0) + h + }) + .fold(0.0, f64::max) + } + + /// Left-to-right packing: rows of children, wrapped at the available width. + fn pack(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> (Vec<(usize, Rect)>, f64) { + let mut out = Vec::new(); + let (mut x, mut y, mut line_h) = (0.0, 0.0, 0.0); + for (i, k) in children.iter().enumerate() { + if !k.common().presence.occupies() { + continue; + } + let (w, h) = self.size_of(k, inner_w, ctx, depth + 1); + if x > 0.0 && x + w > inner_w + 0.05 { + y += line_h; + x = 0.0; + line_h = 0.0; + } + out.push((i, Rect::new(x, y, w, h))); + x += w; + line_h = line_h.max(h); + } + (out, y + line_h) + } + + fn packed_height(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { + self.pack(children, inner_w, ctx, depth).1 + } + + /// Cell rectangles of a row (relative to the row's inner box) and the row height. + fn cells(&self, row: &Subform, cols: &[f64], inner_w: f64, ctx: &Ctx, depth: usize) -> (Vec<(usize, Rect)>, f64) { + let ctx = ctx.child(&row.common); + let mut out = Vec::new(); + let mut col = 0usize; + let mut x = 0.0; + let mut h: f64 = 0.0; + for (i, k) in row.children.iter().enumerate() { + if !k.common().presence.occupies() { + continue; + } + let span = k.common().col_span.max(1); + let w = if col < cols.len() { cols.iter().skip(col).take(span).sum::() } else { k.common().w.unwrap_or((inner_w - x).max(1.0)) }; + let (_, ch) = self.size_of(k, w, &ctx, depth + 1); + out.push((i, Rect::new(x, 0.0, w, ch))); + h = h.max(ch); + x += w; + col += span; + } + let h = match row.common.h { + Some(rh) => rh, + None => h + row.common.margin.vertical(), + }; + (out, clamp_opt(h, row.common.min_h, row.common.max_h)) + } + + fn row_height(&self, row: &Subform, cols: &[f64], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { + self.cells(row, cols, inner_w, ctx, depth).1 + } + + // ───────────────────────────────────────────────────────────────────────── drawing + + fn draw_row(&mut self, row: &Subform, cols: &[f64], rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + if depth > MAX_DEPTH { + return Ok(()); + } + if let Some(b) = &row.common.border { + self.draw_border(b, rect)?; + } + let ctx = ctx.with_som(&row.common, sib); + let inner = rect.inset(&row.common.margin); + let (cells, _) = self.cells(row, cols, inner.w, &ctx, depth); + for (i, r) in cells { + if let Some(k) = row.children.get(i) { + // Cells stretch to the row height. + self.draw_node(k, Rect::new(inner.x + r.x, inner.y, r.w, inner.h), &ctx, depth + 1, sib_index(&row.children, i))?; + } + } + Ok(()) + } + + fn draw_positioned_child(&mut self, k: &Node, inner: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + let c = k.common(); + if !c.presence.occupies() { + return Ok(()); + } + let (x, y) = (c.x.unwrap_or(0.0), c.y.unwrap_or(0.0)); + let (w, h) = self.size_of(k, (inner.w - x).max(1.0), ctx, depth + 1); + self.draw_node(k, Rect::new(inner.x + x, inner.y + y, w, h), ctx, depth + 1, sib) + } + + fn draw_node(&mut self, node: &Node, rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + if depth > MAX_DEPTH { + return Ok(()); + } + let c = node.common(); + if c.presence == Presence::Invisible { + return Ok(()); + } + match node { + Node::Draw(d) => self.draw_draw(d, rect, ctx), + Node::Field(f) => self.draw_field(f, rect, ctx, None, sib), + Node::ExclGroup(g) => self.draw_group(g, rect, ctx, depth, sib), + Node::Area(a) => { + let ctx = ctx.with_som(&a.common, sib); + for (i, k) in a.children.iter().enumerate() { + self.draw_positioned_child(k, rect, &ctx, depth + 1, sib_index(&a.children, i))?; + } + Ok(()) + } + Node::Subform(s) => { + if let Some(b) = &s.common.border { + self.draw_border(b, rect)?; + } + let parent_ctx = ctx; + let ctx = ctx.with_som(&s.common, sib); + let inner = rect.inset(&s.common.margin); + match s.layout { + Layout::Positioned => { + for (i, k) in s.children.iter().enumerate() { + self.draw_positioned_child(k, inner, &ctx, depth + 1, sib_index(&s.children, i))?; + } + } + Layout::Tb => { + let mut y = inner.y; + for (i, k) in s.children.iter().enumerate() { + if !k.common().presence.occupies() { + continue; + } + let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); + self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, sib_index(&s.children, i))?; + y += h; + } + } + Layout::LrTb | Layout::RlTb => { + let (placed, _) = self.pack(&s.children, inner.w, &ctx, depth); + for (i, r) in placed { + if let Some(k) = s.children.get(i) { + let x = if s.layout == Layout::RlTb { inner.right() - r.x - r.w } else { inner.x + r.x }; + self.draw_node(k, Rect::new(x, inner.y + r.y, r.w, r.h), &ctx, depth + 1, sib_index(&s.children, i))?; + } + } + } + Layout::Table => { + let cols = self.columns(s, inner.w); + let mut y = inner.y; + for (i, k) in s.children.iter().enumerate() { + if !k.common().presence.occupies() { + continue; + } + let csib = sib_index(&s.children, i); + if let Node::Subform(r) = k + && r.layout == Layout::Row + { + let h = self.row_height(r, &cols, inner.w, &ctx, depth + 1); + self.draw_row(r, &cols, Rect::new(inner.x, y, inner.w, h), &ctx, depth + 1, csib)?; + y += h; + } else { + let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); + self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, csib)?; + y += h; + } + } + } + Layout::Row => { + let cols = self.columns(s, inner.w); + self.draw_row(s, &cols, rect, parent_ctx, depth, sib)?; + } + } + Ok(()) + } + } + } + + fn draw_border(&mut self, b: &Border, rect: Rect) -> Result<(), XfaError> { + if let Some(fill) = b.fill() { + self.push(Item::Fill { rect, color: fill.0 })?; + } + let vis = b.visible_edges(); + let (x0, y0, x1, y1) = (rect.x, rect.y, rect.right(), rect.bottom()); + // Top, right, bottom, left; lines sit inside the box. + let segs = [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x0, y1), (x1, y1)), ((x0, y0), (x0, y1))]; + for (i, seg) in segs.iter().enumerate() { + if !vis[i] { + continue; + } + let e = b.edges[i]; + let t = e.thickness.clamp(0.1, 20.0); + let (dx, dy) = match i { + 0 => (0.0, t / 2.0), + 1 => (-t / 2.0, 0.0), + 2 => (0.0, -t / 2.0), + _ => (t / 2.0, 0.0), + }; + let color = if e.stroke == Stroke::Relief { [0.55, 0.55, 0.55] } else { e.color.0 }; + self.push(Item::Line { + from: (seg.0.0 + dx, seg.0.1 + dy), + to: (seg.1.0 + dx, seg.1.1 + dy), + width: t, + color, + dashed: matches!(e.stroke, Stroke::Dashed | Stroke::Dotted), + })?; + } + Ok(()) + } + + fn draw_value_text(&mut self, v: &Value, rect: Rect, font: &Font, para: &Para) -> Result<(), XfaError> { + let rich = match v { + Value::Text(t) => text::plain(t), + Value::Rich(r) => r.clone(), + _ => return Ok(()), + }; + let block = text::layout(&rich, rect, para, &Face::of(font), &|id| self.embed(id)); + self.push_block(block) + } + + fn draw_draw(&mut self, d: &Draw, rect: Rect, ctx: &Ctx) -> Result<(), XfaError> { + if let Some(b) = &d.common.border { + self.draw_border(b, rect)?; + } + let ctx = ctx.child(&d.common); + let inner = rect.inset(&d.common.margin); + match &d.value { + Value::Text(_) | Value::Rich(_) => self.draw_value_text(&d.value, inner, &ctx.font, &ctx.para), + Value::Image { content_type, data } => { + let fitted = match crate::pdf::jpeg_size(data) { + Some((iw, ih)) if iw > 0 && ih > 0 && inner.w > 0.0 && inner.h > 0.0 => { + let k = (inner.w / iw as f64).min(inner.h / ih as f64); + Rect::new(inner.x, inner.y, iw as f64 * k, ih as f64 * k) + } + _ => inner, + }; + self.push(Item::Image { rect: fitted, data: Arc::new(data.clone()), content_type: content_type.clone() }) + } + Value::Line { slope_up, edge } => { + if !edge.visible() { + return Ok(()); + } + let (from, to) = if *slope_up { + ((inner.x, inner.bottom()), (inner.right(), inner.y)) + } else { + ((inner.x, inner.y), (inner.right(), inner.bottom())) + }; + self.push(Item::Line { from, to, width: edge.thickness.clamp(0.1, 20.0), color: edge.color.0, dashed: edge.stroke == Stroke::Dashed }) + } + Value::Rectangle(b) => self.draw_border(b, inner), + Value::Empty => Ok(()), + } + } + + fn draw_group(&mut self, g: &ExclGroup, rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + if let Some(b) = &g.common.border { + self.draw_border(b, rect)?; + } + let ctx = ctx.with_som(&g.common, sib); + // The group's data node holds the selected button's on value. + let selected: Option = + self.data.and_then(|d| d.text_at(&som_to_path(&ctx.som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); + let inner = rect.inset(&g.common.margin); + let group = self.unique_name(g.common.name.as_deref().unwrap_or("group")); + let nodes: Vec = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); + let placed: Vec<(usize, Rect)> = match g.layout { + Layout::Tb => { + let mut y = 0.0; + let mut out = Vec::new(); + for (i, k) in nodes.iter().enumerate() { + if !k.common().presence.occupies() { + continue; + } + let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); + out.push((i, Rect::new(0.0, y, w, h))); + y += h; + } + out + } + Layout::Positioned => nodes + .iter() + .enumerate() + .filter(|(_, k)| k.common().presence.occupies()) + .map(|(i, k)| { + let c = k.common(); + let (w, h) = self.size_of(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), &ctx, depth + 1); + (i, Rect::new(c.x.unwrap_or(0.0), c.y.unwrap_or(0.0), w, h)) + }) + .collect(), + _ => self.pack(&nodes, inner.w, &ctx, depth).0, + }; + for (i, r) in placed { + if let Some(f) = g.fields.get(i) { + let on = f.items.first().cloned().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| (i + 1).to_string()); + let tip = f.tooltip.clone().or_else(|| g.tooltip.clone()); + let chosen = selected.as_deref() == Some(on.as_str()); + self.draw_field(f, Rect::new(inner.x + r.x, inner.y + r.y, r.w, r.h), &ctx, Some((&group, &on, tip, chosen)), sib_index(&nodes, i))?; + } + } + Ok(()) + } + + fn unique_name(&mut self, base: &str) -> String { + let base = clean_name(base); + let n = self.names.entry(base.clone()).or_insert(0); + *n += 1; + if *n == 1 { base } else { format!("{base}_{n}") } + } + + fn draw_field( + &mut self, + f: &Field, + rect: Rect, + ctx: &Ctx, + radio: Option<(&str, &str, Option, bool)>, + sib: usize, + ) -> Result<(), XfaError> { + if let Some(b) = &f.common.border + && f.ui != Ui::Button + { + self.draw_border(b, rect)?; + } + let fctx = ctx.child(&f.common); + let inner = rect.inset(&f.common.margin); + let (mut ui, mut cap_rect) = (inner, None); + if let Some((placement, reserve)) = self.caption_reserve(f, inner.w, ctx) { + let r = reserve.max(0.0); + match placement { + Placement::Top => { + cap_rect = Some(Rect::new(inner.x, inner.y, inner.w, r.min(inner.h))); + ui = Rect::new(inner.x, inner.y + r, inner.w, inner.h - r); + } + Placement::Bottom => { + cap_rect = Some(Rect::new(inner.x, inner.bottom() - r.min(inner.h), inner.w, r.min(inner.h))); + ui = Rect::new(inner.x, inner.y, inner.w, inner.h - r); + } + Placement::Right => { + cap_rect = Some(Rect::new(inner.right() - r.min(inner.w), inner.y, r.min(inner.w), inner.h)); + ui = Rect::new(inner.x, inner.y, inner.w - r, inner.h); + } + _ => { + cap_rect = Some(Rect::new(inner.x, inner.y, r.min(inner.w), inner.h)); + ui = Rect::new(inner.x + r, inner.y, inner.w - r, inner.h); + } + } + } + if let (Some(cr), Some(cap)) = (cap_rect, &f.caption) + && cap.presence == Presence::Visible + { + let font = ctx.font.apply(&cap.font); + let para = ctx.para.apply(&cap.para); + self.draw_value_text(&cap.value, cr, &font, ¶)?; + } + match f.ui { + Ui::Signature | Ui::ImageEdit | Ui::Barcode | Ui::Unknown => { + self.warn_once(format!("{:?} fields are not supported yet and are left blank", f.ui)); + return Ok(()); + } + _ => {} + } + if f.access == Access::NonInteractive && f.ui != Ui::Button { + // Shown as text, not as a field. + return self.draw_value_text(&f.value, ui.inset(&f.ui_margin), &fctx.font, &fctx.para); + } + let face = match (&f.ui, &f.caption) { + // Buttons show their caption in the caption's font. + (Ui::Button, Some(cap)) => Face::of(&ctx.font.apply(&cap.font)), + _ => Face::of(&fctx.font), + }; + let h_align = match (&f.ui, &f.caption) { + (Ui::Button, Some(cap)) => ctx.para.apply(&cap.para).h_align, + _ => fctx.para.h_align, + }; + let name = self.unique_name(f.common.name.as_deref().unwrap_or("field")); + let som = if fctx.som.is_empty() { + format!("{}[{sib}]", f.common.name.as_deref().unwrap_or("field")) + } else { + format!("{}.{}[{sib}]", fctx.som, f.common.name.as_deref().unwrap_or("field")) + }; + let read_only = matches!(f.access, Access::ReadOnly | Access::Protected); + // The data's value wins over the template's default. + let data_value: Option = + self.data.and_then(|d| d.text_at(&som_to_path(&som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); + let plain_value = data_value.clone().or_else(|| f.value.plain().map(|v| v.trim().to_string()).filter(|v| !v.is_empty())); + let (kind, rect, border, value, tooltip) = match f.ui { + Ui::CheckButton => { + let s = f.check_size.unwrap_or(10.0).min(ui.w.max(1.0)).min(ui.h.max(1.0)); + let y = match fctx.para.v_align { + VAlign::Top => ui.y + f.ui_margin.top, + VAlign::Middle => ui.y + (ui.h - s) / 2.0, + VAlign::Bottom => ui.bottom() - s - f.ui_margin.bottom, + }; + let r = Rect::new(ui.x + f.ui_margin.left, y, s, s); + let on = f.items.first().cloned().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "1".into()); + let (kind, tip, checked) = match radio { + Some((group, on_value, tip, chosen)) => ( + WidgetKind::Radio { group: group.to_string(), on: on_value.to_string(), round: f.check_round }, + tip.or_else(|| f.tooltip.clone()), + chosen, + ), + None => ( + WidgetKind::CheckBox { on: on.clone(), round: f.check_round }, + f.tooltip.clone(), + plain_value.as_deref().is_some_and(|v| is_on(v, &on)), + ), + }; + let border = widget_border(f.ui_border.as_ref(), true); + (kind, r, border, checked.then(|| on.clone()), tip) + } + Ui::Button => { + let caption = f.caption.as_ref().and_then(|c| c.value.plain()).unwrap_or_default().trim().to_string(); + // A button's box is its field border; without one it is invisible (a link + // over text, say). + let border = match &f.common.border { + Some(b) => widget_border(Some(b), false), + None => WidgetBorder { color: None, fill: None, shape: BorderShape::None, width: 0.0 }, + }; + (WidgetKind::Button { caption }, ui, border, None, f.tooltip.clone()) + } + Ui::DateTimeEdit => { + let pattern = date_pattern(f.picture.as_deref()); + // Data holds ISO dates; the field shows them in its pattern. + let shown = plain_value.as_ref().map(|v| if data_value.is_some() { iso_to_pattern(v, &pattern) } else { v.clone() }); + (WidgetKind::Date(pattern), ui, widget_border(f.ui_border.as_ref(), false), shown, f.tooltip.clone()) + } + _ => { + if f.ui == Ui::ChoiceList { + self.warn_once("drop-down lists are shown as text fields"); + } + (WidgetKind::Text, ui, widget_border(f.ui_border.as_ref(), false), plain_value, f.tooltip.clone()) + } + }; + if rect.w < 1.0 || rect.h < 1.0 { + return Ok(()); + } + let action = if f.ui == Ui::Button { button_action(&f.scripts) } else { None }; + let items: Vec = match &kind { + WidgetKind::CheckBox { on, .. } => vec![on.clone(), f.items.get(1).cloned().unwrap_or_default()], + WidgetKind::Radio { on, .. } => vec![on.clone()], + _ => Vec::new(), + }; + self.fields += 1; + self.push(Item::Widget(Box::new(Widget { + rect, + kind, + name, + som, + tooltip, + face, + h_align, + multiline: f.multiline, + max_chars: f.max_chars, + read_only, + border, + value, + action, + items, + }))) + } +} + +/// The widget's own border from the `` border element. Without one, text fields and check +/// boxes get a solid box, as Designer draws them. +fn widget_border(b: Option<&Border>, check: bool) -> WidgetBorder { + let Some(b) = b else { + return WidgetBorder { + color: Some([0.0, 0.0, 0.0]), + fill: if check { Some([1.0, 1.0, 1.0]) } else { None }, + shape: BorderShape::Full, + width: 0.5, + }; + }; + let vis = b.visible_edges(); + let shape = match vis { + [false, false, false, false] => BorderShape::None, + [false, false, true, false] => BorderShape::Underline, + _ => BorderShape::Full, + }; + let edge = (0..4).find(|&i| vis[i]).map(|i| b.edges[i]).unwrap_or_default(); + WidgetBorder { + color: (shape != BorderShape::None).then_some(edge.color.0), + fill: b.fill().map(|c| c.0), + shape, + width: edge.thickness.clamp(0.1, 12.0), + } +} diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs new file mode 100644 index 00000000..b1612d8f --- /dev/null +++ b/crates/xfa/src/lib.rs @@ -0,0 +1,92 @@ +//! pdfcraft-xfa — XFA forms (L3). See the README. +//! +//! A dynamic XFA form is a PDF shell around an XML template; viewers without an XFA engine +//! show its one placeholder page ("requires Adobe Reader"). This crate reads the template, +//! lays it out and writes ordinary pages and AcroForm fields into the document, so the rest +//! of PdfCraft (rendering, filling, saving, printing, tools) works on it unchanged. + +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod data; +pub mod fixtures; +pub mod layout; +pub mod model; +pub mod packets; +pub mod parse; +pub mod pdf; +pub mod text; + +use pdfcraft_cos::Document; + +pub use data::{ + DataNode, DatasetsWrite, FieldData, FieldDatum, build_data, iso_to_pattern, parse_datasets, pattern_to_iso, read_values, som_to_path, + write_datasets, +}; +pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, layout}; +pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets}; +pub use parse::{measure, parse}; +pub use pdf::{LAYOUT_KEY, SOM_KEY, existing_layout}; + +#[derive(Debug, thiserror::Error)] +pub enum XfaError { + #[error("the document is not an XFA form")] + NotXfa, + #[error("the XFA template could not be read: {0}")] + Malformed(String), + #[error("{0}")] + TooLarge(String), + #[error("{0}")] + Cos(#[from] pdfcraft_cos::CosError), +} + +/// What laying out a form produced. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Report { + pub pages: usize, + pub fields: usize, + /// Template bytes read. + pub template_bytes: usize, + /// What was approximated or left out, for the user. + pub warnings: Vec, +} + +/// Is this a dynamic XFA form still showing its placeholder pages (one PdfCraft has not laid +/// out yet)? +pub fn is_dynamic(doc: &Document) -> bool { + existing_layout(doc).is_none() && matches!(read_packets(doc), Ok(Some(p)) if p.needs_rendering || !p.has_fields) +} + +/// Lay a template packet (or whole XDP, whose datasets then fill the fields) out. +pub fn layout_xml(xml: &str) -> Result { + let (tpl, warnings) = parse(xml)?; + let data = parse_datasets(xml); + let mut form = layout(&tpl, data.as_ref())?; + form.warnings.splice(0..0, warnings); + Ok(form) +} + +/// Lay a dynamic XFA form out into its own document: its placeholder pages are replaced by the +/// laid-out pages and its fields join the AcroForm, holding the values of the datasets packet. +/// The XFA packets stay, so Adobe's viewers keep rendering it their way. Widget appearances are +/// left for the forms layer to generate. +pub fn render_into(doc: &mut Document) -> Result { + if !is_dynamic(doc) { + return Err(XfaError::NotXfa); + } + let packets = read_packets(doc)?.ok_or(XfaError::NotXfa)?; + if packets.xdp.trim().is_empty() { + return Err(XfaError::Malformed("the XFA packets hold no template".into())); + } + let form = layout_xml(&packets.xdp)?; + if form.pages.is_empty() { + return Err(XfaError::Malformed("the template laid out to no pages".into())); + } + let written = pdf::write_form(doc, &form)?; + let mut warnings = form.warnings; + warnings.extend(written.warnings); + warnings.dedup(); + Ok(Report { pages: written.pages, fields: written.fields, template_bytes: packets.xdp.len(), warnings }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs new file mode 100644 index 00000000..1746e82a --- /dev/null +++ b/crates/xfa/src/model.rs @@ -0,0 +1,568 @@ +//! The template model: what the layout engine needs from an XFA template (XFA 3.3 part 2, +//! "Template Specification"). Every measurement is in points, every position is top-left +//! based, as in XFA itself. + +/// Whether an object is shown and whether it takes space. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Presence { + #[default] + Visible, + /// Takes space, draws nothing. + Invisible, + /// Takes no space. + Hidden, + /// Hidden, and its scripts don't run. + Inactive, +} + +impl Presence { + pub fn parse(s: Option<&str>) -> Presence { + match s.map(str::trim) { + Some("invisible") => Presence::Invisible, + Some("hidden") => Presence::Hidden, + Some("inactive") => Presence::Inactive, + _ => Presence::Visible, + } + } + + /// Takes space in the layout. + pub fn occupies(self) -> bool { + matches!(self, Presence::Visible | Presence::Invisible) + } +} + +/// How a subform places its children. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Layout { + #[default] + Positioned, + /// Top to bottom. + Tb, + /// Left to right, then top to bottom. + LrTb, + /// Right to left, then top to bottom (laid out like `LrTb`, mirrored). + RlTb, + Table, + Row, +} + +impl Layout { + pub fn parse(s: Option<&str>) -> Layout { + match s.map(str::trim) { + Some("tb") => Layout::Tb, + Some("lr-tb") => Layout::LrTb, + Some("rl-tb") => Layout::RlTb, + Some("table") => Layout::Table, + Some("row") => Layout::Row, + _ => Layout::Positioned, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Margin { + pub top: f64, + pub right: f64, + pub bottom: f64, + pub left: f64, +} + +impl Margin { + pub fn horizontal(&self) -> f64 { + self.left + self.right + } + + pub fn vertical(&self) -> f64 { + self.top + self.bottom + } +} + +/// An RGB colour, components 0–1. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Color(pub [f64; 3]); + +impl Color { + pub const BLACK: Color = Color([0.0, 0.0, 0.0]); + pub const WHITE: Color = Color([1.0, 1.0, 1.0]); + + /// `value="r,g,b"` with components 0–255. + pub fn parse(s: &str) -> Option { + let mut parts = s.split(',').map(|p| p.trim().parse::().ok()); + let (r, g, b) = (parts.next()??, parts.next()??, parts.next()??); + let c = |v: f64| if v.is_finite() { (v / 255.0).clamp(0.0, 1.0) } else { 0.0 }; + Some(Color([c(r), c(g), c(b)])) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Stroke { + #[default] + Solid, + Dashed, + Dotted, + /// Raised, lowered, etched and embossed: drawn as a solid grey line. + Relief, +} + +/// One side of a border. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Edge { + pub presence: Presence, + pub thickness: f64, + pub color: Color, + pub stroke: Stroke, +} + +impl Default for Edge { + fn default() -> Self { + Edge { presence: Presence::Visible, thickness: 0.5, color: Color::BLACK, stroke: Stroke::Solid } + } +} + +impl Edge { + pub fn visible(&self) -> bool { + self.presence == Presence::Visible && self.thickness > 0.0 + } +} + +/// A box border: four edges (top, right, bottom, left) and a fill. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Border { + pub presence: Presence, + pub edges: [Edge; 4], + pub fill: Option, +} + +impl Border { + pub fn visible_edges(&self) -> [bool; 4] { + if self.presence != Presence::Visible { + return [false; 4]; + } + [self.edges[0].visible(), self.edges[1].visible(), self.edges[2].visible(), self.edges[3].visible()] + } + + pub fn fill(&self) -> Option { + if self.presence == Presence::Visible { self.fill } else { None } + } +} + +/// Font properties as written on an element; `None` inherits from the ancestors. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FontSpec { + pub typeface: Option, + pub size: Option, + pub bold: Option, + pub italic: Option, + pub underline: Option, + pub color: Option, +} + +/// A font with every property resolved. +#[derive(Clone, Debug, PartialEq)] +pub struct Font { + pub typeface: String, + pub size: f64, + pub bold: bool, + pub italic: bool, + pub underline: bool, + pub color: Color, +} + +impl Default for Font { + /// XFA's default font: Courier 10 pt. + fn default() -> Self { + Font { typeface: "Courier".into(), size: 10.0, bold: false, italic: false, underline: false, color: Color::BLACK } + } +} + +impl Font { + pub fn apply(&self, spec: &FontSpec) -> Font { + Font { + typeface: spec.typeface.clone().unwrap_or_else(|| self.typeface.clone()), + size: spec.size.unwrap_or(self.size), + bold: spec.bold.unwrap_or(self.bold), + italic: spec.italic.unwrap_or(self.italic), + underline: spec.underline.unwrap_or(self.underline), + color: spec.color.unwrap_or(self.color), + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum HAlign { + #[default] + Left, + Center, + Right, + Justify, +} + +impl HAlign { + pub fn parse(s: Option<&str>) -> Option { + match s.map(str::trim) { + Some("left") => Some(HAlign::Left), + Some("center") => Some(HAlign::Center), + Some("right") => Some(HAlign::Right), + Some("justify" | "justifyAll") => Some(HAlign::Justify), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum VAlign { + #[default] + Top, + Middle, + Bottom, +} + +impl VAlign { + pub fn parse(s: Option<&str>) -> Option { + match s.map(str::trim) { + Some("top") => Some(VAlign::Top), + Some("middle") => Some(VAlign::Middle), + Some("bottom") => Some(VAlign::Bottom), + _ => None, + } + } +} + +/// Paragraph properties as written on an element; `None` inherits. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ParaSpec { + pub h_align: Option, + pub v_align: Option, + pub line_height: Option, + pub margin_left: Option, + pub margin_right: Option, + pub space_above: Option, + pub space_below: Option, + pub text_indent: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Para { + pub h_align: HAlign, + pub v_align: VAlign, + /// `None`: 1.15 × the font size. + pub line_height: Option, + pub margin_left: f64, + pub margin_right: f64, + pub space_above: f64, + pub space_below: f64, + pub text_indent: f64, +} + +impl Para { + pub fn apply(&self, spec: &ParaSpec) -> Para { + Para { + h_align: spec.h_align.unwrap_or(self.h_align), + v_align: spec.v_align.unwrap_or(self.v_align), + line_height: spec.line_height.or(self.line_height), + margin_left: spec.margin_left.unwrap_or(self.margin_left), + margin_right: spec.margin_right.unwrap_or(self.margin_right), + space_above: spec.space_above.unwrap_or(self.space_above), + space_below: spec.space_below.unwrap_or(self.space_below), + text_indent: spec.text_indent.unwrap_or(self.text_indent), + } + } +} + +/// What every container and leaf shares: name, box, presence, margins, border, text defaults. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Common { + pub name: Option, + pub id: Option, + pub x: Option, + pub y: Option, + pub w: Option, + pub h: Option, + pub min_w: Option, + pub max_w: Option, + pub min_h: Option, + pub max_h: Option, + pub presence: Presence, + pub margin: Margin, + pub border: Option, + pub font: FontSpec, + pub para: ParaSpec, + /// Table cells: how many columns this cell spans. + pub col_span: usize, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Node { + Subform(Box), + Area(Box), + Field(Box), + Draw(Box), + ExclGroup(Box), +} + +impl Node { + pub fn common(&self) -> &Common { + match self { + Node::Subform(s) => &s.common, + Node::Area(a) => &a.common, + Node::Field(f) => &f.common, + Node::Draw(d) => &d.common, + Node::ExclGroup(g) => &g.common, + } + } +} + +/// How often a subform occurs when no data drives it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Occur { + pub min: usize, + /// `None`: unbounded. + pub max: Option, + pub initial: usize, +} + +impl Default for Occur { + fn default() -> Self { + Occur { min: 1, max: Some(1), initial: 1 } + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Subform { + pub common: Common, + pub layout: Layout, + pub children: Vec, + pub page_set: Option, + pub occur: Occur, + /// ``: start on a new page. + pub break_before_page: bool, + pub break_after_page: bool, + /// Tables: the width of each column. + pub column_widths: Vec, + /// ``: the named child (a header row) is repeated after a page break. + pub overflow_leader: Option, +} + +/// A positioned group without a box of its own. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Area { + pub common: Common, + pub children: Vec, +} + +/// A radio button group. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ExclGroup { + pub common: Common, + pub layout: Layout, + pub fields: Vec, + pub tooltip: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum Ui { + #[default] + TextEdit, + NumericEdit, + DateTimeEdit, + CheckButton, + ChoiceList, + Button, + Signature, + ImageEdit, + Barcode, + PasswordEdit, + Unknown, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Script { + pub activity: String, + pub text: String, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Access { + #[default] + Open, + ReadOnly, + Protected, + NonInteractive, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Field { + pub common: Common, + pub ui: Ui, + /// The border drawn by the widget itself (``), inside the field. + pub ui_border: Option, + pub ui_margin: Margin, + /// Check buttons: the side of the square (default 10 pt) and whether it is round. + pub check_size: Option, + pub check_round: bool, + pub multiline: bool, + pub caption: Option, + pub value: Value, + /// Check buttons: the on value then the off value; choice lists: the items. + pub items: Vec, + pub max_chars: Option, + pub tooltip: Option, + pub access: Access, + /// The edit picture clause (`…`), e.g. `date{YYYY-MM-DD}`. + pub picture: Option, + pub scripts: Vec