M12: a date typed by another of an XFA field's edit patterns is kept in its first

This commit is contained in:
Eric Kaden
2026-10-19 17:21:08 -04:00
parent bf88255c15
commit 3a5bcbdf97
8 changed files with 139 additions and 5 deletions
+14 -4
View File
@@ -2428,10 +2428,20 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
Edit::SetAnnotationInfo { page, index, author, subject, icon } => {
pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?;
}
Edit::SetFieldValue { name, value } => match cx.js.as_mut() {
Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js)?,
None => pdfcraft_forms::set_value(doc, name, value)?,
},
Edit::SetFieldValue { name, value } => {
let mut set = |doc: &mut pdfcraft_cos::Document, value: &FieldValue| match cx.js.as_mut() {
Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js),
None => pdfcraft_forms::set_value(doc, name, value),
};
if let Err(refused) = set(doc, value) {
// A date the field's pattern doesn't read may be typed by another of an XFA date
// field's patterns: it is kept in the first.
match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) {
Some(typed) => set(doc, &typed)?,
None => return Err(refused.into()),
}
}
}
Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() {
Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?,
None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?,
+20
View File
@@ -6153,3 +6153,23 @@ fn undo_never_makes_an_unsaved_document_clean() {
s.undo(new).unwrap();
assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean");
}
#[test]
fn a_date_typed_by_another_of_an_xfa_fields_edit_patterns_is_kept_in_its_first() {
let mut s = Session::new();
let id = s
.open("dates.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::date_alternatives_template())), None)
.expect("opens");
let value = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "leave").unwrap().value.clone();
s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("261018".into()) }).expect("yymmdd is one of its patterns");
assert_eq!(value(&s), ["20261018"]);
s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("2026118".into()) }).expect("so is yyyymd");
assert_eq!(value(&s), ["20261108"]);
let refused = s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("2618".into()) });
assert!(refused.is_err_and(|e| e.to_string().contains("yyyymmdd")), "none of its patterns reads it");
assert_eq!(value(&s), ["20261108"]);
// The data holds the date.
let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).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].leave[0]")), Some("2026-11-08"));
}
+22
View File
@@ -810,6 +810,28 @@ pub(crate) fn on_data_replaced(doc: &mut pdfcraft_cos::Document, tpl: &Template,
r.finish().map(|_| ())
}
/// A text value typed into the laid-out XFA date field `name` by another of its edit picture's
/// patterns, written in the first ([`pdfcraft_xfa::typed_date`]); `None` for anything else.
/// `tpl` is the template the scripts use, when they run; otherwise it is read from `doc`.
pub(crate) fn typed_date(
doc: &pdfcraft_cos::Document,
tpl: Option<&Template>,
name: &str,
value: &pdfcraft_forms::FieldValue,
) -> Option<pdfcraft_forms::FieldValue> {
let pdfcraft_forms::FieldValue::Text(text) = value else { return None };
let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find_map(|(som, n)| (n == name).then_some(som))?;
let read;
let tpl = match tpl {
Some(t) => t,
None => {
read = pdfcraft_xfa::template_of(doc).ok()?;
&read
}
};
pdfcraft_xfa::typed_date(tpl, &som, text).map(pdfcraft_forms::FieldValue::Text)
}
/// A button's click script (or any event, by SOM path and activity), then calculations.
pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &str, activity: &str, run: &mut XfaRun) -> Result<(), String> {
let mut r = Runner::new(doc, tpl, run, EVENT_BUDGET_MS);
+18
View File
@@ -375,6 +375,24 @@ pub fn fields_template(data: &str) -> String {
)
}
/// Date fields whose edit pictures list alternatives, as forms let a date be typed several ways
/// (`date{YYYYMMDD}|date{YYMMDD}|…`); one with a single pattern; a text field.
pub fn date_alternatives_template() -> String {
r##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<field name="leave" w="2in" h="0.3in"><ui><dateTimeEdit/><picture>date{YYYYMMDD}|date{DDMMMYY}|date{YYMMDD}|date{YYYYMMD}|date{YY MM DD}</picture></ui><value><date/></value></field>
<field name="born" w="2in" h="0.3in"><ui><dateTimeEdit/><picture>date{YYYY-MM-DD}</picture></ui><value><date/></value></field>
<field name="name" w="2in" h="0.3in"><ui><textEdit/></ui></field>
</subform>
</template>
</xdp:xdp>
"##
.to_string()
}
/// A form of `sections` sections of `rows` rows, each row with a calculated total (half in
/// JavaScript, half in FormCalc) and a section sum over its rows: 2 × sections × rows + sections
/// calculate scripts, every one reading values the earlier ones settled.
+1 -1
View File
@@ -395,7 +395,7 @@ fn is_row(node: &Node) -> bool {
}
/// The Acrobat date pattern for an XFA picture clause such as `date{YYYY-MM-DD}`.
fn date_pattern(picture: Option<&str>) -> String {
pub(crate) fn date_pattern(picture: Option<&str>) -> String {
picture.and_then(date_letters).unwrap_or_else(|| "yyyy-mm-dd".into())
}
+2
View File
@@ -15,6 +15,7 @@ pub mod model;
pub mod packets;
pub mod parse;
pub mod pdf;
pub mod picture;
pub mod script;
pub mod text;
@@ -99,6 +100,7 @@ pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, Widge
pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets};
pub use parse::{measure, parse};
pub use pdf::{CLICK_KEY, LAYOUT_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape};
pub use picture::typed_date;
pub use script::{
FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, fields_by_som, form_tree,
has_scripts, overrides, refresh_value, rerender, set_overrides, template_of,
+47
View File
@@ -0,0 +1,47 @@
//! Picture clauses that list alternatives (`date{YYYYMMDD}|date{YYMMDD}`): a date may be typed
//! by any of them, and is shown, and kept in the field, by the first.
use crate::data::{date_letters, iso_to_pattern, pattern_to_iso, template_field};
use crate::layout::date_pattern;
use crate::model::{Template, Ui};
/// The alternatives of a picture clause: its parts between `|`s that aren't inside braces or
/// quotes.
pub(crate) fn alternatives(picture: &str) -> Vec<&str> {
let mut out = Vec::new();
let (mut depth, mut quoted, mut start) = (0usize, false, 0);
for (i, c) in picture.char_indices() {
match c {
'\'' => quoted = !quoted,
'{' if !quoted => depth = depth.saturating_add(1),
'}' if !quoted => depth = depth.saturating_sub(1),
'|' if !quoted && depth == 0 => {
out.extend(picture.get(start..i));
start = i + 1;
}
_ => {}
}
}
out.extend(picture.get(start..));
out
}
/// `text` typed into the date field at `som` by one of its edit picture's alternatives after the
/// first, written in the first, which the field shows and checks typed dates against (`261018`
/// becomes `20261018` for `date{YYYYMMDD}|date{YYMMDD}`). A pattern reads the text only when the
/// date it gives, written in that pattern, is the text again. `None` when the field isn't a date
/// field with alternatives, the first pattern reads the text, or no other does.
pub fn typed_date(tpl: &Template, som: &str, text: &str) -> Option<String> {
let f = template_field(tpl, som)?;
if f.ui != Ui::DateTimeEdit {
return None;
}
let picture = f.picture.as_deref()?;
let reads = |pattern: &str| pattern_to_iso(text, pattern).filter(|iso| iso_to_pattern(iso, pattern).eq_ignore_ascii_case(text.trim()));
let first = date_pattern(Some(picture));
if reads(&first).is_some() {
return None;
}
let iso = alternatives(picture).into_iter().skip(1).filter_map(date_letters).find_map(|p| reads(&p))?;
Some(iso_to_pattern(&iso, &first))
}
+15
View File
@@ -2141,3 +2141,18 @@ fn som_indices_count_earlier_same_named_siblings() {
let sibs = SomIndices::of(&children);
assert_eq!((0..=children.len()).map(|i| sibs.at(i)).collect::<Vec<_>>(), [0, 0, 1, 0, 2, 1, 0]);
}
#[test]
fn a_date_typed_by_another_of_its_edit_pictures_patterns_is_written_in_the_first() {
let (tpl, _) = crate::parse(&crate::fixtures::date_alternatives_template()).unwrap();
let typed = |som: &str, text: &str| crate::typed_date(&tpl, som, text);
let leave = "form[0].leave[0]";
assert_eq!(typed(leave, "261018").as_deref(), Some("20261018"), "by yymmdd");
assert_eq!(typed(leave, "2026118").as_deref(), Some("20261108"), "by yyyymd");
assert_eq!(typed(leave, "26 10 18").as_deref(), Some("20261018"), "by yy mm dd");
assert_eq!(typed(leave, "20261018"), None, "the first pattern reads it: it stays as typed");
assert_eq!(typed(leave, "not a date"), None);
assert_eq!(typed(leave, "2618"), None, "no pattern has room for four digits");
assert_eq!(typed("form[0].born[0]", "181026"), None, "one pattern: nothing else to try");
assert_eq!(typed("form[0].name[0]", "261018"), None, "not a date field");
}