* M12: lay dynamic XFA forms out from their template (#60) A dynamic XFA form is a PDF shell around an XML template: one placeholder page ("requires Adobe Reader") and /NeedsRendering true. Every viewer without an XFA engine shows the placeholder, and so did PrintCraft. New crate printcraft-xfa (L3, already reserved in the layering table). It reads the XDP packets, parses the template (XFA 3.3: subforms, areas, fields, draws, exclusion groups, page sets, fonts, paragraphs, borders, captions, rich text, inline JPEGs, occurrences, breaks, overflow leaders, column widths), lays it out (positioned, tb, lr-tb, table/row; containers without a width size to their content; flow across content areas and page masters; header rows repeat after a break; hidden takes no space; page number and count fields resolve) and writes real pages and AcroForm widgets into the document: text, date (AFDate format and keystroke actions from the picture clause), check boxes, radio groups, push buttons with reset/print/save/URI actions. Standard-14 fonts stand in for Arial, Courier New and friends; nothing is embedded. The engine lays a dynamic form out when it opens it. The original bytes stay: the layout is one appended revision, the widgets get appearances from the forms crate, and the XFA packets remain so Adobe's viewers keep rendering the form their way. A marker on the AcroForm (/PCXfaLayout) stops a saved form from being laid out twice; each field keeps its SOM path (/PCSom) for the data round trip to come. doc_info, the CLI's info and the notice bar report the layout. Checked on IRCC's IMM 5669 (Schedule A, Designer 6.2, 464 KB template, AES-encrypted, usage rights): four pages and 308 fields in 27 ms, the same page structure as the form, filled through form_fill, saved in place with the original bytes intact and reopened with the values. Not done, and said so in the parity notes: the datasets packet is neither read nor written, so Adobe's viewers don't show values filled in here; no FormCalc or XFA JavaScript (rows don't grow, nothing calculates); choice lists are text fields; signature, image and barcode fields are blank; metrics are approximate and fidelity is eyeballed, not measured. Tests: crates/xfa (parsing, sizing, pagination with repeated headers, PDF writing, hostile input), the engine (open, fill, incremental save, reopen without a second layout), the automation tools (open, info, render, fill, save, reopen) and the UI notice (kittest). * M12: XFA forms read and write their datasets packet; static forms too (#60) With the layout alone, a form filled in PrintCraft opened blank in Adobe Reader, and one filled in Acrobat opened blank here: Adobe's viewers keep the values in the XFA datasets packet, not in the AcroForm fields. The xfa crate's new data module reads the datasets packet (normal binding: a field's node is named after it under its named ancestor subforms, repeated instances as repeated siblings) and writes it back from the fields. Laying a form out now takes each field's value from its data node (dates in ISO form shown in the field's pattern, check boxes and radio groups by their on values) and gives a repeating subform or table row as many instances as the data has, so rows another viewer added appear. Every laid-out field carries its SOM path and its on/off items, so the packet can be rebuilt from the AcroForm at any time. The engine writes the datasets after every form edit (one revision with the field change), and on open applies the datasets to the fields of a static XFA form, or of a dynamic one laid out earlier, when another viewer changed them since. The static-form notice now says the two are kept in step instead of warning that Acrobat may disagree. Checked on IRCC's IMM 5669: fill, save in place (original bytes intact), and the file holds <IMM_5669><page1><familyName>SINGH</familyName>…, the structure Acrobat writes for this form; reopening shows the values. Not done: explicit bind refs, global binding and data descriptions are not followed; no import or export of standalone XML or XDP data files. * M12: XFA layout memoizes container sizes, bounding measurement work (#60) A container without a width measured its content for its width and again for its height at every level, so nested width-less subforms took 2^depth measurements, all synchronously in Session::open. Container sizes are now cached per (node, width, depth), the root and page-area items keep stable addresses instead of being cloned per measurement, and a measurement budget turns anything left into TooLarge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M12: rustfmt the merged XFA notice (#60) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M12: XFA datasets are merged into, not replaced; capped; UTF-16 kept; rewrites recorded (#60) - write_datasets merged nothing: it replaced <xfa:data> with the AcroForm-bound values alone, dropping unbound, namespaced and deep data. It now edits the existing packet text in place: a bound node's text is replaced only when it differs, missing nodes are added below the deepest existing one, and everything else (unbound data, other namespaces, attributes, comments, layout) stays byte for byte. A packet of an XFA array that relies on the envelope's prefixes is read inside a wrapper declaring them. - What can't be written (a node holding structured content, too-deep paths or absurd SOM indices, conflicting fields, an unreadable or non-UTF packet) comes back as DatasetsWrite.warnings; the engine keeps them in Document.xfa_warnings and info.warnings (surviving re-reads), and doc_info reports xfa_warnings. - Building data counted same-name siblings for every one it added (quadratic in an untrusted index) with no total cap. Children are now indexed by name, indices above 10,000 and paths deeper than 64 are refused, and one write creates at most 20,000 nodes. - Packets are decoded exactly (UTF-8, UTF-16 LE/BE with or without BOM) and written back in the encoding they came in; anything else is reported and left untouched instead of being rewritten from a lossy UTF-8 reading. - A static XFA form whose fields are rewritten from the datasets on open now says so in info.warnings, naming the fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0bbba8cafa
commit
42ddb2d546
|
|
@ -3488,6 +3488,7 @@ dependencies = [
|
|||
"pdfcraft-engine",
|
||||
"pdfcraft-organize",
|
||||
"pdfcraft-render",
|
||||
"pdfcraft-xfa",
|
||||
"png 0.18.1",
|
||||
"serde_json",
|
||||
]
|
||||
|
|
@ -3591,6 +3592,7 @@ dependencies = [
|
|||
"pdfcraft-redact",
|
||||
"pdfcraft-render",
|
||||
"pdfcraft-sign",
|
||||
"pdfcraft-xfa",
|
||||
"pdfcraft-xfdf",
|
||||
"png 0.18.1",
|
||||
"thiserror 2.0.21",
|
||||
|
|
@ -3771,6 +3773,7 @@ dependencies = [
|
|||
"pdfcraft-fonts",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-render",
|
||||
"pdfcraft-xfa",
|
||||
"png 0.18.1",
|
||||
"pollster 1.0.1",
|
||||
"rfd",
|
||||
|
|
@ -3794,6 +3797,17 @@ dependencies = [
|
|||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfa"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"roxmltree",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfdf"
|
||||
version = "0.2.1"
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ pdfcraft-export = { path = "crates/export", version = "0.2.0" }
|
|||
pdfcraft-edit = { path = "crates/edit", version = "0.2.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.2.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.2.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.2.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.2.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.2.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.2.0" }
|
||||
|
|
|
|||
|
|
@ -105,7 +105,18 @@ fn read(path: &str) -> Result<Arc<Vec<u8>>, String> {
|
|||
|
||||
fn info(args: &[String]) -> Result<(), String> {
|
||||
let path = *positional(args).first().ok_or("info: missing file")?;
|
||||
let info = inspect(read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
|
||||
let bytes = read(path)?;
|
||||
let password = flag(args, "--password");
|
||||
let info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?;
|
||||
// Dynamic XFA forms: say what laying the template out gives (what the app shows).
|
||||
let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic))
|
||||
.then(|| {
|
||||
let mut s = pdfcraft_engine::Session::new();
|
||||
let id = s.open("info.pdf", None, bytes.clone(), password).ok()?;
|
||||
let d = s.get(id)?;
|
||||
d.xfa.as_ref().map(|x| serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }))
|
||||
})
|
||||
.flatten();
|
||||
let json = serde_json::json!({
|
||||
"file": path,
|
||||
"pdf_version": info.pdf_version,
|
||||
|
|
@ -115,6 +126,7 @@ fn info(args: &[String]) -> Result<(), String> {
|
|||
"title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator,
|
||||
"encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript,
|
||||
"xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }),
|
||||
"xfa_layout": xfa_layout,
|
||||
"bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(),
|
||||
"links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(),
|
||||
"page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::<Vec<_>>(),
|
||||
|
|
|
|||
|
|
@ -23,4 +23,5 @@ default = ["mcp"]
|
|||
mcp = ["dep:base64"]
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
|
|
|
|||
|
|
@ -1558,8 +1558,12 @@ fn info(d: &Document) -> Value {
|
|||
"creator": i.creator, "producer": i.producer,
|
||||
"tagged": i.tagged,
|
||||
"has_javascript": i.has_javascript,
|
||||
// XFA forms aren't read yet: "static" (fields work, XFA data ignored) or "dynamic" (placeholder pages).
|
||||
// XFA forms: "static" (the PDF's own fields work; the XFA data is ignored) or "dynamic"
|
||||
// (laid out from the template by PdfCraft, see xfa_layout; placeholder pages when that failed).
|
||||
"xfa": i.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }),
|
||||
"xfa_layout": d.xfa.as_ref().map(|x| json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })),
|
||||
// What was rewritten from, or could not be written to, the XFA data.
|
||||
"xfa_warnings": d.xfa_warnings,
|
||||
"security": security,
|
||||
"pages": i.pages.iter().enumerate().map(|(n, p)| json!({
|
||||
"page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation,
|
||||
|
|
|
|||
|
|
@ -1830,6 +1830,43 @@ fn exporting_to_word_html_and_rtf() {
|
|||
assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "a.xyz" })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_xfa_forms_open_render_fill_and_save_through_tools() {
|
||||
let dir = workdir("xfa");
|
||||
std::fs::write(dir.join("xfa.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(2))).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let opened = ok(&mut a, "doc_open", json!({ "path": "xfa.pdf" }));
|
||||
assert_eq!(opened["pages"], 2, "laid out from the template, not the placeholder page");
|
||||
let doc = opened["doc"].as_u64().unwrap();
|
||||
let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
|
||||
assert_eq!(info["xfa"], "dynamic");
|
||||
assert_eq!(info["xfa_layout"]["pages"], 2);
|
||||
assert_eq!(info["xfa_layout"]["fields"], 11);
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let family = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "familyName").expect("familyName");
|
||||
assert_eq!((family["type"].as_str(), family["tooltip"].as_str(), family["page"].as_u64()), (Some("text"), Some("Your family name"), Some(1)));
|
||||
let answer = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "answer").expect("radio group");
|
||||
assert_eq!(answer["options"], json!(["Y", "N"]));
|
||||
// The page renders with the widgets' own appearances: the check box's border is drawn.
|
||||
let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 36 })).unwrap();
|
||||
let Content::Png { data, .. } = &png[0] else { panic!("expected an image") };
|
||||
let decoder = png::Decoder::new(std::io::Cursor::new(data.as_slice()));
|
||||
let mut reader = decoder.read_info().unwrap();
|
||||
let mut buf = vec![0; reader.output_buffer_size().unwrap()];
|
||||
reader.next_frame(&mut buf).unwrap();
|
||||
assert!(buf.iter().filter(|b| **b < 128).count() > 200, "the page is not blank");
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "familyName": "Singh", "agree": true, "answer": "N", "born": "2001-02-03" } }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" }));
|
||||
ok(&mut a, "doc_close", json!({ "doc": doc }));
|
||||
let reopened = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }));
|
||||
assert_eq!(reopened["pages"], 2, "not laid out twice");
|
||||
let doc2 = reopened["doc"].as_u64().unwrap();
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc2 }));
|
||||
let by = |n: &str| fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == n).unwrap()["value"].clone();
|
||||
assert_eq!((by("familyName"), by("agree"), by("answer"), by("born")), (json!("Singh"), json!(true), json!("N"), json!("2001-02-03")));
|
||||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc2 }))["xfa_layout"]["pages"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_stack_printing_through_tools() {
|
||||
let dir = workdir("cut-stack");
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ pdfcraft-cos = { workspace = true }
|
|||
pdfcraft-organize = { workspace = true }
|
||||
pdfcraft-annot = { workspace = true }
|
||||
pdfcraft-forms = { workspace = true }
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
pdfcraft-edit = { workspace = true }
|
||||
pdfcraft-redact = { workspace = true }
|
||||
pdfcraft-print = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ pub use pdfcraft_forms::{
|
|||
|
||||
pub use pdfcraft_a11y as a11y;
|
||||
pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine};
|
||||
pub use pdfcraft_xfa::Report as XfaLayout;
|
||||
|
||||
/// A change to an existing page image.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
|
@ -216,6 +217,12 @@ pub struct Document {
|
|||
config: RenderConfig,
|
||||
/// What field scripts printed or asked for (see [`Session::take_js_output`]).
|
||||
js_output: js::JsOutput,
|
||||
/// Dynamic XFA forms: what laying the template out produced (pages and fields are
|
||||
/// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves).
|
||||
pub xfa: Option<XfaLayout>,
|
||||
/// XFA forms: what was approximated, rewritten or could not be written to the XFA data
|
||||
/// (also in `info.warnings`, kept there when the document is re-read).
|
||||
pub xfa_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
|
|
@ -1699,6 +1706,74 @@ pub struct Session {
|
|||
js_off: bool,
|
||||
}
|
||||
|
||||
/// 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 report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?;
|
||||
for f in pdfcraft_forms::fields(doc) {
|
||||
pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?;
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn xfa_sync_datasets(doc: &mut pdfcraft_cos::Document) -> Result<Vec<String>, String> {
|
||||
let data = xfa_field_data(doc);
|
||||
pdfcraft_xfa::write_datasets(doc, &data).map(|r| r.warnings).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
let mut changed = Vec::new();
|
||||
for (name, value) in pdfcraft_xfa::read_values(doc, &data) {
|
||||
let Some(f) = fields.iter().find(|f| f.name == name) else { continue };
|
||||
let new = match value {
|
||||
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
|
||||
&& pdfcraft_forms::set_value(doc, &name, &v).is_ok()
|
||||
{
|
||||
changed.push(name);
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Validate the signature fields of `cos` (written as `bytes`).
|
||||
fn signatures_of(cos: &pdfcraft_cos::Document, bytes: &[u8], trust: &TrustStore, cache: &pdfcraft_sign::DigestCache) -> Arc<Vec<SignatureInfo>> {
|
||||
Arc::new(pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache))
|
||||
|
|
@ -1785,6 +1860,91 @@ impl Session {
|
|||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
// A dynamic XFA form is a shell around an XML template; lay the template out into real
|
||||
// pages and fields so the rest of the engine works on it. The original bytes stay: the
|
||||
// laid-out form is one appended revision.
|
||||
let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() };
|
||||
// Write `work` as one more revision and reopen it: what the document then is.
|
||||
let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc<Vec<u8>>, DocInfo, pdfcraft_cos::Document), String> {
|
||||
let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?;
|
||||
let new_info = inspect(new_bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?;
|
||||
let new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?;
|
||||
Ok((new_bytes, new_info, new_cos))
|
||||
};
|
||||
let (bytes, info, cos, xfa) = match (info.xfa, cos) {
|
||||
(Some(pdfcraft_render::Xfa::Dynamic), Ok(Ok(cos))) if pdfcraft_xfa::existing_layout(&cos).is_none() => {
|
||||
let mut work = cos.clone();
|
||||
let laid_out = guard(|| xfa_layout(&mut work))
|
||||
.unwrap_or_else(|m| Err(format!("laying it out failed unexpectedly ({m})")))
|
||||
.and_then(|report| rebase(&work).map(|(b, i, c)| (b, i, c, report)));
|
||||
match laid_out {
|
||||
Ok((b, i, c, report)) => (b, i, Ok(Ok(c)), Some(report)),
|
||||
Err(e) => {
|
||||
let mut info = info;
|
||||
info.warnings.push(format!("This dynamic XFA form could not be laid out: {e}"));
|
||||
(bytes, info, Ok(Ok(cos)), None)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A static XFA form, or a dynamic one laid out earlier: its datasets may hold values
|
||||
// filled in by another viewer since; give the fields those values.
|
||||
(Some(_), Ok(Ok(cos))) => {
|
||||
let report = pdfcraft_xfa::existing_layout(&cos);
|
||||
let mut work = cos.clone();
|
||||
let synced =
|
||||
guard(|| xfa_values_from_datasets(&mut work)).unwrap_or_else(|m| Err(format!("reading its data failed unexpectedly ({m})")));
|
||||
match synced {
|
||||
Ok(names) if names.is_empty() => (bytes, info, Ok(Ok(cos)), report),
|
||||
Ok(names) => match rebase(&work) {
|
||||
Ok((b, mut i, c)) => {
|
||||
// The fields were rewritten from the XFA data: say so, and which.
|
||||
let shown: Vec<&str> = names.iter().take(5).map(String::as_str).collect();
|
||||
let more = if names.len() > shown.len() { format!(" and {} more", names.len() - shown.len()) } else { String::new() };
|
||||
i.warnings.push(format!(
|
||||
"{} form field value{} were taken from this form's XFA data (filled in by another viewer): {}{more}",
|
||||
names.len(),
|
||||
if names.len() == 1 { "" } else { "s" },
|
||||
shown.join(", ")
|
||||
));
|
||||
(b, i, Ok(Ok(c)), report)
|
||||
}
|
||||
Err(e) => {
|
||||
let mut info = info;
|
||||
info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}"));
|
||||
(bytes, info, Ok(Ok(cos)), report)
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let mut info = info;
|
||||
info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}"));
|
||||
(bytes, info, Ok(Ok(cos)), report)
|
||||
}
|
||||
}
|
||||
}
|
||||
(_, cos) => (bytes, info, cos, None),
|
||||
};
|
||||
// XFA notes survive the document being re-read after edits.
|
||||
let xfa_warnings: Vec<String> = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() };
|
||||
let id = self.push_document(name, path, bytes, info, cos, render_password, password, xfa)?;
|
||||
if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) {
|
||||
note_warnings(&mut d.xfa_warnings, &xfa_warnings);
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// The last step of opening: build the document record and register it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn push_document(
|
||||
&mut self,
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
info: DocInfo,
|
||||
cos: Result<Result<pdfcraft_cos::Document, pdfcraft_cos::CosError>, Box<dyn std::any::Any + Send>>,
|
||||
render_password: Option<String>,
|
||||
password: Option<&str>,
|
||||
xfa: Option<XfaLayout>,
|
||||
) -> Result<DocId, OpenError> {
|
||||
let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() };
|
||||
let renderer = RenderPool::new(bytes.clone(), render_threads(), config.clone());
|
||||
let (editor, read_only_reason) = match cos {
|
||||
|
|
@ -1825,6 +1985,8 @@ impl Session {
|
|||
editor,
|
||||
config,
|
||||
js_output: Default::default(),
|
||||
xfa,
|
||||
xfa_warnings: Vec::new(),
|
||||
});
|
||||
Ok(id)
|
||||
}
|
||||
|
|
@ -1840,6 +2002,7 @@ impl Session {
|
|||
let js_off = self.js_off;
|
||||
let doc = self.doc_mut(id)?;
|
||||
let name = doc.name.clone();
|
||||
let is_xfa = doc.info.xfa.is_some();
|
||||
let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48));
|
||||
cx.today = today;
|
||||
let reason = doc.read_only_reason.clone().unwrap_or_default();
|
||||
|
|
@ -1855,6 +2018,13 @@ impl Session {
|
|||
// `next` is a copy: if the edit fails or crashes, the document is unchanged.
|
||||
guard(|| run_edit(&mut next, &edit, &mut cx))
|
||||
.unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?;
|
||||
// XFA forms keep their values in the datasets packet too, for Adobe's viewers.
|
||||
let mut xfa_notes = Vec::new();
|
||||
if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full) {
|
||||
xfa_notes = guard(|| xfa_sync_datasets(&mut next))
|
||||
.unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})")))
|
||||
.map_err(EditError::Write)?;
|
||||
}
|
||||
// Signed documents are only ever saved incrementally: an edit that needs a full rewrite
|
||||
// (applying redactions, changing security, sanitizing) would invalidate the signatures.
|
||||
let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed();
|
||||
|
|
@ -1886,6 +2056,9 @@ impl Session {
|
|||
}
|
||||
doc.dirty = true;
|
||||
doc.generation += 1;
|
||||
note_warnings(&mut doc.xfa_warnings, &xfa_notes);
|
||||
let notes = doc.xfa_warnings.clone();
|
||||
note_warnings(&mut doc.info.warnings, ¬es);
|
||||
if let Some(js) = cx.js {
|
||||
doc.js_output.append(js.output);
|
||||
}
|
||||
|
|
@ -1986,6 +2159,7 @@ impl Session {
|
|||
l.visible = old.visible;
|
||||
}
|
||||
}
|
||||
note_warnings(&mut info.warnings, &doc.xfa_warnings);
|
||||
doc.info = info;
|
||||
doc.form = Arc::new(pdfcraft_forms::fields(&editor.cos));
|
||||
doc.marks = pdfcraft_edit::marks_present(&editor.cos);
|
||||
|
|
|
|||
|
|
@ -1410,3 +1410,130 @@ fn guard_turns_a_panic_into_an_error() {
|
|||
let n = 3;
|
||||
assert_eq!(guard(|| -> u8 { panic!("page {n} is bad") }), Err("page 3 is bad".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally() {
|
||||
let original = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(3)));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("form.pdf", None, original.clone(), None).expect("opens");
|
||||
let doc = s.get(id).unwrap();
|
||||
let x = doc.xfa.as_ref().expect("laid out from the template");
|
||||
assert_eq!((x.pages, x.fields), (2, 14), "{x:?}");
|
||||
assert!(x.warnings.is_empty(), "{:?}", x.warnings);
|
||||
assert_eq!(doc.info.pages.len(), 2, "the placeholder page is gone");
|
||||
assert_eq!(doc.info.xfa, Some(pdfcraft_render::Xfa::Dynamic), "still an XFA form for Adobe's viewers");
|
||||
assert!(!doc.dirty, "laying out is not an edit");
|
||||
assert!(doc.bytes.starts_with(&original[..]), "the layout is an appended revision; the original bytes stay");
|
||||
let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(names.contains(&"familyName") && names.contains(&"answer") && names.contains(&"what2"), "{names:?}");
|
||||
let family = doc.form.iter().find(|f| f.name == "familyName").unwrap();
|
||||
assert_eq!((family.tooltip.as_deref(), family.max_len), (Some("Your family name"), Some(30)));
|
||||
let answer = doc.form.iter().find(|f| f.name == "answer").unwrap();
|
||||
assert_eq!(answer.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["Y", "N"]);
|
||||
assert!(page_texts(&s, id)[0].contains("Sample Form"), "{:?}", page_texts(&s, id));
|
||||
// Fill it in and save: one more incremental revision on top.
|
||||
s.apply(id, Edit::SetFieldValue { name: "familyName".into(), value: FieldValue::Text("Singh".into()) }).unwrap();
|
||||
s.apply(id, Edit::SetFieldValue { name: "answer".into(), value: FieldValue::Radio(Some("N".into())) }).unwrap();
|
||||
assert!(s.get(id).unwrap().dirty);
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
assert!(saved.starts_with(&original[..]));
|
||||
// The datasets packet carries the values too, for Adobe's viewers.
|
||||
let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap();
|
||||
let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).expect("a datasets packet");
|
||||
assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].head[0].familyName[0]")), Some("Singh"));
|
||||
assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].head[0].answer[0]")), Some("N"));
|
||||
let id2 = s.open("saved.pdf", None, saved, None).unwrap();
|
||||
let d2 = s.get(id2).unwrap();
|
||||
assert_eq!(d2.info.pages.len(), 2, "a saved form is not laid out a second time");
|
||||
assert_eq!(d2.xfa.as_ref().map(|x| (x.pages, x.fields)), Some((2, 14)), "the layout is remembered");
|
||||
assert_eq!(d2.form.iter().find(|f| f.name == "familyName").unwrap().value, vec!["Singh".to_string()]);
|
||||
assert_eq!(d2.form.iter().find(|f| f.name == "answer").unwrap().value, vec!["N".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_xfa_form_without_a_template_opens_with_its_placeholder_and_a_warning() {
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell("<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"></xdp:xdp>"));
|
||||
let mut s = Session::new();
|
||||
let id = s.open("form.pdf", None, bytes, None).expect("opens");
|
||||
let doc = s.get(id).unwrap();
|
||||
assert!(doc.xfa.is_none());
|
||||
assert_eq!(doc.info.pages.len(), 1);
|
||||
assert!(doc.info.warnings.iter().any(|w| w.contains("could not be laid out") && w.contains("no template")), "{:?}", doc.info.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_xfa_forms_open_with_the_values_and_rows_of_their_data() {
|
||||
let data = "<form><head><familyName>Kaur</familyName><born>2001-02-03</born><answer>Y</answer></head><table><row><what0>a</what0></row><row><what0>b</what0></row><row><what0>c</what0></row></table></form>";
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template_with_data(1, data)));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("form.pdf", None, bytes, None).expect("opens");
|
||||
let doc = s.get(id).unwrap();
|
||||
let value = |n: &str| doc.form.iter().find(|f| f.name == n).unwrap_or_else(|| panic!("no field {n}")).value.clone();
|
||||
assert_eq!(value("familyName"), vec!["Kaur".to_string()]);
|
||||
assert_eq!(value("born"), vec!["2001-02-03".to_string()]);
|
||||
assert_eq!(value("answer"), vec!["Y".to_string()]);
|
||||
assert_eq!(value("what0_3"), vec!["c".to_string()], "three rows from the data: {:?}", doc.form.iter().map(|f| &f.name).collect::<Vec<_>>());
|
||||
assert!(!doc.dirty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back() {
|
||||
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.clone(), None).expect("opens");
|
||||
let doc = s.get(id).unwrap();
|
||||
assert_eq!(doc.info.xfa, Some(pdfcraft_render::Xfa::Static));
|
||||
assert!(doc.xfa.is_none(), "nothing was laid out");
|
||||
assert_eq!(doc.form.iter().find(|f| f.name == "form1[0].page1[0].name[0]").unwrap().value, vec!["Ada".to_string()]);
|
||||
assert_eq!(doc.form.iter().find(|f| f.name == "form1[0].page1[0].agree[0]").unwrap().value, vec!["1".to_string()]);
|
||||
assert!(!doc.dirty && doc.bytes.starts_with(&original[..]), "the values are an appended revision");
|
||||
// Rewriting the fields from the XFA data is recorded, not silent.
|
||||
let note = doc.info.warnings.iter().find(|w| w.contains("taken from this form's XFA data")).expect("a warning");
|
||||
assert!(note.starts_with("2 form field values") && note.contains("form1[0].page1[0].name[0]"), "{note}");
|
||||
s.apply(id, Edit::SetFieldValue { name: "form1[0].page1[0].name[0]".into(), value: FieldValue::Text("Grace".into()) }).unwrap();
|
||||
s.apply(id, Edit::SetFieldValue { name: "form1[0].page1[0].agree[0]".into(), value: FieldValue::Check(false) }).unwrap();
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap();
|
||||
let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).unwrap();
|
||||
assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].name[0]")), Some("Grace"));
|
||||
assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].agree[0]")), Some(""), "unchecked, with no off value");
|
||||
// Reopening agrees with itself: nothing to apply, nothing dirty.
|
||||
let id2 = s.open("again.pdf", None, saved, None).unwrap();
|
||||
let d2 = s.get(id2).unwrap();
|
||||
assert_eq!(d2.form.iter().find(|f| f.name == "form1[0].page1[0].name[0]").unwrap().value, vec!["Grace".to_string()]);
|
||||
assert!(!d2.dirty);
|
||||
assert!(!d2.info.warnings.iter().any(|w| w.contains("taken from this form's XFA data")), "nothing was rewritten");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xfa_data_no_field_binds_to_survives_an_edit_and_save() {
|
||||
// Regression: editing a field replaced the whole data element with the fields' values.
|
||||
let data = concat!(
|
||||
"<form1><page1><name>Ada</name><agree>1</agree><extra xmlns:my=\"urn:my\"><my:note id=\"7\">keep</my:note></extra></page1>",
|
||||
"<history><entry><when>2020</when></entry></history></form1><my:other xmlns:my=\"urn:my\"><my:x>unbound</my:x></my:other>"
|
||||
);
|
||||
let original = Arc::new(pdfcraft_xfa::fixtures::static_shell(data));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("static.pdf", None, original, None).expect("opens");
|
||||
s.apply(id, Edit::SetFieldValue { name: "form1[0].page1[0].name[0]".into(), value: FieldValue::Text("Grace".into()) }).unwrap();
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap();
|
||||
let xdp = pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp;
|
||||
for kept in [
|
||||
"<extra xmlns:my=\"urn:my\"><my:note id=\"7\">keep</my:note></extra>",
|
||||
"<history><entry><when>2020</when></entry></history>",
|
||||
"<my:other xmlns:my=\"urn:my\"><my:x>unbound</my:x></my:other>",
|
||||
] {
|
||||
assert!(xdp.contains(kept), "lost {kept:?}: {xdp}");
|
||||
}
|
||||
let data = pdfcraft_xfa::parse_datasets(&xdp).unwrap();
|
||||
assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].name[0]")), Some("Grace"));
|
||||
assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].agree[0]")), Some("1"));
|
||||
// A value that can't be written is reported on the document.
|
||||
let original = Arc::new(pdfcraft_xfa::fixtures::static_shell("<form1><page1><name><b>rich</b></name></page1></form1>"));
|
||||
let id = s.open("rich.pdf", None, original, None).expect("opens");
|
||||
s.apply(id, Edit::SetFieldValue { name: "form1[0].page1[0].name[0]".into(), value: FieldValue::Text("plain".into()) }).unwrap();
|
||||
let doc = s.get(id).unwrap();
|
||||
assert!(doc.info.warnings.iter().any(|w| w.contains("structured content")), "{:?}", doc.info.warnings);
|
||||
assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ js-sys = "0.3"
|
|||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
egui_kittest = { workspace = true, features = ["eframe", "wgpu"] }
|
||||
|
|
|
|||
|
|
@ -935,7 +935,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
|||
// Opened without the owner password and something is restricted.
|
||||
let secured = doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble())));
|
||||
let repaired = !doc.repair_log().is_empty();
|
||||
match notices(view, info, secured, repaired, crate::sign_ui::banner(&doc.signatures), ui, &t) {
|
||||
match notices(view, doc, secured, repaired, crate::sign_ui::banner(&doc.signatures), ui, &t) {
|
||||
Some(Notice::Repairs) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Advanced)),
|
||||
Some(Notice::Security) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Security)),
|
||||
Some(Notice::Signatures) => app.right = Some(RightPanel::Signatures),
|
||||
|
|
@ -1905,13 +1905,15 @@ enum Notice {
|
|||
/// security, forms and warnings.
|
||||
fn notices(
|
||||
view: &mut DocView,
|
||||
info: &DocInfo,
|
||||
doc: &pdfcraft_engine::Document,
|
||||
secured: bool,
|
||||
repaired: bool,
|
||||
signed: Option<(&str, Color32, &str, String)>,
|
||||
ui: &mut egui::Ui,
|
||||
t: &Tokens,
|
||||
) -> Option<Notice> {
|
||||
let info = &doc.info;
|
||||
let xfa = doc.xfa.as_ref();
|
||||
if let Some((icon, color, template, arg)) = signed {
|
||||
let mut open = false;
|
||||
egui::Frame::NONE.fill(t.accent_soft).inner_margin(egui::Margin::symmetric(14, 7)).show(ui, |ui| {
|
||||
|
|
@ -1934,6 +1936,18 @@ fn notices(
|
|||
let mut open_repairs = false;
|
||||
let msg = if secured {
|
||||
Some(("lock", tl!("This document is secured. Some changes are restricted by its security settings.").to_string(), false))
|
||||
} else if let Some(x) = xfa {
|
||||
let lang = crate::i18n::current();
|
||||
let pages = crate::i18n::trn(lang, x.pages as u64, "{n} page", "{n} pages");
|
||||
let fields = crate::i18n::trn(lang, x.fields as u64, "{n} field", "{n} fields");
|
||||
let mut text =
|
||||
crate::i18n::fmt(tl!("Dynamic XFA form laid out from its template: {pages}, {fields}."), &[("pages", &pages), ("fields", &fields)]);
|
||||
if !x.warnings.is_empty() {
|
||||
text.push(' ');
|
||||
text.push_str(&x.warnings.join("; "));
|
||||
text.push('.');
|
||||
}
|
||||
Some(("text-cursor-input", text, true))
|
||||
} else if info.xfa == Some(pdfcraft_render::Xfa::Dynamic) {
|
||||
Some((
|
||||
"triangle-alert",
|
||||
|
|
@ -1942,8 +1956,8 @@ fn notices(
|
|||
))
|
||||
} else if info.xfa == Some(pdfcraft_render::Xfa::Static) {
|
||||
Some((
|
||||
"triangle-alert",
|
||||
tl!("This form also contains XFA data, which PdfCraft doesn't read yet. You can fill its fields, but Acrobat may show the XFA values instead.").to_string(),
|
||||
"text-cursor-input",
|
||||
tl!("XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in.").to_string(),
|
||||
true,
|
||||
))
|
||||
} else if !info.fields.is_empty() {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
//! XFA forms (#60): not read yet, so the document says so instead of silently showing a
|
||||
//! placeholder page or fields whose values Acrobat would override.
|
||||
//! XFA forms (#60): a dynamic form is laid out from its template and says so; a static one takes
|
||||
//! its values from the XFA data; one whose template can't be read says that instead of silently
|
||||
//! showing a placeholder page.
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
|
@ -47,9 +48,23 @@ fn xfa(h: &Harness<'static, PdfCraftApp>) -> Option<pdfcraft_render::Xfa> {
|
|||
s.session.get(s.views[0].id).unwrap().info.xfa
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dynamic_xfa_form_is_laid_out_and_filled() {
|
||||
let h = open(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(2)));
|
||||
assert_eq!(xfa(&h), Some(pdfcraft_render::Xfa::Dynamic));
|
||||
h.get_by_label_contains("laid out from its template: 2 pages, 11 fields");
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[0].id).unwrap();
|
||||
assert_eq!(doc.info.pages.len(), 2);
|
||||
assert!(doc.form.iter().any(|f| f.name == "familyName"));
|
||||
// The notice offers to highlight the fields, like any form.
|
||||
h.get_by_label("Highlight fields");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dynamic_xfa_form_says_its_page_is_a_placeholder() {
|
||||
// No fields, the form lives in the XFA packets (an array of name/stream pairs here).
|
||||
// No fields, the form lives in the XFA packets (an array of name/stream pairs here), but
|
||||
// the packets hold no template: the placeholder page stays, with a notice.
|
||||
let h = open(pdf("/AcroForm << /Fields [] /XFA [(template) 5 0 R] >>", "/NeedsRendering true", &[XFA_PACKET]));
|
||||
assert_eq!(xfa(&h), Some(pdfcraft_render::Xfa::Dynamic));
|
||||
h.get_by_label_contains("dynamic XFA form");
|
||||
|
|
@ -59,11 +74,18 @@ fn a_dynamic_xfa_form_says_its_page_is_a_placeholder() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn a_static_xfa_form_can_be_filled_but_warns_about_its_xfa_data() {
|
||||
fn a_static_xfa_form_is_filled_from_its_data_and_says_so() {
|
||||
let field = "<< /FT /Tx /T (name) /Rect [20 20 200 40] /Type /Annot /Subtype /Widget /P 3 0 R >>";
|
||||
let h = open(pdf("/AcroForm << /Fields [6 0 R] /XFA 5 0 R >>", "", &[XFA_PACKET, field]));
|
||||
assert_eq!(xfa(&h), Some(pdfcraft_render::Xfa::Static));
|
||||
h.get_by_label_contains("also contains XFA data");
|
||||
h.get_by_label_contains("XFA data are kept in step");
|
||||
// Values saved in the datasets by another viewer show up in the fields.
|
||||
let h = open(pdfcraft_xfa::fixtures::static_shell("<form1><page1><name>Ada</name><agree>1</agree></page1></form1>"));
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[0].id).unwrap();
|
||||
assert_eq!(doc.form.iter().find(|f| f.name == "form1[0].page1[0].name[0]").unwrap().value, vec!["Ada".to_string()]);
|
||||
assert_eq!(doc.form.iter().find(|f| f.name == "form1[0].page1[0].agree[0]").unwrap().value, vec!["1".to_string()]);
|
||||
assert!(!doc.dirty);
|
||||
// The fields can still be highlighted from the notice.
|
||||
h.get_by_label("Highlight fields");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "pdfcraft-xfa"
|
||||
description = "XFA forms: reads a dynamic form's XDP packets and lays its template out as PDF pages and AcroForm fields"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
roxmltree = "0.20"
|
||||
base64 = "0.22"
|
||||
thiserror = { workspace = true }
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
# pdfcraft-xfa
|
||||
|
||||
XFA forms (XFA 3.3, the template and layout parts). Layer L3; depends on `pdfcraft-cos` and
|
||||
`pdfcraft-fonts` only.
|
||||
|
||||
A dynamic XFA form is a PDF shell around an XML template (`/AcroForm /XFA`): one placeholder page
|
||||
("requires Adobe Reader") and `/NeedsRendering true`. Viewers without an XFA engine show the
|
||||
placeholder. This crate reads the template, lays it out, and writes ordinary pages and AcroForm
|
||||
fields into the document, so everything else in PdfCraft (rendering, filling, saving, printing,
|
||||
the automation tools) works on it unchanged. The engine does this when it opens such a form.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Packets** (`read_packets`): the XDP from one stream or the `(name, stream)` array; UTF-8
|
||||
and UTF-16; capped at 64 MB.
|
||||
- **Template** (`parse`): subforms, areas, fields, draws, exclusion groups, page sets with
|
||||
page areas, content areas and media; measurements in mm, cm, in, pt and px; fonts, paragraphs,
|
||||
margins, borders (edge order rules), captions with reserves, check-button items, pictures,
|
||||
rich text (`exData` XHTML: paragraphs, `br`, bold/italic/underline/size spans, embedded fields),
|
||||
inline JPEG images, lines, rectangles, occurrences, `breakBefore`/`breakAfter`, overflow
|
||||
leaders, `columnWidths` and `colSpan`.
|
||||
- **Layout** (`layout`): positioned, `tb`, `lr-tb`/`rl-tb`, `table` and `row` layouts; a
|
||||
container without a width is as wide as its content; flow across content areas and pages with
|
||||
page masters chosen by occurrence; a table's header row repeats after a break; presence
|
||||
`hidden` takes no space, `invisible` takes space; repeating subforms get their initial
|
||||
instances; fields that compute the page number or count on layout show the numbers.
|
||||
- **PDF** (`pdf::write_form`): pages with content streams in the standard 14 fonts (Arial and
|
||||
friends → Helvetica, Courier New → Courier, serif faces → Times; nothing is embedded), JPEG
|
||||
XObjects, and widgets: text (multi-line, max length, alignment), date (Acrobat's `AFDate`
|
||||
format and keystroke actions from the picture clause), check boxes and radio groups (from
|
||||
`exclGroup`), push buttons with Reset / Print / Save As / URI actions recognised from their
|
||||
scripts. Appearance streams are left empty for the forms layer to generate. Each field keeps
|
||||
its SOM path in `/PCSom`; the AcroForm gets `/PCXfaLayout` so a saved form is not laid out
|
||||
twice. The XFA packets stay, so Adobe's viewers keep rendering the form from them.
|
||||
|
||||
- **Data** (`data`): the `datasets` packet. On layout, a field takes its value from the data
|
||||
node at its SOM path (dates in ISO form, check and radio states by their on values), and a
|
||||
repeating subform or row gets as many instances as the data has. `write_datasets` merges the
|
||||
AcroForm fields' values (by `/PCSom`, or by the Designer field names of a static form) into
|
||||
the existing `xfa:data`: only the bound nodes' text changes and missing nodes are added, so
|
||||
unbound data, other namespaces, attributes and comments stay byte for byte. Values it can't
|
||||
write (a node holding structured content, absurd SOM indices, a packet that is neither UTF-8
|
||||
nor UTF-16) come back as warnings; UTF-16 packets are written back as UTF-16. `read_values`
|
||||
goes the other way, so a form filled here shows its values in Adobe's viewers and a form
|
||||
filled there shows them here. The engine does both: values on open (recorded in the
|
||||
document's warnings), the packet after every form edit.
|
||||
|
||||
Layout measures each container once per (node, width): width-less containers would otherwise
|
||||
be measured exponentially often in their nesting. A measurement budget backs this up.
|
||||
|
||||
## API sketch
|
||||
|
||||
```rust
|
||||
if pdfcraft_xfa::is_dynamic(&doc) {
|
||||
let report = pdfcraft_xfa::render_into(&mut doc)?; // pages, fields, warnings
|
||||
for f in pdfcraft_forms::fields(&doc) { pdfcraft_forms::redraw_field(&mut doc, &f.name)?; }
|
||||
}
|
||||
let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for tests
|
||||
```
|
||||
|
||||
## 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.
|
||||
- **Scripting.** No FormCalc or XFA JavaScript (`xfa.host`, `xfa.layout`, `instanceManager`);
|
||||
rows are not added by button, validations and calculations don't run. Buttons map only the
|
||||
common idioms (reset, print, save as, launchURL).
|
||||
- **Static XFA forms** (`/NeedsRendering` absent, AcroForm fields present) keep their AcroForm;
|
||||
only their data is read and written.
|
||||
- Choice lists are text fields; signature, image, barcode and password fields are left blank;
|
||||
PNG and GIF images, `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
|
||||
fidelity has been checked by eye on real government forms, not pixel-compared.
|
||||
|
|
@ -0,0 +1,793 @@
|
|||
//! The data model (`datasets` packet, XFA 3.3 part 1, "Data Binding"): reading the values a
|
||||
//! form was saved with, and writing the values of its fields back, so Adobe's viewers (which
|
||||
//! lay the form out from the XFA packets) show what was filled in here.
|
||||
//!
|
||||
//! Binding is the default "normal" one: a field's data node is named after the field and nested
|
||||
//! under nodes named after its named ancestor subforms, repeated instances as repeated sibling
|
||||
//! elements. Explicit `bind ref` expressions are not followed.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
|
||||
|
||||
use crate::XfaError;
|
||||
use crate::packets::{Encoding, encode};
|
||||
|
||||
const XFA_DATA_NS: &str = "http://www.xfa.org/schema/xfa-data/1.0/";
|
||||
/// Most data nodes read.
|
||||
const MAX_NODES: u32 = 1_000_000;
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// One element of the data document.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct DataNode {
|
||||
pub name: String,
|
||||
pub text: String,
|
||||
pub children: Vec<DataNode>,
|
||||
}
|
||||
|
||||
/// A path into the data: `(name, index among same-named siblings)` per level.
|
||||
pub type DataPath = Vec<(String, usize)>;
|
||||
|
||||
impl DataNode {
|
||||
/// The node at `path` below this one.
|
||||
pub fn get(&self, path: &[(String, usize)]) -> Option<&DataNode> {
|
||||
let mut node = self;
|
||||
for (name, idx) in path {
|
||||
node = node.children.iter().filter(|c| &c.name == name).nth(*idx)?;
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
/// How many children of the node at `parent` are named `name`.
|
||||
pub fn count(&self, parent: &[(String, usize)], name: &str) -> usize {
|
||||
self.get(parent).map_or(0, |n| n.children.iter().filter(|c| c.name == name).count())
|
||||
}
|
||||
|
||||
pub fn text_at(&self, path: &[(String, usize)]) -> Option<&str> {
|
||||
self.get(path).map(|n| n.text.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
fn to_node(n: roxmltree::Node, depth: usize) -> DataNode {
|
||||
let name = n.tag_name().name().to_string();
|
||||
let children: Vec<DataNode> =
|
||||
if depth < MAX_DEPTH { n.children().filter(|c| c.is_element()).map(|c| to_node(c, depth + 1)).collect() } else { Vec::new() };
|
||||
// The text of a leaf is its value; a container's own text is whitespace.
|
||||
let text = if children.is_empty() { n.children().filter_map(|c| c.text()).collect::<String>() } else { String::new() };
|
||||
DataNode { name, text, children }
|
||||
}
|
||||
|
||||
/// The `xfa:data` element of the datasets packet in `xdp` (or a bare datasets document), as a
|
||||
/// tree whose root is that element.
|
||||
pub fn parse_datasets(xdp: &str) -> Option<DataNode> {
|
||||
let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES };
|
||||
let doc = roxmltree::Document::parse_with_options(xdp, opts).ok()?;
|
||||
let datasets = doc.descendants().find(|n| n.is_element() && n.tag_name().name() == "datasets")?;
|
||||
let data = datasets.children().find(|n| n.is_element() && n.tag_name().name() == "data")?;
|
||||
Some(to_node(data, 0))
|
||||
}
|
||||
|
||||
/// The data path a SOM expression names: `form[0].#subform[0].name[1]` → `[(form, 0), (name, 1)]`.
|
||||
/// Unnamed containers (`#subform`) have no data node.
|
||||
pub fn som_to_path(som: &str) -> DataPath {
|
||||
som.split('.')
|
||||
.filter_map(|seg| {
|
||||
let seg = seg.trim();
|
||||
if seg.is_empty() || seg.starts_with('#') || seg.starts_with('$') {
|
||||
return None;
|
||||
}
|
||||
let (name, idx) = match seg.split_once('[') {
|
||||
Some((n, rest)) => (n, rest.trim_end_matches(']').parse::<usize>().unwrap_or(0)),
|
||||
None => (seg, 0),
|
||||
};
|
||||
Some((name.to_string(), idx.min(100_000)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What a field holds, for writing the datasets.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum FieldData {
|
||||
Text(String),
|
||||
/// Checked or not.
|
||||
Check(bool),
|
||||
/// The selected button's on value, if any.
|
||||
Radio(Option<String>),
|
||||
/// Nothing to write (buttons, signatures).
|
||||
None,
|
||||
}
|
||||
|
||||
/// One field of the AcroForm, as the engine sees it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FieldDatum {
|
||||
/// The terminal field dictionary (where `/PCSom` and `/PCItems` live).
|
||||
pub obj: ObjRef,
|
||||
/// The fully qualified field name (a static form's SOM path).
|
||||
pub name: String,
|
||||
pub data: FieldData,
|
||||
}
|
||||
|
||||
fn text_of(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
|
||||
d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text()))
|
||||
}
|
||||
|
||||
/// The SOM path of a field: its `/PCSom` when PdfCraft generated it, else its name.
|
||||
fn som_of(doc: &Document, f: &FieldDatum) -> String {
|
||||
doc.get(f.obj).as_dict().and_then(|d| text_of(doc, d, crate::pdf::SOM_KEY)).unwrap_or_else(|| f.name.clone())
|
||||
}
|
||||
|
||||
/// The on and off item values of a check box (`/PCItems`), or the widget's on state.
|
||||
fn items_of(doc: &Document, f: &FieldDatum) -> (String, String) {
|
||||
let d = doc.get(f.obj);
|
||||
let items: Vec<String> = d
|
||||
.as_dict()
|
||||
.and_then(|d| d.get(b"PCItems").map(|o| doc.resolve(o)))
|
||||
.and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| x.as_string().map(|s| s.to_text())).collect()))
|
||||
.unwrap_or_default();
|
||||
let on = items.first().cloned().unwrap_or_else(|| "1".into());
|
||||
let off = items.get(1).cloned().unwrap_or_default();
|
||||
(on, off)
|
||||
}
|
||||
|
||||
/// The Acrobat date pattern a field's format action names (`AFDate_FormatEx("yyyy-mm-dd")`).
|
||||
fn date_pattern_of(doc: &Document, f: &FieldDatum) -> Option<String> {
|
||||
let d = doc.get(f.obj);
|
||||
let aa = doc.resolve(d.as_dict()?.get(b"AA")?);
|
||||
let fa = doc.resolve(aa.as_dict()?.get(b"F")?);
|
||||
let js = text_of(doc, fa.as_dict()?, b"JS")?;
|
||||
let i = js.find("AFDate_FormatEx(")?;
|
||||
let rest = &js[i + "AFDate_FormatEx(".len()..];
|
||||
let q = rest.find('"')?;
|
||||
let body = &rest[q + 1..];
|
||||
Some(body[..body.find('"')?].to_string())
|
||||
}
|
||||
|
||||
/// The tokens of a date pattern: `yyyy`, `yy`, `mm`, `m`, `dd`, `d`, or a literal character.
|
||||
fn date_tokens(pattern: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let chars: Vec<char> = pattern.chars().collect();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let c = chars[i].to_ascii_lowercase();
|
||||
if "ymd".contains(c) {
|
||||
let mut j = i;
|
||||
while j < chars.len() && chars[j].to_ascii_lowercase() == c {
|
||||
j += 1;
|
||||
}
|
||||
out.push(std::iter::repeat_n(c, j - i).collect());
|
||||
i = j;
|
||||
} else {
|
||||
out.push(chars[i].to_string());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `2001-02-03` shown in `pattern` (`mm/dd/yyyy` → `02/03/2001`). Unknown patterns and
|
||||
/// non-dates come back as they are.
|
||||
pub fn iso_to_pattern(iso: &str, pattern: &str) -> String {
|
||||
let parts: Vec<&str> = iso.trim().splitn(3, '-').collect();
|
||||
let [y, m, d] = parts[..] else { return iso.to_string() };
|
||||
let (Ok(y), Ok(m), Ok(d)) = (y.parse::<u32>(), m.parse::<u32>(), d.parse::<u32>()) else { return iso.to_string() };
|
||||
let mut out = String::new();
|
||||
for t in date_tokens(pattern) {
|
||||
match t.as_str() {
|
||||
"yyyy" => out.push_str(&format!("{y:04}")),
|
||||
"yy" => out.push_str(&format!("{:02}", y % 100)),
|
||||
"mm" => out.push_str(&format!("{m:02}")),
|
||||
"m" => out.push_str(&m.to_string()),
|
||||
"dd" => out.push_str(&format!("{d:02}")),
|
||||
"d" => out.push_str(&d.to_string()),
|
||||
other => out.push_str(other),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A date typed as `pattern` back to `yyyy-mm-dd`. `None` when it doesn't match.
|
||||
pub fn pattern_to_iso(text: &str, pattern: &str) -> Option<String> {
|
||||
let text = text.trim();
|
||||
let (mut y, mut m, mut d) = (None, None, None);
|
||||
let mut rest = text;
|
||||
let tokens = date_tokens(pattern);
|
||||
for (i, t) in tokens.iter().enumerate() {
|
||||
match t.as_str() {
|
||||
"yyyy" | "yy" | "mm" | "m" | "dd" | "d" => {
|
||||
// Digits up to the next literal (or a fixed width).
|
||||
let fixed = matches!(t.as_str(), "yyyy" | "yy" | "mm" | "dd");
|
||||
let width = if fixed {
|
||||
t.len()
|
||||
} else {
|
||||
rest.chars().take_while(|c| c.is_ascii_digit()).count().min(if t == "d" || t == "m" { 2 } else { 4 })
|
||||
};
|
||||
let digits: String = rest.chars().take(width).collect();
|
||||
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
let v: u32 = digits.parse().ok()?;
|
||||
rest = &rest[digits.len()..];
|
||||
match t.as_str() {
|
||||
"yyyy" => y = Some(v),
|
||||
"yy" => y = Some(if v < 50 { 2000 + v } else { 1900 + v }),
|
||||
"mm" | "m" => m = Some(v),
|
||||
_ => d = Some(v),
|
||||
}
|
||||
}
|
||||
lit => {
|
||||
rest = rest.strip_prefix(lit)?;
|
||||
}
|
||||
}
|
||||
let _ = i;
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (y, m, d) = (y?, m?, d?);
|
||||
(1..=12).contains(&m).then(|| format!("{y:04}-{m:02}-{d:02}")).filter(|_| (1..=31).contains(&d))
|
||||
}
|
||||
|
||||
fn xml_name(s: &str) -> String {
|
||||
let mut out: String = s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' { c } else { '_' }).collect();
|
||||
if out.is_empty() || !(out.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')) {
|
||||
out.insert(0, '_');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `s` as XML character data. Characters XML 1.0 cannot hold are left out; the flag says
|
||||
/// whether any were.
|
||||
fn escape(s: &str) -> (String, bool) {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut dropped = false;
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
// Parsers turn a raw carriage return into a line feed; a reference keeps it.
|
||||
'\r' => out.push_str("
"),
|
||||
'\u{FFFE}' | '\u{FFFF}' => dropped = true,
|
||||
c if c.is_control() && c != '\n' && c != '\t' => dropped = true,
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
(out, dropped)
|
||||
}
|
||||
|
||||
/// Most data nodes one write may create (instances skipped by a SOM index count too).
|
||||
const MAX_NEW_NODES: usize = 20_000;
|
||||
/// Highest same-name sibling index a write follows.
|
||||
const MAX_INDEX: usize = 10_000;
|
||||
|
||||
/// A data tree being built for writing.
|
||||
#[derive(Default)]
|
||||
struct Build {
|
||||
name: String,
|
||||
text: String,
|
||||
children: Vec<Build>,
|
||||
/// Positions in `children` by name, in order.
|
||||
by_name: HashMap<String, Vec<usize>>,
|
||||
}
|
||||
|
||||
impl Build {
|
||||
/// The node at `path` below this one, created as needed. `None` when the path is deeper
|
||||
/// than [`MAX_DEPTH`], an index is above [`MAX_INDEX`], or creating it would spend more
|
||||
/// than `budget` new nodes.
|
||||
fn at(&mut self, path: &[(String, usize)], budget: &mut usize) -> Option<&mut Build> {
|
||||
if path.len() > MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let mut node = self;
|
||||
for (name, idx) in path {
|
||||
if *idx > MAX_INDEX {
|
||||
return None;
|
||||
}
|
||||
let name = xml_name(name);
|
||||
let have = node.by_name.get(&name).map_or(0, Vec::len);
|
||||
if *idx >= have {
|
||||
let need = idx - have + 1;
|
||||
if need > *budget {
|
||||
return None;
|
||||
}
|
||||
*budget -= need;
|
||||
for _ in 0..need {
|
||||
let pos = node.children.len();
|
||||
node.by_name.entry(name.clone()).or_default().push(pos);
|
||||
node.children.push(Build { name: name.clone(), ..Default::default() });
|
||||
}
|
||||
}
|
||||
let pos = *node.by_name.get(&name)?.get(*idx)?;
|
||||
node = node.children.get_mut(pos)?;
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
/// The tree as XML. Depth is bounded by [`Build::at`].
|
||||
fn write(&self, out: &mut String) {
|
||||
if self.children.is_empty() {
|
||||
out.push_str(&format!("<{}>{}</{}>", self.name, escape(&self.text).0, self.name));
|
||||
return;
|
||||
}
|
||||
out.push_str(&format!("<{}>", self.name));
|
||||
self.write_children(out);
|
||||
out.push_str(&format!("</{}>", self.name));
|
||||
}
|
||||
|
||||
fn write_children(&self, out: &mut String) {
|
||||
for c in &self.children {
|
||||
c.write(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A value to write: where (the field's SOM path, and the data path it binds to) and what.
|
||||
struct Want {
|
||||
som: String,
|
||||
path: DataPath,
|
||||
text: String,
|
||||
}
|
||||
|
||||
fn wanted(doc: &Document, fields: &[FieldDatum]) -> Vec<Want> {
|
||||
let mut out = Vec::new();
|
||||
for f in fields {
|
||||
let som = som_of(doc, f);
|
||||
let path = som_to_path(&som);
|
||||
if path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let text = match &f.data {
|
||||
FieldData::Text(t) => match date_pattern_of(doc, f) {
|
||||
Some(p) => pattern_to_iso(t, &p).unwrap_or_else(|| t.clone()),
|
||||
None => t.clone(),
|
||||
},
|
||||
FieldData::Check(on) => {
|
||||
let (on_item, off_item) = items_of(doc, f);
|
||||
if *on { on_item } else { off_item }
|
||||
}
|
||||
FieldData::Radio(sel) => sel.clone().unwrap_or_default(),
|
||||
FieldData::None => continue,
|
||||
};
|
||||
out.push(Want { som, path, text });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Note that a value could not be written (once per kind of reason, naming a few fields).
|
||||
fn skipped(warnings: &mut Vec<String>, reason: &str, som: &str) {
|
||||
const MAX_WARNINGS: usize = 20;
|
||||
if let Some(w) = warnings.iter_mut().find(|w| w.starts_with(reason)) {
|
||||
if w.matches(", ").count() < 4 {
|
||||
w.push_str(&format!(", {som}"));
|
||||
} else if !w.ends_with('…') {
|
||||
w.push_str(", …");
|
||||
}
|
||||
} else if warnings.len() < MAX_WARNINGS {
|
||||
warnings.push(format!("{reason}: {som}"));
|
||||
}
|
||||
}
|
||||
|
||||
const TOO_MANY: &str = "XFA data not written for fields whose data path is too deep or repeats too often";
|
||||
const STRUCTURED: &str = "XFA data not written for fields whose data node holds structured content";
|
||||
const CONFLICT: &str = "XFA data not written for fields bound to the same data node as another field";
|
||||
const CONTROL: &str = "Characters XML can't hold were left out of the XFA data of";
|
||||
|
||||
/// Fresh data for `wants`: the children of an `xfa:data` element.
|
||||
fn build_children(wants: &[Want], warnings: &mut Vec<String>) -> String {
|
||||
let mut root = Build::default();
|
||||
let mut budget = MAX_NEW_NODES;
|
||||
for w in wants {
|
||||
match root.at(&w.path, &mut budget) {
|
||||
Some(node) => node.text = w.text.clone(),
|
||||
None => skipped(warnings, TOO_MANY, &w.som),
|
||||
}
|
||||
if escape(&w.text).1 {
|
||||
skipped(warnings, CONTROL, &w.som);
|
||||
}
|
||||
}
|
||||
let mut out = String::new();
|
||||
root.write_children(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// The `xfa:data` element for `fields`, built afresh.
|
||||
pub fn build_data(doc: &Document, fields: &[FieldDatum]) -> String {
|
||||
format!("<xfa:data>{}</xfa:data>", build_children(&wanted(doc, fields), &mut Vec::new()))
|
||||
}
|
||||
|
||||
fn fresh_datasets(data_children: &str) -> String {
|
||||
format!("<xfa:datasets xmlns:xfa=\"{XFA_DATA_NS}\"><xfa:data>{data_children}</xfa:data></xfa:datasets>")
|
||||
}
|
||||
|
||||
/// Where an element's start tag ends (the index of its `>`), skipping quoted attribute values.
|
||||
fn start_tag_end(src: &str, range: &std::ops::Range<usize>) -> Option<usize> {
|
||||
let bytes = src.as_bytes();
|
||||
let mut quote = None;
|
||||
for i in range.clone() {
|
||||
let b = *bytes.get(i)?;
|
||||
match (quote, b) {
|
||||
(Some(q), _) if b == q => quote = None,
|
||||
(Some(_), _) => {}
|
||||
(None, b'"' | b'\'') => quote = Some(b),
|
||||
(None, b'>') => return Some(i),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// An element's qualified name as written.
|
||||
fn qname<'s>(src: &'s str, range: &std::ops::Range<usize>) -> Option<&'s str> {
|
||||
let rest = src.get(range.start.checked_add(1)?..range.end)?;
|
||||
let end = rest.find(|c: char| c.is_whitespace() || c == '/' || c == '>')?;
|
||||
rest.get(..end).filter(|n| !n.is_empty())
|
||||
}
|
||||
|
||||
/// One change to the packet text: replace `start..end` with `text`.
|
||||
struct Edit {
|
||||
start: usize,
|
||||
end: usize,
|
||||
text: String,
|
||||
}
|
||||
|
||||
/// The edit that puts `inner` (markup) at the end of `node`'s content.
|
||||
fn append_edit(src: &str, node: roxmltree::Node, inner: &str) -> Option<Edit> {
|
||||
let range = node.range();
|
||||
let tag_end = start_tag_end(src, &range)?;
|
||||
if tag_end.checked_add(1)? == range.end && src.get(..tag_end)?.ends_with('/') {
|
||||
// `<a/>` becomes `<a>inner</a>`.
|
||||
let name = qname(src, &range)?;
|
||||
return Some(Edit { start: tag_end.checked_sub(1)?, end: range.end, text: format!(">{inner}</{name}>") });
|
||||
}
|
||||
let close = range.start + src.get(range.clone())?.rfind("</")?;
|
||||
Some(Edit { start: close, end: close, text: inner.to_string() })
|
||||
}
|
||||
|
||||
/// The edit that makes `text` (escaped) the whole content of the leaf `node`.
|
||||
fn replace_edit(src: &str, node: roxmltree::Node, text: &str) -> Option<Edit> {
|
||||
let range = node.range();
|
||||
let tag_end = start_tag_end(src, &range)?;
|
||||
if tag_end.checked_add(1)? == range.end && src.get(..tag_end)?.ends_with('/') {
|
||||
let name = qname(src, &range)?;
|
||||
return Some(Edit { start: tag_end.checked_sub(1)?, end: range.end, text: format!(">{text}</{name}>") });
|
||||
}
|
||||
let close = range.start + src.get(range.clone())?.rfind("</")?;
|
||||
(close > tag_end).then(|| Edit { start: tag_end + 1, end: close, text: text.to_string() })
|
||||
}
|
||||
|
||||
/// What merging the values into a packet's text came to.
|
||||
enum Merged {
|
||||
/// The data already holds every value.
|
||||
Unchanged,
|
||||
Changed(String),
|
||||
/// The text could not be merged into (the reason is in the warnings).
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Where a datasets packet sits: one packet of an array (`Packet`), or inside the whole XDP.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Within {
|
||||
Packet,
|
||||
Xdp,
|
||||
}
|
||||
|
||||
const WRAP_OPEN: &str = "<pdfcraft-wrap xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\" xmlns:xdp=\"http://ns.adobe.com/xdp/\">";
|
||||
const WRAP_CLOSE: &str = "</pdfcraft-wrap>";
|
||||
|
||||
/// Write `wants` into the data of the datasets packet in `text`, changing only what has to
|
||||
/// 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.
|
||||
fn merge(text: &str, within: Within, wants: &[Want], warnings: &mut Vec<String>) -> Merged {
|
||||
let opts = || roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES };
|
||||
// A packet of an array may use the prefixes the XDP envelope declares: read it inside
|
||||
// an element declaring the usual ones.
|
||||
let wrapped;
|
||||
let (src, shift, parsed) = match roxmltree::Document::parse_with_options(text, opts()) {
|
||||
Ok(d) => (text, 0, d),
|
||||
Err(first) => {
|
||||
wrapped = format!("{WRAP_OPEN}{text}{WRAP_CLOSE}");
|
||||
match (within, roxmltree::Document::parse_with_options(&wrapped, opts())) {
|
||||
(Within::Packet, Ok(d)) => (wrapped.as_str(), WRAP_OPEN.len(), d),
|
||||
_ => {
|
||||
warnings.push(format!("The XFA data could not be read ({first}); the field values were not written to it"));
|
||||
return Merged::Failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name;
|
||||
let datasets = parsed
|
||||
.descendants()
|
||||
.find(|n| is(n, "datasets") && n.tag_name().namespace() == Some(XFA_DATA_NS))
|
||||
.or_else(|| parsed.descendants().find(|n| is(n, "datasets")));
|
||||
let mut edits = Vec::new();
|
||||
let mut data = None;
|
||||
match datasets {
|
||||
None if within == Within::Xdp => {
|
||||
let root = parsed.root_element();
|
||||
match append_edit(src, root, &fresh_datasets(&build_children(wants, warnings))) {
|
||||
Some(e) => edits.push(e),
|
||||
None => {
|
||||
warnings.push("The XFA packets could not be extended with data; the field values were not written".into());
|
||||
return Merged::Failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
warnings.push("The XFA datasets packet holds no datasets element; the field values were not written to it".into());
|
||||
return Merged::Failed;
|
||||
}
|
||||
Some(ds) => match ds.children().find(|n| is(n, "data")) {
|
||||
Some(d) => data = Some(d),
|
||||
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));
|
||||
match append_edit(src, ds, &element) {
|
||||
Some(e) => edits.push(e),
|
||||
None => {
|
||||
warnings.push("The XFA datasets could not be extended with data; the field values were not written".into());
|
||||
return Merged::Failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
if let Some(data) = data {
|
||||
merge_values(src, data, wants, &mut edits, warnings);
|
||||
}
|
||||
if edits.is_empty() {
|
||||
return Merged::Unchanged;
|
||||
}
|
||||
// Apply from the end, so earlier positions stay valid; overlapping edits (two fields bound
|
||||
// to one node) keep the first.
|
||||
edits.sort_by_key(|e| (e.start, e.end));
|
||||
let mut kept: Vec<Edit> = Vec::with_capacity(edits.len());
|
||||
for e in edits {
|
||||
if kept.last().is_some_and(|k| e.start < k.end) {
|
||||
if kept.last().is_some_and(|k| k.text != e.text) && !warnings.iter().any(|w| w == CONFLICT) {
|
||||
warnings.push(CONFLICT.into());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
kept.push(e);
|
||||
}
|
||||
let mut out = src.to_string();
|
||||
for e in kept.iter().rev() {
|
||||
if out.get(e.start..e.end).is_none() {
|
||||
warnings.push("The XFA data could not be updated; the field values were not written to it".into());
|
||||
return Merged::Failed;
|
||||
}
|
||||
out.replace_range(e.start..e.end, &e.text);
|
||||
}
|
||||
if shift > 0 {
|
||||
match out.get(shift..out.len().saturating_sub(WRAP_CLOSE.len())) {
|
||||
Some(inner) => out = inner.to_string(),
|
||||
None => return Merged::Failed,
|
||||
}
|
||||
}
|
||||
Merged::Changed(out)
|
||||
}
|
||||
|
||||
/// The edits that put `wants` into the `data` element.
|
||||
fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Vec<Edit>, warnings: &mut Vec<String>) {
|
||||
// Element children by local name, per element visited (built once each).
|
||||
let mut index: HashMap<roxmltree::NodeId, HashMap<String, Vec<roxmltree::NodeId>>> = HashMap::new();
|
||||
let tree = data.document();
|
||||
// Nodes to add, per existing element they go into.
|
||||
let mut added: Vec<(roxmltree::NodeId, Build)> = Vec::new();
|
||||
let mut budget = MAX_NEW_NODES;
|
||||
for w in wants {
|
||||
let (escaped, dropped) = escape(&w.text);
|
||||
let mut node = data;
|
||||
let mut depth = 0;
|
||||
let mut have = 0;
|
||||
for (name, idx) in &w.path {
|
||||
let kids = index.entry(node.id()).or_insert_with(|| {
|
||||
let mut by: HashMap<String, Vec<roxmltree::NodeId>> = HashMap::new();
|
||||
for c in node.children().filter(|c| c.is_element()) {
|
||||
by.entry(c.tag_name().name().to_string()).or_default().push(c.id());
|
||||
}
|
||||
by
|
||||
});
|
||||
let same = kids.get(name.as_str());
|
||||
match same.and_then(|v| v.get(*idx)).and_then(|id| tree.get_node(*id)) {
|
||||
Some(next) => {
|
||||
node = next;
|
||||
depth += 1;
|
||||
}
|
||||
None => {
|
||||
have = same.map_or(0, Vec::len);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if depth == w.path.len() {
|
||||
// The data node exists: give it the value, unless it already holds it.
|
||||
let current: String = node.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect();
|
||||
if node.children().any(|c| c.is_element()) {
|
||||
let all: String = node.descendants().filter(|c| c.is_text()).filter_map(|c| c.text()).collect();
|
||||
if all.trim() != w.text.trim() {
|
||||
skipped(warnings, STRUCTURED, &w.som);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if current == w.text {
|
||||
continue;
|
||||
}
|
||||
match replace_edit(src, node, &escaped) {
|
||||
Some(e) => edits.push(e),
|
||||
None => skipped(warnings, STRUCTURED, &w.som),
|
||||
}
|
||||
} else {
|
||||
// Missing: add the rest of the path below the deepest node that exists. An empty
|
||||
// value needs no node.
|
||||
if w.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut rest: DataPath = w.path.get(depth..).unwrap_or_default().to_vec();
|
||||
if let Some(first) = rest.first_mut() {
|
||||
first.1 = first.1.saturating_sub(have);
|
||||
}
|
||||
let pos = match added.iter().position(|(id, _)| *id == node.id()) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
added.push((node.id(), Build::default()));
|
||||
added.len() - 1
|
||||
}
|
||||
};
|
||||
let built = added.get_mut(pos).and_then(|(_, b)| b.at(&rest, &mut budget));
|
||||
match built {
|
||||
Some(b) => b.text = w.text.clone(),
|
||||
None => {
|
||||
skipped(warnings, TOO_MANY, &w.som);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if dropped {
|
||||
skipped(warnings, CONTROL, &w.som);
|
||||
}
|
||||
}
|
||||
for (id, build) in added {
|
||||
let Some(node) = tree.get_node(id) else { continue };
|
||||
let mut inner = String::new();
|
||||
build.write_children(&mut inner);
|
||||
match append_edit(src, node, &inner) {
|
||||
Some(e) => edits.push(e),
|
||||
None => warnings.push("Some XFA data nodes could not be added; their field values were not written".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`write_datasets`] did.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct DatasetsWrite {
|
||||
/// The datasets packet changed (a new revision of it was added to the document).
|
||||
pub written: bool,
|
||||
/// Values that could not be written, and why.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A packet stream's text and encoding; `None` (with a warning) when it can't be read exactly.
|
||||
fn packet_text(doc: &Document, o: &Object, warnings: &mut Vec<String>) -> Option<(String, Encoding)> {
|
||||
let s = doc.resolve(o);
|
||||
let Object::Stream(s) = &*s else {
|
||||
warnings.push("The XFA data packet is not a stream; the field values were not written to it".into());
|
||||
return None;
|
||||
};
|
||||
let bytes = match s.decoded_within(64 << 20) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warnings.push(format!("The XFA data could not be decoded ({e}); the field values were not written to it"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let decoded = crate::packets::decode(&bytes);
|
||||
if decoded.is_none() {
|
||||
warnings.push("The XFA data is neither UTF-8 nor UTF-16; the field values were not written to it, so it stays as it was".into());
|
||||
}
|
||||
decoded
|
||||
}
|
||||
|
||||
/// Write the fields' values into the datasets packet, keeping all the data no field binds to.
|
||||
/// Nothing is written when the document has no XFA entry or the data already holds the values;
|
||||
/// values that can't be written are reported, never silently dropped.
|
||||
pub fn write_datasets(doc: &mut Document, fields: &[FieldDatum]) -> Result<DatasetsWrite, XfaError> {
|
||||
let mut report = DatasetsWrite::default();
|
||||
let Some(root) = doc.root() else { return Ok(report) };
|
||||
let catalog = doc.get(root);
|
||||
let Some(acro_obj) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).cloned() else { return Ok(report) };
|
||||
let acro_ref = acro_obj.as_ref();
|
||||
let Some(mut acro) = doc.resolve(&acro_obj).as_dict().cloned() else { return Ok(report) };
|
||||
let Some(xfa) = acro.get(b"XFA").cloned() else { return Ok(report) };
|
||||
let wants = wanted(doc, fields);
|
||||
let warnings = &mut report.warnings;
|
||||
match &*doc.resolve(&xfa) {
|
||||
Object::Array(items) => {
|
||||
let mut items = items.clone();
|
||||
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());
|
||||
let packet = match at {
|
||||
Some(i) => {
|
||||
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) };
|
||||
match merge(&text, Within::Packet, &wants, warnings) {
|
||||
Merged::Changed(t) => Some((i, encode(&t, encoding))),
|
||||
Merged::Unchanged | Merged::Failed => None,
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
match (at, packet) {
|
||||
(Some(i), Some((_, bytes))) => {
|
||||
let r = doc.add(Object::Stream(Stream::flate(Dict::new(), &bytes)));
|
||||
if let Some(slot) = items.get_mut(i + 1) {
|
||||
*slot = Object::Ref(r);
|
||||
}
|
||||
}
|
||||
(Some(_), None) => return Ok(report),
|
||||
(None, _) => {
|
||||
// No datasets packet yet: before the postamble, or last.
|
||||
let text = fresh_datasets(&build_children(&wants, warnings));
|
||||
let r = doc.add(Object::Stream(Stream::flate(Dict::new(), text.as_bytes())));
|
||||
let pos = items.iter().position(|n| n.as_string().is_some_and(|s| s.to_text() == "postamble")).unwrap_or(items.len());
|
||||
items.insert(pos, Object::Ref(r));
|
||||
items.insert(pos, Object::String(PdfString::text("datasets")));
|
||||
}
|
||||
}
|
||||
acro.set(b"XFA".to_vec(), Object::Array(items));
|
||||
}
|
||||
Object::Stream(_) => {
|
||||
let Some((text, encoding)) = packet_text(doc, &xfa, warnings) else { return Ok(report) };
|
||||
let Merged::Changed(new) = merge(&text, Within::Xdp, &wants, warnings) else { return Ok(report) };
|
||||
let r = doc.add(Object::Stream(Stream::flate(Dict::new(), &encode(&new, encoding))));
|
||||
acro.set(b"XFA".to_vec(), Object::Ref(r));
|
||||
}
|
||||
_ => return Ok(report),
|
||||
}
|
||||
match acro_ref {
|
||||
Some(ar) => doc.set(ar, Object::Dict(acro)),
|
||||
None => doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(acro)))?,
|
||||
}
|
||||
report.written = true;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// The values the datasets hold for `fields`: `(field name, what it should hold)`, only for
|
||||
/// fields the data mentions.
|
||||
pub fn read_values(doc: &Document, fields: &[FieldDatum]) -> Vec<(String, FieldData)> {
|
||||
let Ok(Some(p)) = crate::read_packets(doc) else { return Vec::new() };
|
||||
let Some(data) = parse_datasets(&p.xdp) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for f in fields {
|
||||
let path = som_to_path(&som_of(doc, f));
|
||||
let Some(text) = data.text_at(&path) else { continue };
|
||||
let value = match &f.data {
|
||||
FieldData::Text(_) => FieldData::Text(match date_pattern_of(doc, f) {
|
||||
Some(pat) => iso_to_pattern(text, &pat),
|
||||
None => text.to_string(),
|
||||
}),
|
||||
FieldData::Check(_) => {
|
||||
let (on, _) = items_of(doc, f);
|
||||
FieldData::Check(is_on(text, &on))
|
||||
}
|
||||
FieldData::Radio(_) => FieldData::Radio((!text.trim().is_empty()).then(|| text.trim().to_string())),
|
||||
FieldData::None => continue,
|
||||
};
|
||||
out.push((f.name.clone(), value));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Does a data value mean "checked" for a button whose on value is `on`?
|
||||
pub fn is_on(text: &str, on: &str) -> bool {
|
||||
let t = text.trim();
|
||||
if t == on {
|
||||
return true;
|
||||
}
|
||||
!matches!(t.to_ascii_lowercase().as_str(), "" | "0" | "off" | "false" | "no") && on.trim().is_empty()
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
//! Synthetic fixtures for tests in this and other crates (never real forms).
|
||||
|
||||
#![doc(hidden)]
|
||||
|
||||
/// A small dynamic form: a letter page master with a page-number draw, a title, a text field
|
||||
/// with a caption on top, a check box, a radio group beside a question, a date field, a table
|
||||
/// with a repeated header row and `rows` data rows, and a last section that starts a new page.
|
||||
pub fn template(rows: usize) -> String {
|
||||
let mut t = String::from(
|
||||
r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
|
||||
<config xmlns="http://www.xfa.org/schema/xci/3.0/"><present><pdf><version>1.7</version></pdf></present><template><base/></template></config>
|
||||
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form" layout="tb" locale="en_US">
|
||||
<pageSet>
|
||||
<pageArea name="front" id="front"><occur min="1" max="1"/>
|
||||
<contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/>
|
||||
<medium stock="letter" short="8.5in" long="11in"/>
|
||||
<draw name="pn" x="7in" y="10.6in" w="1in" h="0.2in"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>Page <span xfa:embed="#pageNo" xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"/> of <span xfa:embed="#pageCount" xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"/></p></body></exData></value><font typeface="Arial" size="8pt"/></draw>
|
||||
<field name="pageNo" id="pageNo" presence="hidden" w="1in" h="0.2in"><ui><textEdit/></ui><event activity="ready" ref="$layout"><script contentType="application/x-javascript">this.rawValue = xfa.layout.page(this);</script></event></field>
|
||||
<field name="pageCount" id="pageCount" presence="hidden" w="1in" h="0.2in"><ui><textEdit/></ui><event activity="ready" ref="$layout"><script contentType="application/x-javascript">this.rawValue = xfa.layout.pageCount();</script></event></field>
|
||||
</pageArea>
|
||||
<pageArea name="rest"><occur max="-1"/>
|
||||
<contentArea x="0.5in" y="1in" w="7.5in" h="9.5in"/>
|
||||
<medium stock="letter" short="8.5in" long="11in"/>
|
||||
</pageArea>
|
||||
</pageSet>
|
||||
<subform name="head" layout="lr-tb">
|
||||
<draw name="title" w="7.5in" h="0.4in"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p style="font-weight:bold">Sample Form</p><p>Second line</p></body></exData></value><font typeface="Arial" size="12pt"/><para hAlign="center"/></draw>
|
||||
<draw name="hidden" presence="hidden" w="7.5in" h="3in"><value><text>never shown</text></value></draw>
|
||||
<field name="familyName" w="3in" h="0.5in"><ui><textEdit><border><edge presence="hidden"/><edge presence="hidden"/><edge/><edge presence="hidden"/></border></textEdit></ui><font typeface="Courier New" size="9pt"/><caption placement="top" reserve="0.2in"><value><text>Family name</text></value><font typeface="Arial" size="7pt"/></caption><assist><toolTip>Your family name</toolTip></assist><value><text maxChars="30"/></value></field>
|
||||
<field name="agree" w="2in" h="0.25in"><ui><checkButton><border><edge/><fill/></border></checkButton></ui><caption placement="right" reserve="1.5in"><value><text>I agree</text></value></caption><items><integer>1</integer><integer>0</integer></items></field>
|
||||
<field name="born" w="2in" h="0.5in"><ui><dateTimeEdit/><picture>date{YYYY-MM-DD}</picture></ui><caption placement="top" reserve="0.2in"><value><text>Date of birth</text></value></caption><value><date/></value></field>
|
||||
<draw name="q" w="6.5in" h="0.25in"><value><text>Have you ever?</text></value></draw>
|
||||
<exclGroup name="answer" layout="lr-tb">
|
||||
<field name="yes" w="0.5in" h="0.25in"><ui><checkButton/></ui><items><text>Y</text></items></field>
|
||||
<field name="no" w="0.5in" h="0.25in"><ui><checkButton/></ui><items><text>N</text></items></field>
|
||||
</exclGroup>
|
||||
<field name="go" w="1in" h="0.3in"><ui><button/></ui><caption><value><text>Reset</text></value></caption><border><edge stroke="raised"/><fill><color value="212,208,200"/></fill></border><event activity="click"><script contentType="application/x-javascript">xfa.host.resetData();</script></event></field>
|
||||
</subform>
|
||||
<subform name="table" layout="table" columnWidths="2in 3in 2.5in">
|
||||
<overflow leader="header"/>
|
||||
<subform name="header" layout="row"><occur max="-1"/>
|
||||
<draw w="2in" h="0.3in"><value><text>From</text></value><border><edge/><fill><color value="200,200,200"/></fill></border></draw>
|
||||
<draw h="0.3in"><value><text>Activity</text></value><border><edge/></border></draw>
|
||||
<draw h="0.3in"><value><text>Place</text></value><border><edge/></border></draw>
|
||||
</subform>
|
||||
"##,
|
||||
);
|
||||
for i in 0..rows {
|
||||
t.push_str(&format!(
|
||||
r##" <subform name="row" layout="row"><occur max="-1"/><field name="from{i}" h="0.4in"><ui><textEdit/></ui></field><field name="what{i}" h="0.4in"><ui><textEdit/></ui></field><field name="where{i}" h="0.4in"><ui><textEdit/></ui></field></subform>
|
||||
"##
|
||||
));
|
||||
}
|
||||
t.push_str(
|
||||
r##" </subform>
|
||||
<subform name="last" layout="tb"><breakBefore targetType="pageArea"/>
|
||||
<draw name="sig" w="7.5in" h="0.3in"><value><text>Signature</text></value></draw>
|
||||
</subform>
|
||||
</subform>
|
||||
</template>
|
||||
</xdp:xdp>
|
||||
"##,
|
||||
);
|
||||
t
|
||||
}
|
||||
|
||||
/// [`template`] with a datasets packet holding `data` (the children of `xfa:data`).
|
||||
pub fn template_with_data(rows: usize, data: &str) -> String {
|
||||
let t = template(rows);
|
||||
let packet =
|
||||
format!("<xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data>{data}</xfa:data></xfa:datasets>\n</xdp:xdp>");
|
||||
t.replace("</xdp:xdp>", &packet)
|
||||
}
|
||||
|
||||
/// A static XFA form: an AcroForm with Designer-style field names (`form1[0].page1[0].name[0]`,
|
||||
/// a text field, and `…agree[0]`, a check box with on state `1`), plus XFA packets whose
|
||||
/// datasets hold `data` (the children of `xfa:data`). No `/NeedsRendering`.
|
||||
pub fn static_shell(data: &str) -> Vec<u8> {
|
||||
let xdp = format!(
|
||||
"<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"><template xmlns=\"http://www.xfa.org/schema/xfa-template/3.3/\"><subform name=\"form1\"/></template><xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data>{data}</xfa:data></xfa:datasets></xdp:xdp>"
|
||||
);
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /XFA 5 0 R /Fields [6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >> >> >> >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Annots [8 0 R 9 0 R] >>".to_vec(),
|
||||
b"<< /Length 0 >>\nstream\n\nendstream".to_vec(),
|
||||
format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()).into_bytes(),
|
||||
b"<< /T (form1[0]) /Kids [7 0 R] >>".to_vec(),
|
||||
b"<< /T (page1[0]) /Parent 6 0 R /Kids [8 0 R 9 0 R] >>".to_vec(),
|
||||
b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name[0]) /Parent 7 0 R /Rect [72 700 300 720] /P 3 0 R /DA (/Helv 10 Tf 0 g) /MK << /BC [0 0 0] >> >>".to_vec(),
|
||||
b"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree[0]) /Parent 7 0 R /Rect [72 660 84 672] /P 3 0 R /V /Off /AS /Off /MK << /BC [0 0 0] >> /AP << /N << /1 10 0 R /Off 10 0 R >> >> >>".to_vec(),
|
||||
b"<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Length 0 >>\nstream\n\nendstream".to_vec(),
|
||||
];
|
||||
assemble(objs)
|
||||
}
|
||||
|
||||
fn assemble(objs: Vec<Vec<u8>>) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// A PDF shell around `xdp`: one placeholder page, `/NeedsRendering true`, no fields.
|
||||
pub fn shell(xdp: &str) -> Vec<u8> {
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".to_vec(),
|
||||
b"<< /Length 44 >>\nstream\nBT /F1 12 Tf 72 700 Td (Please wait...) Tj ET\nendstream".to_vec(),
|
||||
format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()).into_bytes(),
|
||||
];
|
||||
assemble(objs)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,92 @@
|
|||
//! pdfcraft-xfa — XFA forms (L3). See the README.
|
||||
//!
|
||||
//! A dynamic XFA form is a PDF shell around an XML template; viewers without an XFA engine
|
||||
//! show its one placeholder page ("requires Adobe Reader"). This crate reads the template,
|
||||
//! lays it out and writes ordinary pages and AcroForm fields into the document, so the rest
|
||||
//! of PdfCraft (rendering, filling, saving, printing, tools) works on it unchanged.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod data;
|
||||
pub mod fixtures;
|
||||
pub mod layout;
|
||||
pub mod model;
|
||||
pub mod packets;
|
||||
pub mod parse;
|
||||
pub mod pdf;
|
||||
pub mod text;
|
||||
|
||||
use pdfcraft_cos::Document;
|
||||
|
||||
pub use data::{
|
||||
DataNode, DatasetsWrite, FieldData, FieldDatum, build_data, iso_to_pattern, parse_datasets, pattern_to_iso, read_values, som_to_path,
|
||||
write_datasets,
|
||||
};
|
||||
pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, layout};
|
||||
pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets};
|
||||
pub use parse::{measure, parse};
|
||||
pub use pdf::{LAYOUT_KEY, SOM_KEY, existing_layout};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum XfaError {
|
||||
#[error("the document is not an XFA form")]
|
||||
NotXfa,
|
||||
#[error("the XFA template could not be read: {0}")]
|
||||
Malformed(String),
|
||||
#[error("{0}")]
|
||||
TooLarge(String),
|
||||
#[error("{0}")]
|
||||
Cos(#[from] pdfcraft_cos::CosError),
|
||||
}
|
||||
|
||||
/// What laying out a form produced.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Report {
|
||||
pub pages: usize,
|
||||
pub fields: usize,
|
||||
/// Template bytes read.
|
||||
pub template_bytes: usize,
|
||||
/// What was approximated or left out, for the user.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Is this a dynamic XFA form still showing its placeholder pages (one PdfCraft has not laid
|
||||
/// out yet)?
|
||||
pub fn is_dynamic(doc: &Document) -> bool {
|
||||
existing_layout(doc).is_none() && matches!(read_packets(doc), Ok(Some(p)) if p.needs_rendering || !p.has_fields)
|
||||
}
|
||||
|
||||
/// Lay a template packet (or whole XDP, whose datasets then fill the fields) out.
|
||||
pub fn layout_xml(xml: &str) -> Result<Form, XfaError> {
|
||||
let (tpl, warnings) = parse(xml)?;
|
||||
let data = parse_datasets(xml);
|
||||
let mut form = layout(&tpl, data.as_ref())?;
|
||||
form.warnings.splice(0..0, warnings);
|
||||
Ok(form)
|
||||
}
|
||||
|
||||
/// Lay a dynamic XFA form out into its own document: its placeholder pages are replaced by the
|
||||
/// laid-out pages and its fields join the AcroForm, holding the values of the datasets packet.
|
||||
/// The XFA packets stay, so Adobe's viewers keep rendering it their way. Widget appearances are
|
||||
/// left for the forms layer to generate.
|
||||
pub fn render_into(doc: &mut Document) -> Result<Report, XfaError> {
|
||||
if !is_dynamic(doc) {
|
||||
return Err(XfaError::NotXfa);
|
||||
}
|
||||
let packets = read_packets(doc)?.ok_or(XfaError::NotXfa)?;
|
||||
if packets.xdp.trim().is_empty() {
|
||||
return Err(XfaError::Malformed("the XFA packets hold no template".into()));
|
||||
}
|
||||
let form = layout_xml(&packets.xdp)?;
|
||||
if form.pages.is_empty() {
|
||||
return Err(XfaError::Malformed("the template laid out to no pages".into()));
|
||||
}
|
||||
let written = pdf::write_form(doc, &form)?;
|
||||
let mut warnings = form.warnings;
|
||||
warnings.extend(written.warnings);
|
||||
warnings.dedup();
|
||||
Ok(Report { pages: written.pages, fields: written.fields, template_bytes: packets.xdp.len(), warnings })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,568 @@
|
|||
//! The template model: what the layout engine needs from an XFA template (XFA 3.3 part 2,
|
||||
//! "Template Specification"). Every measurement is in points, every position is top-left
|
||||
//! based, as in XFA itself.
|
||||
|
||||
/// Whether an object is shown and whether it takes space.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Presence {
|
||||
#[default]
|
||||
Visible,
|
||||
/// Takes space, draws nothing.
|
||||
Invisible,
|
||||
/// Takes no space.
|
||||
Hidden,
|
||||
/// Hidden, and its scripts don't run.
|
||||
Inactive,
|
||||
}
|
||||
|
||||
impl Presence {
|
||||
pub fn parse(s: Option<&str>) -> Presence {
|
||||
match s.map(str::trim) {
|
||||
Some("invisible") => Presence::Invisible,
|
||||
Some("hidden") => Presence::Hidden,
|
||||
Some("inactive") => Presence::Inactive,
|
||||
_ => Presence::Visible,
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes space in the layout.
|
||||
pub fn occupies(self) -> bool {
|
||||
matches!(self, Presence::Visible | Presence::Invisible)
|
||||
}
|
||||
}
|
||||
|
||||
/// How a subform places its children.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Layout {
|
||||
#[default]
|
||||
Positioned,
|
||||
/// Top to bottom.
|
||||
Tb,
|
||||
/// Left to right, then top to bottom.
|
||||
LrTb,
|
||||
/// Right to left, then top to bottom (laid out like `LrTb`, mirrored).
|
||||
RlTb,
|
||||
Table,
|
||||
Row,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
pub fn parse(s: Option<&str>) -> Layout {
|
||||
match s.map(str::trim) {
|
||||
Some("tb") => Layout::Tb,
|
||||
Some("lr-tb") => Layout::LrTb,
|
||||
Some("rl-tb") => Layout::RlTb,
|
||||
Some("table") => Layout::Table,
|
||||
Some("row") => Layout::Row,
|
||||
_ => Layout::Positioned,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Margin {
|
||||
pub top: f64,
|
||||
pub right: f64,
|
||||
pub bottom: f64,
|
||||
pub left: f64,
|
||||
}
|
||||
|
||||
impl Margin {
|
||||
pub fn horizontal(&self) -> f64 {
|
||||
self.left + self.right
|
||||
}
|
||||
|
||||
pub fn vertical(&self) -> f64 {
|
||||
self.top + self.bottom
|
||||
}
|
||||
}
|
||||
|
||||
/// An RGB colour, components 0–1.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Color(pub [f64; 3]);
|
||||
|
||||
impl Color {
|
||||
pub const BLACK: Color = Color([0.0, 0.0, 0.0]);
|
||||
pub const WHITE: Color = Color([1.0, 1.0, 1.0]);
|
||||
|
||||
/// `value="r,g,b"` with components 0–255.
|
||||
pub fn parse(s: &str) -> Option<Color> {
|
||||
let mut parts = s.split(',').map(|p| p.trim().parse::<f64>().ok());
|
||||
let (r, g, b) = (parts.next()??, parts.next()??, parts.next()??);
|
||||
let c = |v: f64| if v.is_finite() { (v / 255.0).clamp(0.0, 1.0) } else { 0.0 };
|
||||
Some(Color([c(r), c(g), c(b)]))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Stroke {
|
||||
#[default]
|
||||
Solid,
|
||||
Dashed,
|
||||
Dotted,
|
||||
/// Raised, lowered, etched and embossed: drawn as a solid grey line.
|
||||
Relief,
|
||||
}
|
||||
|
||||
/// One side of a border.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Edge {
|
||||
pub presence: Presence,
|
||||
pub thickness: f64,
|
||||
pub color: Color,
|
||||
pub stroke: Stroke,
|
||||
}
|
||||
|
||||
impl Default for Edge {
|
||||
fn default() -> Self {
|
||||
Edge { presence: Presence::Visible, thickness: 0.5, color: Color::BLACK, stroke: Stroke::Solid }
|
||||
}
|
||||
}
|
||||
|
||||
impl Edge {
|
||||
pub fn visible(&self) -> bool {
|
||||
self.presence == Presence::Visible && self.thickness > 0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A box border: four edges (top, right, bottom, left) and a fill.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Border {
|
||||
pub presence: Presence,
|
||||
pub edges: [Edge; 4],
|
||||
pub fill: Option<Color>,
|
||||
}
|
||||
|
||||
impl Border {
|
||||
pub fn visible_edges(&self) -> [bool; 4] {
|
||||
if self.presence != Presence::Visible {
|
||||
return [false; 4];
|
||||
}
|
||||
[self.edges[0].visible(), self.edges[1].visible(), self.edges[2].visible(), self.edges[3].visible()]
|
||||
}
|
||||
|
||||
pub fn fill(&self) -> Option<Color> {
|
||||
if self.presence == Presence::Visible { self.fill } else { None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Font properties as written on an element; `None` inherits from the ancestors.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct FontSpec {
|
||||
pub typeface: Option<String>,
|
||||
pub size: Option<f64>,
|
||||
pub bold: Option<bool>,
|
||||
pub italic: Option<bool>,
|
||||
pub underline: Option<bool>,
|
||||
pub color: Option<Color>,
|
||||
}
|
||||
|
||||
/// A font with every property resolved.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Font {
|
||||
pub typeface: String,
|
||||
pub size: f64,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
pub underline: bool,
|
||||
pub color: Color,
|
||||
}
|
||||
|
||||
impl Default for Font {
|
||||
/// XFA's default font: Courier 10 pt.
|
||||
fn default() -> Self {
|
||||
Font { typeface: "Courier".into(), size: 10.0, bold: false, italic: false, underline: false, color: Color::BLACK }
|
||||
}
|
||||
}
|
||||
|
||||
impl Font {
|
||||
pub fn apply(&self, spec: &FontSpec) -> Font {
|
||||
Font {
|
||||
typeface: spec.typeface.clone().unwrap_or_else(|| self.typeface.clone()),
|
||||
size: spec.size.unwrap_or(self.size),
|
||||
bold: spec.bold.unwrap_or(self.bold),
|
||||
italic: spec.italic.unwrap_or(self.italic),
|
||||
underline: spec.underline.unwrap_or(self.underline),
|
||||
color: spec.color.unwrap_or(self.color),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum HAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
Justify,
|
||||
}
|
||||
|
||||
impl HAlign {
|
||||
pub fn parse(s: Option<&str>) -> Option<HAlign> {
|
||||
match s.map(str::trim) {
|
||||
Some("left") => Some(HAlign::Left),
|
||||
Some("center") => Some(HAlign::Center),
|
||||
Some("right") => Some(HAlign::Right),
|
||||
Some("justify" | "justifyAll") => Some(HAlign::Justify),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum VAlign {
|
||||
#[default]
|
||||
Top,
|
||||
Middle,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl VAlign {
|
||||
pub fn parse(s: Option<&str>) -> Option<VAlign> {
|
||||
match s.map(str::trim) {
|
||||
Some("top") => Some(VAlign::Top),
|
||||
Some("middle") => Some(VAlign::Middle),
|
||||
Some("bottom") => Some(VAlign::Bottom),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph properties as written on an element; `None` inherits.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ParaSpec {
|
||||
pub h_align: Option<HAlign>,
|
||||
pub v_align: Option<VAlign>,
|
||||
pub line_height: Option<f64>,
|
||||
pub margin_left: Option<f64>,
|
||||
pub margin_right: Option<f64>,
|
||||
pub space_above: Option<f64>,
|
||||
pub space_below: Option<f64>,
|
||||
pub text_indent: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Para {
|
||||
pub h_align: HAlign,
|
||||
pub v_align: VAlign,
|
||||
/// `None`: 1.15 × the font size.
|
||||
pub line_height: Option<f64>,
|
||||
pub margin_left: f64,
|
||||
pub margin_right: f64,
|
||||
pub space_above: f64,
|
||||
pub space_below: f64,
|
||||
pub text_indent: f64,
|
||||
}
|
||||
|
||||
impl Para {
|
||||
pub fn apply(&self, spec: &ParaSpec) -> Para {
|
||||
Para {
|
||||
h_align: spec.h_align.unwrap_or(self.h_align),
|
||||
v_align: spec.v_align.unwrap_or(self.v_align),
|
||||
line_height: spec.line_height.or(self.line_height),
|
||||
margin_left: spec.margin_left.unwrap_or(self.margin_left),
|
||||
margin_right: spec.margin_right.unwrap_or(self.margin_right),
|
||||
space_above: spec.space_above.unwrap_or(self.space_above),
|
||||
space_below: spec.space_below.unwrap_or(self.space_below),
|
||||
text_indent: spec.text_indent.unwrap_or(self.text_indent),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What every container and leaf shares: name, box, presence, margins, border, text defaults.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Common {
|
||||
pub name: Option<String>,
|
||||
pub id: Option<String>,
|
||||
pub x: Option<f64>,
|
||||
pub y: Option<f64>,
|
||||
pub w: Option<f64>,
|
||||
pub h: Option<f64>,
|
||||
pub min_w: Option<f64>,
|
||||
pub max_w: Option<f64>,
|
||||
pub min_h: Option<f64>,
|
||||
pub max_h: Option<f64>,
|
||||
pub presence: Presence,
|
||||
pub margin: Margin,
|
||||
pub border: Option<Border>,
|
||||
pub font: FontSpec,
|
||||
pub para: ParaSpec,
|
||||
/// Table cells: how many columns this cell spans.
|
||||
pub col_span: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Node {
|
||||
Subform(Box<Subform>),
|
||||
Area(Box<Area>),
|
||||
Field(Box<Field>),
|
||||
Draw(Box<Draw>),
|
||||
ExclGroup(Box<ExclGroup>),
|
||||
}
|
||||
|
||||
impl Node {
|
||||
pub fn common(&self) -> &Common {
|
||||
match self {
|
||||
Node::Subform(s) => &s.common,
|
||||
Node::Area(a) => &a.common,
|
||||
Node::Field(f) => &f.common,
|
||||
Node::Draw(d) => &d.common,
|
||||
Node::ExclGroup(g) => &g.common,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How often a subform occurs when no data drives it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Occur {
|
||||
pub min: usize,
|
||||
/// `None`: unbounded.
|
||||
pub max: Option<usize>,
|
||||
pub initial: usize,
|
||||
}
|
||||
|
||||
impl Default for Occur {
|
||||
fn default() -> Self {
|
||||
Occur { min: 1, max: Some(1), initial: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Subform {
|
||||
pub common: Common,
|
||||
pub layout: Layout,
|
||||
pub children: Vec<Node>,
|
||||
pub page_set: Option<PageSet>,
|
||||
pub occur: Occur,
|
||||
/// `<breakBefore targetType="pageArea">`: start on a new page.
|
||||
pub break_before_page: bool,
|
||||
pub break_after_page: bool,
|
||||
/// Tables: the width of each column.
|
||||
pub column_widths: Vec<f64>,
|
||||
/// `<overflow leader="…">`: the named child (a header row) is repeated after a page break.
|
||||
pub overflow_leader: Option<String>,
|
||||
}
|
||||
|
||||
/// A positioned group without a box of its own.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Area {
|
||||
pub common: Common,
|
||||
pub children: Vec<Node>,
|
||||
}
|
||||
|
||||
/// A radio button group.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ExclGroup {
|
||||
pub common: Common,
|
||||
pub layout: Layout,
|
||||
pub fields: Vec<Field>,
|
||||
pub tooltip: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Ui {
|
||||
#[default]
|
||||
TextEdit,
|
||||
NumericEdit,
|
||||
DateTimeEdit,
|
||||
CheckButton,
|
||||
ChoiceList,
|
||||
Button,
|
||||
Signature,
|
||||
ImageEdit,
|
||||
Barcode,
|
||||
PasswordEdit,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Script {
|
||||
pub activity: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Access {
|
||||
#[default]
|
||||
Open,
|
||||
ReadOnly,
|
||||
Protected,
|
||||
NonInteractive,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Field {
|
||||
pub common: Common,
|
||||
pub ui: Ui,
|
||||
/// The border drawn by the widget itself (`<ui><textEdit><border>`), inside the field.
|
||||
pub ui_border: Option<Border>,
|
||||
pub ui_margin: Margin,
|
||||
/// Check buttons: the side of the square (default 10 pt) and whether it is round.
|
||||
pub check_size: Option<f64>,
|
||||
pub check_round: bool,
|
||||
pub multiline: bool,
|
||||
pub caption: Option<Caption>,
|
||||
pub value: Value,
|
||||
/// Check buttons: the on value then the off value; choice lists: the items.
|
||||
pub items: Vec<String>,
|
||||
pub max_chars: Option<usize>,
|
||||
pub tooltip: Option<String>,
|
||||
pub access: Access,
|
||||
/// The edit picture clause (`<ui>…<picture>`), e.g. `date{YYYY-MM-DD}`.
|
||||
pub picture: Option<String>,
|
||||
pub scripts: Vec<Script>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Draw {
|
||||
pub common: Common,
|
||||
pub value: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub enum Value {
|
||||
#[default]
|
||||
Empty,
|
||||
Text(String),
|
||||
Rich(RichText),
|
||||
Image {
|
||||
content_type: String,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
/// A diagonal line through the box: `\` (top-left to bottom-right) or `/`.
|
||||
Line {
|
||||
slope_up: bool,
|
||||
edge: Edge,
|
||||
},
|
||||
Rectangle(Border),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
match self {
|
||||
Value::Empty => true,
|
||||
Value::Text(t) => t.trim().is_empty(),
|
||||
Value::Rich(r) => r.paragraphs.iter().all(|p| p.runs.iter().all(|r| r.text.trim().is_empty() && r.embed.is_none())),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The plain text of a text or rich value.
|
||||
pub fn plain(&self) -> Option<String> {
|
||||
match self {
|
||||
Value::Text(t) => Some(t.clone()),
|
||||
Value::Rich(r) => Some(r.plain()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rich text (`exData` with XHTML): paragraphs of styled runs.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct RichText {
|
||||
pub paragraphs: Vec<Paragraph>,
|
||||
}
|
||||
|
||||
impl RichText {
|
||||
pub fn plain(&self) -> String {
|
||||
self.paragraphs.iter().map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Paragraph {
|
||||
pub runs: Vec<Run>,
|
||||
pub h_align: Option<HAlign>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Run {
|
||||
pub text: String,
|
||||
pub bold: Option<bool>,
|
||||
pub italic: Option<bool>,
|
||||
pub underline: Option<bool>,
|
||||
pub size: Option<f64>,
|
||||
/// A floating field embedded in the text (`xfa:embed="#id"`), shown by its value.
|
||||
pub embed: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Placement {
|
||||
#[default]
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
Inline,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Caption {
|
||||
pub placement: Placement,
|
||||
/// Space set aside for the caption along its placement axis.
|
||||
pub reserve: Option<f64>,
|
||||
pub presence: Presence,
|
||||
pub value: Value,
|
||||
pub font: FontSpec,
|
||||
pub para: ParaSpec,
|
||||
}
|
||||
|
||||
/// A rectangle in points, top-left based.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Rect {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub w: f64,
|
||||
pub h: f64,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
pub fn new(x: f64, y: f64, w: f64, h: f64) -> Rect {
|
||||
Rect { x, y, w, h: h.max(0.0) }
|
||||
}
|
||||
|
||||
pub fn right(&self) -> f64 {
|
||||
self.x + self.w
|
||||
}
|
||||
|
||||
pub fn bottom(&self) -> f64 {
|
||||
self.y + self.h
|
||||
}
|
||||
|
||||
pub fn inset(&self, m: &Margin) -> Rect {
|
||||
Rect::new(self.x + m.left, self.y + m.top, (self.w - m.horizontal()).max(0.0), (self.h - m.vertical()).max(0.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// The master pages.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct PageSet {
|
||||
pub areas: Vec<PageArea>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct PageArea {
|
||||
pub name: Option<String>,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
/// Where flowed content goes, in order.
|
||||
pub content: Vec<Rect>,
|
||||
/// Draws and fields printed on every page that uses this master.
|
||||
pub items: Vec<Node>,
|
||||
/// How many pages may use this master (`None`: unbounded).
|
||||
pub occur_max: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Template {
|
||||
pub root: Subform,
|
||||
/// Fields that compute the page number or the page count on layout, by `id`, so text that
|
||||
/// embeds them can show the numbers.
|
||||
pub page_roles: Vec<(String, PageRole)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PageRole {
|
||||
Number,
|
||||
Count,
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
//! The XDP packets inside a PDF (`/AcroForm /XFA`, ISO 32000-1 §12.7.8): either one stream
|
||||
//! holding the whole XDP, or an array of (name, stream) pairs.
|
||||
|
||||
use pdfcraft_cos::{Document, Object};
|
||||
|
||||
use crate::XfaError;
|
||||
|
||||
/// Most decoded packet bytes read in total.
|
||||
const MAX_XDP: usize = 64 << 20;
|
||||
|
||||
/// What was found in the XFA entry.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Packets {
|
||||
/// The XDP as one XML document (packets concatenated in order), or the template packet
|
||||
/// alone when there is no XDP envelope. Empty when the entry holds no template.
|
||||
pub xdp: String,
|
||||
/// `/NeedsRendering true`: the form expects to be laid out from the template.
|
||||
pub needs_rendering: bool,
|
||||
/// Whether the AcroForm has any fields of its own.
|
||||
pub has_fields: bool,
|
||||
}
|
||||
|
||||
/// How a packet's bytes encode its text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Encoding {
|
||||
/// UTF-8, with or without a byte order mark.
|
||||
Utf8 { bom: bool },
|
||||
/// UTF-16, little-endian, with or without a byte order mark.
|
||||
Utf16Le { bom: bool },
|
||||
/// UTF-16, big-endian, with or without a byte order mark.
|
||||
Utf16Be { bom: bool },
|
||||
}
|
||||
|
||||
fn utf16(bytes: &[u8], from: fn([u8; 2]) -> u16) -> Option<String> {
|
||||
let (chunks, rest) = bytes.as_chunks::<2>();
|
||||
if !rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
char::decode_utf16(chunks.iter().map(|c| from(*c))).collect::<Result<String, _>>().ok()
|
||||
}
|
||||
|
||||
/// A packet's text and how it was encoded, when it decodes without loss: UTF-8 or UTF-16,
|
||||
/// told apart by a byte order mark or (without one) by how `<` is written. `None` for
|
||||
/// anything else (invalid UTF-8, a legacy encoding, a broken surrogate), which can be read
|
||||
/// only approximately and so must not be written back.
|
||||
pub fn decode(bytes: &[u8]) -> Option<(String, Encoding)> {
|
||||
match bytes {
|
||||
[0xFF, 0xFE, rest @ ..] => utf16(rest, u16::from_le_bytes).map(|t| (t, Encoding::Utf16Le { bom: true })),
|
||||
[0xFE, 0xFF, rest @ ..] => utf16(rest, u16::from_be_bytes).map(|t| (t, Encoding::Utf16Be { bom: true })),
|
||||
[0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8(rest.to_vec()).ok().map(|t| (t, Encoding::Utf8 { bom: true })),
|
||||
[b'<', 0, ..] => utf16(bytes, u16::from_le_bytes).map(|t| (t, Encoding::Utf16Le { bom: false })),
|
||||
[0, b'<', ..] => utf16(bytes, u16::from_be_bytes).map(|t| (t, Encoding::Utf16Be { bom: false })),
|
||||
_ => String::from_utf8(bytes.to_vec()).ok().map(|t| (t, Encoding::Utf8 { bom: false })),
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` in `encoding` (the inverse of [`decode`]).
|
||||
pub fn encode(text: &str, encoding: Encoding) -> Vec<u8> {
|
||||
let units = |bom: bool, to: fn(u16) -> [u8; 2]| {
|
||||
let mut out = Vec::with_capacity(text.len().saturating_mul(2).saturating_add(2));
|
||||
if bom {
|
||||
out.extend_from_slice(&to(0xFEFF));
|
||||
}
|
||||
for u in text.encode_utf16() {
|
||||
out.extend_from_slice(&to(u));
|
||||
}
|
||||
out
|
||||
};
|
||||
match encoding {
|
||||
Encoding::Utf8 { bom } => {
|
||||
let mut out = Vec::with_capacity(text.len() + 3);
|
||||
if bom {
|
||||
out.extend_from_slice(&[0xEF, 0xBB, 0xBF]);
|
||||
}
|
||||
out.extend_from_slice(text.as_bytes());
|
||||
out
|
||||
}
|
||||
Encoding::Utf16Le { bom } => units(bom, u16::to_le_bytes),
|
||||
Encoding::Utf16Be { bom } => units(bom, u16::to_be_bytes),
|
||||
}
|
||||
}
|
||||
|
||||
/// A packet's text for reading: exact when it decodes, else UTF-8 with replacement characters.
|
||||
fn to_text(bytes: &[u8]) -> String {
|
||||
if let Some((text, _)) = decode(bytes) {
|
||||
return text;
|
||||
}
|
||||
let lossy16 = |rest: &[u8], from: fn([u8; 2]) -> u16| -> String {
|
||||
char::decode_utf16(rest.as_chunks::<2>().0.iter().map(|c| from(*c))).map(|c| c.unwrap_or('\u{FFFD}')).collect()
|
||||
};
|
||||
match bytes {
|
||||
[0xFF, 0xFE, rest @ ..] => lossy16(rest, u16::from_le_bytes),
|
||||
[0xFE, 0xFF, rest @ ..] => lossy16(rest, u16::from_be_bytes),
|
||||
[b'<', 0, ..] => lossy16(bytes, u16::from_le_bytes),
|
||||
[0, b'<', ..] => lossy16(bytes, u16::from_be_bytes),
|
||||
_ => String::from_utf8_lossy(bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes)).into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the XFA packets, if the document has any.
|
||||
pub fn read_packets(doc: &Document) -> Result<Option<Packets>, 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(acro) = acro.as_dict() else { return Ok(None) };
|
||||
let Some(xfa) = acro.get(b"XFA").map(|x| doc.resolve(x)) else { return Ok(None) };
|
||||
// `/NeedsRendering` is a catalog entry (ISO 32000-1 Table 28); some producers put it on
|
||||
// the form dictionary, so look there too.
|
||||
let bool_of = |v: Option<&Object>| match v.map(|v| doc.resolve(v)).as_deref() {
|
||||
Some(Object::Bool(b)) => Some(*b),
|
||||
_ => None,
|
||||
};
|
||||
let needs_rendering = bool_of(catalog.as_dict().and_then(|c| c.get(b"NeedsRendering"))).or_else(|| bool_of(acro.get(b"NeedsRendering")));
|
||||
let has_fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().map(|a| !a.is_empty())).unwrap_or(false);
|
||||
let mut total = 0usize;
|
||||
let mut read = |o: &Object| -> Result<Option<String>, XfaError> {
|
||||
let s = doc.resolve(o);
|
||||
let Object::Stream(s) = &*s else { return Ok(None) };
|
||||
let bytes = s.decoded_within(MAX_XDP)?;
|
||||
total = total.saturating_add(bytes.len());
|
||||
if total > MAX_XDP {
|
||||
return Err(XfaError::TooLarge("the XFA packets are larger than 64 MB".into()));
|
||||
}
|
||||
Ok(Some(to_text(&bytes)))
|
||||
};
|
||||
let mut xdp = String::new();
|
||||
match &*xfa {
|
||||
Object::Array(items) => {
|
||||
let mut template = None;
|
||||
let mut i = 0;
|
||||
while i < items.len() {
|
||||
let name = items.get(i).and_then(|n| n.as_string()).map(|s| s.to_text());
|
||||
let stream = if name.is_some() { items.get(i + 1) } else { items.get(i) };
|
||||
if let Some(o) = stream
|
||||
&& let Some(text) = read(o)?
|
||||
{
|
||||
if name.as_deref() == Some("template") {
|
||||
template = Some(text.clone());
|
||||
}
|
||||
xdp.push_str(&text);
|
||||
xdp.push('\n');
|
||||
}
|
||||
i += if name.is_some() { 2 } else { 1 };
|
||||
}
|
||||
if !xdp.contains("<template") {
|
||||
xdp = template.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
Object::Stream(_) => {
|
||||
xdp = read(&xfa)?.unwrap_or_default();
|
||||
}
|
||||
// Not a stream or an array: an XFA entry all the same, with nothing to lay out.
|
||||
_ => {}
|
||||
}
|
||||
if !xdp.contains("<template") {
|
||||
xdp.clear();
|
||||
}
|
||||
Ok(Some(Packets { xdp, needs_rendering: needs_rendering.unwrap_or(false), has_fields }))
|
||||
}
|
||||
|
|
@ -0,0 +1,583 @@
|
|||
//! Template XML → [`model`](crate::model). Only what the layout engine uses is read; everything
|
||||
//! else in the template is ignored, never rejected.
|
||||
|
||||
use base64::Engine;
|
||||
use roxmltree::Node as XmlNode;
|
||||
|
||||
use crate::XfaError;
|
||||
use crate::model::*;
|
||||
|
||||
/// Deepest element nesting followed (templates are shallow; hostile ones are not).
|
||||
const MAX_DEPTH: usize = 64;
|
||||
/// Most XML nodes a template may have.
|
||||
const MAX_NODES: u32 = 2_000_000;
|
||||
/// Largest decoded inline image.
|
||||
const MAX_IMAGE: usize = 32 << 20;
|
||||
/// Longest text run kept.
|
||||
const MAX_TEXT: usize = 1 << 20;
|
||||
|
||||
/// Parse a measurement such as `12.7mm`, `72pt`, `1in`, `2.54cm` into points. Bare numbers are
|
||||
/// points. `None` for anything else (including `em`).
|
||||
pub fn measure(s: &str) -> Option<f64> {
|
||||
let s = s.trim();
|
||||
let split = s.find(|c: char| c.is_ascii_alphabetic() || c == '%').unwrap_or(s.len());
|
||||
let (num, unit) = s.split_at(split);
|
||||
let v: f64 = num.trim().parse().ok()?;
|
||||
if !v.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let k = match unit.trim() {
|
||||
"" | "pt" => 1.0,
|
||||
"mm" => 72.0 / 25.4,
|
||||
"cm" => 72.0 / 2.54,
|
||||
"in" => 72.0,
|
||||
"px" => 0.75,
|
||||
"pc" => 12.0,
|
||||
_ => return None,
|
||||
};
|
||||
Some((v * k).clamp(-100_000.0, 100_000.0))
|
||||
}
|
||||
|
||||
fn attr_measure(n: XmlNode, name: &str) -> Option<f64> {
|
||||
n.attribute(name).and_then(measure)
|
||||
}
|
||||
|
||||
fn child<'a, 'd>(n: XmlNode<'a, 'd>, name: &str) -> Option<XmlNode<'a, 'd>> {
|
||||
n.children().find(|c| c.is_element() && c.tag_name().name() == name)
|
||||
}
|
||||
|
||||
fn children<'a, 'd>(n: XmlNode<'a, 'd>, name: &'a str) -> impl Iterator<Item = XmlNode<'a, 'd>> + 'a {
|
||||
n.children().filter(move |c| c.is_element() && c.tag_name().name() == name)
|
||||
}
|
||||
|
||||
fn text_of(n: XmlNode) -> String {
|
||||
let mut s = String::new();
|
||||
for d in n.descendants() {
|
||||
if d.is_text()
|
||||
&& let Some(t) = d.text()
|
||||
{
|
||||
s.push_str(t);
|
||||
if s.len() > MAX_TEXT {
|
||||
s.truncate(s.char_indices().take_while(|(i, _)| *i < MAX_TEXT).last().map_or(0, |(i, c)| i + c.len_utf8()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn parse_margin(n: Option<XmlNode>) -> Margin {
|
||||
let Some(n) = n else { return Margin::default() };
|
||||
Margin {
|
||||
top: attr_measure(n, "topInset").unwrap_or(0.0),
|
||||
right: attr_measure(n, "rightInset").unwrap_or(0.0),
|
||||
bottom: attr_measure(n, "bottomInset").unwrap_or(0.0),
|
||||
left: attr_measure(n, "leftInset").unwrap_or(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_color(n: Option<XmlNode>) -> Option<Color> {
|
||||
n.and_then(|c| c.attribute("value")).and_then(Color::parse)
|
||||
}
|
||||
|
||||
fn parse_edge(n: XmlNode) -> Edge {
|
||||
Edge {
|
||||
presence: Presence::parse(n.attribute("presence")),
|
||||
thickness: attr_measure(n, "thickness").unwrap_or(0.5).max(0.0),
|
||||
color: parse_color(child(n, "color")).unwrap_or(Color::BLACK),
|
||||
stroke: match n.attribute("stroke") {
|
||||
Some("dashed" | "dashDot" | "dashDotDot") => Stroke::Dashed,
|
||||
Some("dotted") => Stroke::Dotted,
|
||||
Some("raised" | "lowered" | "etched" | "embossed") => Stroke::Relief,
|
||||
_ => Stroke::Solid,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `<fill>`: a colour, or white when the element is present without one.
|
||||
fn parse_fill(n: Option<XmlNode>) -> Option<Color> {
|
||||
let n = n?;
|
||||
if Presence::parse(n.attribute("presence")) != Presence::Visible {
|
||||
return None;
|
||||
}
|
||||
Some(parse_color(child(n, "color")).unwrap_or(Color::WHITE))
|
||||
}
|
||||
|
||||
/// `<border>`: edges in XFA order. One edge applies to all four; two: top/bottom then
|
||||
/// left/right; three: top, left/right, bottom; four: top, right, bottom, left.
|
||||
fn parse_border(n: Option<XmlNode>) -> Option<Border> {
|
||||
let n = n?;
|
||||
let edges: Vec<Edge> = children(n, "edge").map(parse_edge).collect();
|
||||
let d = Edge::default();
|
||||
let e = |i: usize| edges.get(i).copied().unwrap_or(d);
|
||||
let four = match edges.len() {
|
||||
0 => [d; 4],
|
||||
1 => [e(0); 4],
|
||||
2 => [e(0), e(1), e(0), e(1)],
|
||||
3 => [e(0), e(1), e(2), e(1)],
|
||||
_ => [e(0), e(1), e(2), e(3)],
|
||||
};
|
||||
Some(Border { presence: Presence::parse(n.attribute("presence")), edges: four, fill: parse_fill(child(n, "fill")) })
|
||||
}
|
||||
|
||||
fn parse_font(n: Option<XmlNode>) -> FontSpec {
|
||||
let Some(n) = n else { return FontSpec::default() };
|
||||
FontSpec {
|
||||
typeface: n.attribute("typeface").map(|t| t.trim().to_string()).filter(|t| !t.is_empty()),
|
||||
size: attr_measure(n, "size").filter(|s| *s > 0.0),
|
||||
bold: n.attribute("weight").map(|w| w == "bold"),
|
||||
italic: n.attribute("posture").map(|p| p == "italic"),
|
||||
underline: n.attribute("underline").map(|u| u != "0"),
|
||||
color: parse_color(child(n, "fill").and_then(|f| child(f, "color"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_para(n: Option<XmlNode>) -> ParaSpec {
|
||||
let Some(n) = n else { return ParaSpec::default() };
|
||||
ParaSpec {
|
||||
h_align: HAlign::parse(n.attribute("hAlign")),
|
||||
v_align: VAlign::parse(n.attribute("vAlign")),
|
||||
line_height: attr_measure(n, "lineHeight").filter(|v| *v > 0.0),
|
||||
margin_left: attr_measure(n, "marginLeft"),
|
||||
margin_right: attr_measure(n, "marginRight"),
|
||||
space_above: attr_measure(n, "spaceAbove"),
|
||||
space_below: attr_measure(n, "spaceBelow"),
|
||||
text_indent: attr_measure(n, "textIndent"),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_common(n: XmlNode) -> Common {
|
||||
Common {
|
||||
name: n.attribute("name").map(str::to_string).filter(|s| !s.is_empty()),
|
||||
id: n.attribute("id").map(str::to_string),
|
||||
x: attr_measure(n, "x"),
|
||||
y: attr_measure(n, "y"),
|
||||
w: attr_measure(n, "w"),
|
||||
h: attr_measure(n, "h"),
|
||||
min_w: attr_measure(n, "minW"),
|
||||
max_w: attr_measure(n, "maxW"),
|
||||
min_h: attr_measure(n, "minH"),
|
||||
max_h: attr_measure(n, "maxH"),
|
||||
presence: Presence::parse(n.attribute("presence")),
|
||||
margin: parse_margin(child(n, "margin")),
|
||||
border: parse_border(child(n, "border")),
|
||||
font: parse_font(child(n, "font")),
|
||||
para: parse_para(child(n, "para")),
|
||||
col_span: n.attribute("colSpan").and_then(|c| c.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, 64),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rich text: XHTML `body > p > span…`. `br` starts a new paragraph; `xfa:embed` spans become
|
||||
/// embedded fields.
|
||||
fn parse_rich(body: XmlNode) -> RichText {
|
||||
#[derive(Clone, Default)]
|
||||
struct Style {
|
||||
bold: Option<bool>,
|
||||
italic: Option<bool>,
|
||||
underline: Option<bool>,
|
||||
size: Option<f64>,
|
||||
}
|
||||
fn styled(base: &Style, css: Option<&str>) -> Style {
|
||||
let mut s = base.clone();
|
||||
for decl in css.unwrap_or("").split(';') {
|
||||
let Some((k, v)) = decl.split_once(':') else { continue };
|
||||
let v = v.trim();
|
||||
match k.trim() {
|
||||
"font-weight" => s.bold = Some(v == "bold" || v.parse::<u32>().is_ok_and(|w| w >= 600)),
|
||||
"font-style" => s.italic = Some(v == "italic" || v == "oblique"),
|
||||
"text-decoration" => s.underline = Some(v.contains("underline")),
|
||||
"font-size" => s.size = measure(v).filter(|x| *x > 0.0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
// Paragraph boundaries from `p` are implicit (dropped when empty); `br` makes an explicit
|
||||
// line break that stays even when the line is blank.
|
||||
fn walk(n: XmlNode, style: &Style, out: &mut RichText, explicit: &mut Vec<bool>, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for c in n.children() {
|
||||
if c.is_text() {
|
||||
if let Some(t) = c.text()
|
||||
&& !t.is_empty()
|
||||
{
|
||||
push_run(
|
||||
out,
|
||||
explicit,
|
||||
Run {
|
||||
text: t.to_string(),
|
||||
bold: style.bold,
|
||||
italic: style.italic,
|
||||
underline: style.underline,
|
||||
size: style.size,
|
||||
embed: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !c.is_element() {
|
||||
continue;
|
||||
}
|
||||
match c.tag_name().name() {
|
||||
"p" => {
|
||||
let align = c.attribute("style").and_then(|s| {
|
||||
s.split(';')
|
||||
.find_map(|d| d.split_once(':').filter(|(k, _)| k.trim() == "text-align").and_then(|(_, v)| HAlign::parse(Some(v))))
|
||||
});
|
||||
out.paragraphs.push(Paragraph { runs: Vec::new(), h_align: align });
|
||||
explicit.push(false);
|
||||
walk(c, &styled(style, c.attribute("style")), out, explicit, depth + 1);
|
||||
// The next text starts a new paragraph.
|
||||
out.paragraphs.push(Paragraph::default());
|
||||
explicit.push(false);
|
||||
}
|
||||
"br" => {
|
||||
out.paragraphs.push(Paragraph::default());
|
||||
explicit.push(true);
|
||||
}
|
||||
"span" | "b" | "i" | "u" | "a" | "div" | "font" => {
|
||||
let mut st = styled(style, c.attribute("style"));
|
||||
match c.tag_name().name() {
|
||||
"b" => st.bold = Some(true),
|
||||
"i" => st.italic = Some(true),
|
||||
"u" => st.underline = Some(true),
|
||||
_ => {}
|
||||
}
|
||||
let embed = c.attributes().find(|a| a.name() == "embed").map(|a| a.value().trim_start_matches('#').to_string());
|
||||
if let Some(e) = embed {
|
||||
push_run(
|
||||
out,
|
||||
explicit,
|
||||
Run { text: String::new(), bold: st.bold, italic: st.italic, underline: st.underline, size: st.size, embed: Some(e) },
|
||||
);
|
||||
}
|
||||
walk(c, &st, out, explicit, depth + 1);
|
||||
}
|
||||
_ => walk(c, style, out, explicit, depth + 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn push_run(out: &mut RichText, explicit: &mut Vec<bool>, run: Run) {
|
||||
if out.paragraphs.is_empty() {
|
||||
out.paragraphs.push(Paragraph::default());
|
||||
explicit.push(false);
|
||||
}
|
||||
if let Some(p) = out.paragraphs.last_mut() {
|
||||
p.runs.push(run);
|
||||
}
|
||||
}
|
||||
let mut out = RichText::default();
|
||||
let mut explicit = Vec::new();
|
||||
walk(body, &Style::default(), &mut out, &mut explicit, 0);
|
||||
// Drop the empty paragraphs `p` boundaries leave behind; blank lines from `br` stay.
|
||||
let mut cleaned: Vec<Paragraph> = Vec::new();
|
||||
for (i, p) in out.paragraphs.into_iter().enumerate() {
|
||||
let blank = p.runs.iter().all(|r| r.text.trim().is_empty() && r.embed.is_none());
|
||||
if blank && !explicit.get(i).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
cleaned.push(p);
|
||||
}
|
||||
while cleaned.last().is_some_and(|p| p.runs.iter().all(|r| r.text.trim().is_empty() && r.embed.is_none())) {
|
||||
cleaned.pop();
|
||||
}
|
||||
RichText { paragraphs: cleaned }
|
||||
}
|
||||
|
||||
/// `<value>`: text, rich text, an image, a line or a rectangle.
|
||||
fn parse_value(n: Option<XmlNode>, warnings: &mut Vec<String>) -> Value {
|
||||
let Some(n) = n else { return Value::Empty };
|
||||
for c in n.children().filter(|c| c.is_element()) {
|
||||
match c.tag_name().name() {
|
||||
"text" => return Value::Text(text_of(c)),
|
||||
"exData" => {
|
||||
let html = c.attribute("contentType").is_some_and(|t| t.contains("html")) || child(c, "body").is_some();
|
||||
if html {
|
||||
let body = child(c, "body").unwrap_or(c);
|
||||
return Value::Rich(parse_rich(body));
|
||||
}
|
||||
return Value::Text(text_of(c));
|
||||
}
|
||||
"image" => {
|
||||
let raw: Vec<u8> = text_of(c).bytes().filter(|b| !b.is_ascii_whitespace()).collect();
|
||||
let data = if c.attribute("transferEncoding").is_none_or(|e| e == "base64") {
|
||||
match base64::engine::general_purpose::STANDARD.decode(&raw) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
warnings.push("an image's base64 data could not be decoded; it is left out".into());
|
||||
return Value::Empty;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
if data.len() > MAX_IMAGE {
|
||||
warnings.push("an image is larger than 32 MB; it is left out".into());
|
||||
return Value::Empty;
|
||||
}
|
||||
return Value::Image { content_type: c.attribute("contentType").unwrap_or("").to_string(), data };
|
||||
}
|
||||
"line" => {
|
||||
return Value::Line { slope_up: c.attribute("slope") == Some("/"), edge: child(c, "edge").map(parse_edge).unwrap_or_default() };
|
||||
}
|
||||
"rectangle" => {
|
||||
let mut b = parse_border(Some(c)).unwrap_or_default();
|
||||
b.fill = parse_fill(child(c, "fill"));
|
||||
return Value::Rectangle(b);
|
||||
}
|
||||
"date" | "time" | "dateTime" | "integer" | "decimal" | "float" | "boolean" => return Value::Text(text_of(c)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Value::Empty
|
||||
}
|
||||
|
||||
fn parse_caption(n: Option<XmlNode>, warnings: &mut Vec<String>) -> Option<Caption> {
|
||||
let n = n?;
|
||||
Some(Caption {
|
||||
placement: match n.attribute("placement") {
|
||||
Some("right") => Placement::Right,
|
||||
Some("top") => Placement::Top,
|
||||
Some("bottom") => Placement::Bottom,
|
||||
Some("inline") => Placement::Inline,
|
||||
_ => Placement::Left,
|
||||
},
|
||||
reserve: attr_measure(n, "reserve").filter(|r| *r >= 0.0),
|
||||
presence: Presence::parse(n.attribute("presence")),
|
||||
value: parse_value(child(n, "value"), warnings),
|
||||
font: parse_font(child(n, "font")),
|
||||
para: parse_para(child(n, "para")),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_items(n: XmlNode) -> Vec<String> {
|
||||
// Several <items> lists may exist (display and bound values); the first one is what the
|
||||
// widget shows / the check button's on and off values.
|
||||
let Some(items) = children(n, "items").find(|i| i.attribute("presence") != Some("hidden")).or_else(|| child(n, "items")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
items.children().filter(|c| c.is_element()).map(text_of).take(1000).collect()
|
||||
}
|
||||
|
||||
fn parse_field(n: XmlNode, warnings: &mut Vec<String>) -> Field {
|
||||
let ui = child(n, "ui");
|
||||
let ui_el = ui.and_then(|u| u.children().find(|c| c.is_element() && c.tag_name().name() != "extras" && c.tag_name().name() != "picture"));
|
||||
let kind = match ui_el.map(|e| e.tag_name().name()) {
|
||||
None | Some("textEdit") => Ui::TextEdit,
|
||||
Some("numericEdit") => Ui::NumericEdit,
|
||||
Some("dateTimeEdit") => Ui::DateTimeEdit,
|
||||
Some("checkButton") => Ui::CheckButton,
|
||||
Some("choiceList") => Ui::ChoiceList,
|
||||
Some("button") => Ui::Button,
|
||||
Some("signature") => Ui::Signature,
|
||||
Some("imageEdit") => Ui::ImageEdit,
|
||||
Some("barcode") => Ui::Barcode,
|
||||
Some("passwordEdit") => Ui::PasswordEdit,
|
||||
Some(_) => Ui::Unknown,
|
||||
};
|
||||
let value_el = child(n, "value");
|
||||
let max_chars =
|
||||
value_el.and_then(|v| child(v, "text")).and_then(|t| t.attribute("maxChars")).and_then(|m| m.trim().parse::<usize>().ok()).filter(|m| *m > 0);
|
||||
let mut scripts = Vec::new();
|
||||
for ev in children(n, "event") {
|
||||
if let Some(s) = child(ev, "script") {
|
||||
scripts.push(Script { activity: ev.attribute("activity").unwrap_or("").to_string(), text: text_of(s) });
|
||||
}
|
||||
}
|
||||
Field {
|
||||
common: parse_common(n),
|
||||
ui: kind,
|
||||
ui_border: ui_el.and_then(|e| parse_border(child(e, "border"))),
|
||||
ui_margin: parse_margin(ui_el.and_then(|e| child(e, "margin"))),
|
||||
check_size: ui_el.and_then(|e| attr_measure(e, "size")).filter(|s| *s > 0.0),
|
||||
check_round: ui_el.is_some_and(|e| e.attribute("shape") == Some("round")),
|
||||
multiline: ui_el.is_some_and(|e| e.attribute("multiLine").is_some_and(|m| m == "1" || m == "true")),
|
||||
caption: parse_caption(child(n, "caption"), warnings),
|
||||
value: parse_value(value_el, warnings),
|
||||
items: parse_items(n),
|
||||
max_chars,
|
||||
tooltip: child(n, "assist").and_then(|a| child(a, "toolTip")).map(text_of).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()),
|
||||
access: match n.attribute("access") {
|
||||
Some("readOnly") => Access::ReadOnly,
|
||||
Some("protected") => Access::Protected,
|
||||
Some("nonInteractive") => Access::NonInteractive,
|
||||
_ => Access::Open,
|
||||
},
|
||||
picture: ui.and_then(|u| child(u, "picture")).map(text_of).map(|p| p.trim().to_string()).filter(|p| !p.is_empty()),
|
||||
scripts,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_occur(n: Option<XmlNode>) -> Occur {
|
||||
let Some(n) = n else { return Occur::default() };
|
||||
let int = |a: &str| n.attribute(a).and_then(|v| v.trim().parse::<i64>().ok());
|
||||
let min = int("min").unwrap_or(1).clamp(0, 1000) as usize;
|
||||
let max = match int("max") {
|
||||
Some(m) if m < 0 => None,
|
||||
Some(m) => Some((m.clamp(0, 1000) as usize).max(min)),
|
||||
None => Some(min.max(1)),
|
||||
};
|
||||
let initial = int("initial").map_or(min, |i| i.clamp(0, 1000) as usize).max(min);
|
||||
Occur { min, max, initial: max.map_or(initial, |m| initial.min(m)) }
|
||||
}
|
||||
|
||||
fn parse_page_set(n: XmlNode, warnings: &mut Vec<String>, depth: usize) -> PageSet {
|
||||
let mut areas = Vec::new();
|
||||
for pa in children(n, "pageArea") {
|
||||
let medium = child(pa, "medium");
|
||||
let short = medium.and_then(|m| attr_measure(m, "short")).unwrap_or(612.0).clamp(1.0, 14_400.0);
|
||||
let long = medium.and_then(|m| attr_measure(m, "long")).unwrap_or(792.0).clamp(1.0, 14_400.0);
|
||||
let (width, height) = if medium.and_then(|m| m.attribute("orientation")) == Some("landscape") { (long, short) } else { (short, long) };
|
||||
let mut content: Vec<Rect> = children(pa, "contentArea")
|
||||
.map(|c| {
|
||||
Rect::new(
|
||||
attr_measure(c, "x").unwrap_or(0.0),
|
||||
attr_measure(c, "y").unwrap_or(0.0),
|
||||
attr_measure(c, "w").unwrap_or(width).clamp(1.0, width),
|
||||
attr_measure(c, "h").unwrap_or(height).clamp(1.0, height),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if content.is_empty() {
|
||||
content.push(Rect::new(0.0, 0.0, width, height));
|
||||
}
|
||||
let items = pa.children().filter(|c| c.is_element()).filter_map(|c| parse_node(c, warnings, depth + 1)).collect();
|
||||
let occur = parse_occur(child(pa, "occur"));
|
||||
areas.push(PageArea { name: pa.attribute("name").map(str::to_string), width, height, content, items, occur_max: occur.max });
|
||||
}
|
||||
// Nested page sets: take their areas too.
|
||||
for ps in children(n, "pageSet") {
|
||||
if depth < MAX_DEPTH {
|
||||
areas.extend(parse_page_set(ps, warnings, depth + 1).areas);
|
||||
}
|
||||
}
|
||||
PageSet { areas }
|
||||
}
|
||||
|
||||
fn parse_subform(n: XmlNode, warnings: &mut Vec<String>, depth: usize) -> Subform {
|
||||
let common = parse_common(n);
|
||||
let mut children_out = Vec::new();
|
||||
let mut page_set = None;
|
||||
for c in n.children().filter(|c| c.is_element()) {
|
||||
match c.tag_name().name() {
|
||||
"pageSet" => page_set = Some(parse_page_set(c, warnings, depth + 1)),
|
||||
_ => {
|
||||
if let Some(node) = parse_node(c, warnings, depth + 1) {
|
||||
children_out.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let column_widths: Vec<f64> =
|
||||
n.attribute("columnWidths").map(|s| s.split_whitespace().filter_map(measure).map(|w| w.max(0.0)).take(256).collect()).unwrap_or_default();
|
||||
let break_to_page = |name: &str| children(n, name).any(|b| b.attribute("targetType").is_none_or(|t| t == "pageArea" || t == "contentArea"));
|
||||
Subform {
|
||||
common,
|
||||
layout: Layout::parse(n.attribute("layout")),
|
||||
children: children_out,
|
||||
page_set,
|
||||
occur: parse_occur(child(n, "occur")),
|
||||
break_before_page: break_to_page("breakBefore")
|
||||
|| child(n, "break").is_some_and(|b| b.attribute("before").is_some_and(|v| v == "pageArea" || v == "contentArea")),
|
||||
break_after_page: break_to_page("breakAfter"),
|
||||
column_widths,
|
||||
overflow_leader: child(n, "overflow").and_then(|o| o.attribute("leader")).map(str::to_string).filter(|s| !s.is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_node(c: XmlNode, warnings: &mut Vec<String>, depth: usize) -> Option<Node> {
|
||||
if depth > MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
Some(match c.tag_name().name() {
|
||||
"subform" => Node::Subform(Box::new(parse_subform(c, warnings, depth))),
|
||||
"subformSet" => {
|
||||
// A set of alternative subforms: lay out its members in order.
|
||||
let mut sf = parse_subform(c, warnings, depth);
|
||||
sf.layout = Layout::Tb;
|
||||
Node::Subform(Box::new(sf))
|
||||
}
|
||||
"area" => Node::Area(Box::new(Area {
|
||||
common: parse_common(c),
|
||||
children: c.children().filter(|k| k.is_element()).filter_map(|k| parse_node(k, warnings, depth + 1)).collect(),
|
||||
})),
|
||||
"field" => Node::Field(Box::new(parse_field(c, warnings))),
|
||||
"draw" => Node::Draw(Box::new(Draw { common: parse_common(c), value: parse_value(child(c, "value"), warnings) })),
|
||||
"exclGroup" => Node::ExclGroup(Box::new(ExclGroup {
|
||||
common: parse_common(c),
|
||||
layout: Layout::parse(c.attribute("layout")),
|
||||
fields: children(c, "field").map(|f| parse_field(f, warnings)).collect(),
|
||||
tooltip: child(c, "assist").and_then(|a| child(a, "toolTip")).map(text_of).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()),
|
||||
})),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Which page-number role a field with a layout:ready script plays, if any.
|
||||
fn page_role(f: &Field) -> Option<PageRole> {
|
||||
let script = f.scripts.iter().find(|s| s.activity == "ready" || s.activity == "layoutReady")?;
|
||||
if script.text.contains("pageCount") {
|
||||
Some(PageRole::Count)
|
||||
} else if script.text.contains("layout.page(") || script.text.contains("layout.absPage(") {
|
||||
Some(PageRole::Number)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_page_roles(nodes: &[Node], out: &mut Vec<(String, PageRole)>, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for n in nodes {
|
||||
match n {
|
||||
Node::Field(f) => {
|
||||
if let (Some(id), Some(role)) = (&f.common.id, page_role(f)) {
|
||||
out.push((id.clone(), role));
|
||||
}
|
||||
}
|
||||
Node::Subform(s) => {
|
||||
collect_page_roles(&s.children, out, depth + 1);
|
||||
if let Some(ps) = &s.page_set {
|
||||
for a in &ps.areas {
|
||||
collect_page_roles(&a.items, out, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Node::Area(a) => collect_page_roles(&a.children, out, depth + 1),
|
||||
Node::ExclGroup(_) | Node::Draw(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a template packet. Returns the template and the warnings met on the way.
|
||||
pub fn parse(xml: &str) -> Result<(Template, Vec<String>), XfaError> {
|
||||
let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES };
|
||||
let doc = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| XfaError::Malformed(format!("template XML: {e}")))?;
|
||||
let root = doc.root_element();
|
||||
// The XDP's config packet has a <template> element too: the one we want is in the
|
||||
// xfa-template namespace, or at least has a subform in it.
|
||||
let is_template = |n: &XmlNode| {
|
||||
n.is_element()
|
||||
&& n.tag_name().name() == "template"
|
||||
&& (n.tag_name().namespace().is_some_and(|ns| ns.contains("xfa-template")) || children(*n, "subform").next().is_some())
|
||||
};
|
||||
let template = if is_template(&root) {
|
||||
root
|
||||
} else {
|
||||
root.descendants().find(is_template).ok_or_else(|| XfaError::Malformed("no <template> element".into()))?
|
||||
};
|
||||
let mut warnings = Vec::new();
|
||||
let sf = children(template, "subform").next().ok_or_else(|| XfaError::Malformed("the template has no root subform".into()))?;
|
||||
let mut root_sf = parse_subform(sf, &mut warnings, 0);
|
||||
if root_sf.page_set.is_none() {
|
||||
// A page set may sit beside the root subform.
|
||||
root_sf.page_set = child(template, "pageSet").map(|ps| parse_page_set(ps, &mut warnings, 0));
|
||||
}
|
||||
let mut page_roles = Vec::new();
|
||||
collect_page_roles(&[Node::Subform(Box::new(root_sf.clone()))], &mut page_roles, 0);
|
||||
Ok((Template { root: root_sf, page_roles }, warnings))
|
||||
}
|
||||
|
|
@ -0,0 +1,517 @@
|
|||
//! Turn laid-out pages into PDF objects inside an open document: page dictionaries with content
|
||||
//! streams (standard-14 fonts, WinAnsi), image XObjects and AcroForm widgets. Widget appearance
|
||||
//! streams are left empty here; the forms layer regenerates them from the field dictionaries.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
|
||||
use pdfcraft_fonts::{literal, win_ansi};
|
||||
|
||||
use crate::XfaError;
|
||||
use crate::layout::{Action, BorderShape, Form, Item, Page, Widget, WidgetKind};
|
||||
use crate::model::Rect;
|
||||
use crate::text::{Face, Family};
|
||||
|
||||
/// Private key on generated fields: the template's SOM path, so data can be put back.
|
||||
pub const SOM_KEY: &[u8] = b"PCSom";
|
||||
/// Private key on the AcroForm: what laying the form out produced, so a reopened file is not
|
||||
/// laid out again.
|
||||
pub const LAYOUT_KEY: &[u8] = b"PCXfaLayout";
|
||||
|
||||
/// Width and height of a JPEG from its first frame header (SOF0–SOF15, not the DNL/JPG/DAC/DHT
|
||||
/// markers). `None` for anything else.
|
||||
pub fn jpeg_size(data: &[u8]) -> Option<(u32, u32)> {
|
||||
jpeg_frame(data).map(|(w, h, _)| (w, h))
|
||||
}
|
||||
|
||||
fn jpeg_frame(data: &[u8]) -> Option<(u32, u32, u8)> {
|
||||
if data.get(0..2)? != [0xFF, 0xD8] {
|
||||
return None;
|
||||
}
|
||||
let mut i = 2usize;
|
||||
let mut guard = 0;
|
||||
while i + 4 <= data.len() && guard < 10_000 {
|
||||
guard += 1;
|
||||
if *data.get(i)? != 0xFF {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let marker = *data.get(i + 1)?;
|
||||
if marker == 0xFF {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (0xD0..=0xD9).contains(&marker) || marker == 0x01 {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
let len = u16::from_be_bytes([*data.get(i + 2)?, *data.get(i + 3)?]) as usize;
|
||||
let sof = matches!(marker, 0xC0..=0xCF) && !matches!(marker, 0xC4 | 0xC8 | 0xCC);
|
||||
if sof {
|
||||
let h = u16::from_be_bytes([*data.get(i + 5)?, *data.get(i + 6)?]) as u32;
|
||||
let w = u16::from_be_bytes([*data.get(i + 7)?, *data.get(i + 8)?]) as u32;
|
||||
let comps = *data.get(i + 9)?;
|
||||
return (w > 0 && h > 0).then_some((w, h, comps));
|
||||
}
|
||||
i = i.checked_add(2)?.checked_add(len.max(2))?;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Format a number for content streams.
|
||||
fn n(v: f64) -> String {
|
||||
let v = if v.is_finite() { v } else { 0.0 };
|
||||
let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v });
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
if s == "-0" { "0".into() } else { s.into() }
|
||||
}
|
||||
|
||||
fn rgb(c: [f64; 3]) -> String {
|
||||
format!("{} {} {}", n(c[0]), n(c[1]), n(c[2]))
|
||||
}
|
||||
|
||||
fn rgb_array(c: [f64; 3]) -> Object {
|
||||
Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect())
|
||||
}
|
||||
|
||||
fn standard_font(base: &str) -> Object {
|
||||
let mut f = Dict::new();
|
||||
f.set(b"Type".to_vec(), Object::name("Font"));
|
||||
f.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
f.set(b"BaseFont".to_vec(), Object::name(base));
|
||||
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||||
Object::Dict(f)
|
||||
}
|
||||
|
||||
/// Every face the content streams or `/DA` strings may name.
|
||||
fn all_faces() -> Vec<(&'static str, &'static str)> {
|
||||
let mut out = Vec::new();
|
||||
for fam in [Family::Helvetica, Family::Courier, Family::Times] {
|
||||
for (b, i) in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
out.push((fam.resource_name(b, i), fam.base_font(b, i)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A PDF appearance-state name for a check button's on value.
|
||||
fn state_name(on: &str) -> String {
|
||||
let s: String = on.trim().chars().map(|c| if c.is_whitespace() || "()<>[]{}/%#".contains(c) || c.is_control() { '_' } else { c }).collect();
|
||||
if s.is_empty() || s == "Off" { "Yes".into() } else { s }
|
||||
}
|
||||
|
||||
fn empty_form(doc: &mut Document, w: f64, h: f64) -> Object {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, w, h].iter().map(|v| Object::Real(*v)).collect()));
|
||||
Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, Vec::new()))))
|
||||
}
|
||||
|
||||
fn js_action(js: &str) -> Object {
|
||||
let mut a = Dict::new();
|
||||
a.set(b"S".to_vec(), Object::name("JavaScript"));
|
||||
a.set(b"JS".to_vec(), Object::String(PdfString::literal(js.as_bytes().to_vec())));
|
||||
Object::Dict(a)
|
||||
}
|
||||
|
||||
pub struct Written {
|
||||
pub pages: usize,
|
||||
pub fields: usize,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
struct Emitter<'a> {
|
||||
doc: &'a mut Document,
|
||||
fields: Vec<ObjRef>,
|
||||
radio_groups: HashMap<String, (ObjRef, Vec<ObjRef>)>,
|
||||
/// The on state selected in each radio group, from the data.
|
||||
radio_selected: HashMap<String, String>,
|
||||
field_count: usize,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl Emitter<'_> {
|
||||
fn da(face: &Face) -> Vec<u8> {
|
||||
format!("/{} {} Tf {} rg", face.resource_name(), n(face.size), rgb(face.color)).into_bytes()
|
||||
}
|
||||
|
||||
fn widget(&mut self, w: &Widget, page: &Page, page_ref: ObjRef) -> Result<Option<ObjRef>, XfaError> {
|
||||
let r = w.rect;
|
||||
let (x0, y0, x1, y1) = (r.x, page.height - r.bottom(), r.right(), page.height - r.y);
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Widget"));
|
||||
d.set(b"F".to_vec(), Object::Int(4));
|
||||
d.set(b"P".to_vec(), Object::Ref(page_ref));
|
||||
d.set(b"Rect".to_vec(), Object::Array([x0, y0, x1, y1].iter().map(|v| Object::Real(*v)).collect()));
|
||||
d.set(b"DA".to_vec(), Object::String(PdfString::literal(Self::da(&w.face))));
|
||||
if let Some(t) = &w.tooltip {
|
||||
d.set(b"TU".to_vec(), Object::String(PdfString::text(t)));
|
||||
}
|
||||
d.set(SOM_KEY.to_vec(), Object::String(PdfString::text(&w.som)));
|
||||
let mut mk = Dict::new();
|
||||
if let Some(c) = w.border.color
|
||||
&& w.border.shape != BorderShape::None
|
||||
{
|
||||
mk.set(b"BC".to_vec(), rgb_array(c));
|
||||
}
|
||||
if let Some(c) = w.border.fill {
|
||||
mk.set(b"BG".to_vec(), rgb_array(c));
|
||||
}
|
||||
let mut bs = Dict::new();
|
||||
bs.set(b"W".to_vec(), Object::Real(if w.border.shape == BorderShape::None { 0.0 } else { w.border.width.clamp(0.1, 12.0) }));
|
||||
bs.set(b"S".to_vec(), Object::name(if w.border.shape == BorderShape::Underline { "U" } else { "S" }));
|
||||
d.set(b"BS".to_vec(), Object::Dict(bs));
|
||||
let mut ff: i64 = 0;
|
||||
if w.read_only {
|
||||
ff |= 1;
|
||||
}
|
||||
let mut top_level = true;
|
||||
match &w.kind {
|
||||
WidgetKind::Text | WidgetKind::Date(_) => {
|
||||
d.set(b"FT".to_vec(), Object::name("Tx"));
|
||||
if w.multiline {
|
||||
ff |= 1 << 12;
|
||||
}
|
||||
if let Some(m) = w.max_chars {
|
||||
d.set(b"MaxLen".to_vec(), Object::Int(m.min(100_000) as i64));
|
||||
}
|
||||
let q = match w.h_align {
|
||||
crate::model::HAlign::Center => 1,
|
||||
crate::model::HAlign::Right => 2,
|
||||
_ => 0,
|
||||
};
|
||||
if q != 0 {
|
||||
d.set(b"Q".to_vec(), Object::Int(q));
|
||||
}
|
||||
if let Some(v) = &w.value {
|
||||
d.set(b"V".to_vec(), Object::String(PdfString::text(v)));
|
||||
d.set(b"DV".to_vec(), Object::String(PdfString::text(v)));
|
||||
}
|
||||
if let WidgetKind::Date(pattern) = &w.kind {
|
||||
let mut aa = Dict::new();
|
||||
aa.set(b"F".to_vec(), js_action(&format!("AFDate_FormatEx(\"{pattern}\");")));
|
||||
aa.set(b"K".to_vec(), js_action(&format!("AFDate_KeystrokeEx(\"{pattern}\");")));
|
||||
d.set(b"AA".to_vec(), Object::Dict(aa));
|
||||
}
|
||||
}
|
||||
WidgetKind::CheckBox { on, .. } => {
|
||||
d.set(b"FT".to_vec(), Object::name("Btn"));
|
||||
let on = state_name(on);
|
||||
let checked = w.value.is_some();
|
||||
d.set(b"V".to_vec(), Object::name(if checked { &on } else { "Off" }));
|
||||
d.set(b"AS".to_vec(), Object::name(if checked { &on } else { "Off" }));
|
||||
if checked {
|
||||
d.set(b"DV".to_vec(), Object::name(&on));
|
||||
}
|
||||
let mut nd = Dict::new();
|
||||
nd.set(on.into_bytes(), empty_form(self.doc, r.w, r.h));
|
||||
nd.set(b"Off".to_vec(), empty_form(self.doc, r.w, r.h));
|
||||
let mut ap = Dict::new();
|
||||
ap.set(b"N".to_vec(), Object::Dict(nd));
|
||||
d.set(b"AP".to_vec(), Object::Dict(ap));
|
||||
d.set(b"PCItems".to_vec(), Object::Array(w.items.iter().map(|i| Object::String(PdfString::text(i))).collect()));
|
||||
}
|
||||
WidgetKind::Radio { group, on, .. } => {
|
||||
top_level = false;
|
||||
let parent = match self.radio_groups.get(group) {
|
||||
Some((p, _)) => *p,
|
||||
None => {
|
||||
let mut g = Dict::new();
|
||||
g.set(b"FT".to_vec(), Object::name("Btn"));
|
||||
g.set(b"Ff".to_vec(), Object::Int((1 << 15) | (1 << 14)));
|
||||
g.set(b"T".to_vec(), Object::String(PdfString::text(group)));
|
||||
g.set(b"V".to_vec(), Object::name("Off"));
|
||||
g.set(b"DA".to_vec(), Object::String(PdfString::literal(Self::da(&w.face))));
|
||||
g.set(b"Kids".to_vec(), Object::Array(Vec::new()));
|
||||
// The group's SOM path: the button's without its last step.
|
||||
let group_som = w.som.rsplit_once('.').map(|(g, _)| g.to_string()).unwrap_or_else(|| w.som.clone());
|
||||
g.set(SOM_KEY.to_vec(), Object::String(PdfString::text(&group_som)));
|
||||
let p = self.doc.add(Object::Dict(g));
|
||||
self.radio_groups.insert(group.clone(), (p, Vec::new()));
|
||||
self.fields.push(p);
|
||||
self.field_count += 1;
|
||||
p
|
||||
}
|
||||
};
|
||||
d.set(b"Parent".to_vec(), Object::Ref(parent));
|
||||
let state = state_name(on);
|
||||
let chosen = w.value.is_some();
|
||||
d.set(b"AS".to_vec(), Object::name(if chosen { &state } else { "Off" }));
|
||||
if chosen {
|
||||
self.radio_selected.insert(group.clone(), state.clone());
|
||||
}
|
||||
let mut nd = Dict::new();
|
||||
nd.set(state.into_bytes(), empty_form(self.doc, r.w, r.h));
|
||||
nd.set(b"Off".to_vec(), empty_form(self.doc, r.w, r.h));
|
||||
let mut ap = Dict::new();
|
||||
ap.set(b"N".to_vec(), Object::Dict(nd));
|
||||
d.set(b"AP".to_vec(), Object::Dict(ap));
|
||||
d.remove(b"DA");
|
||||
}
|
||||
WidgetKind::Button { caption } => {
|
||||
d.set(b"FT".to_vec(), Object::name("Btn"));
|
||||
ff |= 1 << 16;
|
||||
mk.set(b"CA".to_vec(), Object::String(PdfString::text(caption)));
|
||||
if let Some(a) = &w.action {
|
||||
let mut ad = Dict::new();
|
||||
match a {
|
||||
Action::Reset => {
|
||||
ad.set(b"S".to_vec(), Object::name("ResetForm"));
|
||||
}
|
||||
Action::Print => {
|
||||
ad.set(b"S".to_vec(), Object::name("Named"));
|
||||
ad.set(b"N".to_vec(), Object::name("Print"));
|
||||
}
|
||||
Action::SaveAs => {
|
||||
ad.set(b"S".to_vec(), Object::name("Named"));
|
||||
ad.set(b"N".to_vec(), Object::name("SaveAs"));
|
||||
}
|
||||
Action::Url(u) => {
|
||||
ad.set(b"S".to_vec(), Object::name("URI"));
|
||||
ad.set(b"URI".to_vec(), Object::String(PdfString::literal(u.as_bytes().to_vec())));
|
||||
}
|
||||
}
|
||||
d.set(b"A".to_vec(), Object::Dict(ad));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !mk.is_empty() {
|
||||
d.set(b"MK".to_vec(), Object::Dict(mk));
|
||||
}
|
||||
if ff != 0 {
|
||||
d.set(b"Ff".to_vec(), Object::Int(ff));
|
||||
}
|
||||
if top_level {
|
||||
d.set(b"T".to_vec(), Object::String(PdfString::text(&w.name)));
|
||||
}
|
||||
let r = self.doc.add(Object::Dict(d));
|
||||
if top_level {
|
||||
self.fields.push(r);
|
||||
self.field_count += 1;
|
||||
} else if let WidgetKind::Radio { group, .. } = &w.kind
|
||||
&& let Some((_, kids)) = self.radio_groups.get_mut(group)
|
||||
{
|
||||
kids.push(r);
|
||||
}
|
||||
Ok(Some(r))
|
||||
}
|
||||
|
||||
fn page(&mut self, page: &Page, pages_ref: ObjRef) -> Result<ObjRef, XfaError> {
|
||||
let page_ref = self.doc.add(Object::Dict(Dict::new()));
|
||||
let mut content: Vec<u8> = Vec::new();
|
||||
let mut fonts: BTreeMap<&'static str, &'static str> = BTreeMap::new();
|
||||
let mut xobjects = Dict::new();
|
||||
let mut annots = Vec::new();
|
||||
let h = page.height;
|
||||
for (i, item) in page.items.iter().enumerate() {
|
||||
match item {
|
||||
Item::Fill { rect, color } => {
|
||||
content.extend(format!("{} rg {} {} {} {} re f\n", rgb(*color), n(rect.x), n(h - rect.bottom()), n(rect.w), n(rect.h)).bytes());
|
||||
}
|
||||
Item::Line { from, to, width, color, dashed } => {
|
||||
content.extend(
|
||||
format!(
|
||||
"q {} RG {} w {}{} {} m {} {} l S Q\n",
|
||||
rgb(*color),
|
||||
n(*width),
|
||||
if *dashed { "[2 2] 0 d " } else { "" },
|
||||
n(from.0),
|
||||
n(h - from.1),
|
||||
n(to.0),
|
||||
n(h - to.1)
|
||||
)
|
||||
.bytes(),
|
||||
);
|
||||
}
|
||||
Item::Text(s) => {
|
||||
let name = s.face.resource_name();
|
||||
fonts.insert(name, s.face.family.base_font(s.face.bold, s.face.italic));
|
||||
content.extend(
|
||||
format!("BT /{name} {} Tf {} rg 1 0 0 1 {} {} Tm ", n(s.face.size), rgb(s.face.color), n(s.x), n(h - s.baseline)).bytes(),
|
||||
);
|
||||
content.extend(literal(&win_ansi(&s.text)));
|
||||
content.extend_from_slice(b" Tj ET\n");
|
||||
}
|
||||
Item::Image { rect, data, content_type } => {
|
||||
let Some((iw, ih, comps)) = jpeg_frame(data) else {
|
||||
self.warnings.push(format!(
|
||||
"a {} image is not a JPEG and is left out",
|
||||
if content_type.is_empty() { "picture" } else { content_type.as_str() }
|
||||
));
|
||||
continue;
|
||||
};
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
d.set(b"Width".to_vec(), Object::Int(i64::from(iw)));
|
||||
d.set(b"Height".to_vec(), Object::Int(i64::from(ih)));
|
||||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
d.set(
|
||||
b"ColorSpace".to_vec(),
|
||||
Object::name(match comps {
|
||||
1 => "DeviceGray",
|
||||
4 => "DeviceCMYK",
|
||||
_ => "DeviceRGB",
|
||||
}),
|
||||
);
|
||||
if comps == 4 {
|
||||
// Adobe APP14 JPEGs store inverted CMYK.
|
||||
d.set(b"Decode".to_vec(), Object::Array([1, 0, 1, 0, 1, 0, 1, 0].iter().map(|v| Object::Int(*v)).collect()));
|
||||
}
|
||||
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
|
||||
let r = self.doc.add(Object::Stream(Stream::from_raw(d, data.as_ref().clone())));
|
||||
let name = format!("Im{i}");
|
||||
xobjects.set(name.clone().into_bytes(), Object::Ref(r));
|
||||
content.extend(format!("q {} 0 0 {} {} {} cm /{name} Do Q\n", n(rect.w), n(rect.h), n(rect.x), n(h - rect.bottom())).bytes());
|
||||
}
|
||||
Item::Widget(w) => {
|
||||
if let Some(r) = self.widget(w, page, page_ref)? {
|
||||
annots.push(Object::Ref(r));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut font_dict = Dict::new();
|
||||
for (name, base) in fonts {
|
||||
font_dict.set(name.as_bytes().to_vec(), standard_font(base));
|
||||
}
|
||||
let mut res = Dict::new();
|
||||
res.set(b"Font".to_vec(), Object::Dict(font_dict));
|
||||
if !xobjects.is_empty() {
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xobjects));
|
||||
}
|
||||
let mut cd = Dict::new();
|
||||
cd.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
let contents = self.doc.add(Object::Stream(Stream::flate(Dict::new(), &content)));
|
||||
let _ = cd;
|
||||
let mut pd = Dict::new();
|
||||
pd.set(b"Type".to_vec(), Object::name("Page"));
|
||||
pd.set(b"Parent".to_vec(), Object::Ref(pages_ref));
|
||||
pd.set(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, page.width, page.height].iter().map(|v| Object::Real(*v)).collect()));
|
||||
pd.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
pd.set(b"Contents".to_vec(), Object::Ref(contents));
|
||||
if !annots.is_empty() {
|
||||
pd.set(b"Annots".to_vec(), Object::Array(annots));
|
||||
}
|
||||
self.doc.set(page_ref, Object::Dict(pd));
|
||||
Ok(page_ref)
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the document's pages with the laid-out form and add its fields to the AcroForm. The
|
||||
/// XFA packets and everything else in the catalog stay.
|
||||
pub fn write_form(doc: &mut Document, form: &Form) -> Result<Written, XfaError> {
|
||||
let catalog_ref = doc.root().ok_or_else(|| XfaError::Malformed("the document has no catalog".into()))?;
|
||||
let mut catalog = doc.get(catalog_ref).as_dict().cloned().ok_or_else(|| XfaError::Malformed("the catalog is not a dictionary".into()))?;
|
||||
let mut catalog_changed = false;
|
||||
let pages_ref = match catalog.get(b"Pages").and_then(Object::as_ref) {
|
||||
Some(r) if doc.get(r).as_dict().is_some() => r,
|
||||
_ => {
|
||||
let mut p = Dict::new();
|
||||
p.set(b"Type".to_vec(), Object::name("Pages"));
|
||||
let r = doc.add(Object::Dict(p));
|
||||
catalog.set(b"Pages".to_vec(), Object::Ref(r));
|
||||
catalog_changed = true;
|
||||
r
|
||||
}
|
||||
};
|
||||
let mut em =
|
||||
Emitter { doc, fields: Vec::new(), radio_groups: HashMap::new(), radio_selected: HashMap::new(), field_count: 0, warnings: Vec::new() };
|
||||
let mut kids = Vec::new();
|
||||
for page in &form.pages {
|
||||
kids.push(Object::Ref(em.page(page, pages_ref)?));
|
||||
}
|
||||
let groups: Vec<(String, (ObjRef, Vec<ObjRef>))> = em.radio_groups.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
for (name, (parent, widgets)) in groups {
|
||||
let selected = em.radio_selected.get(&name).cloned();
|
||||
em.doc.update_dict(parent, |d| {
|
||||
d.set(b"Kids".to_vec(), Object::Array(widgets.iter().map(|r| Object::Ref(*r)).collect()));
|
||||
if let Some(s) = &selected {
|
||||
d.set(b"V".to_vec(), Object::name(s));
|
||||
}
|
||||
})?;
|
||||
}
|
||||
let Emitter { doc, fields, field_count, warnings, .. } = em;
|
||||
let count = kids.len();
|
||||
doc.update_dict(pages_ref, |d| {
|
||||
d.set(b"Type".to_vec(), Object::name("Pages"));
|
||||
d.set(b"Kids".to_vec(), Object::Array(kids));
|
||||
d.set(b"Count".to_vec(), Object::Int(count as i64));
|
||||
d.remove(b"Parent");
|
||||
})?;
|
||||
// AcroForm: keep what is there (the XFA packets above all), add our fields and fonts.
|
||||
let acro_ref = catalog.get(b"AcroForm").and_then(Object::as_ref);
|
||||
let mut acro = match catalog.get(b"AcroForm") {
|
||||
Some(o) => doc.resolve(o).as_dict().cloned().unwrap_or_default(),
|
||||
None => Dict::new(),
|
||||
};
|
||||
let mut all: Vec<Object> = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default();
|
||||
all.extend(fields.iter().map(|r| Object::Ref(*r)));
|
||||
acro.set(b"Fields".to_vec(), Object::Array(all));
|
||||
let mut dr = acro.get(b"DR").map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()).unwrap_or_default();
|
||||
let mut dr_fonts = dr.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
|
||||
for (name, base) in all_faces() {
|
||||
if !dr_fonts.contains(name.as_bytes()) {
|
||||
dr_fonts.set(name.as_bytes().to_vec(), standard_font(base));
|
||||
}
|
||||
}
|
||||
if !dr_fonts.contains(b"ZaDb") {
|
||||
let mut z = Dict::new();
|
||||
z.set(b"Type".to_vec(), Object::name("Font"));
|
||||
z.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
z.set(b"BaseFont".to_vec(), Object::name("ZapfDingbats"));
|
||||
dr_fonts.set(b"ZaDb".to_vec(), Object::Dict(z));
|
||||
}
|
||||
dr.set(b"Font".to_vec(), Object::Dict(dr_fonts));
|
||||
acro.set(b"DR".to_vec(), Object::Dict(dr));
|
||||
if !acro.contains(b"DA") {
|
||||
acro.set(b"DA".to_vec(), Object::String(PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())));
|
||||
}
|
||||
let mut mark = Dict::new();
|
||||
mark.set(b"Pages".to_vec(), Object::Int(count as i64));
|
||||
mark.set(b"Fields".to_vec(), Object::Int(field_count as i64));
|
||||
mark.set(b"Warnings".to_vec(), Object::Array(warnings.iter().map(|w| Object::String(PdfString::text(w))).collect()));
|
||||
acro.set(LAYOUT_KEY.to_vec(), Object::Dict(mark));
|
||||
match acro_ref {
|
||||
Some(r) => doc.set(r, Object::Dict(acro)),
|
||||
None => {
|
||||
catalog.set(b"AcroForm".to_vec(), Object::Dict(acro));
|
||||
catalog_changed = true;
|
||||
}
|
||||
}
|
||||
if catalog_changed {
|
||||
doc.set(catalog_ref, Object::Dict(catalog));
|
||||
}
|
||||
Ok(Written { pages: count, fields: field_count, warnings })
|
||||
}
|
||||
|
||||
/// The layout a document already carries from an earlier pass, if any.
|
||||
pub fn existing_layout(doc: &Document) -> Option<crate::Report> {
|
||||
let root = doc.root()?;
|
||||
let catalog = doc.get(root);
|
||||
let acro = doc.resolve(catalog.as_dict()?.get(b"AcroForm")?);
|
||||
let mark = doc.resolve(acro.as_dict()?.get(LAYOUT_KEY)?);
|
||||
let mark = mark.as_dict()?;
|
||||
let warnings = mark
|
||||
.get(b"Warnings")
|
||||
.map(|w| doc.resolve(w))
|
||||
.and_then(|w| w.as_array().cloned())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|w| w.as_string().map(|s| s.to_text()))
|
||||
.take(100)
|
||||
.collect();
|
||||
Some(crate::Report {
|
||||
pages: mark.int(b"Pages").unwrap_or(0).clamp(0, 100_000) as usize,
|
||||
fields: mark.int(b"Fields").unwrap_or(0).clamp(0, 1_000_000) as usize,
|
||||
template_bytes: 0,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// The rectangle of a widget in PDF user space (for tests).
|
||||
pub fn pdf_rect(r: Rect, page_height: f64) -> [f64; 4] {
|
||||
[r.x, page_height - r.bottom(), r.right(), page_height - r.y]
|
||||
}
|
||||
|
|
@ -0,0 +1,546 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{Document, Object, SaveOptions, write_incremental};
|
||||
|
||||
use super::*;
|
||||
use crate::data::{
|
||||
DatasetsWrite, FieldData, FieldDatum, build_data, iso_to_pattern, parse_datasets, pattern_to_iso, read_values, som_to_path, write_datasets,
|
||||
};
|
||||
use crate::fixtures::{shell, static_shell, template, template_with_data};
|
||||
use crate::layout::Item;
|
||||
use crate::model::*;
|
||||
|
||||
fn widgets(page: &Page) -> Vec<&Widget> {
|
||||
page.items.iter().filter_map(|i| if let Item::Widget(w) = i { Some(w.as_ref()) } else { None }).collect()
|
||||
}
|
||||
|
||||
fn texts(page: &Page) -> Vec<String> {
|
||||
page.items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s.text.clone()) } else { None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measurements_convert_to_points() {
|
||||
assert_eq!(measure("72pt"), Some(72.0));
|
||||
assert_eq!(measure("1in"), Some(72.0));
|
||||
assert!((measure("25.4mm").unwrap() - 72.0).abs() < 1e-9);
|
||||
assert!((measure("2.54cm").unwrap() - 72.0).abs() < 1e-9);
|
||||
assert_eq!(measure(" 12 "), Some(12.0));
|
||||
assert_eq!(measure("3em"), None);
|
||||
assert_eq!(measure("abc"), None);
|
||||
assert_eq!(measure("NaNpt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_template_parses_with_fonts_captions_and_rich_text() {
|
||||
let (tpl, warnings) = parse(&template(2)).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(tpl.root.layout, Layout::Tb);
|
||||
let ps = tpl.root.page_set.as_ref().unwrap();
|
||||
assert_eq!(ps.areas.len(), 2);
|
||||
assert_eq!((ps.areas[0].width, ps.areas[0].height), (612.0, 792.0));
|
||||
assert_eq!(ps.areas[0].occur_max, Some(1));
|
||||
assert_eq!(ps.areas[1].occur_max, None);
|
||||
assert_eq!(ps.areas[0].content[0], Rect::new(36.0, 36.0, 540.0, 720.0));
|
||||
assert_eq!(tpl.page_roles, vec![("pageNo".to_string(), PageRole::Number), ("pageCount".to_string(), PageRole::Count)]);
|
||||
let Node::Subform(head) = &tpl.root.children[0] else { panic!() };
|
||||
assert_eq!(head.layout, Layout::LrTb);
|
||||
let Node::Draw(title) = &head.children[0] else { panic!() };
|
||||
let Value::Rich(r) = &title.value else { panic!("rich text") };
|
||||
assert_eq!(r.paragraphs.len(), 2, "{r:?}");
|
||||
assert_eq!(r.paragraphs[0].runs[0].bold, Some(true));
|
||||
assert_eq!(r.plain(), "Sample Form\nSecond line");
|
||||
let Node::Field(f) = &head.children[2] else { panic!() };
|
||||
assert_eq!(f.common.name.as_deref(), Some("familyName"));
|
||||
assert_eq!(f.common.font.typeface.as_deref(), Some("Courier New"));
|
||||
assert_eq!(f.max_chars, Some(30));
|
||||
assert_eq!(f.tooltip.as_deref(), Some("Your family name"));
|
||||
let cap = f.caption.as_ref().unwrap();
|
||||
assert_eq!((cap.placement, cap.reserve), (Placement::Top, Some(14.4)));
|
||||
// The widget border: three hidden edges and a visible bottom one.
|
||||
assert_eq!(f.ui_border.as_ref().unwrap().visible_edges(), [false, false, true, false]);
|
||||
let Node::Field(check) = &head.children[3] else { panic!() };
|
||||
assert_eq!((check.ui.clone(), check.items.clone()), (Ui::CheckButton, vec!["1".to_string(), "0".to_string()]));
|
||||
let Node::Field(date) = &head.children[4] else { panic!() };
|
||||
assert_eq!((date.ui.clone(), date.picture.as_deref()), (Ui::DateTimeEdit, Some("date{YYYY-MM-DD}")));
|
||||
let Node::ExclGroup(g) = &head.children[6] else { panic!() };
|
||||
assert_eq!(g.fields.len(), 2);
|
||||
let Node::Subform(table) = &tpl.root.children[1] else { panic!() };
|
||||
assert_eq!(table.column_widths, vec![144.0, 216.0, 180.0]);
|
||||
assert_eq!(table.overflow_leader.as_deref(), Some("header"));
|
||||
let Node::Subform(last) = &tpl.root.children[2] else { panic!() };
|
||||
assert!(last.break_before_page);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_xdp_template_wins_over_the_config_packets_template_element() {
|
||||
// The config packet has a <template> too; the real one is in the xfa-template namespace.
|
||||
let (tpl, _) = parse(&template(0)).unwrap();
|
||||
assert_eq!(tpl.root.common.name.as_deref(), Some("form"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_places_captions_fields_and_sizes_containers_to_their_content() {
|
||||
let form = layout_xml(&template(1)).unwrap();
|
||||
assert_eq!(form.pages.len(), 2, "head and table on page 1, the last section after its break");
|
||||
let p1 = &form.pages[0];
|
||||
let t = texts(p1);
|
||||
assert!(t.iter().any(|s| s == "Sample Form") && t.iter().any(|s| s == "Second line"), "{t:?}");
|
||||
assert!(t.iter().any(|s| s == "Family name"), "caption drawn: {t:?}");
|
||||
assert!(!t.iter().any(|s| s.contains("never shown")), "hidden draws take no space and draw nothing");
|
||||
assert!(t.iter().any(|s| s == "Page 1 of 2"), "embedded page number and count: {t:?}");
|
||||
assert!(!texts(&form.pages[1]).iter().any(|s| s.starts_with("Page ")), "the second master has no page-number draw");
|
||||
let w = widgets(p1);
|
||||
let family = w.iter().find(|w| w.name == "familyName").unwrap();
|
||||
// Below the 0.4in title, on a line of its own (3in × 0.5in); the caption takes the top 0.2in.
|
||||
assert!((family.rect.x - 36.0).abs() < 0.01 && (family.rect.y - 79.2).abs() < 0.01, "{:?}", family.rect);
|
||||
assert!((family.rect.w - 216.0).abs() < 0.01 && (family.rect.h - 21.6).abs() < 0.01, "{:?}", family.rect);
|
||||
assert_eq!(family.kind, WidgetKind::Text);
|
||||
assert_eq!(family.border.shape, BorderShape::Underline);
|
||||
assert_eq!(family.max_chars, Some(30));
|
||||
assert_eq!(family.tooltip.as_deref(), Some("Your family name"));
|
||||
assert_eq!(family.face.family, crate::text::Family::Courier);
|
||||
assert_eq!(family.som, "form[0].head[0].familyName[0]");
|
||||
let agree = w.iter().find(|w| w.name == "agree").unwrap();
|
||||
assert_eq!(agree.kind, WidgetKind::CheckBox { on: "1".into(), round: false });
|
||||
assert!((agree.rect.w - 10.0).abs() < 0.01, "a 10 pt square: {:?}", agree.rect);
|
||||
let born = w.iter().find(|w| w.name == "born").unwrap();
|
||||
assert_eq!(born.kind, WidgetKind::Date("yyyy-mm-dd".into()));
|
||||
// The radio group has no width: it sits beside the question on the same line.
|
||||
let q_y = p1
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|i| {
|
||||
if let Item::Text(s) = i
|
||||
&& s.text == "Have you ever?"
|
||||
{
|
||||
Some(s.baseline)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let yes = w.iter().find(|w| matches!(&w.kind, WidgetKind::Radio { group, on, .. } if group == "answer" && on == "Y")).unwrap();
|
||||
assert!(yes.rect.x > 6.5 * 72.0 && yes.rect.y < q_y, "beside the question, not under it: {:?} vs baseline {q_y}", yes.rect);
|
||||
let go = w.iter().find(|w| w.name == "go").unwrap();
|
||||
assert_eq!(go.kind, WidgetKind::Button { caption: "Reset".into() });
|
||||
assert_eq!(go.action, Some(Action::Reset));
|
||||
assert_eq!(go.border.fill, Some([212.0 / 255.0, 208.0 / 255.0, 200.0 / 255.0]));
|
||||
// Table cells take the column widths; the second column has no width of its own.
|
||||
let what = w.iter().find(|w| w.name == "what0").unwrap();
|
||||
assert!((what.rect.x - (36.0 + 144.0)).abs() < 0.01 && (what.rect.w - 216.0).abs() < 0.01, "{:?}", what.rect);
|
||||
assert_eq!(
|
||||
form.fields,
|
||||
9,
|
||||
"familyName, agree, born, yes, no, go, 3 cells; pageNo/pageCount are hidden: {:?}",
|
||||
w.iter().map(|w| &w.name).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_break_across_pages_and_repeat_their_header_row() {
|
||||
let form = layout_xml(&template(40)).unwrap();
|
||||
assert!(form.pages.len() >= 3, "{} pages", form.pages.len());
|
||||
// The header row's "Activity" cell appears at the top of every page the table continues on.
|
||||
for (i, p) in form.pages.iter().enumerate().take(form.pages.len() - 1) {
|
||||
let headers = p.items.iter().filter(|it| matches!(it, Item::Text(s) if s.text == "Activity")).count();
|
||||
assert_eq!(headers, 1, "page {}: header rows {headers}", i + 1);
|
||||
}
|
||||
// Rows are whole: no cell straddles the bottom of the content area.
|
||||
for p in &form.pages {
|
||||
for w in widgets(p) {
|
||||
assert!(w.rect.bottom() <= 36.0 + 720.0 + 0.01, "{} ends below the content area: {:?}", w.name, w.rect);
|
||||
}
|
||||
}
|
||||
let all: Vec<&str> = form.pages.iter().flat_map(|p| widgets(p).into_iter().map(|w| w.name.as_str()).collect::<Vec<_>>()).collect();
|
||||
assert_eq!(all.iter().filter(|n| n.starts_with("from")).count(), 40);
|
||||
assert_eq!(all.len(), all.iter().collect::<std::collections::HashSet<_>>().len(), "field names are unique");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields() {
|
||||
let mut doc = Document::open(Arc::new(shell(&template(2)))).unwrap();
|
||||
assert!(is_dynamic(&doc));
|
||||
let report = render_into(&mut doc).unwrap();
|
||||
assert_eq!((report.pages, report.fields), (2, 11), "fields: 5 on the head, the radio group once, 6 cells");
|
||||
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
let root = doc.get(doc.root().unwrap());
|
||||
let catalog = root.as_dict().unwrap();
|
||||
let pages = doc.resolve(catalog.get(b"Pages").unwrap());
|
||||
assert_eq!(pages.as_dict().unwrap().int(b"Count"), Some(2));
|
||||
let kids = doc.resolve(pages.as_dict().unwrap().get(b"Kids").unwrap()).as_array().unwrap().clone();
|
||||
let page1 = doc.resolve(&kids[0]);
|
||||
let annots = doc.resolve(page1.as_dict().unwrap().get(b"Annots").unwrap()).as_array().unwrap().len();
|
||||
assert!(annots >= 8, "{annots} widgets on page 1");
|
||||
let acro = doc.resolve(catalog.get(b"AcroForm").unwrap());
|
||||
let acro = acro.as_dict().unwrap();
|
||||
assert!(acro.contains(b"XFA"), "the XFA packets stay");
|
||||
assert_eq!(catalog.get(b"NeedsRendering"), Some(&Object::Bool(true)), "still a dynamic form for Adobe's viewers");
|
||||
let fields = doc.resolve(acro.get(b"Fields").unwrap()).as_array().unwrap().len();
|
||||
assert_eq!(fields, 11, "top-level fields (the radio group counts once)");
|
||||
let dr = doc.resolve(acro.get(b"DR").unwrap());
|
||||
let fonts = doc.resolve(dr.as_dict().unwrap().get(b"Font").unwrap());
|
||||
assert!(fonts.as_dict().unwrap().contains(b"Cour") && fonts.as_dict().unwrap().contains(b"ZaDb"));
|
||||
// A field carries its SOM path for data round trips.
|
||||
let first = doc.resolve(&doc.resolve(acro.get(b"Fields").unwrap()).as_array().unwrap()[0]);
|
||||
assert!(first.as_dict().unwrap().contains(SOM_KEY));
|
||||
// Not dynamic twice: a second pass is refused rather than doubling the pages.
|
||||
assert!(!is_dynamic(&doc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_without_xfa_are_not_touched() {
|
||||
let mut doc = Document::new_empty();
|
||||
assert!(!is_dynamic(&doc));
|
||||
assert!(matches!(render_into(&mut doc), Err(XfaError::NotXfa)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_templates_fail_without_panicking() {
|
||||
assert!(layout_xml("not xml").is_err());
|
||||
assert!(layout_xml("<template/>").is_err());
|
||||
assert!(layout_xml("<template><subform/></template>").is_ok(), "an empty form is one empty page");
|
||||
// Deep nesting is cut off, not followed.
|
||||
let deep = format!("<template><subform layout=\"tb\">{}{}</subform></template>", "<subform layout=\"tb\">".repeat(500), "</subform>".repeat(500));
|
||||
assert!(layout_xml(&deep).is_ok());
|
||||
// Absurd sizes are clamped; a page can't be a mile wide.
|
||||
let huge = r##"<template><subform layout="tb"><pageSet><pageArea><medium short="1e9in" long="-5in"/></pageArea></pageSet><draw w="1e12pt" h="1e12pt"><value><text>x</text></value></draw></subform></template>"##;
|
||||
let f = layout_xml(huge).unwrap();
|
||||
assert!(f.pages[0].width <= 14_400.0 && f.pages[0].height >= 1.0);
|
||||
// Too many pages is an error, not an endless loop.
|
||||
let many = format!(
|
||||
"<template><subform layout=\"tb\"><pageSet><pageArea><contentArea w=\"100pt\" h=\"100pt\"/><medium short=\"100pt\" long=\"100pt\"/></pageArea></pageSet>{}</subform></template>",
|
||||
"<draw h=\"90pt\"><value><text>x</text></value></draw>".repeat(MAX_PAGES + 5)
|
||||
);
|
||||
assert!(matches!(layout_xml(&many), Err(XfaError::TooLarge(_))));
|
||||
// A bad image and a bad base64 are warnings, not failures.
|
||||
let img = r##"<template><subform layout="tb"><draw w="1in" h="1in"><value><image contentType="image/png">!!!notbase64</image></value></draw></subform></template>"##;
|
||||
let f = layout_xml(img).unwrap();
|
||||
assert!(!f.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_packets_can_be_one_stream_or_named_parts() {
|
||||
// Named parts, with the template split from the rest.
|
||||
let doc = Document::open(Arc::new(shell(&template(0)))).unwrap();
|
||||
let p = read_packets(&doc).unwrap().unwrap();
|
||||
assert!(p.needs_rendering && !p.has_fields);
|
||||
assert!(p.xdp.contains("<template"));
|
||||
// UTF-16 packets are read too.
|
||||
let mut utf16 = vec![0xFF, 0xFE];
|
||||
for u in "<template><subform/></template>".encode_utf16() {
|
||||
utf16.extend_from_slice(&u.to_le_bytes());
|
||||
}
|
||||
let xdp = String::from_utf8_lossy(&utf16).into_owned();
|
||||
let _ = xdp;
|
||||
let doc = Document::open(Arc::new(shell_bytes(&utf16))).unwrap();
|
||||
let p = read_packets(&doc).unwrap().unwrap();
|
||||
assert!(p.xdp.starts_with("<template>"), "{:?}", &p.xdp[..20.min(p.xdp.len())]);
|
||||
}
|
||||
|
||||
/// Like [`shell`], but the packet bytes are given as-is.
|
||||
fn shell_bytes(packet: &[u8]) -> Vec<u8> {
|
||||
let text = shell("PLACEHOLDER");
|
||||
let needle = b"<< /Length 11 >>\nstream\nPLACEHOLDER\nendstream";
|
||||
let pos = text.windows(needle.len()).position(|w| w == needle).unwrap();
|
||||
let mut out = text[..pos].to_vec();
|
||||
out.extend_from_slice(format!("<< /Length {} >>\nstream\n", packet.len()).as_bytes());
|
||||
out.extend_from_slice(packet);
|
||||
out.extend_from_slice(b"\nendstream");
|
||||
out.extend_from_slice(&text[pos + needle.len()..]);
|
||||
// The xref offsets after the stream are off; the reader reconstructs them.
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_headers_give_the_size() {
|
||||
// A minimal SOF0 header: 300 × 200, 3 components.
|
||||
let mut j = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
|
||||
j.extend_from_slice(&[0; 14]);
|
||||
j.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0xC8, 0x01, 0x2C, 0x03]);
|
||||
assert_eq!(pdf::jpeg_size(&j), Some((300, 200)));
|
||||
assert_eq!(pdf::jpeg_size(b"\x89PNG"), None);
|
||||
assert_eq!(pdf::jpeg_size(&[0xFF, 0xD8, 0xFF]), None);
|
||||
}
|
||||
|
||||
const DATA: &str = "<form><head><familyName>Singh</familyName><agree>1</agree><born>2001-02-03</born><answer>N</answer></head><table><row><from0>2019</from0><what0>Studied</what0><where0>Delhi</where0></row><row><from0>2021</from0><what0>Worked</what0><where0>Toronto</where0></row><row><from0>2023</from0><what0>Moved</what0><where0>Ottawa</where0></row></table></form>";
|
||||
|
||||
#[test]
|
||||
fn datasets_parse_into_a_tree_and_som_paths_find_their_nodes() {
|
||||
let d = parse_datasets(&template_with_data(1, DATA)).expect("datasets");
|
||||
assert_eq!(d.name, "data");
|
||||
assert_eq!(d.text_at(&som_to_path("form[0].head[0].familyName[0]")), Some("Singh"));
|
||||
assert_eq!(d.text_at(&som_to_path("form[0].#subform[0].head[0].answer[0]")), Some("N"), "unnamed containers have no data node");
|
||||
assert_eq!(d.text_at(&som_to_path("form[0].table[0].row[2].where0[0]")), Some("Ottawa"));
|
||||
assert_eq!(d.text_at(&som_to_path("form[0].table[0].row[3].where0[0]")), None);
|
||||
assert_eq!(d.count(&som_to_path("form[0].table[0]"), "row"), 3);
|
||||
assert_eq!(som_to_path("a[1].b"), vec![("a".to_string(), 1), ("b".to_string(), 0)]);
|
||||
assert!(parse_datasets("<xdp:xdp xmlns:xdp=\"x\"/>").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dates_convert_between_iso_and_acrobat_patterns() {
|
||||
assert_eq!(iso_to_pattern("2001-02-03", "yyyy-mm-dd"), "2001-02-03");
|
||||
assert_eq!(iso_to_pattern("2001-02-03", "mm/dd/yyyy"), "02/03/2001");
|
||||
assert_eq!(iso_to_pattern("2001-02-03", "d.m.yy"), "3.2.01");
|
||||
assert_eq!(iso_to_pattern("not a date", "mm/dd/yyyy"), "not a date");
|
||||
assert_eq!(pattern_to_iso("02/03/2001", "mm/dd/yyyy").as_deref(), Some("2001-02-03"));
|
||||
assert_eq!(pattern_to_iso("3.2.01", "d.m.yy").as_deref(), Some("2001-02-03"));
|
||||
assert_eq!(pattern_to_iso("2001-02-03", "yyyy-mm-dd").as_deref(), Some("2001-02-03"));
|
||||
assert_eq!(pattern_to_iso("13/40/2001", "mm/dd/yyyy"), None);
|
||||
assert_eq!(pattern_to_iso("hello", "mm/dd/yyyy"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_fills_the_fields_and_adds_rows() {
|
||||
let form = layout_xml(&template_with_data(1, DATA)).unwrap();
|
||||
let all: Vec<&Widget> = form.pages.iter().flat_map(widgets).collect();
|
||||
let by = |n: &str| all.iter().find(|w| w.name == n).unwrap_or_else(|| panic!("no widget {n}"));
|
||||
assert_eq!(by("familyName").value.as_deref(), Some("Singh"));
|
||||
assert_eq!(by("agree").value.as_deref(), Some("1"), "checked by its on value");
|
||||
assert_eq!(by("born").value.as_deref(), Some("2001-02-03"));
|
||||
let no = all.iter().find(|w| matches!(&w.kind, WidgetKind::Radio { on, .. } if on == "N")).unwrap();
|
||||
let yes = all.iter().find(|w| matches!(&w.kind, WidgetKind::Radio { on, .. } if on == "Y")).unwrap();
|
||||
assert!(no.value.is_some() && yes.value.is_none(), "the group's data picks the N button");
|
||||
// One row in the template, three in the data: three rows laid out, each with its values.
|
||||
let wheres: Vec<&str> = all.iter().filter(|w| w.name.starts_with("where0")).filter_map(|w| w.value.as_deref()).collect();
|
||||
assert_eq!(wheres, ["Delhi", "Toronto", "Ottawa"]);
|
||||
assert_eq!(by("where0_3").som, "form[0].table[0].row[2].where0[0]");
|
||||
assert_eq!(by("agree").items, vec!["1".to_string(), "0".to_string()]);
|
||||
}
|
||||
|
||||
/// The AcroForm's terminal fields as the engine would hand them over.
|
||||
fn field_data(doc: &Document) -> Vec<FieldDatum> {
|
||||
let root = doc.get(doc.root().unwrap());
|
||||
let acro = doc.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap());
|
||||
let fields = doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().clone();
|
||||
let mut out = Vec::new();
|
||||
for f in fields {
|
||||
let r = f.as_ref().unwrap();
|
||||
let d = doc.get(r);
|
||||
let d = d.as_dict().unwrap();
|
||||
let name = d.get(b"T").and_then(|t| t.as_string()).map(|s| s.to_text()).unwrap_or_default();
|
||||
let v = d.get(b"V").map(|v| doc.resolve(v));
|
||||
let data = match d.name(b"FT") {
|
||||
Some(b"Tx") => FieldData::Text(v.and_then(|v| v.as_string().map(|s| s.to_text())).unwrap_or_default()),
|
||||
Some(b"Btn") if d.int(b"Ff").unwrap_or(0) & (1 << 15) != 0 => {
|
||||
FieldData::Radio(v.and_then(|v| v.as_name().map(|n| String::from_utf8_lossy(n).into_owned())).filter(|n| n != "Off"))
|
||||
}
|
||||
Some(b"Btn") if d.int(b"Ff").unwrap_or(0) & (1 << 16) != 0 => FieldData::None,
|
||||
Some(b"Btn") => FieldData::Check(v.and_then(|v| v.as_name().map(|n| n != b"Off")).unwrap_or(false)),
|
||||
_ => FieldData::None,
|
||||
};
|
||||
out.push(FieldDatum { obj: r, name, data });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_datasets_packet_is_written_from_the_fields_and_read_back() {
|
||||
let mut doc = Document::open(Arc::new(shell(&template_with_data(1, DATA)))).unwrap();
|
||||
render_into(&mut doc).unwrap();
|
||||
// The laid-out fields hold the data's values; writing them back reproduces it.
|
||||
let fields = field_data(&doc);
|
||||
assert!(fields.iter().any(|f| f.name == "familyName" && f.data == FieldData::Text("Singh".into())), "{fields:?}");
|
||||
assert!(fields.iter().any(|f| f.name == "answer" && f.data == FieldData::Radio(Some("N".into()))), "{fields:?}");
|
||||
// The data already holds what the fields hold: nothing to write.
|
||||
assert_eq!(write_datasets(&mut doc, &fields).unwrap(), DatasetsWrite::default());
|
||||
let doc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
let p = read_packets(&doc).unwrap().unwrap();
|
||||
let d = parse_datasets(&p.xdp).unwrap();
|
||||
assert_eq!(d.text_at(&som_to_path("form[0].head[0].familyName[0]")), Some("Singh"));
|
||||
assert_eq!(d.text_at(&som_to_path("form[0].head[0].agree[0]")), Some("1"));
|
||||
assert_eq!(d.text_at(&som_to_path("form[0].head[0].answer[0]")), Some("N"));
|
||||
assert_eq!(d.text_at(&som_to_path("form[0].table[0].row[2].where0[0]")), Some("Ottawa"));
|
||||
assert_eq!(p.xdp.matches("<xfa:datasets").count(), 1, "the packet is replaced, not duplicated");
|
||||
// Change a value, write again: the data follows, and reading gives the field its value.
|
||||
let mut doc = doc;
|
||||
let mut fields = field_data(&doc);
|
||||
for f in &mut fields {
|
||||
if f.name == "familyName" {
|
||||
f.data = FieldData::Text("Kaur".into());
|
||||
}
|
||||
if f.name == "agree" {
|
||||
f.data = FieldData::Check(false);
|
||||
}
|
||||
}
|
||||
let w = write_datasets(&mut doc, &fields).unwrap();
|
||||
assert!(w.written && w.warnings.is_empty(), "{w:?}");
|
||||
let values = read_values(&doc, &fields);
|
||||
assert!(values.contains(&("familyName".to_string(), FieldData::Text("Kaur".into()))), "{values:?}");
|
||||
assert!(values.contains(&("agree".to_string(), FieldData::Check(false))), "{values:?}");
|
||||
// A form without a datasets packet gets one, before the postamble.
|
||||
let mut doc = Document::open(Arc::new(shell(&template(1)))).unwrap();
|
||||
render_into(&mut doc).unwrap();
|
||||
let fields = field_data(&doc);
|
||||
assert!(write_datasets(&mut doc, &fields).unwrap().written);
|
||||
let p = read_packets(&doc).unwrap().unwrap();
|
||||
assert!(parse_datasets(&p.xdp).is_some());
|
||||
// No XFA at all: nothing to write.
|
||||
let mut plain = Document::new_empty();
|
||||
assert!(!write_datasets(&mut plain, &[]).unwrap().written);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_forms_read_their_values_from_the_datasets() {
|
||||
let doc = Document::open(Arc::new(static_shell("<form1><page1><name>Ada</name><agree>1</agree></page1></form1>"))).unwrap();
|
||||
assert!(!is_dynamic(&doc));
|
||||
let fields = vec![
|
||||
FieldDatum { obj: pdfcraft_cos::ObjRef::new(8, 0), name: "form1[0].page1[0].name[0]".into(), data: FieldData::Text(String::new()) },
|
||||
FieldDatum { obj: pdfcraft_cos::ObjRef::new(9, 0), name: "form1[0].page1[0].agree[0]".into(), data: FieldData::Check(false) },
|
||||
];
|
||||
let values = read_values(&doc, &fields);
|
||||
assert_eq!(
|
||||
values,
|
||||
vec![
|
||||
("form1[0].page1[0].name[0]".to_string(), FieldData::Text("Ada".into())),
|
||||
("form1[0].page1[0].agree[0]".to_string(), FieldData::Check(true))
|
||||
]
|
||||
);
|
||||
assert_eq!(build_data(&doc, &fields), "<xfa:data><form1><page1><name></name><agree></agree></page1></form1></xfa:data>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_width_less_subforms_lay_out_in_polynomial_time() {
|
||||
// Regression: a container without a width measured its content once for its width and
|
||||
// again for its height, at every level: 2^depth measurements. Two children per level make
|
||||
// it worse still. This must finish at once, and lay the innermost fields out.
|
||||
for depth in [40, 60] {
|
||||
let open = "<subform layout=\"lr-tb\"><field name=\"a\" w=\"20pt\" h=\"10pt\"/>".repeat(depth);
|
||||
let xml = format!(
|
||||
"<template><subform layout=\"tb\" name=\"form\">{open}<field name=\"inner\" w=\"30pt\" h=\"10pt\"/>{}</subform></template>",
|
||||
"</subform>".repeat(depth)
|
||||
);
|
||||
let start = std::time::Instant::now();
|
||||
let form = layout_xml(&xml).expect("lays out");
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(20), "took {:?}", start.elapsed());
|
||||
let names: Vec<String> = form.pages.iter().flat_map(widgets).map(|w| w.name.clone()).collect();
|
||||
assert!(names.iter().any(|n| n == "inner") || depth > 60, "{depth}: {names:?}");
|
||||
assert!(names.len() > 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// The static fixture's two fields, holding `name` and `agree`.
|
||||
fn static_fields(name: &str, agree: bool) -> Vec<FieldDatum> {
|
||||
vec![
|
||||
FieldDatum { obj: pdfcraft_cos::ObjRef::new(8, 0), name: "form1[0].page1[0].name[0]".into(), data: FieldData::Text(name.into()) },
|
||||
FieldDatum { obj: pdfcraft_cos::ObjRef::new(9, 0), name: "form1[0].page1[0].agree[0]".into(), data: FieldData::Check(agree) },
|
||||
]
|
||||
}
|
||||
|
||||
/// The decoded XFA stream (object 5 in the static fixture, or whatever `/XFA` now points to).
|
||||
fn xfa_bytes(doc: &Document) -> Vec<u8> {
|
||||
let root = doc.get(doc.root().unwrap());
|
||||
let acro = doc.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap());
|
||||
let xfa = doc.resolve(acro.as_dict().unwrap().get(b"XFA").unwrap());
|
||||
let Object::Stream(s) = &*xfa else { panic!("a stream") };
|
||||
s.decoded_within(1 << 20).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writing_values_keeps_the_data_no_field_binds_to() {
|
||||
// Regression: the data element was rebuilt from the fields alone, dropping everything else.
|
||||
let data = concat!(
|
||||
"<form1 a=\"1\"><page1><name>Ada</name><notes xmlns:my=\"urn:my\"><my:extra kind='x>y'>keep me</my:extra></notes>",
|
||||
"<rich><p>bold <b>text</b></p></rich></page1><deep><a><b><c>deep value</c></b></a></deep></form1>",
|
||||
"<other xmlns=\"urn:other\"><x>unbound</x></other><!-- a comment --><empty/>"
|
||||
);
|
||||
let mut doc = Document::open(Arc::new(static_shell(data))).unwrap();
|
||||
let w = write_datasets(&mut doc, &static_fields("Grace & <Co>\r\n", true)).unwrap();
|
||||
assert!(w.written && w.warnings.is_empty(), "{w:?}");
|
||||
let saved = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
let xdp = String::from_utf8(xfa_bytes(&saved)).unwrap();
|
||||
// Everything no field binds to is still there, as written.
|
||||
for kept in [
|
||||
"<form1 a=\"1\">",
|
||||
"<notes xmlns:my=\"urn:my\"><my:extra kind='x>y'>keep me</my:extra></notes>",
|
||||
"<rich><p>bold <b>text</b></p></rich>",
|
||||
"<deep><a><b><c>deep value</c></b></a></deep>",
|
||||
"<other xmlns=\"urn:other\"><x>unbound</x></other><!-- a comment --><empty/>",
|
||||
"<template xmlns=",
|
||||
] {
|
||||
assert!(xdp.contains(kept), "lost {kept:?}: {xdp}");
|
||||
}
|
||||
// The bound value changed in place; the missing check box node was added beside it.
|
||||
assert!(xdp.contains("<name>Grace & <Co>
\n</name>"), "{xdp}");
|
||||
let d = parse_datasets(&xdp).unwrap();
|
||||
assert_eq!(d.text_at(&som_to_path("form1[0].page1[0].name[0]")), Some("Grace & <Co>\r\n"));
|
||||
assert_eq!(d.text_at(&som_to_path("form1[0].page1[0].agree[0]")), Some("1"));
|
||||
assert_eq!(xdp.matches("<page1>").count(), 1, "added into the existing node, not a second one");
|
||||
// Writing the same values again changes nothing.
|
||||
let mut again = saved;
|
||||
assert_eq!(write_datasets(&mut again, &static_fields("Grace & <Co>\r\n", true)).unwrap(), DatasetsWrite::default());
|
||||
// A field bound to a node holding structured content is reported, not flattened.
|
||||
let mut doc = Document::open(Arc::new(static_shell("<form1><page1><name><p>rich</p></name></page1></form1>"))).unwrap();
|
||||
let w = write_datasets(&mut doc, &static_fields("plain", false)).unwrap();
|
||||
assert!(w.warnings.iter().any(|m| m.contains("structured content") && m.contains("name[0]")), "{w:?}");
|
||||
assert!(String::from_utf8(xfa_bytes(&doc)).unwrap().contains("<name><p>rich</p></name>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_packets_are_read_and_written_back_as_utf16() {
|
||||
let mut doc = Document::open(Arc::new(static_shell("<form1><page1><name>Zoë 日本</name><other>ünbound</other></page1></form1>"))).unwrap();
|
||||
let text = String::from_utf8(xfa_bytes(&doc)).unwrap();
|
||||
for (encoding, bom) in [(Encoding::Utf16Le { bom: true }, &[0xFF, 0xFE][..]), (Encoding::Utf16Be { bom: true }, &[0xFE, 0xFF][..])] {
|
||||
let mut doc16 = doc.clone();
|
||||
doc16.set(
|
||||
pdfcraft_cos::ObjRef::new(5, 0),
|
||||
Object::Stream(pdfcraft_cos::Stream::flate(pdfcraft_cos::Dict::new(), &encode_packet(&text, encoding))),
|
||||
);
|
||||
// Read exactly, not as mojibake.
|
||||
let values = read_values(&doc16, &static_fields("", false));
|
||||
assert_eq!(values.first(), Some(&("form1[0].page1[0].name[0]".to_string(), FieldData::Text("Zoë 日本".into()))));
|
||||
let w = write_datasets(&mut doc16, &static_fields("Łódź", false)).unwrap();
|
||||
assert!(w.written && w.warnings.is_empty(), "{w:?}");
|
||||
let bytes = xfa_bytes(&doc16);
|
||||
assert!(bytes.starts_with(bom), "still UTF-16 with its byte order mark");
|
||||
let (back, enc) = decode_packet(&bytes).unwrap();
|
||||
assert_eq!(enc, encoding);
|
||||
assert!(back.contains("<name>Łódź</name>") && back.contains("<other>ünbound</other>"), "{back}");
|
||||
}
|
||||
// A packet in neither UTF-8 nor UTF-16 (Latin-1 here) is left alone, and that is reported.
|
||||
let latin1: Vec<u8> = text.replace("Zoë 日本", "Zo\u{eb}").chars().map(|c| c as u32 as u8).collect();
|
||||
doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Stream(pdfcraft_cos::Stream::flate(pdfcraft_cos::Dict::new(), &latin1)));
|
||||
let w = write_datasets(&mut doc, &static_fields("x", true)).unwrap();
|
||||
assert!(!w.written && !w.warnings.is_empty(), "{w:?}");
|
||||
assert_eq!(xfa_bytes(&doc), latin1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_som_indices_are_capped_not_built() {
|
||||
// Regression: each index created its missing siblings one count at a time (quadratic), with
|
||||
// no limit on the total. Many fields with absurd indices must finish at once, reporting
|
||||
// what they could not write.
|
||||
let mut doc = Document::open(Arc::new(static_shell("<form1/>"))).unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
let fields: Vec<FieldDatum> = (0..300)
|
||||
.map(|i| FieldDatum {
|
||||
obj: pdfcraft_cos::ObjRef::new(1000 + i, 0),
|
||||
name: format!("form1[0].a[{}].b[99999].c[99999].d[{i}]", 9_000 + i),
|
||||
data: FieldData::Text("v".into()),
|
||||
})
|
||||
.collect();
|
||||
let w = write_datasets(&mut doc, &fields).unwrap();
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(10), "took {:?}", start.elapsed());
|
||||
assert!(w.warnings.iter().any(|m| m.contains("repeats too often")), "{w:?}");
|
||||
let xdp = String::from_utf8(xfa_bytes(&doc)).unwrap();
|
||||
assert!(xdp.matches("<a>").count() <= 20_000, "at most the node budget is created");
|
||||
// Building fresh data is capped the same way.
|
||||
let start = std::time::Instant::now();
|
||||
let data = build_data(&doc, &fields);
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(10));
|
||||
assert!(data.len() < 2_000_000);
|
||||
// And a moderate index still works.
|
||||
let mut doc = Document::open(Arc::new(static_shell("<form1><row><v>1</v></row></form1>"))).unwrap();
|
||||
let row = |i: usize| FieldDatum {
|
||||
obj: pdfcraft_cos::ObjRef::new(500, 0),
|
||||
name: format!("form1[0].row[{i}].v[0]"),
|
||||
data: FieldData::Text(i.to_string()),
|
||||
};
|
||||
let w = write_datasets(&mut doc, &[row(0), row(3)]).unwrap();
|
||||
assert!(w.written && w.warnings.is_empty(), "{w:?}");
|
||||
let d = parse_datasets(&String::from_utf8(xfa_bytes(&doc)).unwrap()).unwrap();
|
||||
assert_eq!(d.count(&som_to_path("form1[0]"), "row"), 4);
|
||||
assert_eq!(d.text_at(&som_to_path("form1[0].row[3].v[0]")), Some("3"));
|
||||
assert!(d.get(&som_to_path("form1[0].row[2]")).is_some(), "the instances in between exist, empty");
|
||||
}
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
//! Text measurement and line breaking with the standard 14 fonts that stand in for the
|
||||
//! template's typefaces (no font programs are embedded in dynamic XFA forms, and none are
|
||||
//! bundled here).
|
||||
|
||||
use crate::model::{Font, HAlign, Para, Rect, RichText, Run};
|
||||
|
||||
/// The standard-14 family a typeface maps to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Family {
|
||||
Helvetica,
|
||||
Courier,
|
||||
Times,
|
||||
}
|
||||
|
||||
impl Family {
|
||||
pub fn of(typeface: &str) -> Family {
|
||||
let t = typeface.to_ascii_lowercase();
|
||||
if t.contains("courier") || t.contains("mono") {
|
||||
Family::Courier
|
||||
} else if t.contains("times")
|
||||
|| t.contains("georgia")
|
||||
|| t.contains("garamond")
|
||||
|| t.contains("minion")
|
||||
|| t.contains("serif") && !t.contains("sans")
|
||||
{
|
||||
Family::Times
|
||||
} else {
|
||||
Family::Helvetica
|
||||
}
|
||||
}
|
||||
|
||||
/// The PDF base font name for a face.
|
||||
pub fn base_font(self, bold: bool, italic: bool) -> &'static str {
|
||||
match (self, bold, italic) {
|
||||
(Family::Helvetica, false, false) => "Helvetica",
|
||||
(Family::Helvetica, true, false) => "Helvetica-Bold",
|
||||
(Family::Helvetica, false, true) => "Helvetica-Oblique",
|
||||
(Family::Helvetica, true, true) => "Helvetica-BoldOblique",
|
||||
(Family::Courier, false, false) => "Courier",
|
||||
(Family::Courier, true, false) => "Courier-Bold",
|
||||
(Family::Courier, false, true) => "Courier-Oblique",
|
||||
(Family::Courier, true, true) => "Courier-BoldOblique",
|
||||
(Family::Times, false, false) => "Times-Roman",
|
||||
(Family::Times, true, false) => "Times-Bold",
|
||||
(Family::Times, false, true) => "Times-Italic",
|
||||
(Family::Times, true, true) => "Times-BoldItalic",
|
||||
}
|
||||
}
|
||||
|
||||
/// The resource name used in content streams and `/DA` strings (Acrobat's conventions).
|
||||
pub fn resource_name(self, bold: bool, italic: bool) -> &'static str {
|
||||
match (self, bold, italic) {
|
||||
(Family::Helvetica, false, false) => "Helv",
|
||||
(Family::Helvetica, true, false) => "HeBo",
|
||||
(Family::Helvetica, false, true) => "HeOb",
|
||||
(Family::Helvetica, true, true) => "HeBO",
|
||||
(Family::Courier, false, false) => "Cour",
|
||||
(Family::Courier, true, false) => "CoBo",
|
||||
(Family::Courier, false, true) => "CoOb",
|
||||
(Family::Courier, true, true) => "CoBO",
|
||||
(Family::Times, false, false) => "TiRo",
|
||||
(Family::Times, true, false) => "TiBo",
|
||||
(Family::Times, false, true) => "TiIt",
|
||||
(Family::Times, true, true) => "TiBI",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A resolved face: family, weight, posture, size and colour.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Face {
|
||||
pub family: Family,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
pub size: f64,
|
||||
pub color: [f64; 3],
|
||||
pub underline: bool,
|
||||
}
|
||||
|
||||
impl Face {
|
||||
pub fn of(font: &Font) -> Face {
|
||||
Face {
|
||||
family: Family::of(&font.typeface),
|
||||
bold: font.bold,
|
||||
italic: font.italic,
|
||||
size: font.size.clamp(1.0, 300.0),
|
||||
color: font.color.0,
|
||||
underline: font.underline,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_run(&self, run: &Run) -> Face {
|
||||
Face {
|
||||
family: self.family,
|
||||
bold: run.bold.unwrap_or(self.bold),
|
||||
italic: run.italic.unwrap_or(self.italic),
|
||||
size: run.size.unwrap_or(self.size).clamp(1.0, 300.0),
|
||||
color: self.color,
|
||||
underline: run.underline.unwrap_or(self.underline),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resource_name(&self) -> &'static str {
|
||||
self.family.resource_name(self.bold, self.italic)
|
||||
}
|
||||
|
||||
/// Advance of `s` at this face's size.
|
||||
pub fn width(&self, s: &str) -> f64 {
|
||||
match self.family {
|
||||
Family::Courier => s.chars().count() as f64 * 0.6 * self.size,
|
||||
Family::Helvetica => pdfcraft_fonts::helvetica_width(s, self.size) * if self.bold { 1.04 } else { 1.0 },
|
||||
Family::Times => pdfcraft_fonts::helvetica_width(s, self.size) * if self.bold { 0.96 } else { 0.92 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default line height: XFA's font leading for the standard faces.
|
||||
pub fn line_height(size: f64) -> f64 {
|
||||
size * 1.15
|
||||
}
|
||||
|
||||
/// One positioned piece of text, ready to emit.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Span {
|
||||
pub x: f64,
|
||||
/// Baseline, top-left based (y grows downwards).
|
||||
pub baseline: f64,
|
||||
pub text: String,
|
||||
pub face: Face,
|
||||
}
|
||||
|
||||
/// A laid-out block of text.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Block {
|
||||
pub spans: Vec<Span>,
|
||||
/// Height used, from the top of the box.
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Word {
|
||||
text: String,
|
||||
face: Face,
|
||||
width: f64,
|
||||
}
|
||||
|
||||
/// Break the runs of a paragraph into words (spaces stay attached to the word before them).
|
||||
fn words(runs: &[Run], base: &Face, embed: &dyn Fn(&str) -> String) -> Vec<Word> {
|
||||
let mut out = Vec::new();
|
||||
for run in runs {
|
||||
let face = base.with_run(run);
|
||||
let text: String = match &run.embed {
|
||||
Some(id) => embed(id),
|
||||
None => run
|
||||
.text
|
||||
.chars()
|
||||
.filter_map(|c| match c {
|
||||
'\r' | '\n' | '\t' | '\u{2028}' | '\u{2029}' | '\u{2009}' | '\u{202F}' | '\u{3000}' => Some(' '),
|
||||
'\u{00AD}' | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}' => None,
|
||||
'\u{2011}' | '\u{2010}' | '\u{2012}' | '\u{2013}' => Some('-'),
|
||||
'\u{2018}' | '\u{2019}' => Some('\''),
|
||||
_ => Some(c),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let mut cur = String::new();
|
||||
for ch in text.chars() {
|
||||
cur.push(ch);
|
||||
if ch == ' ' {
|
||||
let width = face.width(&cur);
|
||||
out.push(Word { text: std::mem::take(&mut cur), face: face.clone(), width });
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
let width = face.width(&cur);
|
||||
out.push(Word { text: cur, face, width });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Lay `rich` out inside `rect` with `para` and `base` face. `embed` resolves embedded fields to
|
||||
/// their text. Returns the spans (absolute coordinates) and the height the text takes.
|
||||
pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn Fn(&str) -> String) -> Block {
|
||||
struct Line {
|
||||
words: Vec<Word>,
|
||||
width: f64,
|
||||
size: f64,
|
||||
first: bool,
|
||||
}
|
||||
let avail = (rect.w - para.margin_left - para.margin_right).max(1.0);
|
||||
let mut lines: Vec<(Line, Option<HAlign>)> = Vec::new();
|
||||
let mut total = 0.0;
|
||||
for (pi, p) in rich.paragraphs.iter().enumerate() {
|
||||
let ws = words(&p.runs, base, embed);
|
||||
let mut line = Line { words: Vec::new(), width: 0.0, size: base.size, first: true };
|
||||
let mut first_in_para = true;
|
||||
let indent = |first: bool| if first { para.text_indent.max(0.0) } else { 0.0 };
|
||||
for w in ws {
|
||||
let fits = line.width + w.width <= avail - indent(line.first) || line.words.is_empty() && w.width <= avail - indent(line.first);
|
||||
if !fits && !line.words.is_empty() {
|
||||
let done = std::mem::replace(&mut line, Line { words: Vec::new(), width: 0.0, size: base.size, first: false });
|
||||
lines.push((done, p.h_align));
|
||||
}
|
||||
if w.width > avail - indent(line.first) && line.words.is_empty() {
|
||||
// A word longer than a line: break it by character.
|
||||
let mut piece = String::new();
|
||||
for ch in w.text.chars() {
|
||||
piece.push(ch);
|
||||
if w.face.width(&piece) > avail - indent(line.first) && piece.chars().count() > 1 {
|
||||
piece.pop();
|
||||
let width = w.face.width(&piece);
|
||||
line.words.push(Word { text: std::mem::take(&mut piece), face: w.face.clone(), width });
|
||||
line.width += width;
|
||||
line.size = line.size.max(w.face.size);
|
||||
let done = std::mem::replace(&mut line, Line { words: Vec::new(), width: 0.0, size: base.size, first: false });
|
||||
lines.push((done, p.h_align));
|
||||
piece.push(ch);
|
||||
}
|
||||
}
|
||||
if !piece.is_empty() {
|
||||
let width = w.face.width(&piece);
|
||||
line.size = line.size.max(w.face.size);
|
||||
line.width += width;
|
||||
line.words.push(Word { text: piece, face: w.face.clone(), width });
|
||||
}
|
||||
first_in_para = false;
|
||||
continue;
|
||||
}
|
||||
line.size = line.size.max(w.face.size);
|
||||
line.width += w.width;
|
||||
line.words.push(w);
|
||||
first_in_para = false;
|
||||
}
|
||||
if !line.words.is_empty() || first_in_para && pi + 1 < rich.paragraphs.len() {
|
||||
lines.push((line, p.h_align));
|
||||
}
|
||||
total += para.space_above + para.space_below;
|
||||
}
|
||||
let heights: Vec<f64> = lines.iter().map(|(l, _)| para.line_height.unwrap_or_else(|| line_height(l.size))).collect();
|
||||
total += heights.iter().sum::<f64>();
|
||||
let start_y = match para.v_align {
|
||||
crate::model::VAlign::Top => rect.y,
|
||||
crate::model::VAlign::Middle => rect.y + ((rect.h - total) / 2.0).max(0.0),
|
||||
crate::model::VAlign::Bottom => rect.y + (rect.h - total).max(0.0),
|
||||
};
|
||||
let mut spans = Vec::new();
|
||||
let mut y = start_y + para.space_above;
|
||||
for ((line, align), lh) in lines.into_iter().zip(heights) {
|
||||
let indent = if line.first { para.text_indent.max(0.0) } else { 0.0 };
|
||||
// Trailing spaces don't count for alignment.
|
||||
let trailing = line.words.last().map_or(0.0, |w| if w.text.ends_with(' ') { w.face.width(" ") } else { 0.0 });
|
||||
let used = (line.width - trailing).max(0.0);
|
||||
let mut x = rect.x + para.margin_left + indent;
|
||||
x += match align.unwrap_or(para.h_align) {
|
||||
HAlign::Left | HAlign::Justify => 0.0,
|
||||
HAlign::Center => ((avail - indent - used) / 2.0).max(0.0),
|
||||
HAlign::Right => (avail - indent - used).max(0.0),
|
||||
};
|
||||
// Baseline: ascent sits about 0.8 of the size below the line top for these faces.
|
||||
let baseline = y + lh - (lh - line.size * 0.8).max(0.0) / 2.0 - line.size * 0.08;
|
||||
let mut run: Option<Span> = None;
|
||||
for w in line.words {
|
||||
match &mut run {
|
||||
Some(s) if s.face == w.face => s.text.push_str(&w.text),
|
||||
_ => {
|
||||
if let Some(s) = run.take() {
|
||||
x += s.face.width(&s.text);
|
||||
spans.push(s);
|
||||
}
|
||||
run = Some(Span { x, baseline, text: w.text, face: w.face });
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(s) = run.take() {
|
||||
spans.push(s);
|
||||
}
|
||||
y += lh;
|
||||
}
|
||||
Block { spans, height: total }
|
||||
}
|
||||
|
||||
/// Height `rich` needs at `width` (for boxes without an explicit height).
|
||||
pub fn measure_height(rich: &RichText, width: f64, para: &Para, base: &Face, embed: &dyn Fn(&str) -> String) -> f64 {
|
||||
let p = Para { v_align: crate::model::VAlign::Top, ..para.clone() };
|
||||
layout(rich, Rect::new(0.0, 0.0, width.max(1.0), 0.0), &p, base, embed).height
|
||||
}
|
||||
|
||||
/// Plain text as rich text (paragraphs split on line breaks).
|
||||
pub fn plain(text: &str) -> RichText {
|
||||
RichText {
|
||||
paragraphs: text
|
||||
.split(['\n', '\r', '\u{2028}', '\u{2029}'])
|
||||
.map(|l| crate::model::Paragraph { runs: vec![Run { text: l.to_string(), ..Run::default() }], h_align: None })
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
|
@ -5115,11 +5115,11 @@ title = "View and fill static XFA forms"
|
|||
area = "F"
|
||||
tier = "P1"
|
||||
milestone = "M12"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = "not read yet; detected on open (DocInfo.xfa, doc_info \"xfa\") and a notice says the fields can be filled but Acrobat may show the XFA values instead (#60)"
|
||||
status = "partial"
|
||||
commands = ["file.open"]
|
||||
tools = ["doc_open", "form_fill"]
|
||||
evidence = ["crates/engine/src/tests.rs::static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back", "crates/ui-egui/tests/xfa.rs::a_static_xfa_form_is_filled_from_its_data_and_says_so", "crates/xfa/src/tests.rs::static_forms_read_their_values_from_the_datasets", "crates/xfa/src/tests.rs::writing_values_keeps_the_data_no_field_binds_to", "crates/xfa/src/tests.rs::utf16_packets_are_read_and_written_back_as_utf16", "crates/engine/src/tests.rs::xfa_data_no_field_binds_to_survives_an_edit_and_save"]
|
||||
notes = "the AcroForm fields are shown and filled as in any form; on open their values come from the XFA datasets (what another viewer saved) by the fields' Designer names (recorded as a document warning), and every edit merges the values back into the datasets, keeping data no field binds to, so Acrobat shows the same values; values that can't be written are warnings. Missing: no template semantics (no scripts, calculations or hidden subforms), explicit bind refs are not followed (#60)"
|
||||
|
||||
[[feature]]
|
||||
id = "form.xfa-dynamic"
|
||||
|
|
@ -5127,11 +5127,11 @@ title = "View and fill dynamic XFA forms"
|
|||
area = "F"
|
||||
tier = "P1"
|
||||
milestone = "M12"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = "not rendered yet; detected on open and a notice says the page shown is the file's placeholder (#60)"
|
||||
status = "partial"
|
||||
commands = ["file.open"]
|
||||
tools = ["doc_open", "doc_info", "form_fields", "form_fill"]
|
||||
evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time"]
|
||||
notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG images) into real pages and AcroForm fields (text, date with AFDate formats, check boxes, radio groups, buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. Missing: no FormCalc or XFA JavaScript, so rows don't grow by button and calculations don't run; choice lists are text fields; signature, image and barcode fields are blank; PNG images; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
|
||||
|
||||
[[feature]]
|
||||
id = "form.xfa-formcalc"
|
||||
|
|
@ -5151,11 +5151,11 @@ title = "XFA XML data import and export"
|
|||
area = "F"
|
||||
tier = "P2"
|
||||
milestone = "M12"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
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"]
|
||||
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). Missing: importing or exporting a standalone XML/XDP data file, explicit bind refs, data descriptions"
|
||||
|
||||
[[feature]]
|
||||
id = "form.xfa-flatten"
|
||||
|
|
|
|||
Loading…
Reference in New Issue