mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 12:08:46 -06:00
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:
co-authored by
Claude Opus 5.5
parent
ff361fe138
commit
748b20abb9
+5
-294
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user