From 748b20abb91ee2ad8a5e9cdc23fdbc6aceb672ff Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:05:58 +0100 Subject: [PATCH] refactor(engine): move the XFA form helpers to xfa_form.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 299 +--------------------------------- crates/engine/src/xfa_form.rs | 297 +++++++++++++++++++++++++++++++++ 2 files changed, 302 insertions(+), 294 deletions(-) create mode 100644 crates/engine/src/xfa_form.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 15f6629e0..56677ed5d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -48,6 +48,7 @@ pub mod text_fonts; mod text_preview; pub mod word_count; pub mod xfa; +mod xfa_form; pub use signature_image::{ImageSignaturePreview, SignatureImage}; pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; @@ -188,7 +189,6 @@ fn record_link_scan() { pub use document::{Document, TextNotice, display_bytes_for, render_password_for}; use document::{display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices}; -use edit::EditCtx; pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; pub use error::{EditError, SourceProblem}; pub use history::Label; @@ -197,6 +197,10 @@ use pdfcraft_render::{Layer, LayerOp, RenderPool}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; +use xfa_form::{ + XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, decode_data_file, image_content_type, is_xfa_data_file, note_warnings, strip_xfa, sync_xfa_state, + undrawn_field_warnings, xfa_after_data_change, xfa_layout, xfa_redraw, xfa_sync_datasets, xfa_values_from_datasets, +}; /// Stable identifier of an open document within a session. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -465,299 +469,6 @@ pub fn source_page_count(bytes: &Arc>, password: Option<&str>) -> Option pdfcraft_organize::page_count(&doc).ok() } -/// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. -fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { - let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; - xfa_redraw(doc)?; - // The form's own values: one its field's font can't draw is drawn with `?` and reported. - report.warnings.extend(undrawn_field_warnings(doc)); - Ok(report) -} - -/// Give the fields of a laid-out XFA form their appearances. A signed signature box keeps the -/// picture its signer's application drew. -pub(crate) fn xfa_redraw(doc: &mut pdfcraft_cos::Document) -> Result<(), String> { - for f in pdfcraft_forms::fields(doc) { - if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) { - continue; - } - pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?; - } - Ok(()) -} - -/// A warning for each field whose appearance PdfCraft drew with `?` for a character its font -/// can't draw (laying out an XFA form's own values). -fn undrawn_field_warnings(doc: &pdfcraft_cos::Document) -> Vec { - pdfcraft_forms::undrawn_fields(doc) - .into_iter() - .map(|(name, c)| { - format!("form field {name:?} holds \"{}\" (U+{:04X}), which its font can't draw: it shows ? in its place", c.escape_debug(), u32::from(c)) - }) - .collect() -} - -/// 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. -/// `datasets`: the stream this edit already wrote, replaced in place rather than added again -/// (and set to the one written). -/// `tpl`: the form's parsed template when the document has one cached (else it is parsed). -fn xfa_sync_datasets( - doc: &mut pdfcraft_cos::Document, - tpl: Option<&pdfcraft_xfa::model::Template>, - datasets: &mut Option, -) -> Result, String> { - let data = xfa_field_data(doc); - let r = pdfcraft_xfa::write_datasets_with(doc, tpl, &data, *datasets).map_err(|e| e.to_string())?; - if r.stream.is_some() { - *datasets = r.stream; - } - Ok(r.warnings) -} - -/// What a document whose form script ran away is told. -const XFA_SCRIPTS_OFF: &str = - "A script of this form ran too long and was abandoned; the form's scripts are off for this document (reopen it to run them again)"; - -/// 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()); - } - } -} - -/// A data file's text: UTF-8 or UTF-16 (by a byte order mark, or how `<` is written), or -/// Latin-1 when its XML declaration says so. `None`: not text at all (FDF); `Err`: an -/// encoding this can't read. -pub(crate) fn decode_data_file(bytes: &[u8]) -> Option> { - if bytes.starts_with(b"%FDF") { - return None; - } - let declared = |text: &str| -> Option { - let head = text.get(..text.len().min(256))?; - let decl = head.strip_prefix('\u{feff}').unwrap_or(head).trim_start().strip_prefix("")?.0; - let enc = decl.split_once("encoding=")?.1; - let quote = enc.chars().next()?; - Some(enc.get(1..)?.split(quote).next()?.trim().to_ascii_lowercase()) - }; - if let Some((text, _)) = pdfcraft_xfa::packets::decode(bytes) { - match declared(&text).as_deref() { - None | Some("utf-8" | "utf8" | "utf-16" | "utf-16le" | "utf-16be" | "us-ascii" | "ascii") => return Some(Ok(text)), - Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") if !bytes.starts_with(&[0xEF, 0xBB, 0xBF]) => { - // Each byte is the character it names (the 0x80–0x9F block of Windows-1252 - // differs, and is rare in form data). - return Some(Ok(bytes.iter().map(|b| char::from(*b)).collect())); - } - Some(other) => return Some(Err(format!("the data file's encoding {other:?} is not supported (UTF-8, UTF-16 and ISO-8859-1 are)"))), - } - } - // Not valid UTF-8/UTF-16: Latin-1 by declaration, else refused. - let latin1: String = bytes.iter().map(|b| char::from(*b)).collect(); - match declared(&latin1).as_deref() { - Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") => Some(Ok(latin1)), - _ => Some(Err("the data file is not UTF-8, UTF-16 or ISO-8859-1 text".into())), - } -} - -/// Take the XFA out of `doc` and note what went with it: the packets, data no field showed, -/// buttons that did nothing but run scripts, and fields whose value differs from the form's -/// data (data PdfCraft could not read into them, now lost). -pub(crate) fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> { - let fields = pdfcraft_forms::fields(doc); - let data = xfa_field_data(doc); - let differ = pdfcraft_xfa::read_values(doc, &data) - .into_iter() - .filter(|(name, value)| { - let Some(f) = fields.iter().find(|f| f.name == *name) else { return false }; - match value { - pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(), - pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on, - pdfcraft_xfa::FieldData::Radio(sel) => f.value.first() != sel.as_ref(), - pdfcraft_xfa::FieldData::None => false, - } - }) - .count(); - let r = pdfcraft_xfa::strip(doc).map_err(|e| EditError::Write(e.to_string()))?; - cx.xfa_removed.extend(r.notes()); - if differ > 0 { - cx.xfa_removed.push(format!( - "{differ} field(s) held a value other than the form's XFA data (data PdfCraft could not read into them); the data's version is gone" - )); - } - Ok(()) -} - -/// Keep the document's XFA state in step with its file after an edit: a script may have laid -/// the form out again (rows added, subforms shown), an edit may have taken the XFA out -/// (flattened, or made static), or an undo put it back. -fn sync_xfa_state(doc: &mut Document) { - let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return }; - match pdfcraft_xfa::existing_layout(cos) { - Some(report) => { - if doc.xfa.is_none() { - // Back after an undo: the XFA's own warnings apply again, the strip's don't. - doc.xfa_warnings.retain(|w| !w.starts_with(XFA_REMOVED_PREFIX)); - if doc.xfa_template.is_none() && !doc.xfa_scripts_off { - doc.xfa_template = xfa::template(cos); - } - } - doc.xfa = Some(report); - } - None => { - doc.xfa = None; - doc.xfa_template = None; - // Only what the strip said stays (the XFA's own warnings no longer apply). - doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); - } - } -} - -/// How the warnings of a strip start, so they can be told from the XFA's own. -const XFA_REMOVED_PREFIX: &str = "XFA removed: "; - -/// Is this an XFA data file (an XDP, a datasets document, a data element or a data document), -/// rather than XFDF, Acrobat's `` XML or a table? By its root element. -pub(crate) fn is_xfa_data_file(text: &str) -> bool { - let text = text.get(..text.len().min(65_536)).unwrap_or(text); - let mut rest = text.trim_start_matches('\u{feff}').trim_start(); - // Past the XML declaration, comments and processing instructions. - let mut guard = 0; - while guard < 100 { - guard += 1; - if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); - } else if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); - } else if let Some(r) = rest.strip_prefix("').map_or("", |(_, r)| r).trim_start(); - } else { - break; - } - } - let Some(tag) = rest.strip_prefix('<') else { return false }; - let name = tag.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or(""); - let local = name.rsplit(':').next().unwrap_or(name); - !local.is_empty() && !matches!(local, "xfdf" | "fields" | "html" | "body") -} - -/// The content type a picture is saved under, from its bytes: the kinds a laid-out form shows. -pub(crate) fn image_content_type(bytes: &[u8]) -> Option<&'static str> { - match pdfcraft_xfa::image::kind(bytes)? { - pdfcraft_xfa::image::Kind::Jpeg => Some("image/jpeg"), - pdfcraft_xfa::image::Kind::Png => Some("image/png"), - pdfcraft_xfa::image::Kind::Gif => Some("image/gif"), - pdfcraft_xfa::image::Kind::Bmp => Some("image/bmp"), - pdfcraft_xfa::image::Kind::Tiff => Some("image/tiff"), - } -} - -/// After the XFA data changed outside the fields (an import, a picture): a laid-out form is -/// laid out again from the data (then, when `calculate`, its calculations run), and the -/// fields take the data's values. -pub(crate) fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { - if let Some(tpl) = cx.xfa.clone() { - let mut run = - xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; - let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run)) - .unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})"))); - cx.xfa_datasets = run.datasets; - cx.xfa_ran_away |= run.ran_away; - if let Err(e) = r { - cx.xfa_out.errors.push(e); - } - } - let taken = xfa_values_from_datasets(doc).map_err(EditError::Write)?; - if !taken.is_empty() { - cx.xfa_out.console.push(format!("{} field value(s) taken from the imported data", taken.len())); - } - // Not laid out again (a static form, or scripts off): fields the data no longer holds are - // emptied, so the data replaced is the data shown. - if cx.xfa.is_none() && calculate { - let data = xfa_field_data(doc); - let present: std::collections::HashSet = pdfcraft_xfa::read_values(doc, &data).into_iter().map(|(n, _)| n).collect(); - for f in pdfcraft_forms::fields(doc) { - if present.contains(&f.name) || f.value.is_empty() { - continue; - } - use pdfcraft_forms::FieldKind as K; - let empty = match f.kind { - K::Text => FieldValue::Text(String::new()), - K::CheckBox => FieldValue::Check(false), - K::Radio => FieldValue::Radio(None), - K::Combo | K::List => FieldValue::Choice(Vec::new()), - K::PushButton | K::Signature => continue, - }; - if let Err(e) = pdfcraft_forms::set_value(doc, &f.name, &empty) { - cx.xfa_out.errors.push(format!("{}: not emptied by the import: {e}", f.name)); - } - } - } - Ok(()) -} - -/// 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); - // The first field of each name, as a lookup by name would find it. - let mut by_name = std::collections::HashMap::new(); - for f in &fields { - by_name.entry(f.name.as_str()).or_insert(f); - } - let mut updates = Vec::new(); - for (name, value) in pdfcraft_xfa::read_values(doc, &data) { - let Some(f) = by_name.get(name.as_str()) else { continue }; - let new = match value { - pdfcraft_xfa::FieldData::Text(t) if matches!(f.kind, pdfcraft_forms::FieldKind::Combo | pdfcraft_forms::FieldKind::List) => { - // Saved values, one per line for a multi-select list; shown text stands for - // its saved value (an older viewer may have written it). - let saved = |v: &str| f.options.iter().find(|(e, s)| e == v || s == v).map_or(v.to_string(), |(e, _)| e.clone()); - let picked: Vec = if f.has(pdfcraft_forms::flags::MULTI_SELECT) { - t.lines().map(str::trim).filter(|l| !l.is_empty()).map(saved).collect() - } else { - vec![saved(t.trim())] - }; - (f.value != picked).then_some(FieldValue::Choice(picked)) - } - 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 { - updates.push((name, v)); - } - } - // All at once: one by one, each value would read the whole form again, which took minutes - // on a form with thousands of fields. - let Ok(results) = pdfcraft_forms::set_values(doc, &updates) else { return Ok(Vec::new()) }; - Ok(updates.into_iter().zip(results).filter(|(_, r)| r.is_ok()).map(|((name, _), _)| name).collect()) -} - /// Validate the signature fields of `cos` (written as `bytes`). fn signatures_of(cos: &pdfcraft_cos::Document, bytes: &[u8], trust: &TrustStore, cache: &pdfcraft_sign::DigestCache) -> Vec { pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache) diff --git a/crates/engine/src/xfa_form.rs b/crates/engine/src/xfa_form.rs new file mode 100644 index 000000000..5ceaba1cd --- /dev/null +++ b/crates/engine/src/xfa_form.rs @@ -0,0 +1,297 @@ +//! A document's XFA form: laying it out, keeping its datasets and fields in step, and taking it out. + +use crate::edit::EditCtx; +use crate::{Document, EditError, FieldValue, XfaLayout, guard, xfa}; + +/// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. +pub(crate) fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { + let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; + xfa_redraw(doc)?; + // The form's own values: one its field's font can't draw is drawn with `?` and reported. + report.warnings.extend(undrawn_field_warnings(doc)); + Ok(report) +} + +/// Give the fields of a laid-out XFA form their appearances. A signed signature box keeps the +/// picture its signer's application drew. +pub(crate) fn xfa_redraw(doc: &mut pdfcraft_cos::Document) -> Result<(), String> { + for f in pdfcraft_forms::fields(doc) { + if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) { + continue; + } + pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?; + } + Ok(()) +} + +/// A warning for each field whose appearance PdfCraft drew with `?` for a character its font +/// can't draw (laying out an XFA form's own values). +pub(crate) fn undrawn_field_warnings(doc: &pdfcraft_cos::Document) -> Vec { + pdfcraft_forms::undrawn_fields(doc) + .into_iter() + .map(|(name, c)| { + format!("form field {name:?} holds \"{}\" (U+{:04X}), which its font can't draw: it shows ? in its place", c.escape_debug(), u32::from(c)) + }) + .collect() +} + +/// 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. +/// `datasets`: the stream this edit already wrote, replaced in place rather than added again +/// (and set to the one written). +/// `tpl`: the form's parsed template when the document has one cached (else it is parsed). +pub(crate) fn xfa_sync_datasets( + doc: &mut pdfcraft_cos::Document, + tpl: Option<&pdfcraft_xfa::model::Template>, + datasets: &mut Option, +) -> Result, String> { + let data = xfa_field_data(doc); + let r = pdfcraft_xfa::write_datasets_with(doc, tpl, &data, *datasets).map_err(|e| e.to_string())?; + if r.stream.is_some() { + *datasets = r.stream; + } + Ok(r.warnings) +} + +/// What a document whose form script ran away is told. +pub(crate) const XFA_SCRIPTS_OFF: &str = + "A script of this form ran too long and was abandoned; the form's scripts are off for this document (reopen it to run them again)"; + +/// Most XFA warnings kept per document. +const MAX_XFA_WARNINGS: usize = 50; + +/// Add `new` to `list` (no repeats, at most [`MAX_XFA_WARNINGS`]). +pub(crate) 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()); + } + } +} + +/// A data file's text: UTF-8 or UTF-16 (by a byte order mark, or how `<` is written), or +/// Latin-1 when its XML declaration says so. `None`: not text at all (FDF); `Err`: an +/// encoding this can't read. +pub(crate) fn decode_data_file(bytes: &[u8]) -> Option> { + if bytes.starts_with(b"%FDF") { + return None; + } + let declared = |text: &str| -> Option { + let head = text.get(..text.len().min(256))?; + let decl = head.strip_prefix('\u{feff}').unwrap_or(head).trim_start().strip_prefix("")?.0; + let enc = decl.split_once("encoding=")?.1; + let quote = enc.chars().next()?; + Some(enc.get(1..)?.split(quote).next()?.trim().to_ascii_lowercase()) + }; + if let Some((text, _)) = pdfcraft_xfa::packets::decode(bytes) { + match declared(&text).as_deref() { + None | Some("utf-8" | "utf8" | "utf-16" | "utf-16le" | "utf-16be" | "us-ascii" | "ascii") => return Some(Ok(text)), + Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") if !bytes.starts_with(&[0xEF, 0xBB, 0xBF]) => { + // Each byte is the character it names (the 0x80–0x9F block of Windows-1252 + // differs, and is rare in form data). + return Some(Ok(bytes.iter().map(|b| char::from(*b)).collect())); + } + Some(other) => return Some(Err(format!("the data file's encoding {other:?} is not supported (UTF-8, UTF-16 and ISO-8859-1 are)"))), + } + } + // Not valid UTF-8/UTF-16: Latin-1 by declaration, else refused. + let latin1: String = bytes.iter().map(|b| char::from(*b)).collect(); + match declared(&latin1).as_deref() { + Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") => Some(Ok(latin1)), + _ => Some(Err("the data file is not UTF-8, UTF-16 or ISO-8859-1 text".into())), + } +} + +/// Take the XFA out of `doc` and note what went with it: the packets, data no field showed, +/// buttons that did nothing but run scripts, and fields whose value differs from the form's +/// data (data PdfCraft could not read into them, now lost). +pub(crate) fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> { + let fields = pdfcraft_forms::fields(doc); + let data = xfa_field_data(doc); + let differ = pdfcraft_xfa::read_values(doc, &data) + .into_iter() + .filter(|(name, value)| { + let Some(f) = fields.iter().find(|f| f.name == *name) else { return false }; + match value { + pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(), + pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on, + pdfcraft_xfa::FieldData::Radio(sel) => f.value.first() != sel.as_ref(), + pdfcraft_xfa::FieldData::None => false, + } + }) + .count(); + let r = pdfcraft_xfa::strip(doc).map_err(|e| EditError::Write(e.to_string()))?; + cx.xfa_removed.extend(r.notes()); + if differ > 0 { + cx.xfa_removed.push(format!( + "{differ} field(s) held a value other than the form's XFA data (data PdfCraft could not read into them); the data's version is gone" + )); + } + Ok(()) +} + +/// Keep the document's XFA state in step with its file after an edit: a script may have laid +/// the form out again (rows added, subforms shown), an edit may have taken the XFA out +/// (flattened, or made static), or an undo put it back. +pub(crate) fn sync_xfa_state(doc: &mut Document) { + let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return }; + match pdfcraft_xfa::existing_layout(cos) { + Some(report) => { + if doc.xfa.is_none() { + // Back after an undo: the XFA's own warnings apply again, the strip's don't. + doc.xfa_warnings.retain(|w| !w.starts_with(XFA_REMOVED_PREFIX)); + if doc.xfa_template.is_none() && !doc.xfa_scripts_off { + doc.xfa_template = xfa::template(cos); + } + } + doc.xfa = Some(report); + } + None => { + doc.xfa = None; + doc.xfa_template = None; + // Only what the strip said stays (the XFA's own warnings no longer apply). + doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); + } + } +} + +/// How the warnings of a strip start, so they can be told from the XFA's own. +pub(crate) const XFA_REMOVED_PREFIX: &str = "XFA removed: "; + +/// Is this an XFA data file (an XDP, a datasets document, a data element or a data document), +/// rather than XFDF, Acrobat's `` XML or a table? By its root element. +pub(crate) fn is_xfa_data_file(text: &str) -> bool { + let text = text.get(..text.len().min(65_536)).unwrap_or(text); + let mut rest = text.trim_start_matches('\u{feff}').trim_start(); + // Past the XML declaration, comments and processing instructions. + let mut guard = 0; + while guard < 100 { + guard += 1; + if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); + } else if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); + } else if let Some(r) = rest.strip_prefix("').map_or("", |(_, r)| r).trim_start(); + } else { + break; + } + } + let Some(tag) = rest.strip_prefix('<') else { return false }; + let name = tag.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or(""); + let local = name.rsplit(':').next().unwrap_or(name); + !local.is_empty() && !matches!(local, "xfdf" | "fields" | "html" | "body") +} + +/// The content type a picture is saved under, from its bytes: the kinds a laid-out form shows. +pub(crate) fn image_content_type(bytes: &[u8]) -> Option<&'static str> { + match pdfcraft_xfa::image::kind(bytes)? { + pdfcraft_xfa::image::Kind::Jpeg => Some("image/jpeg"), + pdfcraft_xfa::image::Kind::Png => Some("image/png"), + pdfcraft_xfa::image::Kind::Gif => Some("image/gif"), + pdfcraft_xfa::image::Kind::Bmp => Some("image/bmp"), + pdfcraft_xfa::image::Kind::Tiff => Some("image/tiff"), + } +} + +/// After the XFA data changed outside the fields (an import, a picture): a laid-out form is +/// laid out again from the data (then, when `calculate`, its calculations run), and the +/// fields take the data's values. +pub(crate) fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { + if let Some(tpl) = cx.xfa.clone() { + let mut run = + xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; + let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run)) + .unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})"))); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + if let Err(e) = r { + cx.xfa_out.errors.push(e); + } + } + let taken = xfa_values_from_datasets(doc).map_err(EditError::Write)?; + if !taken.is_empty() { + cx.xfa_out.console.push(format!("{} field value(s) taken from the imported data", taken.len())); + } + // Not laid out again (a static form, or scripts off): fields the data no longer holds are + // emptied, so the data replaced is the data shown. + if cx.xfa.is_none() && calculate { + let data = xfa_field_data(doc); + let present: std::collections::HashSet = pdfcraft_xfa::read_values(doc, &data).into_iter().map(|(n, _)| n).collect(); + for f in pdfcraft_forms::fields(doc) { + if present.contains(&f.name) || f.value.is_empty() { + continue; + } + use pdfcraft_forms::FieldKind as K; + let empty = match f.kind { + K::Text => FieldValue::Text(String::new()), + K::CheckBox => FieldValue::Check(false), + K::Radio => FieldValue::Radio(None), + K::Combo | K::List => FieldValue::Choice(Vec::new()), + K::PushButton | K::Signature => continue, + }; + if let Err(e) = pdfcraft_forms::set_value(doc, &f.name, &empty) { + cx.xfa_out.errors.push(format!("{}: not emptied by the import: {e}", f.name)); + } + } + } + Ok(()) +} + +/// Give the fields the values the XFA datasets hold (a form filled in another viewer). Returns +/// the names of the fields that changed. +pub(crate) fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result, String> { + let data = xfa_field_data(doc); + let fields = pdfcraft_forms::fields(doc); + // The first field of each name, as a lookup by name would find it. + let mut by_name = std::collections::HashMap::new(); + for f in &fields { + by_name.entry(f.name.as_str()).or_insert(f); + } + let mut updates = Vec::new(); + for (name, value) in pdfcraft_xfa::read_values(doc, &data) { + let Some(f) = by_name.get(name.as_str()) else { continue }; + let new = match value { + pdfcraft_xfa::FieldData::Text(t) if matches!(f.kind, pdfcraft_forms::FieldKind::Combo | pdfcraft_forms::FieldKind::List) => { + // Saved values, one per line for a multi-select list; shown text stands for + // its saved value (an older viewer may have written it). + let saved = |v: &str| f.options.iter().find(|(e, s)| e == v || s == v).map_or(v.to_string(), |(e, _)| e.clone()); + let picked: Vec = if f.has(pdfcraft_forms::flags::MULTI_SELECT) { + t.lines().map(str::trim).filter(|l| !l.is_empty()).map(saved).collect() + } else { + vec![saved(t.trim())] + }; + (f.value != picked).then_some(FieldValue::Choice(picked)) + } + 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 { + updates.push((name, v)); + } + } + // All at once: one by one, each value would read the whole form again, which took minutes + // on a form with thousands of fields. + let Ok(results) = pdfcraft_forms::set_values(doc, &updates) else { return Ok(Vec::new()) }; + Ok(updates.into_iter().zip(results).filter(|(_, r)| r.is_ok()).map(|((name, _), _)| name).collect()) +}