refactor(engine): move the XFA form helpers to xfa_form.rs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
alehel
2026-10-25 23:05:58 +01:00
co-authored by Claude Opus 5.5
parent ff361fe138
commit 748b20abb9
2 changed files with 302 additions and 294 deletions
+5 -294
View File
@@ -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<Vec<u8>>, 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<XfaLayout, String> {
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<String> {
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<pdfcraft_xfa::FieldDatum> {
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<pdfcraft_cos::ObjRef>,
) -> Result<Vec<String>, 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<String>, 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<Result<String, String>> {
if bytes.starts_with(b"%FDF") {
return None;
}
let declared = |text: &str| -> Option<String> {
let head = text.get(..text.len().min(256))?;
let decl = head.strip_prefix('\u{feff}').unwrap_or(head).trim_start().strip_prefix("<?xml")?;
let decl = decl.split_once("?>")?.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 `<fields>` 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("<?") {
rest = r.split_once("?>").map_or("", |(_, r)| r).trim_start();
} else if let Some(r) = rest.strip_prefix("<!--") {
rest = r.split_once("-->").map_or("", |(_, r)| r).trim_start();
} else if let Some(r) = rest.strip_prefix("<!") {
rest = r.split_once('>').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<String> = 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<Vec<String>, 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<String> = 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<SignatureInfo> {
pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache)
+297
View File
@@ -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<XfaLayout, String> {
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<String> {
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<pdfcraft_xfa::FieldDatum> {
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<pdfcraft_cos::ObjRef>,
) -> Result<Vec<String>, 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<String>, 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<Result<String, String>> {
if bytes.starts_with(b"%FDF") {
return None;
}
let declared = |text: &str| -> Option<String> {
let head = text.get(..text.len().min(256))?;
let decl = head.strip_prefix('\u{feff}').unwrap_or(head).trim_start().strip_prefix("<?xml")?;
let decl = decl.split_once("?>")?.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 `<fields>` 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("<?") {
rest = r.split_once("?>").map_or("", |(_, r)| r).trim_start();
} else if let Some(r) = rest.strip_prefix("<!--") {
rest = r.split_once("-->").map_or("", |(_, r)| r).trim_start();
} else if let Some(r) = rest.strip_prefix("<!") {
rest = r.split_once('>').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<String> = 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<Vec<String>, 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<String> = 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())
}