mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 12:08:46 -06:00
298 lines
14 KiB
Rust
298 lines
14 KiB
Rust
//! 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())
|
||
}
|