From 42ddb2d546cf352eea1fe4bd7a40fe6dab097044 Mon Sep 17 00:00:00 2001 From: ysrdevs <32823553+ysrdevs@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:24:54 -0500 Subject: [PATCH] XFA: lay dynamic forms out from their template and keep their data in step (#60) (#121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M12: lay dynamic XFA forms out from their template (#60) A dynamic XFA form is a PDF shell around an XML template: one placeholder page ("requires Adobe Reader") and /NeedsRendering true. Every viewer without an XFA engine shows the placeholder, and so did PrintCraft. New crate printcraft-xfa (L3, already reserved in the layering table). It reads the XDP packets, parses the template (XFA 3.3: subforms, areas, fields, draws, exclusion groups, page sets, fonts, paragraphs, borders, captions, rich text, inline JPEGs, occurrences, breaks, overflow leaders, column widths), lays it out (positioned, tb, lr-tb, table/row; containers without a width size to their content; flow across content areas and page masters; header rows repeat after a break; hidden takes no space; page number and count fields resolve) and writes real pages and AcroForm widgets into the document: text, date (AFDate format and keystroke actions from the picture clause), check boxes, radio groups, push buttons with reset/print/save/URI actions. Standard-14 fonts stand in for Arial, Courier New and friends; nothing is embedded. The engine lays a dynamic form out when it opens it. The original bytes stay: the layout is one appended revision, the widgets get appearances from the forms crate, and the XFA packets remain so Adobe's viewers keep rendering the form their way. A marker on the AcroForm (/PCXfaLayout) stops a saved form from being laid out twice; each field keeps its SOM path (/PCSom) for the data round trip to come. doc_info, the CLI's info and the notice bar report the layout. Checked on IRCC's IMM 5669 (Schedule A, Designer 6.2, 464 KB template, AES-encrypted, usage rights): four pages and 308 fields in 27 ms, the same page structure as the form, filled through form_fill, saved in place with the original bytes intact and reopened with the values. Not done, and said so in the parity notes: the datasets packet is neither read nor written, so Adobe's viewers don't show values filled in here; no FormCalc or XFA JavaScript (rows don't grow, nothing calculates); choice lists are text fields; signature, image and barcode fields are blank; metrics are approximate and fidelity is eyeballed, not measured. Tests: crates/xfa (parsing, sizing, pagination with repeated headers, PDF writing, hostile input), the engine (open, fill, incremental save, reopen without a second layout), the automation tools (open, info, render, fill, save, reopen) and the UI notice (kittest). * M12: XFA forms read and write their datasets packet; static forms too (#60) With the layout alone, a form filled in PrintCraft opened blank in Adobe Reader, and one filled in Acrobat opened blank here: Adobe's viewers keep the values in the XFA datasets packet, not in the AcroForm fields. The xfa crate's new data module reads the datasets packet (normal binding: a field's node is named after it under its named ancestor subforms, repeated instances as repeated siblings) and writes it back from the fields. Laying a form out now takes each field's value from its data node (dates in ISO form shown in the field's pattern, check boxes and radio groups by their on values) and gives a repeating subform or table row as many instances as the data has, so rows another viewer added appear. Every laid-out field carries its SOM path and its on/off items, so the packet can be rebuilt from the AcroForm at any time. The engine writes the datasets after every form edit (one revision with the field change), and on open applies the datasets to the fields of a static XFA form, or of a dynamic one laid out earlier, when another viewer changed them since. The static-form notice now says the two are kept in step instead of warning that Acrobat may disagree. Checked on IRCC's IMM 5669: fill, save in place (original bytes intact), and the file holds SINGH…, the structure Acrobat writes for this form; reopening shows the values. Not done: explicit bind refs, global binding and data descriptions are not followed; no import or export of standalone XML or XDP data files. * M12: XFA layout memoizes container sizes, bounding measurement work (#60) A container without a width measured its content for its width and again for its height at every level, so nested width-less subforms took 2^depth measurements, all synchronously in Session::open. Container sizes are now cached per (node, width, depth), the root and page-area items keep stable addresses instead of being cloned per measurement, and a measurement budget turns anything left into TooLarge. Co-Authored-By: Claude Opus 5.5 * M12: rustfmt the merged XFA notice (#60) Co-Authored-By: Claude Opus 5.5 * M12: XFA datasets are merged into, not replaced; capped; UTF-16 kept; rewrites recorded (#60) - write_datasets merged nothing: it replaced with the AcroForm-bound values alone, dropping unbound, namespaced and deep data. It now edits the existing packet text in place: a bound node's text is replaced only when it differs, missing nodes are added below the deepest existing one, and everything else (unbound data, other namespaces, attributes, comments, layout) stays byte for byte. A packet of an XFA array that relies on the envelope's prefixes is read inside a wrapper declaring them. - What can't be written (a node holding structured content, too-deep paths or absurd SOM indices, conflicting fields, an unreadable or non-UTF packet) comes back as DatasetsWrite.warnings; the engine keeps them in Document.xfa_warnings and info.warnings (surviving re-reads), and doc_info reports xfa_warnings. - Building data counted same-name siblings for every one it added (quadratic in an untrusted index) with no total cap. Children are now indexed by name, indices above 10,000 and paths deeper than 64 are refused, and one write creates at most 20,000 nodes. - Packets are decoded exactly (UTF-8, UTF-16 LE/BE with or without BOM) and written back in the encoding they came in; anything else is reported and left untouched instead of being rewritten from a lossy UTF-8 reading. - A static XFA form whose fields are rewritten from the datasets on open now says so in info.warnings, naming the fields. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 14 + Cargo.toml | 1 + apps/pdfcraft-cli/src/main.rs | 14 +- crates/automation/Cargo.toml | 1 + crates/automation/src/lib.rs | 6 +- crates/automation/tests/automation.rs | 37 + crates/engine/Cargo.toml | 1 + crates/engine/src/lib.rs | 174 ++++ crates/engine/src/tests.rs | 127 +++ crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/canvas.rs | 22 +- crates/ui-egui/tests/xfa.rs | 32 +- crates/xfa/Cargo.toml | 18 + crates/xfa/README.md | 75 ++ crates/xfa/src/data.rs | 793 ++++++++++++++++ crates/xfa/src/fixtures.rs | 127 +++ crates/xfa/src/layout.rs | 1260 +++++++++++++++++++++++++ crates/xfa/src/lib.rs | 92 ++ crates/xfa/src/model.rs | 568 +++++++++++ crates/xfa/src/packets.rs | 159 ++++ crates/xfa/src/parse.rs | 583 ++++++++++++ crates/xfa/src/pdf.rs | 517 ++++++++++ crates/xfa/src/tests.rs | 546 +++++++++++ crates/xfa/src/text.rs | 297 ++++++ parity/acrobat-features.toml | 30 +- 25 files changed, 5469 insertions(+), 26 deletions(-) create mode 100644 crates/xfa/Cargo.toml create mode 100644 crates/xfa/README.md create mode 100644 crates/xfa/src/data.rs create mode 100644 crates/xfa/src/fixtures.rs create mode 100644 crates/xfa/src/layout.rs create mode 100644 crates/xfa/src/lib.rs create mode 100644 crates/xfa/src/model.rs create mode 100644 crates/xfa/src/packets.rs create mode 100644 crates/xfa/src/parse.rs create mode 100644 crates/xfa/src/pdf.rs create mode 100644 crates/xfa/src/tests.rs create mode 100644 crates/xfa/src/text.rs 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