mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
XFA: import and export data files, and pictures for image fields (#1175)
Export Data on an XFA form writes its data as Acrobat does: XML (the data alone, rooted at the form's root data node, its namespaces declared) or an XDP holding the whole datasets and a reference to the PDF (.xdp, a new format). Import Data takes an XDP, a datasets or data element, or a data document whose root is the form's root data node, and makes it the form's whole data, keeping data descriptions; a laid-out form is laid out again from it (rows added and removed) and its calculations run; XFDF, FDF and tables still go the AcroForm way. Undoable. Image fields are now buttons marked /PCXfaImage (no PDF action): the forms layer treats one as an image field, so clicking it (or form_set_image) picks a picture, which is kept in the data as base64 with its xfa:contentType, as Designer saves it, and the laid-out form shows it from there. xfa: replace_data, write_data_image (hostile content types cleaned, 32 MB cap), export_data, import_data (64 MB cap, the parser's node limit, self-closing data elements). engine: export routing by document kind, import routing by the file's root element, re-layout and calculation after the data changes outside the fields, the picture's content type from its bytes. UI: XML and XDP in the export dialog of an XFA form, .xdp in the import filter. Tool texts, README and parity (form.xfa-data) updated. Hardened after an adversarial review pass: the file's data is held as a tree and written with the packet's own namespace prefix (never its markup), every packet rewrite is parsed back before it is written, files capped at 16 MB and 64 levels (a tag scan before the recursive parser, also guarding the datasets parser), pictures at 8 MB and PNG/ JPEG/GIF only, the file's shape and root name checked, UTF-16 and ISO-8859-1 decoded, pictures into data groups refused, cleaned names for pictures, static forms empty the fields the file doesn't hold, more rows than the layout cap reported, one datasets stream per edit, and only image fields take pictures into the data.
This commit is contained in:
1 parent
29dca46089
commit
82b709d516
16 files changed
+1072
-33
No files matched your search
@@ -520,7 +520,8 @@ impl Automation {
|
||||
let id = self.doc(a)?.id;
|
||||
let path = self.resolve(a.str("path")?, true)?;
|
||||
let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let format = pdfcraft_engine::DataFormat::from_extension(&ext).ok_or_else(|| bad("the path must end in .xfdf, .fdf, .xml, .csv or .txt"))?;
|
||||
let format =
|
||||
pdfcraft_engine::DataFormat::from_extension(&ext).ok_or_else(|| bad("the path must end in .xfdf, .fdf, .xml, .xdp, .csv or .txt"))?;
|
||||
let (comments, fields) = match a.opt_str("what")?.unwrap_or("all") {
|
||||
"all" => (true, true),
|
||||
"comments" => (true, false),
|
||||
|
||||
@@ -345,7 +345,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc", "page", "path", "at"],
|
||||
)),
|
||||
t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). Undoable.")
|
||||
t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). On an XFA form the picture is kept in its data too (base64, as Designer saves it) and the form shows it from there. Undoable.")
|
||||
.with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "path": { "type": "string" } }), &["doc", "field", "path"])),
|
||||
t(
|
||||
"form_set_props",
|
||||
@@ -388,10 +388,10 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }),
|
||||
&["doc"],
|
||||
)),
|
||||
t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). what: all (default), comments, fields.")
|
||||
t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). For an XFA form, .xml is its data alone (rooted at the form's root data node, as Acrobat exports it) and .xdp an XML Data Package holding the whole datasets. what: all (default), comments, fields.")
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "what": { "type": "string", "enum": ["all", "comments", "fields"] } }), &["doc", "path"])),
|
||||
t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content). Comments with the same name are replaced; values go through the form's formats and validation. Undoable.")
|
||||
t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.")
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
|
||||
t("form_delete_field", "Delete a field", "Delete a form field and all its widgets. Undoable.")
|
||||
.destructive()
|
||||
|
||||
@@ -3534,6 +3534,39 @@ fn exporting_to_word_html_and_rtf() {
|
||||
assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "a.xyz" })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xfa_data_files_export_and_import_through_tools() {
|
||||
let dir = workdir("xfa-data");
|
||||
let data = "<form><page1><country>JP</country></page1></form>";
|
||||
std::fs::write(dir.join("fields.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data))).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "fields.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "data.xml", "what": "fields" }));
|
||||
ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "data.xdp", "what": "fields" }));
|
||||
let xml = std::fs::read_to_string(dir.join("data.xml")).unwrap();
|
||||
let xdp = std::fs::read_to_string(dir.join("data.xdp")).unwrap();
|
||||
assert!(xml.contains("<form") && xml.contains("<country>JP</country>"), "{xml}");
|
||||
assert!(xdp.contains("<xdp:xdp") && xdp.contains("<country>JP</country>"), "{xdp}");
|
||||
// Edit the exported data and import it back.
|
||||
std::fs::write(dir.join("edited.xdp"), xdp.replace("<country>JP</country>", "<country>CA</country>")).unwrap();
|
||||
let r = ok(&mut a, "doc_import_data", json!({ "doc": doc, "path": "edited.xdp" }));
|
||||
assert!(r["filled_fields"].as_u64().unwrap() >= 1);
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let country = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "country").unwrap();
|
||||
assert_eq!(country["value"], json!("CA"));
|
||||
// A picture for the image field, through form_set_image, lands in the data.
|
||||
std::fs::write(dir.join("pic.gif"), pdfcraft_xfa::fixtures::TINY_GIF).unwrap();
|
||||
ok(&mut a, "form_set_image", json!({ "doc": doc, "field": "photo", "path": "pic.gif" }));
|
||||
ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "after.xml", "what": "fields" }));
|
||||
let after = std::fs::read_to_string(dir.join("after.xml")).unwrap();
|
||||
assert!(after.contains("<photo xfa:contentType=\"image/gif\">R0lGODlh"), "{after}");
|
||||
// XDP is for XFA forms only.
|
||||
std::fs::write(dir.join("plain.pdf"), pdfcraft_xfa::fixtures::static_shell("<form1><page1><name>Ada</name></page1></form1>")).unwrap();
|
||||
let plain = ok(&mut a, "doc_open", json!({ "path": "plain.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_export_data", json!({ "doc": plain, "path": "static.xml", "what": "fields" }));
|
||||
assert!(std::fs::read_to_string(dir.join("static.xml")).unwrap().contains("<name>Ada</name>"), "a static XFA form exports its data too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_xfa_forms_open_render_fill_and_save_through_tools() {
|
||||
let dir = workdir("xfa");
|
||||
|
||||
+169
-5
@@ -2029,7 +2029,34 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32),
|
||||
_ => (1, 1),
|
||||
};
|
||||
pdfcraft_forms::set_button_icon(doc, name, img, px)?;
|
||||
// An XFA image field keeps the picture in the data too (as Designer saves it), and a
|
||||
// laid-out form shows it from there: so only pictures the layout can show, and only
|
||||
// for image fields (a laid-out one carries its SOM path; a static form's field name
|
||||
// is its path).
|
||||
let is_xfa = pdfcraft_xfa::read_packets(doc).ok().flatten().is_some();
|
||||
let image_field = is_xfa
|
||||
&& pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.button == Some(pdfcraft_forms::af::ButtonAction::ImportIcon));
|
||||
if image_field {
|
||||
let Some(content_type) = image_content_type(image) else {
|
||||
return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG or GIF picture; choose one of those")));
|
||||
};
|
||||
pdfcraft_forms::set_button_icon(doc, name, img, px)?;
|
||||
let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, n)| n == name).map(|(s, _)| s).unwrap_or_else(|| name.clone());
|
||||
let path = match cx.xfa.as_deref() {
|
||||
Some(tpl) => pdfcraft_xfa::data_path(tpl, &som),
|
||||
None => pdfcraft_xfa::som_to_path(&som),
|
||||
};
|
||||
let r =
|
||||
pdfcraft_xfa::write_data_image(doc, &path, content_type, image, cx.xfa_datasets).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
if !r.written && r.warnings.is_empty() {
|
||||
return Err(EditError::Write(format!("{name}: the picture could not be written into the form's data")));
|
||||
}
|
||||
cx.xfa_datasets = r.stream.or(cx.xfa_datasets);
|
||||
cx.xfa_out.errors.extend(r.warnings);
|
||||
xfa_after_data_change(doc, cx, false)?;
|
||||
} else {
|
||||
pdfcraft_forms::set_button_icon(doc, name, img, px)?;
|
||||
}
|
||||
}
|
||||
Edit::ResetForm { names } => {
|
||||
pdfcraft_forms::reset(doc, names.as_deref())?;
|
||||
@@ -2174,7 +2201,24 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
}
|
||||
}
|
||||
Edit::ImportData { bytes, .. } => {
|
||||
pdfcraft_xfdf::import(doc, bytes)?;
|
||||
// An XFA form takes XFA data files (XDP, datasets, or a data document) as its data;
|
||||
// XFDF, FDF, Acrobat's XML and tables go the usual way.
|
||||
let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten();
|
||||
match text {
|
||||
Some(Ok(text)) if is_xfa_data_file(&text) => {
|
||||
let expect_root = cx.xfa.as_ref().and_then(|t| t.root.common.name.clone());
|
||||
let r = pdfcraft_xfa::import_data(doc, &text, expect_root.as_deref(), cx.xfa_datasets)
|
||||
.map_err(|e| EditError::Write(e.to_string()))?;
|
||||
cx.xfa_datasets = r.stream.or(cx.xfa_datasets);
|
||||
cx.xfa_out.errors.extend(r.warnings);
|
||||
cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes));
|
||||
xfa_after_data_change(doc, cx, true)?;
|
||||
}
|
||||
Some(Err(why)) => return Err(EditError::Invalid(why)),
|
||||
_ => {
|
||||
pdfcraft_xfdf::import(doc, bytes)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Edit::RemoveHidden { which } => {
|
||||
pdfcraft_redact::sanitize::remove_hidden(doc, which)?;
|
||||
@@ -2508,6 +2552,117 @@ fn note_warnings(list: &mut Vec<String>, new: &[String]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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.
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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 };
|
||||
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> {
|
||||
@@ -3406,11 +3561,20 @@ impl Session {
|
||||
pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))?
|
||||
}
|
||||
};
|
||||
// An XFA form's data is its datasets, as Acrobat exports it: XML or an XDP.
|
||||
let xfa = doc.info.xfa.is_some();
|
||||
if format == DataFormat::Xdp && !xfa {
|
||||
return Err(EditError::Invalid("XDP holds XFA form data; this document is not an XFA form (use .xml, .csv or .txt)".into()));
|
||||
}
|
||||
guard(|| match format {
|
||||
DataFormat::Xfdf => pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes(),
|
||||
DataFormat::Fdf => pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name),
|
||||
other => pdfcraft_xfdf::export_data(&cos, other).into_bytes(),
|
||||
DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()),
|
||||
DataFormat::Fdf => Ok(pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name)),
|
||||
DataFormat::Xml | DataFormat::Xdp if xfa => {
|
||||
pdfcraft_xfa::export_data(&cos, format == DataFormat::Xdp, &doc.name).map(String::into_bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
other => Ok(pdfcraft_xfdf::export_data(&cos, other).into_bytes()),
|
||||
})
|
||||
.map_err(EditError::Write)?
|
||||
.map_err(EditError::Write)
|
||||
}
|
||||
|
||||
|
||||
@@ -2634,6 +2634,139 @@ fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() {
|
||||
assert!(taken[0].contains("langs") && !taken[0].contains("country"), "{}", taken[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xfa_data_exports_imports_and_pictures_round_trip_through_the_session() {
|
||||
use pdfcraft_xfa::som_to_path;
|
||||
let data = "<form><page1><country>JP</country><langs>English\nSpanish</langs></page1></form>";
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data)));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("fields.pdf", None, bytes, None).expect("opens");
|
||||
let field = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).cloned().unwrap_or_else(|| panic!("no field {n}"));
|
||||
// Export: XML is the datasets document, XDP wraps it; the fields' current values are in it.
|
||||
s.apply(id, Edit::SetFieldValue { name: "country".into(), value: FieldValue::Choice(vec!["CA".into()]) }).unwrap();
|
||||
let xml = String::from_utf8(s.export_data(id, DataFormat::Xml, false, true).unwrap()).unwrap();
|
||||
assert!(xml.starts_with("<?xml") && xml.contains("<form") && xml.contains("<country>CA</country>") && !xml.contains("<xfa:datasets"), "{xml}");
|
||||
let xdp = String::from_utf8(s.export_data(id, DataFormat::Xdp, false, true).unwrap()).unwrap();
|
||||
assert!(xdp.contains("<xdp:xdp") && xdp.contains("<country>CA</country>") && xdp.contains("href=\"fields.pdf\""), "{xdp}");
|
||||
// Import: the data file's values replace the form's; the calculation runs; undo brings
|
||||
// the old values back.
|
||||
let file = "<form><page1><country>FR</country><langs>French</langs><pin>1234</pin></page1></form>";
|
||||
s.apply(id, Edit::ImportData { name: "data.xml".into(), bytes: Arc::new(file.as_bytes().to_vec()) }).unwrap();
|
||||
assert_eq!(field(&s, "country").value, vec!["FR".to_string()]);
|
||||
assert_eq!(field(&s, "langs").value, vec!["French".to_string()]);
|
||||
assert_eq!(field(&s, "pin").value, vec!["1234".to_string()]);
|
||||
assert_eq!(field(&s, "summary").value, vec!["Country: FR".to_string()], "{:?}", s.take_js_output(id));
|
||||
s.undo(id).unwrap();
|
||||
assert_eq!(field(&s, "country").value, vec!["CA".to_string()]);
|
||||
s.redo(id).unwrap();
|
||||
assert_eq!(field(&s, "country").value, vec!["FR".to_string()]);
|
||||
// A file in another encoding, and one with a long comment before its root, import too.
|
||||
let latin1: Vec<u8> =
|
||||
"<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?><form><page1><other>Société</other></page1></form>".chars().map(|c| c as u32 as u8).collect();
|
||||
s.apply(id, Edit::ImportData { name: "l1.xml".into(), bytes: Arc::new(latin1) }).unwrap();
|
||||
assert_eq!(field(&s, "other").value, vec!["Société".to_string()]);
|
||||
let commented = format!("<!-- {} --><form><page1><other>after</other></page1></form>", "x".repeat(5000));
|
||||
s.apply(id, Edit::ImportData { name: "c.xml".into(), bytes: Arc::new(commented.into_bytes()) }).unwrap();
|
||||
assert_eq!(field(&s, "other").value, vec!["after".to_string()]);
|
||||
let utf16: Vec<u8> = "<form><page1><other>wide</other></page1></form>".encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
|
||||
s.apply(id, Edit::ImportData { name: "w.xml".into(), bytes: Arc::new([&[0xFF, 0xFE][..], &utf16].concat()) }).unwrap();
|
||||
assert_eq!(field(&s, "other").value, vec!["wide".to_string()]);
|
||||
let shift_jis = b"<?xml version=\"1.0\" encoding=\"Shift_JIS\"?><form/>".to_vec();
|
||||
assert!(s.apply(id, Edit::ImportData { name: "sj.xml".into(), bytes: Arc::new(shift_jis) }).is_err());
|
||||
// A data file for another form is refused.
|
||||
assert!(s.apply(id, Edit::ImportData { name: "o.xml".into(), bytes: Arc::new(b"<other><x>1</x></other>".to_vec()) }).is_err());
|
||||
assert_eq!(field(&s, "other").value, vec!["wide".to_string()]);
|
||||
// XFDF still goes the usual way on an XFA form.
|
||||
let xfdf =
|
||||
"<?xml version=\"1.0\"?><xfdf xmlns=\"http://ns.adobe.com/xfdf/\"><fields><field name=\"pin\"><value>9999</value></field></fields></xfdf>";
|
||||
s.apply(id, Edit::ImportData { name: "d.xfdf".into(), bytes: Arc::new(xfdf.as_bytes().to_vec()) }).unwrap();
|
||||
assert_eq!(field(&s, "pin").value, vec!["9999".to_string()]);
|
||||
assert_eq!(field(&s, "country").value, vec!["FR".to_string()]);
|
||||
// A picture chosen for the image field goes into the data and onto the page.
|
||||
let png = pdfcraft_xfa::fixtures::tiny_png();
|
||||
assert_eq!(field(&s, "photo").button, Some(pdfcraft_forms::af::ButtonAction::ImportIcon), "clicking it picks a picture");
|
||||
s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
|
||||
let d = pdfcraft_xfa::data_of(&cos).unwrap();
|
||||
assert!(d.text_at(&som_to_path("form[0].page1[0].photo[0]")).unwrap().starts_with("R0lGODlh"));
|
||||
assert!(pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp.contains("xfa:contentType=\"image/gif\""));
|
||||
assert_eq!(s.get(id).unwrap().page_images(0).len(), 2, "the field's picture (now the GIF) and the logo");
|
||||
// Only pictures the form can show; only image fields.
|
||||
assert!(s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(b"BM\0\0\0\0".to_vec()) }).is_err());
|
||||
assert!(s.apply(id, Edit::SetFieldImage { name: "country".into(), image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }).is_err());
|
||||
// One datasets stream per edit, however many steps it took.
|
||||
let before = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap().object_numbers().len();
|
||||
s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }).unwrap();
|
||||
s.apply(id, Edit::SetFieldValue { name: "pin".into(), value: FieldValue::Text("1".into()) }).unwrap();
|
||||
let after = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
|
||||
let datasets = after
|
||||
.object_numbers()
|
||||
.into_iter()
|
||||
.filter(|n| matches!(&*after.get(pdfcraft_cos::ObjRef { num: *n, generation: 0 }), pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|b| b.starts_with(b"<xfa:datasets"))))
|
||||
.count();
|
||||
assert!(datasets <= 3, "{datasets} datasets streams (one per edit at most), {before} objects before");
|
||||
s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(png) }).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
|
||||
assert!(pdfcraft_xfa::data_of(&cos).unwrap().text_at(&som_to_path("form[0].page1[0].photo[0]")).unwrap().starts_with("iVBOR"));
|
||||
// Reopened: the picture and the imported values are what they were.
|
||||
let id2 = s.open("again.pdf", None, s.save_bytes(id).unwrap(), None).unwrap();
|
||||
let d2 = s.get(id2).unwrap();
|
||||
assert_eq!(d2.form.iter().find(|f| f.name == "country").unwrap().value, vec!["FR".to_string()]);
|
||||
assert_eq!(d2.page_images(0).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xfa_data_import_on_a_static_form_replaces_every_value_and_pictures_reach_its_data() {
|
||||
let original = Arc::new(pdfcraft_xfa::fixtures::static_shell("<form1><page1><name>Ada</name><agree>1</agree></page1></form1>"));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("static.pdf", None, original, None).expect("opens");
|
||||
let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name.ends_with(n)).map(|f| f.value.clone()).unwrap_or_default();
|
||||
assert_eq!(value(&s, "name[0]"), vec!["Ada".to_string()]);
|
||||
assert_eq!(value(&s, "agree[0]"), vec!["1".to_string()]);
|
||||
// The file names only `name`: `agree` is emptied too, since the data was replaced.
|
||||
s.apply(id, Edit::ImportData { name: "d.xml".into(), bytes: Arc::new(b"<form1><page1><name>Bob</name></page1></form1>".to_vec()) }).unwrap();
|
||||
assert_eq!(value(&s, "name[0]"), vec!["Bob".to_string()]);
|
||||
assert!(value(&s, "agree[0]").is_empty(), "{:?}", value(&s, "agree[0]"));
|
||||
let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
|
||||
let d = pdfcraft_xfa::data_of(&cos).unwrap();
|
||||
assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].name[0]")), Some("Bob"));
|
||||
assert!(d.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].agree[0]")).is_none_or(str::is_empty));
|
||||
let xml = String::from_utf8(s.export_data(id, DataFormat::Xml, false, true).unwrap()).unwrap();
|
||||
assert!(xml.contains("<name>Bob</name>") && !xml.contains("<agree>1</agree>"), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() {
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("rows.pdf", None, bytes, None).expect("opens");
|
||||
let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::<Vec<_>>();
|
||||
let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default();
|
||||
assert_eq!(names(&s).iter().filter(|n| n.starts_with("amount")).count(), 1);
|
||||
let file = "<form><page1><qty>3</qty><table><row><item>a</item><amount>10</amount></row><row><item>b</item><amount>32</amount></row><row><item>c</item><amount>0.5</amount></row></table></page1></form>";
|
||||
s.apply(id, Edit::ImportData { name: "rows.xml".into(), bytes: Arc::new(file.as_bytes().to_vec()) }).unwrap();
|
||||
assert_eq!(names(&s).iter().filter(|n| n.starts_with("amount")).count(), 3, "{:?}", names(&s));
|
||||
assert_eq!(value(&s, "total"), vec!["15".to_string()], "qty 3 × price 5, calculated after the import: {:?}", s.take_js_output(id));
|
||||
assert_eq!(value(&s, "grand"), vec!["42.5".to_string()]);
|
||||
// More rows than the layout shows: the document says so.
|
||||
let rows: String = (0..60).map(|i| format!("<row><item>r{i}</item><amount>1</amount></row>")).collect();
|
||||
let file = format!("<form><page1><qty>1</qty><table>{rows}</table></page1></form>");
|
||||
s.apply(id, Edit::ImportData { name: "many.xml".into(), bytes: Arc::new(file.into_bytes()) }).unwrap();
|
||||
let out = s.take_js_output(id);
|
||||
assert!(out.errors.iter().chain(out.console.iter()).any(|l| l.contains("60 rows")), "{out:?}");
|
||||
// A file with one row takes the others away.
|
||||
let file = "<form><page1><qty>1</qty><table><row><item>only</item><amount>7</amount></row></table></page1></form>";
|
||||
s.apply(id, Edit::ImportData { name: "one.xml".into(), bytes: Arc::new(file.as_bytes().to_vec()) }).unwrap();
|
||||
assert_eq!(names(&s).iter().filter(|n| n.starts_with("amount")).count(), 1, "{:?}", names(&s));
|
||||
assert_eq!(value(&s, "grand"), vec!["7".to_string()]);
|
||||
// Not an XFA data file on an XFA form: refused with the usual message, nothing changed.
|
||||
assert!(s.apply(id, Edit::ImportData { name: "junk.xml".into(), bytes: Arc::new(b"<a><b></a>".to_vec()) }).is_err());
|
||||
assert_eq!(value(&s, "grand"), vec!["7".to_string()]);
|
||||
// The automation tool's view: export then import through a file round-trips the rows.
|
||||
let out = s.export_data(id, DataFormat::Xdp, false, true).unwrap();
|
||||
assert!(String::from_utf8_lossy(&out).contains("<item>only</item>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
|
||||
|
||||
@@ -693,6 +693,17 @@ pub(crate) fn on_changes(doc: &mut pdfcraft_cos::Document, tpl: &Template, names
|
||||
r.finish().map(|_| ())
|
||||
}
|
||||
|
||||
/// The form's data changed outside its scripts (a data file imported, a picture chosen): lay
|
||||
/// the form out again from the data, then, when `calculate`, run every calculation.
|
||||
pub(crate) fn on_data_replaced(doc: &mut pdfcraft_cos::Document, tpl: &Template, calculate: bool, run: &mut XfaRun) -> Result<(), String> {
|
||||
let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS);
|
||||
r.relayout()?;
|
||||
if calculate {
|
||||
r.calculates()?;
|
||||
}
|
||||
r.finish().map(|_| ())
|
||||
}
|
||||
|
||||
/// A button's click script (or any event, by SOM path and activity), then calculations.
|
||||
pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &str, activity: &str, run: &mut XfaRun) -> Result<(), String> {
|
||||
let mut r = Runner::new(doc, tpl, run, EVENT_BUDGET_MS);
|
||||
|
||||
@@ -254,6 +254,11 @@ const MAX_LAYER_STATE: usize = 1024;
|
||||
|
||||
/// A push button's mouse-up action (`/A`, or `/AA /U`, on the field or its widget).
|
||||
fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option<af::ButtonAction> {
|
||||
// A button PdfCraft generated for an XFA image field (`/PCXfaImage true`, no PDF action):
|
||||
// clicking it picks a picture, as `buttonImportIcon()` does.
|
||||
if matches!(d.get(b"PCXfaImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) {
|
||||
return Some(af::ButtonAction::ImportIcon);
|
||||
}
|
||||
let pick = |dict: &Dict| -> Option<Object> {
|
||||
dict.get(b"A").cloned().or_else(|| dict.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|aa| aa.get(b"U").cloned())))
|
||||
};
|
||||
|
||||
@@ -695,11 +695,11 @@ impl PdfCraftApp {
|
||||
Err(e) => app.notify_fmt("Couldn't read {name}: {e}", &[("name", &path.display().to_string()), ("e", &e.to_string())]),
|
||||
};
|
||||
match self.save_override.clone() {
|
||||
Some(p) if [".xfdf", ".fdf", ".xml", ".csv", ".txt"].iter().any(|e| p.ends_with(e)) => import(self, p.into()),
|
||||
Some(p) if [".xfdf", ".fdf", ".xml", ".xdp", ".csv", ".txt"].iter().any(|e| p.ends_with(e)) => import(self, p.into()),
|
||||
Some(_) => {}
|
||||
None => {
|
||||
let dialog = rfd::AsyncFileDialog::new()
|
||||
.add_filter(tl!("Comment and form data").to_string(), &["xfdf", "fdf", "xml", "csv", "txt"])
|
||||
.add_filter(tl!("Comment and form data").to_string(), &["xfdf", "fdf", "xml", "xdp", "csv", "txt"])
|
||||
.set_title(tl!("Import data").to_string());
|
||||
let target = self.active_ids().map(|(_, id)| id);
|
||||
self.ask_one(crate::pickers::Ask::File(dialog), target, import);
|
||||
@@ -820,6 +820,7 @@ impl PdfCraftApp {
|
||||
let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let xfa = doc.info.xfa.is_some();
|
||||
let write = move |app: &mut Self, path: std::path::PathBuf| {
|
||||
// Include what was typed while the save panel was open (#166).
|
||||
if !app.commit_typing_in(id) {
|
||||
@@ -842,6 +843,15 @@ impl PdfCraftApp {
|
||||
let d = rfd::AsyncFileDialog::new().set_title(title).set_file_name(format!("{stem}.xfdf"));
|
||||
let d = if comments {
|
||||
d.add_filter("XFDF", &["xfdf"]).add_filter("FDF", &["fdf"])
|
||||
} else if xfa {
|
||||
// An XFA form's data, as Acrobat exports it, first.
|
||||
d.set_file_name(format!("{stem}.xml"))
|
||||
.add_filter("XML", &["xml"])
|
||||
.add_filter("XDP", &["xdp"])
|
||||
.add_filter("XFDF", &["xfdf"])
|
||||
.add_filter("FDF", &["fdf"])
|
||||
.add_filter("CSV", &["csv"])
|
||||
.add_filter(tl!("Text").to_string(), &["txt"])
|
||||
} else {
|
||||
d.add_filter("XFDF", &["xfdf"])
|
||||
.add_filter("FDF", &["fdf"])
|
||||
|
||||
+11
-3
@@ -83,8 +83,7 @@ let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for
|
||||
## Deliberately not done (yet)
|
||||
|
||||
- **Data binding** is the default one only: explicit `bind ref` expressions, global binding and
|
||||
data descriptions are not followed, and no standalone XML or XDP data file is imported or
|
||||
exported.
|
||||
data descriptions are not followed (a data description is kept as written, not checked).
|
||||
- **Scripting.** JavaScript (boa) and FormCalc (a native interpreter, `pdfcraft_js::formcalc`)
|
||||
share one object model, one set of effects and one set of budgets. FormCalc covers the
|
||||
language and the common built-ins (arithmetic, logical, string, date/time, financial, unit);
|
||||
@@ -119,7 +118,16 @@ let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for
|
||||
values of `<items save="1">` as the options; the data holds saved values, several on
|
||||
separate lines. Password fields are password text fields. Signature fields are `/Sig` fields
|
||||
the app can sign. Image fields show the picture the data or template holds (JPEG, PNG, GIF)
|
||||
but a new picture can't be chosen; barcode fields show their value as text, not as bars.
|
||||
and are buttons that pick a new one, kept in the data as base64 with its `xfa:contentType`
|
||||
(`write_data_image`); barcode fields show their value as text, not as bars.
|
||||
- **Data files.** `export_data` writes the data as Acrobat's Export Data does: an XML document
|
||||
holding the data alone (the form's root data node at the root, its namespaces declared) or
|
||||
an XDP holding the whole datasets and a reference to the PDF. `import_data` takes an XDP, a
|
||||
datasets or data element, or a data document whose root is the form's root data node, and
|
||||
makes it the form's whole data, keeping the rest of the datasets (data descriptions). The
|
||||
file's data is held as a tree and written with the packet's own namespace prefix, never as
|
||||
the file's markup, and every rewrite of the packet is parsed back before it is written; the
|
||||
engine then lays a dynamic form out again from it and runs its calculations.
|
||||
- Missing: `keep` constraints, `subformSet` relations, `rl-tb` is mirrored `lr-tb`, font
|
||||
metrics are the approximate Helvetica ones, and line heights are 1.15 × size.
|
||||
- Acrobat cannot be an oracle here (clean-room rules): layout follows the specification, and
|
||||
|
||||
+423
-6
@@ -454,6 +454,10 @@ enum Op<'a> {
|
||||
Values(&'a [Want]),
|
||||
/// Take the node at this path out.
|
||||
Remove(&'a DataPath),
|
||||
/// Make these nodes the whole content of the `xfa:data` element (an import).
|
||||
ReplaceData(&'a [ImportNode]),
|
||||
/// Make the leaf at this path a picture: base64 text with its content type.
|
||||
Picture(&'a DataPath, &'a str, &'a str),
|
||||
}
|
||||
|
||||
/// Note that a value could not be written (once per kind of reason, naming a few fields).
|
||||
@@ -582,10 +586,133 @@ const WRAP_CLOSE: &str = "</pdfcraft-wrap>";
|
||||
/// change: values go into the data nodes they bind to, missing nodes are added, and everything
|
||||
/// else (data no field binds to, other namespaces, attributes, comments, layout) stays as
|
||||
/// written.
|
||||
/// The prefix bound to the XFA data namespace where `n` is (`None`: unbound or the default
|
||||
/// namespace, which attributes can't use).
|
||||
fn xfa_prefix(n: roxmltree::Node) -> Option<String> {
|
||||
n.namespaces().find(|ns| ns.uri() == XFA_DATA_NS).and_then(|ns| ns.name()).filter(|p| !p.is_empty()).map(str::to_string)
|
||||
}
|
||||
|
||||
/// Data read from a file, held as a tree so it can be written into any packet with the
|
||||
/// prefix that packet uses, never as the file's own markup.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ImportNode {
|
||||
pub name: String,
|
||||
/// The element's namespace (`None`: none).
|
||||
pub ns: Option<String>,
|
||||
/// `(namespace, name, value)`.
|
||||
pub attrs: Vec<(Option<String>, String, String)>,
|
||||
pub text: String,
|
||||
pub children: Vec<ImportNode>,
|
||||
}
|
||||
|
||||
/// Deepest data an import keeps.
|
||||
const MAX_IMPORT_DEPTH: usize = MAX_DEPTH;
|
||||
|
||||
/// `n` as an [`ImportNode`], counting nodes against `budget`; `None` past the depth or node
|
||||
/// limits.
|
||||
fn import_node(n: roxmltree::Node, depth: usize, budget: &mut usize) -> Option<ImportNode> {
|
||||
if depth > MAX_IMPORT_DEPTH || *budget == 0 {
|
||||
return None;
|
||||
}
|
||||
*budget -= 1;
|
||||
let mut children = Vec::new();
|
||||
let mut text = String::new();
|
||||
for c in n.children() {
|
||||
if c.is_element() {
|
||||
children.push(import_node(c, depth + 1, budget)?);
|
||||
} else if let Some(t) = c.text() {
|
||||
text.push_str(t);
|
||||
}
|
||||
}
|
||||
// A group's own text is layout whitespace.
|
||||
if !children.is_empty() && text.trim().is_empty() {
|
||||
text.clear();
|
||||
}
|
||||
Some(ImportNode {
|
||||
name: n.tag_name().name().to_string(),
|
||||
ns: n.tag_name().namespace().map(str::to_string),
|
||||
attrs: n
|
||||
.attributes()
|
||||
.filter(|a| a.name() != "xmlns" && a.namespace() != Some("http://www.w3.org/2000/xmlns/"))
|
||||
.map(|a| (a.namespace().map(str::to_string), a.name().to_string(), a.value().to_string()))
|
||||
.collect(),
|
||||
text,
|
||||
children,
|
||||
})
|
||||
}
|
||||
|
||||
/// `nodes` as markup for a packet whose XFA data namespace prefix is `xfa` (declared where
|
||||
/// they go); other namespaces are declared on the elements and attributes that use them.
|
||||
fn serialize_nodes(nodes: &[ImportNode], xfa: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for n in nodes {
|
||||
serialize_node(n, xfa, &mut out, 0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn serialize_node(n: &ImportNode, xfa: &str, out: &mut String, depth: usize) {
|
||||
if depth > MAX_IMPORT_DEPTH {
|
||||
return;
|
||||
}
|
||||
let mut decls: Vec<(String, String)> = Vec::new();
|
||||
let mut qualify = |ns: &Option<String>, name: &str, attr: bool| -> String {
|
||||
match ns.as_deref() {
|
||||
None => xml_name(name),
|
||||
Some(uri) if uri == XFA_DATA_NS => format!("{xfa}:{}", xml_name(name)),
|
||||
Some("http://www.w3.org/XML/1998/namespace") => format!("xml:{}", xml_name(name)),
|
||||
Some(uri) => {
|
||||
let p = match decls.iter().position(|(_, u)| u == uri) {
|
||||
Some(i) => format!("n{i}"),
|
||||
None => {
|
||||
decls.push((format!("n{}", decls.len()), uri.to_string()));
|
||||
format!("n{}", decls.len() - 1)
|
||||
}
|
||||
};
|
||||
let _ = attr;
|
||||
format!("{p}:{}", xml_name(name))
|
||||
}
|
||||
}
|
||||
};
|
||||
let tag = qualify(&n.ns, &n.name, false);
|
||||
let attrs: Vec<String> = n
|
||||
.attrs
|
||||
.iter()
|
||||
.take(256)
|
||||
.map(|(ns, name, value)| {
|
||||
let q = qualify(ns, name, true);
|
||||
format!(" {q}=\"{}\"", escape(value).0)
|
||||
})
|
||||
.collect();
|
||||
out.push('<');
|
||||
out.push_str(&tag);
|
||||
for (p, uri) in &decls {
|
||||
out.push_str(&format!(" xmlns:{p}=\"{}\"", escape(uri).0));
|
||||
}
|
||||
for a in &attrs {
|
||||
out.push_str(a);
|
||||
}
|
||||
if n.text.is_empty() && n.children.is_empty() {
|
||||
out.push_str("/>");
|
||||
return;
|
||||
}
|
||||
out.push('>');
|
||||
out.push_str(&escape(&n.text).0);
|
||||
for c in &n.children {
|
||||
serialize_node(c, xfa, out, depth + 1);
|
||||
}
|
||||
out.push_str(&format!("</{tag}>"));
|
||||
}
|
||||
|
||||
fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec<String>) -> Merged {
|
||||
let wants: &[Want] = match op {
|
||||
Op::Values(w) => w,
|
||||
Op::Remove(_) => &[],
|
||||
Op::Remove(_) | Op::ReplaceData(_) | Op::Picture(..) => &[],
|
||||
};
|
||||
// Fresh data, where none exists yet (declared with the `xfa` prefix).
|
||||
let fresh_children = |warnings: &mut Vec<String>| match op {
|
||||
Op::ReplaceData(nodes) => serialize_nodes(nodes, "xfa"),
|
||||
_ => build_children(wants, warnings),
|
||||
};
|
||||
if crate::xml_nesting_depth(text) > crate::MAX_XML_DEPTH {
|
||||
warnings.push("The XFA data nests XML elements deeper than the reader accepts; the field values were not written to it".into());
|
||||
@@ -616,10 +743,11 @@ fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec<String>) -> Merg
|
||||
let mut edits = Vec::new();
|
||||
let mut data = None;
|
||||
match datasets {
|
||||
None if matches!(op, Op::Remove(_)) => return Merged::Unchanged,
|
||||
None if matches!(op, Op::Remove(_) | Op::Picture(..)) => return Merged::Unchanged,
|
||||
None if within == Within::Xdp => {
|
||||
let root = parsed.root_element();
|
||||
match append_edit(src, root, &fresh_datasets(&build_children(wants, warnings))) {
|
||||
let children = fresh_children(warnings);
|
||||
match append_edit(src, root, &fresh_datasets(&children)) {
|
||||
Some(e) => edits.push(e),
|
||||
None => {
|
||||
warnings.push("The XFA packets could not be extended with data; the field values were not written".into());
|
||||
@@ -633,10 +761,11 @@ fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec<String>) -> Merg
|
||||
}
|
||||
Some(ds) => match ds.children().find(|n| is(n, "data")) {
|
||||
Some(d) => data = Some(d),
|
||||
None if matches!(op, Op::Remove(_)) => return Merged::Unchanged,
|
||||
None if matches!(op, Op::Remove(_) | Op::Picture(..)) => return Merged::Unchanged,
|
||||
None => {
|
||||
let prefix = qname(src, &ds.range()).and_then(|q| q.split_once(':')).map(|(p, _)| format!("{p}:")).unwrap_or_default();
|
||||
let element = format!("<{prefix}data>{}</{prefix}data>", build_children(wants, warnings));
|
||||
let children = fresh_children(warnings);
|
||||
let element = format!("<{prefix}data>{}</{prefix}data>", children);
|
||||
match append_edit(src, ds, &element) {
|
||||
Some(e) => edits.push(e),
|
||||
None => {
|
||||
@@ -650,6 +779,52 @@ fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec<String>) -> Merg
|
||||
if let Some(data) = data {
|
||||
match op {
|
||||
Op::Values(w) => merge_values(src, data, w, &mut edits, warnings),
|
||||
Op::ReplaceData(nodes) => {
|
||||
// Everything between the start and end tags; `<xfa:data/>` opens up. Written
|
||||
// with the prefix the packet binds to the data namespace.
|
||||
let prefix = xfa_prefix(data);
|
||||
let markup = serialize_nodes(nodes, prefix.as_deref().unwrap_or("xfa"));
|
||||
let range = data.range();
|
||||
match (start_tag_end(src, &range), qname(src, &range)) {
|
||||
(Some(tag_end), Some(name)) if tag_end + 1 == range.end && src.get(..tag_end).is_some_and(|s| s.ends_with('/')) => {
|
||||
edits.push(Edit { start: range.start, end: range.end, text: format!("<{name}>{markup}</{name}>") });
|
||||
}
|
||||
(Some(tag_end), _) => {
|
||||
let close = src.get(range.clone()).and_then(|s| s.rfind("</")).map(|c| range.start + c);
|
||||
match close {
|
||||
Some(close) if tag_end < close => edits.push(Edit { start: tag_end + 1, end: close, text: markup }),
|
||||
_ => {
|
||||
warnings.push("The XFA data element could not be rewritten; the data was not imported".into());
|
||||
return Merged::Failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
warnings.push("The XFA data element could not be rewritten; the data was not imported".into());
|
||||
return Merged::Failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
Op::Picture(path, content_type, base64) => {
|
||||
let mut node = Some(data);
|
||||
for (name, idx) in path.iter() {
|
||||
let want = xml_name(name);
|
||||
node = node.and_then(|n| n.children().filter(|c| c.is_element() && c.tag_name().name() == want).nth(*idx));
|
||||
}
|
||||
let Some(n) = node else { return Merged::Unchanged };
|
||||
if n.children().any(|c| c.is_element()) {
|
||||
// A data group, not a leaf: a picture would wipe what's under it.
|
||||
skipped(warnings, STRUCTURED, &path.iter().map(|(n, i)| format!("{n}[{i}]")).collect::<Vec<_>>().join("."));
|
||||
return Merged::Failed;
|
||||
}
|
||||
let name = qname(src, &n.range()).unwrap_or("value").to_string();
|
||||
let (ct, _) = escape(content_type);
|
||||
let attr = match xfa_prefix(n) {
|
||||
Some(p) => format!("{p}:contentType=\"{ct}\""),
|
||||
None => format!("xmlns:xfa=\"{XFA_DATA_NS}\" xfa:contentType=\"{ct}\""),
|
||||
};
|
||||
edits.push(Edit { start: n.range().start, end: n.range().end, text: format!("<{name} {attr}>{base64}</{name}>") });
|
||||
}
|
||||
Op::Remove(path) => {
|
||||
// The element at `path`, with the whitespace before it.
|
||||
let mut node = Some(data);
|
||||
@@ -933,6 +1108,219 @@ pub fn remove_data_instance(doc: &mut Document, path: &DataPath) -> Result<Datas
|
||||
write_data_ops(doc, &[DataOp::Remove(path.clone())], None)
|
||||
}
|
||||
|
||||
/// Largest picture written into the data (its bytes, before base64): with the 64 MB cap on
|
||||
/// all packets together, several pictures still fit.
|
||||
const MAX_DATA_IMAGE: usize = 8 << 20;
|
||||
/// Longest data file imported.
|
||||
const MAX_IMPORT: usize = 16 << 20;
|
||||
|
||||
/// Put a picture into the data node at `path` as Designer saves one a user chose: base64, with
|
||||
/// its content type (`image/png`, …) as `xfa:contentType` (in the packet's own prefix). The
|
||||
/// node is made when missing; a data group is refused rather than wiped. `reuse`: the stream
|
||||
/// this edit already wrote.
|
||||
pub fn write_data_image(
|
||||
doc: &mut Document,
|
||||
path: &DataPath,
|
||||
content_type: &str,
|
||||
bytes: &[u8],
|
||||
reuse: Option<ObjRef>,
|
||||
) -> Result<DatasetsWrite, XfaError> {
|
||||
use base64::Engine;
|
||||
if path.is_empty() {
|
||||
return Err(XfaError::Malformed("a picture needs a data path".into()));
|
||||
}
|
||||
if bytes.len() > MAX_DATA_IMAGE {
|
||||
return Err(XfaError::TooLarge(format!("the picture is larger than {} MB", MAX_DATA_IMAGE >> 20)));
|
||||
}
|
||||
let ct: String = content_type.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '.' | '+')).take(64).collect();
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
let ensure = [Want { som: String::new(), path: path.clone(), text: String::new(), ensure: true }];
|
||||
rewrite(doc, &[Op::Values(&ensure), Op::Picture(path, &ct, &b64)], reuse)
|
||||
}
|
||||
|
||||
/// What an import of a data file did.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct DataImport {
|
||||
/// Element nodes the new data holds.
|
||||
pub nodes: usize,
|
||||
pub warnings: Vec<String>,
|
||||
/// The datasets stream written.
|
||||
pub stream: Option<ObjRef>,
|
||||
}
|
||||
|
||||
/// The data a file holds, as the children of `xfa:data`: an XDP or datasets document (Export
|
||||
/// Data's output, Designer's), a bare data element, or a data document whose root is the
|
||||
/// form's root data node (named `expect_root`, when the caller knows it). Anything else is
|
||||
/// refused: a form's data is never replaced by what is not its data.
|
||||
pub fn parse_data_file(text: &str, expect_root: Option<&str>) -> Result<Vec<ImportNode>, XfaError> {
|
||||
if text.len() > MAX_IMPORT {
|
||||
return Err(XfaError::TooLarge(format!("the data file is larger than {} MB", MAX_IMPORT >> 20)));
|
||||
}
|
||||
let text = text.trim_start_matches('\u{feff}');
|
||||
if crate::xml_nesting_depth(text) > MAX_IMPORT_DEPTH {
|
||||
return Err(XfaError::TooLarge(format!("the data file nests more than {MAX_IMPORT_DEPTH} levels deep")));
|
||||
}
|
||||
let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES };
|
||||
let parsed = roxmltree::Document::parse_with_options(text, opts).map_err(|e| XfaError::Malformed(format!("the data file is not XML: {e}")))?;
|
||||
let root = parsed.root_element();
|
||||
let local = root.tag_name().name();
|
||||
let in_xfa = |n: &roxmltree::Node| n.tag_name().namespace().is_none_or(|ns| ns == XFA_DATA_NS);
|
||||
let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name && in_xfa(n);
|
||||
let data = match local {
|
||||
"xdp" => {
|
||||
let ds = root.children().find(|n| is(n, "datasets")).ok_or_else(|| XfaError::Malformed("the XDP holds no datasets".into()))?;
|
||||
ds.children().find(|n| is(n, "data")).ok_or_else(|| XfaError::Malformed("the datasets hold no data element".into()))?
|
||||
}
|
||||
"datasets" if in_xfa(&root) => {
|
||||
root.children().find(|n| is(n, "data")).ok_or_else(|| XfaError::Malformed("the datasets hold no data element".into()))?
|
||||
}
|
||||
"data" if in_xfa(&root) => root,
|
||||
_ => {
|
||||
if root.tag_name().namespace().is_some() {
|
||||
return Err(XfaError::Malformed(format!("<{local}> is not XFA form data")));
|
||||
}
|
||||
if let Some(want) = expect_root
|
||||
&& local != want
|
||||
{
|
||||
return Err(XfaError::Malformed(format!("the data file's root is <{local}>; this form's data root is <{want}>")));
|
||||
}
|
||||
let mut budget = MAX_NODES as usize;
|
||||
let node = import_node(root, 0, &mut budget).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?;
|
||||
return Ok(vec![node]);
|
||||
}
|
||||
};
|
||||
let mut budget = MAX_NODES as usize;
|
||||
let mut out = Vec::new();
|
||||
for c in data.children().filter(|c| c.is_element()) {
|
||||
out.push(import_node(c, 1, &mut budget).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Replace the form's data with what the file `text` holds (see [`parse_data_file`]), keeping
|
||||
/// the rest of the datasets (data descriptions, say). `reuse`: the stream this edit already
|
||||
/// wrote.
|
||||
pub fn import_data(doc: &mut Document, text: &str, expect_root: Option<&str>, reuse: Option<ObjRef>) -> Result<DataImport, XfaError> {
|
||||
let nodes = parse_data_file(text, expect_root)?;
|
||||
fn count(n: &[ImportNode]) -> usize {
|
||||
n.iter().map(|c| 1 + count(&c.children)).sum()
|
||||
}
|
||||
let r = rewrite(doc, &[Op::ReplaceData(&nodes)], reuse)?;
|
||||
if !r.written {
|
||||
return Err(XfaError::Malformed(r.warnings.first().cloned().unwrap_or_else(|| "the data could not be written".into())));
|
||||
}
|
||||
Ok(DataImport { nodes: count(&nodes), warnings: r.warnings, stream: r.stream })
|
||||
}
|
||||
|
||||
/// The datasets packet's text: from the XFA array's `datasets` item, or the whole XDP.
|
||||
fn datasets_packet(doc: &Document) -> Result<Option<String>, XfaError> {
|
||||
let Some(root) = doc.root() else { return Ok(None) };
|
||||
let catalog = doc.get(root);
|
||||
let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Ok(None) };
|
||||
let Some(xfa) = acro.as_dict().and_then(|a| a.get(b"XFA")).map(|x| doc.resolve(x)) else { return Ok(None) };
|
||||
let text_of = |o: &Object| -> Result<Option<String>, XfaError> {
|
||||
match &*doc.resolve(o) {
|
||||
Object::Stream(s) => Ok(crate::packets::decode(&s.decoded_within(64 << 20)?).map(|(t, _)| t)),
|
||||
_ => Ok(None),
|
||||
}
|
||||
};
|
||||
match &*xfa {
|
||||
Object::Array(items) => {
|
||||
let at = (0..items.len())
|
||||
.step_by(2)
|
||||
.find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len());
|
||||
match at.and_then(|i| items.get(i + 1)) {
|
||||
Some(o) => text_of(o),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
Object::Stream(_) => text_of(&xfa),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The data to export, as Acrobat's Export Data writes it: `xdp` false gives an XML document
|
||||
/// holding the data alone (the form's root data node at the root, or the data element when
|
||||
/// there are several); true gives an XDP holding the whole datasets and a reference to the
|
||||
/// PDF, named `pdf_name`. A document whose packets can't be read is an error, not empty data.
|
||||
pub fn export_data(doc: &Document, xdp: bool, pdf_name: &str) -> Result<String, XfaError> {
|
||||
if crate::read_packets(doc)?.is_none() {
|
||||
return Err(XfaError::NotXfa);
|
||||
}
|
||||
let packet = datasets_packet(doc)?;
|
||||
let (datasets, data) = match &packet {
|
||||
Some(text) => match datasets_markup(text) {
|
||||
Ok(Some(m)) => m,
|
||||
Ok(None) => (fresh_datasets(""), String::new()),
|
||||
Err(()) => return Err(XfaError::Malformed("the XFA data could not be read".into())),
|
||||
},
|
||||
None => (fresh_datasets(""), String::new()),
|
||||
};
|
||||
let mut out = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
if xdp {
|
||||
let (name, _) = escape(pdf_name);
|
||||
out.push_str("<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\">\n");
|
||||
out.push_str(&datasets);
|
||||
out.push_str(&format!("\n<pdf href=\"{name}\" xmlns=\"http://ns.adobe.com/xdp/pdf/\"/>\n</xdp:xdp>\n"));
|
||||
} else {
|
||||
out.push_str(if data.is_empty() { &datasets } else { &data });
|
||||
out.push('\n');
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The `xfa:datasets` element of `text` as written, and the data alone (its one child, or the
|
||||
/// data element), each with the namespaces it uses declared on it. `Ok(None)`: no datasets
|
||||
/// element; `Err`: the text can't be read.
|
||||
fn datasets_markup(text: &str) -> Result<Option<(String, String)>, ()> {
|
||||
let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES };
|
||||
if crate::xml_nesting_depth(text) > crate::MAX_XML_DEPTH {
|
||||
return Err(());
|
||||
}
|
||||
let wrapped = format!("{WRAP_OPEN}{text}{WRAP_CLOSE}");
|
||||
let (src, parsed) = match roxmltree::Document::parse_with_options(text, opts) {
|
||||
Ok(d) => (text, d),
|
||||
Err(_) => (wrapped.as_str(), roxmltree::Document::parse_with_options(&wrapped, opts).map_err(|_| ())?),
|
||||
};
|
||||
let Some(ds) = parsed.descendants().find(|n| n.is_element() && n.tag_name().name() == "datasets") else { return Ok(None) };
|
||||
let data = ds.children().find(|n| n.is_element() && n.tag_name().name() == "data");
|
||||
let data_alone = match data {
|
||||
Some(d) => {
|
||||
let kids: Vec<roxmltree::Node> = d.children().filter(|c| c.is_element()).collect();
|
||||
match kids.as_slice() {
|
||||
[one] => element_markup(src, *one).ok_or(())?,
|
||||
_ => element_markup(src, d).ok_or(())?,
|
||||
}
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
Ok(Some((element_markup(src, ds).ok_or(())?, data_alone)))
|
||||
}
|
||||
|
||||
/// `n` as written in `src`, with the namespace declarations in scope that its start tag lacks
|
||||
/// added to it, so it stands alone.
|
||||
fn element_markup(src: &str, n: roxmltree::Node) -> Option<String> {
|
||||
let range = n.range();
|
||||
let text = src.get(range.clone())?;
|
||||
let tag_end = start_tag_end(src, &range)?;
|
||||
let start_tag = src.get(range.start..tag_end)?;
|
||||
let mut decls = String::new();
|
||||
for ns in n.namespaces() {
|
||||
let attr = match ns.name() {
|
||||
Some(p) => format!("xmlns:{p}="),
|
||||
None => "xmlns=".to_string(),
|
||||
};
|
||||
if !start_tag.contains(&attr) {
|
||||
decls.push_str(&format!(" {attr}\"{}\"", escape(ns.uri()).0));
|
||||
}
|
||||
}
|
||||
if decls.is_empty() {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
let name_end = qname(src, &range)?.len() + 1;
|
||||
Some(format!("{}{decls}{}", text.get(..name_end)?, text.get(name_end..)?))
|
||||
}
|
||||
|
||||
/// Apply `ops` to the datasets packet, in order, as one rewrite of it: everything one script
|
||||
/// event changed becomes a single new datasets stream. When the packet is already the stream
|
||||
/// `reuse` (written earlier for the same event), that stream is replaced in place instead of
|
||||
@@ -1011,6 +1399,25 @@ fn merge_all(text: &str, within: Within, ops: &[Op], warnings: &mut Vec<String>)
|
||||
current
|
||||
}
|
||||
|
||||
/// Longest packet text written (the packets together are read up to 64 MB).
|
||||
const MAX_PACKET_WRITE: usize = 48 << 20;
|
||||
|
||||
/// Is `text` a packet the reader will take back (well-formed, within size)? A rewrite that
|
||||
/// isn't is not written: better the old data than an unreadable packet.
|
||||
fn readable(text: &str, within: Within, warnings: &mut Vec<String>) -> bool {
|
||||
if text.len() > MAX_PACKET_WRITE {
|
||||
warnings.push(format!("The XFA data would be larger than {} MB; it was not written", MAX_PACKET_WRITE >> 20));
|
||||
return false;
|
||||
}
|
||||
let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES };
|
||||
let ok = roxmltree::Document::parse_with_options(text, opts).is_ok()
|
||||
|| (within == Within::Packet && roxmltree::Document::parse_with_options(&format!("{WRAP_OPEN}{text}{WRAP_CLOSE}"), opts).is_ok());
|
||||
if !ok {
|
||||
warnings.push("The XFA data would not be well-formed XML after this change; it was not written".into());
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
fn rewrite(doc: &mut Document, ops: &[Op], reuse: Option<ObjRef>) -> Result<DatasetsWrite, XfaError> {
|
||||
let mut report = DatasetsWrite::default();
|
||||
if ops.is_empty() {
|
||||
@@ -1034,6 +1441,9 @@ fn rewrite(doc: &mut Document, ops: &[Op], reuse: Option<ObjRef>) -> Result<Data
|
||||
let Some(existing) = items.get(i + 1).cloned() else { return Ok(report) };
|
||||
let Some((text, encoding)) = packet_text(doc, &existing, warnings) else { return Ok(report) };
|
||||
let Some(t) = merge_all(&text, Within::Packet, ops, warnings) else { return Ok(report) };
|
||||
if !readable(&t, Within::Packet, warnings) {
|
||||
return Ok(report);
|
||||
}
|
||||
let r = put_stream(doc, Some(&existing), reuse, &encode(&t, encoding));
|
||||
report.stream = Some(r);
|
||||
if let Some(slot) = items.get_mut(i + 1) {
|
||||
@@ -1047,6 +1457,9 @@ fn rewrite(doc: &mut Document, ops: &[Op], reuse: Option<ObjRef>) -> Result<Data
|
||||
let Some(Op::Values(wants)) = ops.get(first) else { return Ok(report) };
|
||||
let fresh = fresh_datasets(&build_children(wants, warnings));
|
||||
let text = merge_all(&fresh, Within::Packet, ops.get(first + 1..).unwrap_or_default(), warnings).unwrap_or(fresh);
|
||||
if !readable(&text, Within::Packet, warnings) {
|
||||
return Ok(report);
|
||||
}
|
||||
let r = doc.add(Object::Stream(Stream::flate(Dict::new(), text.as_bytes())));
|
||||
report.stream = Some(r);
|
||||
let pos = items.iter().position(|n| n.as_string().is_some_and(|s| s.to_text() == "postamble")).unwrap_or(items.len());
|
||||
@@ -1059,6 +1472,9 @@ fn rewrite(doc: &mut Document, ops: &[Op], reuse: Option<ObjRef>) -> Result<Data
|
||||
Object::Stream(_) => {
|
||||
let Some((text, encoding)) = packet_text(doc, &xfa, warnings) else { return Ok(report) };
|
||||
let Some(new) = merge_all(&text, Within::Xdp, ops, warnings) else { return Ok(report) };
|
||||
if !readable(&new, Within::Xdp, warnings) {
|
||||
return Ok(report);
|
||||
}
|
||||
let r = put_stream(doc, Some(&xfa), reuse, &encode(&new, encoding));
|
||||
report.stream = Some(r);
|
||||
acro.set(b"XFA".to_vec(), Object::Ref(r));
|
||||
@@ -1091,7 +1507,8 @@ fn values_at(doc: &Document, tpl: Option<&Template>, fields: &[FieldDatum]) -> V
|
||||
let mut out = Vec::new();
|
||||
for f in fields {
|
||||
let path = path_of(tpl, &som_of(doc, f));
|
||||
let Some(text) = data.text_at(&path) else { continue };
|
||||
let Some(text) = data.value_at(&path) else { continue };
|
||||
let text = text.as_str();
|
||||
let value = match &f.data {
|
||||
FieldData::Text(_) => FieldData::Text(match date_pattern_of(doc, f) {
|
||||
Some(pat) => iso_to_pattern(text, &pat),
|
||||
|
||||
@@ -60,6 +60,8 @@ pub enum WidgetKind {
|
||||
},
|
||||
/// A signature field, to be signed in the app.
|
||||
Signature,
|
||||
/// An image field: a push button that picks a picture (the picture itself is page content).
|
||||
Image,
|
||||
/// A date field with its Acrobat format pattern (`yyyy-mm-dd`).
|
||||
Date(String),
|
||||
CheckBox {
|
||||
@@ -726,6 +728,7 @@ impl Layouter<'_> {
|
||||
}
|
||||
// Data adds instances of a repeating subform (rows added in another viewer).
|
||||
let instances = self.instances(node, &ctx.som);
|
||||
self.warn_truncated(node, &ctx.som, instances);
|
||||
let ctx_parent = ctx;
|
||||
for i in 0..instances {
|
||||
let ctx = ctx_parent.with_som(&sf.common, sib + i);
|
||||
@@ -791,6 +794,7 @@ impl Layouter<'_> {
|
||||
{
|
||||
// Rows added as data (in another viewer) repeat the row.
|
||||
let instances = self.instances(child, &ctx.som);
|
||||
self.warn_truncated(child, &ctx.som, instances);
|
||||
for inst in 0..instances {
|
||||
let h = self.row_height(row, &cols, inner_w, ctx, depth + 1);
|
||||
if self.make_room(h)?
|
||||
@@ -837,6 +841,22 @@ impl Layouter<'_> {
|
||||
instance_count(&sf.occur, from_data)
|
||||
}
|
||||
|
||||
/// The data holds more instances of `node` than `laid_out`: say so, since calculations
|
||||
/// then see only those.
|
||||
fn warn_truncated(&mut self, node: &Node, parent_som: &str, laid_out: usize) {
|
||||
let Node::Subform(sf) = node else { return };
|
||||
let from_data = match (&sf.common.name, self.data) {
|
||||
(Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&data_path(self.tpl, parent_som), name),
|
||||
_ => 0,
|
||||
};
|
||||
if from_data > laid_out {
|
||||
self.warn_once(format!(
|
||||
"{}: the data holds {from_data} rows; only the first {laid_out} are laid out (calculations see those)",
|
||||
sf.common.name.as_deref().unwrap_or("a subform")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn size_of(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) {
|
||||
let c = node.common();
|
||||
let spent = self.measures.get().saturating_add(1);
|
||||
@@ -1499,8 +1519,7 @@ impl Layouter<'_> {
|
||||
(None, Value::Image { content_type, data }) => self.draw_image(data, content_type, box_)?,
|
||||
_ => {}
|
||||
}
|
||||
self.warn_once("image fields show their picture; a new picture can't be chosen yet");
|
||||
return Ok(());
|
||||
// Then the field itself, which clicking gives a new picture.
|
||||
}
|
||||
Ui::Barcode => {
|
||||
// The value as text, where the bars would be.
|
||||
@@ -1616,6 +1635,10 @@ impl Layouter<'_> {
|
||||
(kind, ui, widget_border(f.ui_border.as_ref(), false), value, f.tooltip.clone())
|
||||
}
|
||||
Ui::Signature => (WidgetKind::Signature, ui, widget_border(f.ui_border.as_ref(), false), None, f.tooltip.clone()),
|
||||
Ui::ImageEdit => {
|
||||
let none = WidgetBorder { color: None, fill: None, shape: BorderShape::None, width: 0.0 };
|
||||
(WidgetKind::Image, ui, none, None, f.tooltip.clone())
|
||||
}
|
||||
Ui::PasswordEdit => (WidgetKind::Password, ui, widget_border(f.ui_border.as_ref(), false), plain_value, f.tooltip.clone()),
|
||||
_ => (WidgetKind::Text, ui, widget_border(f.ui_border.as_ref(), false), plain_value, f.tooltip.clone()),
|
||||
};
|
||||
|
||||
@@ -89,6 +89,7 @@ pub(crate) fn xml_nesting_depth(text: &str) -> usize {
|
||||
max
|
||||
}
|
||||
|
||||
pub use data::{DataImport, ImportNode, export_data, import_data, parse_data_file, write_data_image};
|
||||
pub use data::{
|
||||
DataNode, DataOp, DataPath, DatasetsWrite, FieldData, FieldDatum, add_data_instances, build_data, data_path, iso_to_pattern, parse_datasets,
|
||||
pattern_to_iso, read_values, read_values_with, remove_data_instance, som_to_path, write_data_ops, write_data_value, write_datasets,
|
||||
|
||||
@@ -156,6 +156,10 @@ pub struct Written {
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// On a push button generated for an XFA image field: clicking it picks a picture (the forms
|
||||
/// layer reads this key).
|
||||
pub const IMAGE_FIELD_KEY: &[u8] = b"PCXfaImage";
|
||||
|
||||
/// On an image XObject written for a picture: the SHA-256 (hex) of the picture's bytes. A
|
||||
/// re-layout (after every script event) finds the XObjects on the pages it replaces by this,
|
||||
/// and reuses them instead of decoding and embedding the same picture again. Nothing is kept
|
||||
@@ -370,6 +374,13 @@ impl Emitter<'_> {
|
||||
ff |= 1;
|
||||
}
|
||||
}
|
||||
WidgetKind::Image => {
|
||||
// A push button with nothing drawn: the forms layer treats one so marked as an
|
||||
// image field (clicking picks a picture), with no action other viewers run.
|
||||
d.set(b"FT".to_vec(), Object::name("Btn"));
|
||||
ff |= 1 << 16;
|
||||
d.set(IMAGE_FIELD_KEY.to_vec(), Object::Bool(true));
|
||||
}
|
||||
WidgetKind::CheckBox { on, .. } => {
|
||||
d.set(b"FT".to_vec(), Object::name("Btn"));
|
||||
let on = state_name(on);
|
||||
|
||||
+222
-4
@@ -830,7 +830,8 @@ fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() {
|
||||
assert!(matches!(&other.kind, WidgetKind::Choice { list_box: false, multi: false, editable: true, .. }));
|
||||
assert_eq!(w.iter().find(|w| w.name == "pin").unwrap().kind, WidgetKind::Password);
|
||||
assert_eq!(w.iter().find(|w| w.name == "sign").unwrap().kind, WidgetKind::Signature);
|
||||
assert!(w.iter().all(|w| w.name != "photo" && w.name != "code"), "image and barcode fields make no widget");
|
||||
assert_eq!(w.iter().find(|w| w.name == "photo").unwrap().kind, WidgetKind::Image, "an image field is a button that picks a picture");
|
||||
assert!(w.iter().all(|w| w.name != "code"), "barcode fields make no widget");
|
||||
// The PNG in the image field and the GIF in the draw are pictures, fitted to their boxes.
|
||||
let images: Vec<(&Rect, usize)> =
|
||||
p1.items.iter().filter_map(|i| if let Item::Image { rect, data, .. } = i { Some((rect, data.len())) } else { None }).collect();
|
||||
@@ -839,13 +840,14 @@ fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() {
|
||||
assert_eq!(images[1].1, TINY_GIF.len());
|
||||
let t = texts(p1);
|
||||
assert!(t.iter().any(|s| s == "12345"), "the barcode's value as text: {t:?}");
|
||||
assert!(form.warnings.iter().any(|w| w.contains("barcode")) && form.warnings.iter().any(|w| w.contains("image fields")), "{:?}", form.warnings);
|
||||
assert!(form.warnings.iter().any(|w| w.contains("barcode")), "{:?}", form.warnings);
|
||||
assert!(!form.warnings.iter().any(|w| w.contains("can't be chosen")), "pictures can be chosen now: {:?}", form.warnings);
|
||||
|
||||
// Written into the PDF: /Ch fields with their options and flags, a /Sig field, a password
|
||||
// field, and Flate images (the PNG with a soft mask).
|
||||
let mut doc = Document::open(Arc::new(shell(&fields_template("")))).unwrap();
|
||||
let report = render_into(&mut doc).unwrap();
|
||||
assert_eq!(report.fields, 6, "country, langs, other, pin, sign, summary");
|
||||
assert_eq!(report.fields, 7, "country, langs, other, pin, sign, photo, summary");
|
||||
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
let fields = pdfcraft_forms_free_fields(&doc);
|
||||
@@ -956,7 +958,7 @@ fn pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocate
|
||||
.replace("<image contentType=\"image/png\">", "<image contentType=\"image/png\">AAAA");
|
||||
let mut doc = Document::open(Arc::new(shell(&xml))).unwrap();
|
||||
let report = render_into(&mut doc).unwrap();
|
||||
assert_eq!(report.fields, 6);
|
||||
assert_eq!(report.fields, 7);
|
||||
assert!(report.warnings.iter().any(|w| w.contains("image")), "{:?}", report.warnings);
|
||||
}
|
||||
|
||||
@@ -1570,3 +1572,219 @@ fn a_carriage_return_and_line_feed_are_one_line_break() {
|
||||
let lf = form.pages.iter().flat_map(widgets).find(|w| w.name == "notes").unwrap().rect.h;
|
||||
assert!((crlf - lf).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them() {
|
||||
use crate::data::{export_data, import_data};
|
||||
let data = "<form><page1><country>JP</country><langs>English\nSpanish</langs></page1></form>";
|
||||
let mut doc = Document::open(Arc::new(shell(&fields_template(data)))).unwrap();
|
||||
render_into(&mut doc).unwrap();
|
||||
// XML: the data alone, rooted at the form's root data node, as Acrobat's XML export is.
|
||||
let xml = export_data(&doc, false, "fields.pdf").unwrap();
|
||||
assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<form"), "{xml}");
|
||||
assert!(xml.contains("<country>JP</country>") && xml.contains("<langs>English\nSpanish</langs>"), "{xml}");
|
||||
assert!(xml.trim_end().ends_with("</form>"), "{xml}");
|
||||
assert_eq!(crate::parse_data_file(&xml, Some("form")).unwrap().len(), 1, "the export is importable");
|
||||
// XDP: the datasets inside an xdp envelope with a reference to the PDF.
|
||||
let xdp = export_data(&doc, true, "my \"form\".pdf").unwrap();
|
||||
assert!(xdp.contains("<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\">") && xdp.contains("</xdp:xdp>"), "{xdp}");
|
||||
assert!(xdp.contains("<pdf href=\"my "form".pdf\" xmlns=\"http://ns.adobe.com/xdp/pdf/\"/>"), "{xdp}");
|
||||
assert_eq!(parse_datasets(&xdp).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("JP"));
|
||||
// A form without a datasets packet exports empty data.
|
||||
let bare = Document::open(Arc::new(shell(&fields_template("")))).unwrap();
|
||||
let xml = export_data(&bare, false, "x.pdf").unwrap();
|
||||
assert!(xml.contains("<xfa:data></xfa:data>") || xml.contains("<xfa:data/>"), "{xml}");
|
||||
// A datasets packet alone (no template) still exports its data; no XFA at all is an error.
|
||||
let static_doc = Document::open(Arc::new(static_shell("<form1><page1><name>Ada</name></page1></form1>"))).unwrap();
|
||||
assert!(export_data(&static_doc, false, "s.pdf").unwrap().contains("<name>Ada</name>"));
|
||||
assert!(export_data(&static_doc, true, "s.pdf").unwrap().contains("<xfa:datasets"));
|
||||
// An XFA entry without a template or datasets exports empty data rather than failing.
|
||||
let plain = Document::open(Arc::new(shell("<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"></xdp:xdp>"))).unwrap();
|
||||
assert!(export_data(&plain, false, "p.pdf").unwrap().contains("<xfa:data"));
|
||||
// Import: each shape of file replaces the data; what no field binds to in the datasets
|
||||
// (a data description) stays.
|
||||
let with_desc = fields_template(data).replace(
|
||||
"<xfa:data>",
|
||||
"<dd:dataDescription xmlns:dd=\"http://ns.adobe.com/data-description/\" dd:name=\"form\"><form/></dd:dataDescription><xfa:data>",
|
||||
);
|
||||
let shapes = [
|
||||
format!(
|
||||
"<?xml version=\"1.0\"?>\n<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"><xfa:datasets xmlns:xfa=\"{XFA_NS}\"><xfa:data><form><page1><country>CA</country></page1></form></xfa:data></xfa:datasets></xdp:xdp>"
|
||||
),
|
||||
format!("<xfa:datasets xmlns:xfa=\"{XFA_NS}\"><xfa:data><form><page1><country>CA</country></page1></form></xfa:data></xfa:datasets>"),
|
||||
format!("\u{feff}<xfa:data xmlns:xfa=\"{XFA_NS}\"><form><page1><country>CA</country></page1></form></xfa:data>"),
|
||||
"<form><page1><country>CA</country></page1></form>".to_string(),
|
||||
];
|
||||
for file in &shapes {
|
||||
let mut doc = Document::open(Arc::new(shell(&with_desc))).unwrap();
|
||||
render_into(&mut doc).unwrap();
|
||||
let r = import_data(&mut doc, file, Some("form"), None).unwrap();
|
||||
assert_eq!(r.nodes, 3, "{file}");
|
||||
let d = crate::data_of(&doc).unwrap();
|
||||
assert_eq!(d.text_at(&som_to_path("form[0].page1[0].country[0]")), Some("CA"), "{file}");
|
||||
assert_eq!(d.text_at(&som_to_path("form[0].page1[0].langs[0]")), None, "the old data is gone: {file}");
|
||||
let xdp = export_data(&doc, true, "x.pdf").unwrap();
|
||||
assert!(xdp.contains("dataDescription"), "kept: {xdp}");
|
||||
assert!(!export_data(&doc, false, "x.pdf").unwrap().contains("dataDescription"), "the XML export is the data alone");
|
||||
// Laid out again, the form shows the imported value.
|
||||
let (t, _) = parse(&with_desc).unwrap();
|
||||
rerender(&mut doc, &t).unwrap();
|
||||
let fields = pdfcraft_forms_free_fields(&doc);
|
||||
let c = &fields.iter().find(|(n, _)| n == "country").unwrap().1;
|
||||
assert_eq!(c.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("CA"), "{file}");
|
||||
}
|
||||
// Not XFA data: refused, the document untouched.
|
||||
let mut doc = Document::open(Arc::new(shell(&fields_template(data)))).unwrap();
|
||||
render_into(&mut doc).unwrap();
|
||||
for bad in [
|
||||
"<xfdf xmlns=\"http://ns.adobe.com/xfdf/\"><fields/></xfdf>",
|
||||
"<fields><field name=\"a\"><value>1</value></field></fields>",
|
||||
"not xml at all",
|
||||
"<a><b></a>",
|
||||
] {
|
||||
assert!(import_data(&mut doc, bad, Some("form"), None).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("JP"));
|
||||
// Empty data: the data element is emptied, not removed.
|
||||
let r = import_data(&mut doc, &format!("<xfa:data xmlns:xfa=\"{XFA_NS}\"/>"), Some("form"), None).unwrap();
|
||||
assert_eq!(r.nodes, 0);
|
||||
assert!(crate::data_of(&doc).is_some());
|
||||
assert_eq!(crate::data_of(&doc).unwrap().children.len(), 0);
|
||||
// Hostile: a file over the size cap, and one with more nodes than the parser allows.
|
||||
assert!(import_data(&mut doc, &"x".repeat(17 << 20), None, None).is_err());
|
||||
let many = format!("<form>{}</form>", "<a/>".repeat(1_100_000));
|
||||
assert!(import_data(&mut doc, &many, None, None).is_err());
|
||||
// A self-closing data element opens up for an import.
|
||||
let empty = fields_template("")
|
||||
.replace("</template>", "</template><xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data/></xfa:datasets>");
|
||||
let mut doc = Document::open(Arc::new(shell(&empty))).unwrap();
|
||||
render_into(&mut doc).unwrap();
|
||||
import_data(&mut doc, "<form><page1><country>FR</country></page1></form>", Some("form"), None).unwrap();
|
||||
assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("FR"));
|
||||
}
|
||||
|
||||
const XFA_NS: &str = "http://www.xfa.org/schema/xfa-data/1.0/";
|
||||
|
||||
#[test]
|
||||
fn a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type() {
|
||||
use crate::data::write_data_image;
|
||||
let mut doc = Document::open(Arc::new(shell(&fields_template("")))).unwrap();
|
||||
render_into(&mut doc).unwrap();
|
||||
let path = som_to_path("form[0].page1[0].photo[0]");
|
||||
// No data node yet: it is made.
|
||||
let r = write_data_image(&mut doc, &path, "image/gif", TINY_GIF, None).unwrap();
|
||||
assert!(r.written, "{:?}", r.warnings);
|
||||
let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp;
|
||||
assert!(xdp.contains("<photo xfa:contentType=\"image/gif\">R0lGODlh"), "{xdp}");
|
||||
// Written again: replaced, not duplicated; a hostile content type is cleaned.
|
||||
let r = write_data_image(&mut doc, &path, "image/png\"><evil/><x a=\"", &tiny_png(), None).unwrap();
|
||||
assert!(r.written);
|
||||
let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp;
|
||||
assert_eq!(xdp.matches("<photo ").count(), 1, "{xdp}");
|
||||
assert!(xdp.contains("<photo xfa:contentType=\"image/pngevil/xa\">iVBORw0KGgo"), "{xdp}");
|
||||
assert!(!xdp.contains("<evil/>"));
|
||||
// The form shows it: laid out again, the page's picture is the PNG from the data.
|
||||
let (t, _) = parse(&fields_template("")).unwrap();
|
||||
rerender(&mut doc, &t).unwrap();
|
||||
let d = crate::data_of(&doc).unwrap();
|
||||
assert!(d.text_at(&path).unwrap().starts_with("iVBORw0KGgo"));
|
||||
// Too big is refused before anything is written.
|
||||
assert!(write_data_image(&mut doc, &path, "image/png", &vec![0u8; (8 << 20) + 1], None).is_err());
|
||||
assert!(write_data_image(&mut doc, &Vec::new(), "image/png", TINY_GIF, None).is_err());
|
||||
// The image field is a push button marked as one, with no action and nothing drawn.
|
||||
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
let fields = pdfcraft_forms_free_fields(&doc);
|
||||
let photo = &fields.iter().find(|(n, _)| n == "photo").unwrap().1;
|
||||
assert_eq!(photo.name(b"FT"), Some(b"Btn".as_slice()));
|
||||
assert_eq!(photo.int(b"Ff"), Some(1 << 16));
|
||||
assert_eq!(photo.get(b"PCXfaImage"), Some(&Object::Bool(true)));
|
||||
assert!(!photo.contains(b"A") && !photo.contains(b"MK"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_data_is_written_with_the_packets_own_prefix_and_never_leaves_it_unreadable() {
|
||||
use crate::data::{export_data, import_data, write_data_image};
|
||||
// A file binding the data namespace to another prefix, with an attribute in it.
|
||||
let file = format!(
|
||||
"<x:datasets xmlns:x=\"{XFA_NS}\"><x:data><form x:dataNode=\"dataGroup\"><page1><country>CA</country></page1></form></x:data></x:datasets>"
|
||||
);
|
||||
let mut doc = Document::open(Arc::new(shell(&fields_template("<form><page1><country>JP</country></page1></form>")))).unwrap();
|
||||
render_into(&mut doc).unwrap();
|
||||
import_data(&mut doc, &file, Some("form"), None).unwrap();
|
||||
let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp;
|
||||
assert!(xdp.contains("<form xfa:dataNode=\"dataGroup\">"), "{xdp}");
|
||||
assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("CA"));
|
||||
// An xsi:nil declared on an envelope the file's data element doesn't carry.
|
||||
let file = "<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"><xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data><form><page1><country xsi:nil=\"true\"/><pin>1</pin></page1></form></xfa:data></xfa:datasets></xdp:xdp>";
|
||||
import_data(&mut doc, file, Some("form"), None).unwrap();
|
||||
let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp;
|
||||
assert!(xdp.contains("xmlns:n0=\"http://www.w3.org/2001/XMLSchema-instance\" n0:nil=\"true\""), "{xdp}");
|
||||
assert!(crate::data_of(&doc).is_some(), "the packet still parses");
|
||||
// Our own export, from a packet using another prefix, imports back.
|
||||
let other = fields_template("<form><page1><pin>7</pin></page1></form>")
|
||||
.replace("xfa:datasets", "xd:datasets")
|
||||
.replace("xfa:data>", "xd:data>")
|
||||
.replace("xmlns:xfa=", "xmlns:xd=");
|
||||
let mut doc2 = Document::open(Arc::new(shell(&other))).unwrap();
|
||||
render_into(&mut doc2).unwrap();
|
||||
let exported = export_data(&doc2, true, "o.pdf").unwrap();
|
||||
import_data(&mut doc, &exported, Some("form"), None).unwrap();
|
||||
assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].pin[0]")), Some("7"));
|
||||
// A picture into that other-prefix packet uses its prefix.
|
||||
let path = som_to_path("form[0].page1[0].photo[0]");
|
||||
write_data_image(&mut doc2, &path, "image/gif", TINY_GIF, None).unwrap();
|
||||
let xdp = crate::read_packets(&doc2).unwrap().unwrap().xdp;
|
||||
assert!(xdp.contains("<photo xd:contentType=\"image/gif\">"), "{xdp}");
|
||||
assert!(crate::data_of(&doc2).is_some());
|
||||
// A picture where the data node is a group is refused, the group kept.
|
||||
let grouped = fields_template("<form><page1><photo><note>keep me</note></photo></page1></form>");
|
||||
let mut doc3 = Document::open(Arc::new(shell(&grouped))).unwrap();
|
||||
render_into(&mut doc3).unwrap();
|
||||
let r = write_data_image(&mut doc3, &path, "image/gif", TINY_GIF, None).unwrap();
|
||||
assert!(!r.written && r.warnings.iter().any(|w| w.contains("structured")), "{r:?}");
|
||||
assert!(crate::read_packets(&doc3).unwrap().unwrap().xdp.contains("<note>keep me</note>"));
|
||||
// A field name XML can't hold is written under the same cleaned name the values use.
|
||||
let odd = fields_template("").replace("name=\"photo\"", "name=\"Foto_Straße\"");
|
||||
let mut doc4 = Document::open(Arc::new(shell(&odd))).unwrap();
|
||||
render_into(&mut doc4).unwrap();
|
||||
let r = write_data_image(&mut doc4, &som_to_path("form[0].page1[0].Foto_Straße[0]"), "image/gif", TINY_GIF, None).unwrap();
|
||||
assert!(r.written, "{r:?}");
|
||||
let xdp = crate::read_packets(&doc4).unwrap().unwrap().xdp;
|
||||
assert_eq!(xdp.matches("<Foto_Stra_e").count(), 1, "{xdp}");
|
||||
assert!(xdp.contains("<Foto_Stra_e xfa:contentType=\"image/gif\">R0lG"), "{xdp}");
|
||||
// What is not this form's data is refused: an SVG, an XDP without datasets, an XDP whose
|
||||
// config has a <data> element before the datasets, a data document with another root.
|
||||
let mut doc = Document::open(Arc::new(shell(&fields_template("<form><page1><country>JP</country></page1></form>")))).unwrap();
|
||||
render_into(&mut doc).unwrap();
|
||||
for bad in [
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>".to_string(),
|
||||
"<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"><template/></xdp:xdp>".to_string(),
|
||||
"<other><page1><country>CA</country></page1></other>".to_string(),
|
||||
format!("<x:datasets xmlns:x=\"{XFA_NS}\"><x:data><form><page1><country>CA</country></page1></form></x:data></x:datasets>")
|
||||
.replace("x:data", "x:nodata"),
|
||||
] {
|
||||
assert!(import_data(&mut doc, &bad, Some("form"), None).is_err(), "{bad}");
|
||||
}
|
||||
let config_first = format!(
|
||||
"<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"><config xmlns=\"http://www.xfa.org/schema/xci/3.0/\"><present><data>junk</data></present></config><xfa:datasets xmlns:xfa=\"{XFA_NS}\"><xfa:data><form><page1><country>CA</country></page1></form></xfa:data></xfa:datasets></xdp:xdp>"
|
||||
);
|
||||
import_data(&mut doc, &config_first, Some("form"), None).unwrap();
|
||||
assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("CA"));
|
||||
// Deep nesting is refused before it is parsed; a data path deeper than the data limit too.
|
||||
let deep = format!("{}x{}", "<a>".repeat(20_000), "</a>".repeat(20_000));
|
||||
assert!(import_data(&mut doc, &deep, None, None).is_err());
|
||||
let deepish = format!("<form>{}x{}</form>", "<a>".repeat(100), "</a>".repeat(100));
|
||||
assert!(import_data(&mut doc, &deepish, Some("form"), None).is_err());
|
||||
assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("CA"), "untouched");
|
||||
// The data holds more rows than are laid out: said, not hidden.
|
||||
let rows: String = (0..60).map(|i| format!("<row><item>r{i}</item><amount>1</amount></row>")).collect();
|
||||
let many = scripted_template().replace(
|
||||
"</template>",
|
||||
&format!(
|
||||
"</template><xfa:datasets xmlns:xfa=\"{XFA_NS}\"><xfa:data><form><page1><table>{rows}</table></page1></form></xfa:data></xfa:datasets>"
|
||||
),
|
||||
);
|
||||
let form = layout_xml(&many).unwrap();
|
||||
assert!(form.warnings.iter().any(|w| w.contains("60 rows") && w.contains("first 50")), "{:?}", form.warnings);
|
||||
}
|
||||
@@ -41,6 +41,9 @@ pub enum Format {
|
||||
Xml,
|
||||
Csv,
|
||||
Txt,
|
||||
/// An XFA form's data as an XML Data Package (the engine writes it; for a form without
|
||||
/// XFA packets it is the XML format).
|
||||
Xdp,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
@@ -49,6 +52,7 @@ impl Format {
|
||||
"xfdf" => Format::Xfdf,
|
||||
"fdf" => Format::Fdf,
|
||||
"xml" => Format::Xml,
|
||||
"xdp" => Format::Xdp,
|
||||
"csv" => Format::Csv,
|
||||
"txt" | "tab" | "tsv" => Format::Txt,
|
||||
_ => return None,
|
||||
@@ -577,7 +581,7 @@ pub fn export_data(doc: &Document, format: Format) -> String {
|
||||
})
|
||||
.collect();
|
||||
match format {
|
||||
Format::Xml => {
|
||||
Format::Xml | Format::Xdp => {
|
||||
let mut out = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<fields xmlns:xfdf=\"http://ns.adobe.com/xfdf-transition/\">\n");
|
||||
for (k, v) in &fields {
|
||||
let tag = xml_name(k);
|
||||
|
||||
@@ -4639,7 +4639,7 @@ status = "shipped"
|
||||
commands = ["form.import_data"]
|
||||
tools = ["doc_import_data"]
|
||||
evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
|
||||
notes = "XFDF, FDF; values go through formats and validation; read-only fields take data"
|
||||
notes = "XFDF, FDF; values go through formats and validation; read-only fields take data; XFA forms also take XDP/XML data files (form.xfa-data)"
|
||||
|
||||
[[feature]]
|
||||
id = "form.export-data"
|
||||
@@ -4651,7 +4651,7 @@ status = "shipped"
|
||||
commands = ["form.export_data"]
|
||||
tools = ["doc_export_data"]
|
||||
evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
|
||||
notes = "XFDF, FDF"
|
||||
notes = "XFDF, FDF; XFA forms also export their data as XML or XDP (form.xfa-data)"
|
||||
|
||||
[[feature]]
|
||||
id = "form.data-xml-csv-txt"
|
||||
@@ -5200,10 +5200,10 @@ area = "F"
|
||||
tier = "P2"
|
||||
milestone = "M12"
|
||||
status = "partial"
|
||||
commands = ["file.open", "file.save"]
|
||||
tools = ["doc_open", "form_fill", "doc_save"]
|
||||
evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them"]
|
||||
notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Missing: importing or exporting a standalone XML/XDP data file, explicit bind refs, data descriptions"
|
||||
commands = ["file.open", "file.save", "form.export_data", "form.import_data"]
|
||||
tools = ["doc_open", "form_fill", "doc_save", "doc_export_data", "doc_import_data", "form_set_image"]
|
||||
evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/automation.rs::xfa_data_files_export_and_import_through_tools"]
|
||||
notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed, more than 50 rows reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)"
|
||||
|
||||
[[feature]]
|
||||
id = "form.xfa-flatten"
|
||||
|
||||
Reference in new issue
Block a user