M12: floating fields show the values scripts give them, and the sheet number and count (#6160) (#6166)

This commit is contained in:
mtlcl410 authored and GitHub committed 2026-11-09 08:45:03 +08:00
1 parent 2ef2e2b822
commit 0a029f0aad
21 files changed
+942 -78

No files matched your search

+1 -1
View File
@@ -25,7 +25,7 @@ mod writer;
pub use budget::{DecodeBudget, ImageBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS, MAX_REDACTED_IMAGE_PIXELS};
pub use bytes::Bytes;
pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry};
pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream};
pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream, TextChars};
pub use parser::{Lexer, is_whitespace, parse_indirect};
pub use pdfcraft_crypt::{
Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, effective_password, pdfdoc_byte, pdfdoc_char,
+105 -7
View File
@@ -57,19 +57,90 @@ impl PdfString {
/// non-ASCII bytes (written by some producers) is read as UTF-8 rather than as
/// PDFDocEncoding mojibake, and stray leading U+FEFF (doubled BOMs) are removed.
pub fn to_text(&self) -> String {
let s = match self.bytes.as_slice() {
self.text_chars().collect()
}
/// The characters [`PdfString::to_text`] decodes, one at a time, without building a string.
pub fn text_chars(&self) -> TextChars<'_> {
let decode = match self.bytes.as_slice() {
[0xFE, 0xFF, rest @ ..] => {
let units: Vec<u16> = rest.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect();
String::from_utf16_lossy(&units)
let units: fn(&[u8; 2]) -> u16 = |c| u16::from_be_bytes(*c);
Decode::Utf16(char::decode_utf16(rest.as_chunks::<2>().0.iter().map(units)))
}
[0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
[0xEF, 0xBB, 0xBF, rest @ ..] => Decode::Utf8Lossy { chunks: rest.utf8_chunks(), valid: "".chars(), replace: false },
b => match std::str::from_utf8(b) {
// Pure ASCII is identical either way; only non-ASCII UTF-8 needs the switch.
Ok(utf8) if !b.is_ascii() => utf8.to_owned(),
_ => b.iter().map(|&c| pdfcraft_crypt::pdfdoc_char(c)).collect(),
Ok(utf8) if !b.is_ascii() => Decode::Utf8(utf8.chars()),
_ => Decode::PdfDoc(b.iter()),
},
};
if s.starts_with('\u{FEFF}') { s.trim_start_matches('\u{FEFF}').to_owned() } else { s }
TextChars { decode, leading: true }
}
/// Whether the string decodes to `s` ([`PdfString::to_text`]), compared without allocating.
pub fn text_eq(&self, s: &str) -> bool {
self.text_chars().eq(s.chars())
}
}
/// The characters of a text string: [`PdfString::text_chars`].
pub struct TextChars<'a> {
decode: Decode<'a>,
/// Still at the start, where stray U+FEFF (doubled BOMs) are dropped.
leading: bool,
}
/// The UTF-16BE code units after a BOM (an odd last byte is left out).
type Utf16Units<'a> = std::iter::Map<std::slice::Iter<'a, [u8; 2]>, fn(&[u8; 2]) -> u16>;
enum Decode<'a> {
Utf16(std::char::DecodeUtf16<Utf16Units<'a>>),
/// After a UTF-8 BOM: each invalid sequence is one U+FFFD, as `String::from_utf8_lossy` has it.
Utf8Lossy {
chunks: std::str::Utf8Chunks<'a>,
valid: std::str::Chars<'a>,
replace: bool,
},
Utf8(std::str::Chars<'a>),
PdfDoc(std::slice::Iter<'a, u8>),
}
impl Iterator for Decode<'_> {
type Item = char;
fn next(&mut self) -> Option<char> {
match self {
Decode::Utf16(units) => units.next().map(|u| u.unwrap_or(char::REPLACEMENT_CHARACTER)),
Decode::Utf8Lossy { chunks, valid, replace } => loop {
if let Some(c) = valid.next() {
return Some(c);
}
if *replace {
*replace = false;
return Some(char::REPLACEMENT_CHARACTER);
}
let chunk = chunks.next()?;
*valid = chunk.valid().chars();
*replace = !chunk.invalid().is_empty();
},
Decode::Utf8(chars) => chars.next(),
Decode::PdfDoc(bytes) => bytes.next().map(|&c| pdfcraft_crypt::pdfdoc_char(c)),
}
}
}
impl Iterator for TextChars<'_> {
type Item = char;
fn next(&mut self) -> Option<char> {
loop {
let c = self.decode.next()?;
if self.leading && c == '\u{FEFF}' {
continue;
}
self.leading = false;
return Some(c);
}
}
}
@@ -594,6 +665,33 @@ mod tests {
assert_eq!(PdfString::literal(Vec::new()).to_text(), "");
}
#[test]
fn text_eq_compares_what_to_text_decodes() {
let utf16 = |s: &str| [0xFE, 0xFF].into_iter().chain(s.encode_utf16().flat_map(u16::to_be_bytes)).collect::<Vec<u8>>();
// Each invalid UTF-8 sequence is one U+FFFD, as String::from_utf8_lossy has it.
let lossy = [0xEF, 0xBB, 0xBF, 0xC3, 0x28, b'x', 0xF0, 0x9F, 0x92, 0xFF];
assert_eq!(PdfString::literal(lossy.to_vec()).to_text(), String::from_utf8_lossy(&lossy[3..]));
let cases: Vec<Vec<u8>> = vec![
Vec::new(),
b"Plain".to_vec(),
vec![b'C', b'a', b'f', 0xE9, 0x18],
"目录".as_bytes().to_vec(),
utf16("第一章"),
vec![0xFE, 0xFF, 0xD8, 0x00, 0x00, b'A'],
vec![0xFE, 0xFF, 0xFE, 0xFF, 0x00, b'A', 0xFE, 0xFF],
lossy.to_vec(),
];
for bytes in cases {
let s = PdfString::literal(bytes);
let text = s.to_text();
assert!(s.text_eq(&text), "{text:?}");
assert!(!s.text_eq(&format!("{text}x")), "{text:?}");
assert!(text.is_empty() || !s.text_eq(&text[..text.len() - text.chars().last().map_or(0, char::len_utf8)]), "{text:?}");
}
// A U+FEFF after the first character is text and stays.
assert!(PdfString::literal(utf16("A\u{FEFF}")).text_eq("A\u{FEFF}"));
}
#[test]
fn pdfdoc_encoding_high_range() {
assert_eq!(PdfString::literal(vec![0x84, 0x92, 0xA0, b'A']).to_text(), "—™€A");
+153
View File
@@ -10161,6 +10161,159 @@ fn a_date_its_validate_script_refuses_is_not_tried_again() {
assert!(value(&s, "name").is_empty(), "nothing the refused run changed is kept: {:?}", value(&s, "name"));
}
/// Floating fields (XFA 3.3 p. 1222): text that embeds a hidden field shows the value its
/// initialize script gives it, the value a button's script gives it later and the value a
/// calculation gives one, as it does the sheet number and count; the fields sit in an unnamed
/// subform, as forms place them (#6160).
#[test]
fn text_shows_the_values_scripts_give_its_floating_fields() {
let xdp = 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>
<subform w="7in" h="9in">
<field name="Viewer" id="floatViewer" presence="hidden" access="readOnly" w="1in" h="0.3in"><ui><textEdit/></ui>
<event activity="initialize"><script contentType="application/x-javascript">this.rawValue = "Opened";</script></event><bind match="none"/></field>
<field name="Sheet" id="floatSheet" presence="hidden" w="1in" h="0.3in"><ui><textEdit/></ui>
<event activity="ready" ref="$layout"><script contentType="application/x-javascript">this.rawValue = xfa.layout.sheet(this)+1</script></event><bind match="none"/></field>
<field name="Sheets" id="floatSheets" presence="hidden" w="1in" h="0.3in"><ui><textEdit/></ui>
<event activity="ready" ref="$layout"><script contentType="application/x-javascript">this.rawValue = xfa.layout.sheetCount()</script></event><bind match="none"/></field>
<field name="Shout" id="floatShout" presence="hidden" w="1in" h="0.3in"><ui><textEdit/></ui>
<calculate><script contentType="application/x-javascript">Viewer.rawValue + "!"</script></calculate><bind match="none"/></field>
<field name="set" y="1in" w="2in" h="0.3in"><ui><button/></ui>
<event activity="click"><script contentType="application/x-javascript">Viewer.rawValue = "Clicked";</script></event></field>
<draw name="line" y="2in" w="6in" h="1in"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml" xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><p>Viewer: <span xfa:embedType="uri" xfa:embedMode="raw" xfa:embed="#floatViewer"/><br/>Sheet <span xfa:embed="#floatSheet"/> of <span xfa:embed="#floatSheets"/><br/>Calculated: <span xfa:embed="#floatShout"/></p></body></exData></value></draw>
</subform>
</subform>
</template>
</xdp:xdp>
"##;
let mut s = Session::new();
let id = s.open("float.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens");
let text = page_texts(&s, id).join(
"
",
);
assert!(text.contains("Viewer: Opened"), "{text}");
assert!(text.contains("Sheet 1 of 1"), "{text}");
assert!(text.contains("Calculated: Opened!"), "{text}");
let o = s.run_javascript(id, "", Some("set")).expect("clicks");
assert_eq!(o.error, None, "{o:?}");
let text = page_texts(&s, id).join(
"
",
);
assert!(text.contains("Viewer: Clicked"), "{text}");
assert!(text.contains("Calculated: Clicked!"), "{text}");
}
/// Text that embeds a field follows its data whoever writes it (XFA 3.3 pp. 221–222): a
/// script writing another field bound to the same data, a script writing a page area's field,
/// a typed value in a plain one-line field, a pick in a drop-down list, and a reset (of a
/// drop-down list, and of a page area's field that has no widget).
#[test]
fn floating_fields_follow_their_data_whoever_writes_it() {
let xdp = 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="1in" w="7.5in" h="9in"/><medium short="8.5in" long="11in"/>
<field name="Stamp" id="floatStamp" presence="hidden" w="1in" h="0.3in"><ui><textEdit/></ui></field>
<draw x="0.5in" y="0.2in" w="5in" h="0.4in"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml" xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><p>Stamp: <span xfa:embed="#floatStamp"/></p></body></exData></value></draw>
</pageArea></pageSet>
<subform w="7in" h="8in">
<field name="Note" id="floatNote" presence="hidden" w="1in" h="0.3in"><ui><textEdit/></ui></field>
<area name="box" y="0.5in"><field name="Note" w="2in" h="0.3in"><ui><textEdit/></ui></field></area>
<field name="Edit" id="floatEdit" y="1in" w="2in" h="0.3in"><ui><textEdit/></ui><value><text>start</text></value></field>
<field name="Pick" id="floatPick" y="4.5in" w="2in" h="0.3in"><ui><choiceList/></ui><items><text>A</text><text>B</text></items><value><text>A</text></value></field>
<field name="pickB" y="5in" w="2in" h="0.3in"><ui><button/></ui>
<event activity="click"><script contentType="application/x-javascript">Pick.rawValue = "B";</script></event></field>
<field name="clearPick" y="5.5in" w="2in" h="0.3in"><ui><button/></ui>
<event activity="click"><script contentType="application/x-javascript">xfa.host.resetData("Pick");</script></event></field>
<field name="share" y="1.5in" w="2in" h="0.3in"><ui><button/></ui>
<event activity="click"><script contentType="application/x-javascript">box.Note.rawValue = "Shared";</script></event></field>
<field name="stamp" y="2in" w="2in" h="0.3in"><ui><button/></ui>
<event activity="click"><script contentType="application/x-javascript">front.Stamp.rawValue = "Paid";</script></event></field>
<field name="clear" y="2.5in" w="2in" h="0.3in"><ui><button/></ui>
<event activity="click"><script contentType="application/x-javascript">xfa.host.resetData();</script></event></field>
<draw y="3in" w="6in" h="1in"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml" xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><p>Note: <span xfa:embed="#floatNote"/><br/>Edit: <span xfa:embed="#floatEdit"/><br/>Pick: <span xfa:embed="#floatPick"/></p></body></exData></value></draw>
</subform>
</subform>
</template>
</xdp:xdp>
"##;
let mut s = Session::new();
let id = s.open("follow.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens");
let text = |s: &Session| {
page_texts(s, id).join(
"
",
)
};
let click = |s: &mut Session, button: &str| {
let o = s.run_javascript(id, "", Some(button)).expect("clicks");
assert_eq!(o.error, None, "{button}: {o:?}");
};
assert!(text(&s).contains("Edit: start"), "{}", text(&s));
click(&mut s, "share");
assert!(text(&s).contains("Note: Shared"), "the field bound to the same data: {}", text(&s));
click(&mut s, "stamp");
assert!(text(&s).contains("Stamp: Paid"), "a page area's field: {}", text(&s));
let edit = s.get(id).unwrap().form.iter().find(|f| f.name.ends_with("Edit")).map(|f| f.name.clone()).expect("the Edit field");
s.apply(id, Edit::SetFieldValue { name: edit, value: FieldValue::Text("typed".into()) }).unwrap();
assert!(text(&s).contains("Edit: typed"), "a typed value in a one-line field: {}", text(&s));
click(&mut s, "pickB");
assert!(text(&s).contains("Pick: B"), "{}", text(&s));
click(&mut s, "clearPick");
assert!(text(&s).contains("Pick: A"), "a reset drop-down list: {}", text(&s));
let pick = s.get(id).unwrap().form.iter().find(|f| f.name.ends_with("Pick")).map(|f| f.name.clone()).expect("the Pick field");
s.apply(id, Edit::SetFieldValue { name: pick, value: FieldValue::Choice(vec!["B".into()]) }).unwrap();
assert!(text(&s).contains("Pick: B"), "a picked value: {}", text(&s));
click(&mut s, "pickB");
click(&mut s, "clear");
let after = text(&s);
assert!(after.contains("Edit: start") && !after.contains("Shared"), "reset: {after}");
assert!(after.contains("Pick: A"), "a reset drop-down list: {after}");
assert!(after.contains("Stamp:") && !after.contains("Paid"), "a reset page-area field with no widget: {after}");
}
/// A reset gives a hidden floating field its default in every row of a repeating subform, not
/// only the first, and each row's text shows it.
#[test]
fn a_reset_clears_a_floating_field_in_every_row() {
let xdp = 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>
<subform name="row" layout="tb"><occur min="1" max="-1"/>
<field name="amount" id="floatAmount" presence="hidden" w="1in" h="0.3in"><ui><textEdit/></ui><value><text>none</text></value></field>
<draw w="5in" h="0.4in"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml" xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><p>Amount: <span xfa:embed="#floatAmount"/></p></body></exData></value></draw>
</subform>
<field name="clear" w="2in" h="0.3in"><ui><button/></ui>
<event activity="click"><script contentType="application/x-javascript">xfa.host.resetData();</script></event></field>
</subform>
</template>
<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><form><row><amount>10</amount></row><row><amount>20</amount></row></form></xfa:data></xfa:datasets>
</xdp:xdp>
"##;
let mut s = Session::new();
let id = s.open("rows.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens");
let text = page_texts(&s, id).join(
"
",
);
assert!(text.contains("Amount: 10") && text.contains("Amount: 20"), "{text}");
let o = s.run_javascript(id, "", Some("clear")).expect("clicks");
assert_eq!(o.error, None, "{o:?}");
let text = page_texts(&s, id).join(
"
",
);
assert_eq!(text.matches("Amount: none").count(), 2, "both rows reset: {text}");
}
/// An XFA button's click script may show the attachments panel; a text field's exit script
/// asking the same is noted on the console and ignored.
#[test]
+194 -10
View File
@@ -275,6 +275,9 @@ struct Runner<'a, 'b> {
/// rebuilt when the structure changed).
session: Option<XfaSession>,
changes: Changes,
/// A field embedded in rich text (a floating field) took a new value since the form was
/// last laid out: the text that shows it is laid out again when the event ends.
embedded_changed: bool,
}
impl<'a, 'b> Runner<'a, 'b> {
@@ -304,6 +307,7 @@ impl<'a, 'b> Runner<'a, 'b> {
cached: None,
session: None,
changes: Changes::default(),
embedded_changed: false,
}
}
@@ -422,6 +426,9 @@ impl<'a, 'b> Runner<'a, 'b> {
fn set_value(&mut self, som: &str, value: &str) -> bool {
use pdfcraft_forms::FieldKind as K;
let path = pdfcraft_xfa::data_path(self.tpl, som);
// A floating field shows the value of its data node in the text that embeds it (XFA 3.3
// pp. 221–222): a page area's, or one another field shares, as well.
let embedded = pdfcraft_xfa::path_is_embedded(self.tpl, &path);
let names = self.widgets_at(&path);
// The data text: the widget at `som` says how its kind writes the value; without one,
// the first widget bound to the node, else the value itself.
@@ -471,6 +478,7 @@ impl<'a, 'b> Runner<'a, 'b> {
}
if changed {
self.changes.values += 1;
self.embedded_changed |= embedded;
}
changed
}
@@ -521,6 +529,7 @@ impl<'a, 'b> Runner<'a, 'b> {
}
self.relayouts += 1;
self.flush()?;
self.embedded_changed = false;
let report = pdfcraft_xfa::rerender(self.doc, self.tpl).map_err(|e| e.to_string())?;
self.console(report.warnings);
crate::xfa_redraw(self.doc)?;
@@ -541,13 +550,14 @@ impl<'a, 'b> Runner<'a, 'b> {
} else {
Some(by_som.iter().filter(|(som, _)| names.iter().any(|n| som_matches(som, n))).map(|(_, f)| f.clone()).collect())
};
if listed.as_ref().is_some_and(Vec::is_empty) {
return Ok(());
// Names no widget answers to may still name a floating field (below).
let widgets = !listed.as_ref().is_some_and(Vec::is_empty);
if widgets {
pdfcraft_forms::reset(self.doc, listed.as_deref()).map_err(|e| e.to_string())?;
}
pdfcraft_forms::reset(self.doc, listed.as_deref()).map_err(|e| e.to_string())?;
self.fields = None;
let infos = self.fields().clone();
for (som, name) in &by_som {
for (som, name) in by_som.iter().filter(|_| widgets) {
if listed.as_ref().is_some_and(|l| !l.contains(name)) {
continue;
}
@@ -558,10 +568,28 @@ impl<'a, 'b> Runner<'a, 'b> {
let path = pdfcraft_xfa::data_path(self.tpl, som);
let text = data_text(info);
if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != text {
self.embedded_changed |= pdfcraft_xfa::path_is_embedded(self.tpl, &path);
self.data_op(pdfcraft_xfa::DataOp::Value { path, text });
self.changes.values += 1;
}
}
// A floating field without a widget (a hidden one, as forms place them) goes back to
// its default too, and the text that embeds it shows that.
for (som, default) in pdfcraft_xfa::embedded_fields(self.tpl) {
if by_som.contains_key(&som) || (!names.is_empty() && !names.iter().any(|n| som_matches(&som, n))) {
continue;
}
// Every row that holds it, not only the first.
let names: Vec<String> = pdfcraft_xfa::data_path(self.tpl, &som).into_iter().map(|(name, _)| name).collect();
let paths = self.data.as_ref().map(|d| d.paths_named(&names, pdfcraft_xfa::MAX_FORM_NODES)).unwrap_or_default();
for path in paths {
if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != default {
self.embedded_changed = true;
self.data_op(pdfcraft_xfa::DataOp::Value { path, text: default.clone() });
self.changes.values += 1;
}
}
}
self.invalidate();
Ok(())
}
@@ -719,7 +747,7 @@ impl<'a, 'b> Runner<'a, 'b> {
fn finish(mut self) -> Result<Changes, String> {
#[cfg(test)]
self.check_live();
if self.relayouts_skipped {
if self.relayouts_skipped || self.embedded_changed {
self.relayouts = 0;
self.relayouts_skipped = false;
self.relayout()?;
@@ -779,7 +807,9 @@ fn collect_fields(n: &FormNode, out: &mut Vec<(String, String, String, String)>,
}
}
/// Reflow a laid-out XFA form when an edited multiline field can grow. The datasets must
/// Reflow a laid-out XFA form when an edited multiline field can grow, or an edited text field,
/// choice list or check box is embedded in rich text (a floating field, whose value that text
/// shows). The datasets must
/// already hold the final values, including calculation results. Fixed-height fields keep
/// their existing geometry; forms without scripts parse their template only when needed.
/// `Some` means pages/widgets were rebuilt and the caller must fully refresh its snapshot.
@@ -794,12 +824,14 @@ pub(crate) fn relayout_changed_text(
let current = &*doc;
let mut changed = before
.iter()
.filter(|f| f.kind == pdfcraft_forms::FieldKind::Text && f.has(pdfcraft_forms::flags::MULTILINE))
.filter(|f| {
use pdfcraft_forms::FieldKind as K;
matches!(f.kind, K::Text | K::Combo | K::List | K::CheckBox)
})
.filter_map(|f| {
let object = current.get(f.obj);
let dict = object.as_dict()?;
let value = dict.get(b"V").and_then(|v| current.resolve(v).as_string().map(|s| s.to_text())).unwrap_or_default();
if f.value.first().map_or("", String::as_str) == value {
if value_unchanged(current, f, dict.get(b"V")) {
return None;
}
dict.get(pdfcraft_xfa::SOM_KEY).and_then(|s| current.resolve(s).as_string().map(|s| s.to_text()))
@@ -812,7 +844,7 @@ pub(crate) fn relayout_changed_text(
Some(tpl) => std::borrow::Cow::Borrowed(tpl),
None => std::borrow::Cow::Owned(pdfcraft_xfa::template_of(current).map_err(|e| e.to_string())?),
};
let reflow = changed.any(|som| pdfcraft_xfa::field_grows_with_text(&template, &som));
let reflow = changed.any(|som| pdfcraft_xfa::field_grows_with_text(&template, &som) || pdfcraft_xfa::field_is_embedded(&template, &som));
drop(changed);
if !reflow {
return Ok(None);
@@ -822,6 +854,60 @@ pub(crate) fn relayout_changed_text(
Ok(Some(report.warnings))
}
/// Whether field `f` still holds the value it had (`f.value`), its `/V` being `v`: a list box's
/// picks are an array, a check box's state a name (Off for none, as the field's value has
/// none), and of a text field only the first value counts. Compared in place, so an edit costs
/// no copy of every field's value.
fn value_unchanged(doc: &pdfcraft_cos::Document, f: &pdfcraft_forms::Field, v: Option<&pdfcraft_cos::Object>) -> bool {
use pdfcraft_cos::Object as O;
let text = f.kind == pdfcraft_forms::FieldKind::Text;
let first = f.value.first().map_or("", String::as_str);
// The value is the one string `is` accepts.
let one = |is: &dyn Fn(&str) -> bool| if text { is(first) } else { matches!(f.value.as_slice(), [old] if is(old)) };
let none = if text { first.is_empty() } else { f.value.is_empty() };
let held;
let v = match v {
Some(r @ O::Ref(_)) => {
held = doc.resolve(r);
Some(&*held)
}
v => v,
};
match v {
Some(O::Array(items)) => {
let mut old = f.value.iter();
for item in items {
let held_item;
let item = match item {
O::Ref(_) => {
held_item = doc.resolve(item);
&*held_item
}
i => i,
};
let Some(s) = item.as_string() else { continue };
if text {
return s.text_eq(first);
}
if !old.next().is_some_and(|o| s.text_eq(o)) {
return false;
}
}
if text { none } else { old.next().is_none() }
}
Some(O::Name(n)) if n.as_slice() == b"Off" => none,
Some(O::Name(n)) => {
let n = String::from_utf8_lossy(n);
one(&|old| old == n)
}
Some(v) => match v.as_string() {
Some(s) => one(&|old| s.text_eq(old)),
None => none,
},
None => none,
}
}
/// On open: initialize scripts, then calculations. Returns what they changed.
pub(crate) fn on_open(doc: &mut pdfcraft_cos::Document, tpl: &Template, run: &mut XfaRun) -> Result<Changes, String> {
let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS);
@@ -954,4 +1040,102 @@ mod tests {
Some((2, "form[0].table[0]".into(), "row".into(), 2))
);
}
fn field(obj: pdfcraft_cos::ObjRef, kind: pdfcraft_forms::FieldKind, value: &[&str]) -> pdfcraft_forms::Field {
pdfcraft_forms::Field {
name: "f".into(),
obj,
kind,
value: value.iter().map(|v| (*v).to_owned()).collect(),
default: Vec::new(),
flags: 0,
max_len: None,
options: Vec::new(),
da: String::new(),
quadding: 0,
tooltip: None,
widgets: Vec::new(),
actions: pdfcraft_forms::af::Actions::default(),
button: None,
}
}
/// A field dictionary whose `/V` is `v`, with a SOM path.
fn field_dict(doc: &mut pdfcraft_cos::Document, v: Option<pdfcraft_cos::Object>) -> pdfcraft_cos::ObjRef {
let mut d = pdfcraft_cos::Dict::new();
if let Some(v) = v {
d.set(b"V".to_vec(), v);
}
d.set(pdfcraft_xfa::SOM_KEY.to_vec(), pdfcraft_cos::Object::String(pdfcraft_cos::PdfString::text("form[0].f[0]")));
doc.add(d)
}
#[test]
fn changed_values_are_found_in_place() {
use pdfcraft_cos::{Object as O, PdfString};
use pdfcraft_forms::FieldKind as K;
let s = |t: &str| O::String(PdfString::text(t));
let mut doc = pdfcraft_cos::Document::new_empty();
let same = |doc: &mut pdfcraft_cos::Document, kind, old: &[&str], v: Option<O>| {
let r = field_dict(doc, v);
let dict = doc.get(r);
value_unchanged(doc, &field(r, kind, old), dict.as_dict().and_then(|d| d.get(b"V")))
};
// Text: the first value only, an absent or non-text value being empty.
assert!(same(&mut doc, K::Text, &["Café"], Some(s("Café"))));
assert!(!same(&mut doc, K::Text, &["Café"], Some(s("Cafe"))));
assert!(same(&mut doc, K::Text, &[], None));
assert!(same(&mut doc, K::Text, &[], Some(s(""))));
assert!(!same(&mut doc, K::Text, &["a"], None));
assert!(same(&mut doc, K::Text, &["a", "b"], Some(O::Array(vec![s("a"), s("c")]))));
// A value given by reference is followed.
let inner = doc.add(s("日本"));
assert!(same(&mut doc, K::Text, &["日本"], Some(O::Ref(inner))));
assert!(!same(&mut doc, K::Combo, &["日"], Some(O::Ref(inner))));
// Combo box: one value.
assert!(same(&mut doc, K::Combo, &["Two"], Some(s("Two"))));
assert!(!same(&mut doc, K::Combo, &["Two"], Some(s("Three"))));
assert!(!same(&mut doc, K::Combo, &[], Some(s("Two"))));
assert!(!same(&mut doc, K::Combo, &["Two"], None));
// List box: every pick, in order, by count; non-strings in the array are skipped.
let picks = |v: &[&str]| O::Array(v.iter().map(|t| s(t)).collect());
assert!(same(&mut doc, K::List, &["a", "b"], Some(picks(&["a", "b"]))));
assert!(!same(&mut doc, K::List, &["a", "b"], Some(picks(&["b", "a"]))));
assert!(!same(&mut doc, K::List, &["a", "b"], Some(picks(&["a"]))));
assert!(!same(&mut doc, K::List, &["a"], Some(picks(&["a", "b"]))));
assert!(!same(&mut doc, K::List, &["a", "b"], Some(picks(&["a", "c"]))));
assert!(same(&mut doc, K::List, &[], Some(picks(&[]))));
let item = doc.add(s("b"));
assert!(same(&mut doc, K::List, &["a", "b"], Some(O::Array(vec![s("a"), O::Int(3), O::Ref(item)]))));
// Check box: a state name, Off for none.
assert!(same(&mut doc, K::CheckBox, &["Yes"], Some(O::name("Yes"))));
assert!(!same(&mut doc, K::CheckBox, &["Yes"], Some(O::name("Off"))));
assert!(same(&mut doc, K::CheckBox, &[], Some(O::name("Off"))));
assert!(!same(&mut doc, K::CheckBox, &[], Some(O::name("Yes"))));
assert!(!same(&mut doc, K::CheckBox, &["Yes"], Some(O::name("On"))));
}
#[test]
fn unchanged_values_return_before_the_template_is_read() {
use pdfcraft_cos::{Object as O, PdfString};
use pdfcraft_forms::FieldKind as K;
// Laid out, but with no XFA packets: reading the template fails.
let mut doc = pdfcraft_cos::Document::new_empty();
let mut acro = pdfcraft_cos::Dict::new();
acro.set(pdfcraft_xfa::LAYOUT_KEY.to_vec(), pdfcraft_cos::Dict::new());
let acro = doc.add(acro);
let root = doc.root().unwrap_or_else(|| panic!("no catalog"));
let mut catalog = doc.get(root).as_dict().cloned().unwrap_or_default();
catalog.set(b"AcroForm".to_vec(), O::Ref(acro));
doc.set(root, catalog);
assert!(pdfcraft_xfa::existing_layout(&doc).is_some());
let text = field_dict(&mut doc, Some(O::String(PdfString::text("same"))));
let list = field_dict(&mut doc, Some(O::Array(vec![O::String(PdfString::text("a")), O::String(PdfString::text("b"))])));
let check = field_dict(&mut doc, Some(O::name("Yes")));
let before = [field(text, K::Text, &["same"]), field(list, K::List, &["a", "b"]), field(check, K::CheckBox, &["Yes"])];
assert_eq!(relayout_changed_text(&mut doc, None, &before), Ok(None));
// A changed pick reaches the template, which this document doesn't have.
let before = [field(text, K::Text, &["same"]), field(list, K::List, &["b", "a"]), field(check, K::CheckBox, &["Yes"])];
assert!(relayout_changed_text(&mut doc, None, &before).is_err());
}
}
+16 -8
View File
@@ -292,15 +292,23 @@ impl Interp<'_> {
}
_ => Ok(Val::Null),
},
Val::Layout => match m.as_str() {
"page" | "abspage" => Ok(Val::Num(self.h.doc.page as f64 + if m == "page" { 1.0 } else { 0.0 })),
"pagecount" | "abspagecount" => Ok(Val::Num(self.h.doc.page_count as f64)),
"relayout" | "relayoutpagearea" => {
self.h.push(XfaEffect::Relayout);
Ok(Val::Null)
Val::Layout => {
// No layout method reads its arguments, but each is evaluated once, for what it
// does (a message box in an argument still shows), as the other objects' are.
for i in 0..args.len() {
self.arg(args, i)?;
}
_ => Ok(Val::Num(0.0)),
},
match m.as_str() {
// On screen every page is a sheet of its own (see the JavaScript `sheet`).
"page" | "abspage" | "sheet" => Ok(Val::Num(self.h.doc.page as f64 + if m == "page" { 1.0 } else { 0.0 })),
"pagecount" | "abspagecount" | "sheetcount" => Ok(Val::Num(self.h.doc.page_count as f64)),
"relayout" | "relayoutpagearea" => {
self.h.push(XfaEffect::Relayout);
Ok(Val::Null)
}
_ => Ok(Val::Num(0.0)),
}
}
Val::Xfa => match m.as_str() {
"resolvenode" | "resolvenodes" => {
let s = self.arg_str(args, 0)?;
+30
View File
@@ -808,6 +808,19 @@ mod xfa_model {
assert_eq!(o.result.as_deref(), Some("7"));
}
/// On screen every page is a sheet of its own: `xfa.layout.sheet` counts it from 0, as
/// `absPage` does (forms show `sheet(this) + 1`), and `sheetCount` is the page count.
#[test]
fn the_sheet_is_the_page_on_screen() {
let o = go(
"[xfa.layout.sheet(this) + 1, xfa.layout.sheetCount(), xfa.layout.absPage(this)].join(',')",
"form1[0].page1[0].total[0]",
"calculate",
);
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.result.as_deref(), Some("2,3,1"));
}
#[test]
fn field_names_never_shadow_javascript_builtins() {
let mut f = form();
@@ -1027,6 +1040,23 @@ mod formcalc {
run_formcalc(script, &ev, &doc, &form(), Limits::default())
}
/// `xfa.layout.sheet` and `sheetCount` give what the JavaScript ones give.
#[test]
fn the_sheet_is_the_page_on_screen() {
assert_eq!(calc("xfa.layout.sheet($) + 1"), "2");
assert_eq!(calc("xfa.layout.sheetCount()"), "3");
}
/// A layout method's arguments are evaluated, once each, for what they do: a message box
/// in one still shows.
#[test]
fn layout_method_arguments_are_evaluated_once() {
let o = go("xfa.layout.sheet($host.messageBox(\"Hi\"), $host.messageBox(\"Yo\")) + 1", "form1[0].page1[0].total[0]", "calculate");
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.result.as_deref(), Some("2"));
assert_eq!(o.effects.iter().filter(|e| matches!(e, XfaEffect::MessageBox(_))).count(), 2, "each argument once: {:?}", o.effects);
}
/// The value of a script run as a calculate on `total`.
fn calc(script: &str) -> String {
let o = go(script, "form1[0].page1[0].total[0]", "calculate");
+5
View File
@@ -174,6 +174,11 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current
.function(NativeFunction::from_fn_ptr(layout_page0), js_string!("absPage"), 1)
.function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("pageCount"), 0)
.function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("absPageCount"), 0)
// On screen every page is a sheet of its own: the sheet is the page, counted from 0
// as absPage counts (a form shows `sheet(this) + 1` as the sheet number), and the
// sheet count is the page count.
.function(NativeFunction::from_fn_ptr(layout_page0), js_string!("sheet"), 1)
.function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("sheetCount"), 0)
.function(NativeFunction::from_fn_ptr(relayout), js_string!("relayout"), 0)
.function(NativeFunction::from_fn_ptr(relayout), js_string!("relayoutPageArea"), 0)
.function(NativeFunction::from_fn_ptr(zero), js_string!("h"), 2)
+9 -2
View File
@@ -32,7 +32,13 @@ that warning and keeps the existing layout and accepted widget values.
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, in flowed content and in a top-to-bottom container inside positioned content (a
table in an area) alike; fields that compute the page number or count on layout show the numbers;
table in an area) alike; fields that compute the page number or count on layout show the numbers,
and so do those that compute the sheet number (`sheet(this)+1`) or count, a sheet being a page on
screen; an embedded (floating) field shows its value from the data, which is where a script's
value goes (a repeating row's own instance, a page area's field under the area, a multi-select
list's picks, a date in its display picture), and an event, reset (a hidden one without a
widget included), typed value or pick that changes one lays the form out again;
a field inside an exclusion group is not shown yet;
a draw placed by position turns about its anchor (`rotate`: degrees counter-clockwise, a multiple
of 90), as one item a page break doesn't cut, and its container grows to the box it stands in. A
field, a subform or a draw in a flow doesn't turn.
@@ -111,7 +117,8 @@ let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for
not locale-aware pictures beyond simple number and date ones, or `Get`/`Post`/`Put` (refused).
The object model covers what forms commonly use (`xfa.form`
navigation and SOM resolution, `rawValue`, `presence`, `access`, instance managers,
`xfa.host` messages, reset, print, focus and URLs, `xfa.layout` page numbers, `xfa.event`);
`xfa.host` messages, reset, print, focus and URLs, `xfa.layout` page and sheet numbers,
`xfa.event`);
not `xfa.template`, data descriptions, `xfa.connectionSet`, `border`/`font`/`ui` properties
(reads give a sink that accepts writes), `execEvent`, or the change event per keystroke.
Validate failures show their message and keep the value, as Acrobat does for scripts.
+26 -1
View File
@@ -34,8 +34,8 @@ pub use dates::{iso_to_pattern, pattern_to_iso};
pub(crate) use import::MAX_IMPORT_BYTES;
pub use import::{DataImport, ImportNode, export_data, import_data, parse_data_file, write_data_image};
pub use passwords::{clear_password_values, has_password_values};
pub(crate) use path::template_field;
pub use path::{data_path, parse_datasets, som_to_path};
pub(crate) use path::{fields_with_ids, template_field};
pub use values::{FieldData, FieldDatum, is_on, read_values, read_values_with};
#[cfg(test)]
pub(crate) use write::ENSURED;
@@ -70,6 +70,31 @@ impl DataNode {
Some(node)
}
/// Every path to a node whose names, from here, are `names`: each instance of each step (a
/// repeating subform's rows), at most `cap` of them, in document order.
pub fn paths_named(&self, names: &[String], cap: usize) -> Vec<DataPath> {
fn walk(n: &DataNode, names: &[String], path: &mut DataPath, out: &mut Vec<DataPath>, cap: usize) {
let Some((first, rest)) = names.split_first() else {
if out.len() < cap {
out.push(path.clone());
}
return;
};
for (i, c) in n.children.iter().filter(|c| &c.name == first).enumerate() {
if out.len() >= cap {
return;
}
path.push((first.clone(), i));
walk(c, rest, path, out, cap);
path.pop();
}
}
let mut out = Vec::new();
// The depth is the length of `names`, a template's own path.
walk(self, names, &mut DataPath::new(), &mut out, cap);
out
}
/// 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())
+46
View File
@@ -151,6 +151,52 @@ pub(crate) fn template_field<'a>(tpl: &'a Template, som: &str) -> Option<&'a Fie
}
}
/// Every field with an `id`, with the SOM path the layout gives it: the root subform's
/// fields under the root, a page area's under the area (as master pages lay them out), each
/// named object adding its name and its number among its siblings (repeating subforms as their
/// first instance). Bounded by [`MAX_DEPTH`].
pub(crate) fn fields_with_ids(tpl: &Template) -> Vec<(&str, String, &Field)> {
fn walk<'a>(nodes: &'a [Node], som: &str, out: &mut Vec<(&'a str, String, &'a Field)>, depth: usize) {
if depth > MAX_DEPTH {
return;
}
let sibs = SomIndices::of(nodes);
for (i, n) in nodes.iter().enumerate() {
let here = match &n.common().name {
Some(name) if som.is_empty() => format!("{name}[{}]", sibs.at(i)),
Some(name) => format!("{som}.{name}[{}]", sibs.at(i)),
None => som.to_string(),
};
match n {
Node::Field(f) => {
if let Some(id) = &f.common.id {
out.push((id.as_str(), here, f));
}
}
Node::Subform(s) => {
walk(&s.children, &here, out, depth + 1);
if let Some(ps) = &s.page_set {
for a in &ps.areas {
walk(&a.items, &a.name.as_ref().map(|n| format!("{n}[0]")).unwrap_or_default(), out, depth + 1);
}
}
}
Node::Area(a) => walk(&a.children, &here, out, depth + 1),
Node::ExclGroup(_) | Node::Draw(_) => {}
}
}
}
let mut out = Vec::new();
let root = tpl.root.common.name.as_ref().map(|n| format!("{n}[0]")).unwrap_or_default();
walk(&tpl.root.children, &root, &mut out, 1);
if let Some(ps) = &tpl.root.page_set {
for a in &ps.areas {
walk(&a.items, &a.name.as_ref().map(|n| format!("{n}[0]")).unwrap_or_default(), &mut out, 1);
}
}
out
}
pub(super) fn path_som(path: &DataPath) -> String {
path.iter().map(|(n, i)| format!("{n}[{i}]")).collect::<Vec<_>>().join(".")
}
+3 -4
View File
@@ -13,7 +13,7 @@ use std::sync::Arc;
use base64::Engine;
use crate::XfaError;
use crate::data::{DataNode, data_date_letters, data_path, data_to_iso, date_letters, is_on, iso_to_pattern};
use crate::data::{DataNode, DataPath, data_date_letters, data_path, data_to_iso, date_letters, is_on, iso_to_pattern};
use crate::model::{
Access, Area, Border, Common, Draw, ExclGroup, Field, Font, HAlign, Layout, Margin, Node, Occur, PageArea, PageRole, Para, Placement, Presence,
Rect, Script, SomIndices, Stroke, Subform, Template, Ui, VAlign, Value,
@@ -137,7 +137,7 @@ struct Layouter<'t> {
cur: Option<Cursor>,
names: HashMap<String, usize>,
/// Field values by `id`, for embedded fields in rich text.
values: HashMap<String, String>,
values: HashMap<String, common::EmbedSource>,
warnings: Vec<String>,
warned: HashSet<String>,
fields: usize,
@@ -179,10 +179,9 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result<Form, XfaError>
occur_min: 0,
}],
};
let mut values = HashMap::new();
let values = collect_values(tpl, data);
// One copy of the root for the whole layout: measurements are cached by node address.
let root_node = Node::Subform(Box::new(tpl.root.clone()));
collect_values(std::slice::from_ref(&root_node), &mut values, 0);
let mut l = Layouter {
tpl,
data,
+56 -21
View File
@@ -1,31 +1,66 @@
use super::items::{Action, Item, Rounded};
use super::{HashMap, Layout, MAX_DEPTH, Node, Rect, Script, date_letters, iso_to_pattern};
use super::{DataNode, DataPath, HashMap, Layout, Node, Rect, Script, Template, Ui, data_path, date_letters, iso_to_pattern};
use crate::data::{data_date_letters, data_to_iso, fields_with_ids};
pub(super) fn collect_values(nodes: &[Node], out: &mut HashMap<String, String>, depth: usize) {
if depth > MAX_DEPTH {
return;
/// The value of each field with an `id`, for the rich text that embeds it (floating fields,
/// XFA 3.3 pp. 221–222, p. 1222), with its data path and its template default: the data's value
/// as the field shows it (a multi-select list's picks on separate lines), else the default.
pub(super) fn collect_values(tpl: &Template, data: Option<&DataNode>) -> HashMap<String, EmbedSource> {
let mut out = HashMap::new();
for (id, som, f) in fields_with_ids(tpl) {
let path = data_path(tpl, &som);
// A date shows in its picture clause, as the field does (p. 221): the display picture,
// else the one it is edited in.
let date = (f.ui == Ui::DateTimeEdit)
.then(|| f.display_picture.as_deref().or(f.picture.as_deref()).and_then(date_letters))
.flatten()
.map(|pattern| (pattern, data_date_letters(f)));
let mut src = EmbedSource { path, default: f.value.plain().unwrap_or_default(), value: String::new(), date };
src.value = src.shown(data_value(data, &src.path));
out.insert(id.to_string(), src);
}
for n in nodes {
match n {
Node::Field(f) => {
if let Some(id) = &f.common.id {
out.insert(id.clone(), f.value.plain().unwrap_or_default());
}
}
Node::Subform(s) => {
collect_values(&s.children, out, depth + 1);
if let Some(ps) = &s.page_set {
for a in &ps.areas {
collect_values(&a.items, out, depth + 1);
}
}
}
Node::Area(a) => collect_values(&a.children, out, depth + 1),
Node::ExclGroup(_) | Node::Draw(_) => {}
out
}
/// What an embedded field shows: see [`collect_values`].
pub(super) struct EmbedSource {
pub(super) path: DataPath,
pub(super) default: String,
/// The value of its first instance, as shown.
pub(super) value: String,
/// A date field's display pattern and data pattern.
date: Option<(String, Option<String>)>,
}
impl EmbedSource {
/// What the field shows for the data's value `data` (the default without one).
pub(super) fn shown(&self, data: Option<String>) -> String {
match (data, &self.date) {
(Some(v), Some((pattern, data_pattern))) => iso_to_pattern(&data_to_iso(&v, data_pattern.as_deref()), pattern),
(Some(v), None) => v,
(None, _) => self.default.clone(),
}
}
}
/// `path` with the instance numbers `scope` gives the same names, segment by segment from the
/// root, up to where the two part: the instance of a repeating subform the text is laid out in.
pub(super) fn instance_path(path: &DataPath, scope: &DataPath) -> DataPath {
let mut at = path.clone();
for (seg, (name, index)) in at.iter_mut().zip(scope) {
if seg.0 != *name {
break;
}
seg.1 = *index;
}
at
}
/// The value a field shows from `data` at `path`: unless it is blank, the data's wins.
pub(super) fn data_value(data: Option<&DataNode>, path: &DataPath) -> Option<String> {
data.and_then(|d| d.value_at(path)).map(|t| t.trim().to_string()).filter(|t| !t.is_empty())
}
pub(super) fn clamp_opt(v: f64, min: Option<f64>, max: Option<f64>) -> f64 {
let mut v = v;
if let Some(m) = min {
+6 -4
View File
@@ -222,7 +222,8 @@ impl Layouter<'_> {
Ok(())
}
pub(super) fn draw_value_text(&mut self, v: &Value, rect: Rect, font: &Font, para: &Para) -> Result<(), XfaError> {
/// Draw `v` in `rect`; `scope` is the SOM of what holds it, for the fields it embeds.
pub(super) fn draw_value_text(&mut self, v: &Value, rect: Rect, font: &Font, para: &Para, scope: &str) -> Result<(), XfaError> {
let rich = match v {
Value::Text(t) => text::plain(t),
Value::Rich(r) => r.clone(),
@@ -232,9 +233,10 @@ impl Layouter<'_> {
// Text that doesn't fit the height it was given (the template's face is narrower than
// the standard one drawn in its place, so it wraps to more lines) is drawn smaller,
// down to 70%, rather than over what lies below.
let fits = |k: f64| text::measure_height(&rich, rect.w / k, para, &face, &|id, max| self.embed(id, max)) <= rect.h / k + 1.0;
let embed = |id: &str, max: usize| self.embed_in(id, max, scope);
let fits = |k: f64| text::measure_height(&rich, rect.w / k, para, &face, &embed) <= rect.h / k + 1.0;
let scale = if rect.h < 4.0 || fits(1.0) { 1.0 } else { [0.95, 0.9, 0.85, 0.8, 0.75].into_iter().find(|k| fits(*k)).unwrap_or(0.7) };
let mut block = text::layout(&rich, Rect::new(rect.x, rect.y, rect.w / scale, rect.h / scale), para, &face, &|id, max| self.embed(id, max));
let mut block = text::layout(&rich, Rect::new(rect.x, rect.y, rect.w / scale, rect.h / scale), para, &face, &embed);
if scale < 1.0 {
for s in &mut block.spans {
s.x = rect.x + (s.x - rect.x) * scale;
@@ -252,7 +254,7 @@ impl Layouter<'_> {
let ctx = ctx.child(&d.common);
let inner = rect.inset(&d.common.margin);
match &d.value {
Value::Text(_) | Value::Rich(_) => self.draw_value_text(&d.value, inner, &ctx.font, &ctx.para),
Value::Text(_) | Value::Rich(_) => self.draw_value_text(&d.value, inner, &ctx.font, &ctx.para, &ctx.som),
Value::Image { content_type, data } => self.draw_image(data, content_type, inner),
Value::Line { slope_up, edge } => {
if !edge.visible() {
+3 -3
View File
@@ -52,7 +52,7 @@ impl Layouter<'_> {
{
let font = ctx.font.apply(&cap.font);
let para = ctx.para.apply(&cap.para);
self.draw_value_text(&cap.value, cr, &font, &para)?;
self.draw_value_text(&cap.value, cr, &font, &para, &ctx.som)?;
}
let som_path = || {
if fctx.som.is_empty() {
@@ -112,7 +112,7 @@ impl Layouter<'_> {
if !value.trim().is_empty() {
let mut para = fctx.para.clone();
para.h_align = HAlign::Center;
self.draw_value_text(&Value::Text(value), ui.inset(&f.ui_margin), &fctx.font, &para)?;
self.draw_value_text(&Value::Text(value), ui.inset(&f.ui_margin), &fctx.font, &para, &fctx.som)?;
}
return Ok(());
}
@@ -120,7 +120,7 @@ impl Layouter<'_> {
}
if f.access == Access::NonInteractive && f.ui != Ui::Button {
// Shown as text, not as a field.
return self.draw_value_text(&f.value, ui.inset(&f.ui_margin), &fctx.font, &fctx.para);
return self.draw_value_text(&f.value, ui.inset(&f.ui_margin), &fctx.font, &fctx.para, &fctx.som);
}
// A text field that grew past what a content area holds can't be one widget: its text
// is drawn as lines the page breaks fall between, and stays what the data holds.
+21 -3
View File
@@ -3,7 +3,7 @@ use super::EMBED_COPIED;
use super::items::{Item, Page, UNDERLINE_DROP};
use super::{
Block, Border, Ctx, Cursor, FlowMark, Font, Frame, Layouter, MAX_ITEMS, MAX_MEASURES, MAX_PAGES, PAGE_COUNT_MARK, PageRole, Para, Rc, Rect,
SomIndices, XfaError, text,
SomIndices, XfaError, data_path, text,
};
impl Layouter<'_> {
@@ -19,12 +19,30 @@ impl Layouter<'_> {
}
/// The text an embedded field `id` shows, at most `max` bytes of a field value (the page
/// number and count are a few bytes; the text layout cuts them).
/// number and count are a few bytes; the text layout cuts them): its first instance's.
pub(super) fn embed(&self, id: &str, max: usize) -> String {
self.embed_in(id, max, "")
}
/// [`Self::embed`] in text laid out at `scope` (the SOM of what holds it): a field in a
/// repeating subform shows the value of the instance the text is in, the one whose data path
/// takes the instance numbers `scope` gives the same names.
pub(super) fn embed_in(&self, id: &str, max: usize, scope: &str) -> String {
let v = match self.tpl.page_roles.iter().find(|(i, _)| i == id).map(|(_, r)| *r) {
Some(PageRole::Number) => self.page_no().to_string(),
Some(PageRole::Count) => PAGE_COUNT_MARK.to_string(),
None => self.values.get(id).map(|v| text::prefix(v, max).to_string()).unwrap_or_default(),
None => match self.values.get(id) {
None => String::new(),
Some(src) => {
let at = super::common::instance_path(&src.path, &data_path(self.tpl, scope));
let shown = if at == src.path {
std::borrow::Cow::Borrowed(src.value.as_str())
} else {
std::borrow::Cow::Owned(src.shown(super::common::data_value(self.data, &at)))
};
text::prefix(&shown, max).to_string()
}
},
};
#[cfg(test)]
EMBED_COPIED.with(|c| c.set(c.get().saturating_add(v.len() as u64)));
+3 -2
View File
@@ -102,8 +102,9 @@ pub use parse::{measure, parse};
pub use pdf::{CLICK_KEY, LAYOUT_KEY, NODE_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, field_grows_with_text,
fields_by_som, form_tree, has_scripts, overrides, refresh_value, rerender, set_overrides, template_of,
FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, embedded_fields,
field_grows_with_text, field_is_embedded, fields_by_som, form_tree, has_scripts, overrides, path_is_embedded, refresh_value, rerender,
set_overrides, template_of,
};
#[derive(Debug, thiserror::Error)]
+6
View File
@@ -760,6 +760,12 @@ pub struct Template {
/// 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)>,
/// The `id`s of the fields some rich text embeds (`xfa:embed="#id"`, XFA 3.3 p. 1222): a new
/// value in one changes text shown elsewhere, so the form is laid out again.
pub embedded: std::collections::BTreeSet<String>,
/// The data paths of those fields, by name alone (any instance of a repeating subform), as
/// a script writes them: a write there changes text shown elsewhere.
pub embedded_paths: std::collections::BTreeSet<Vec<String>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+68 -10
View File
@@ -706,15 +706,63 @@ fn parse_node(c: XmlNode, warnings: &mut Vec<String>, depth: usize) -> Option<No
})
}
/// Which page-number role a field with a layout:ready script plays, if any.
/// Which page-number role a field with a layout:ready script plays, if any. On screen every
/// page is a sheet of its own, so the sheet count is the page count, and the sheet number a form
/// shows (`sheet(this) + 1`, the sheet counted from 0) is the page number.
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
// Any of its ready scripts: a field may have one that does something else first.
f.scripts.iter().filter(|s| s.activity == "ready" || s.activity == "layoutReady").find_map(|script| {
// FormCalc names methods case-insensitively; JavaScript does not.
let text: std::borrow::Cow<str> =
if script.formcalc { std::borrow::Cow::Owned(script.text.to_ascii_lowercase()) } else { std::borrow::Cow::Borrowed(&script.text) };
let has = |name: &str| if script.formcalc { text.contains(&name.to_ascii_lowercase()) } else { text.contains(name) };
// `this` in JavaScript, `$` in FormCalc.
let sheet_number = || {
let compact: String = text.chars().filter(|c| !c.is_whitespace()).collect();
compact.contains("layout.sheet(this)+1") || compact.contains("layout.sheet($)+1")
};
if has("pageCount") || has("sheetCount") {
Some(PageRole::Count)
} else if has("layout.page(") || has("layout.absPage(") || sheet_number() {
Some(PageRole::Number)
} else {
None
}
})
}
/// The ids rich text in `nodes` embeds (draws' values, fields' values and captions), down to
/// [`MAX_DEPTH`].
fn collect_embedded(nodes: &[Node], out: &mut std::collections::BTreeSet<String>, depth: usize) {
fn of(value: &Value, out: &mut std::collections::BTreeSet<String>) {
if let Value::Rich(r) = value {
out.extend(r.paragraphs.iter().flat_map(|p| p.runs.iter()).filter_map(|r| r.embed.clone()));
}
}
fn field(f: &Field, out: &mut std::collections::BTreeSet<String>) {
of(&f.value, out);
if let Some(c) = &f.caption {
of(&c.value, out);
}
}
if depth > MAX_DEPTH {
return;
}
for n in nodes {
match n {
Node::Draw(d) => of(&d.value, out),
Node::Field(f) => field(f, out),
Node::ExclGroup(g) => g.fields.iter().for_each(|f| field(f, out)),
Node::Subform(s) => {
collect_embedded(&s.children, out, depth + 1);
if let Some(ps) = &s.page_set {
for a in &ps.areas {
collect_embedded(&a.items, out, depth + 1);
}
}
}
Node::Area(a) => collect_embedded(&a.children, out, depth + 1),
}
}
}
@@ -787,8 +835,18 @@ pub fn parse(xml: &str) -> Result<(Template, Vec<String>), XfaError> {
apply_saved_state(&mut root_sf.children, saved_root, 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))
let root_node = [Node::Subform(Box::new(root_sf.clone()))];
collect_page_roles(&root_node, &mut page_roles, 0);
let mut embedded = std::collections::BTreeSet::new();
collect_embedded(&root_node, &mut embedded, 0);
let mut tpl = Template { root: root_sf, page_roles, embedded, embedded_paths: Default::default() };
let paths = crate::data::fields_with_ids(&tpl)
.into_iter()
.filter(|(id, _, _)| tpl.embedded.contains(*id))
.map(|(_, som, _)| crate::data::data_path(&tpl, &som).into_iter().map(|(name, _)| name).collect())
.collect();
tpl.embedded_paths = paths;
Ok((tpl, warnings))
}
fn parse_access(a: Option<&str>) -> Option<Access> {
+24
View File
@@ -665,6 +665,30 @@ pub fn field_grows_with_text(tpl: &Template, som: &str) -> bool {
crate::data::template_field(tpl, som).is_some_and(Field::grows_with_text)
}
/// Is the field at `som` embedded in rich text (a floating field, XFA 3.3 p. 1222)? Its value
/// is shown in that text, so a new one needs the form laid out again. A page area's fields count
/// too, and so does any field bound to the same data as an embedded one.
pub fn field_is_embedded(tpl: &Template, som: &str) -> bool {
!tpl.embedded_paths.is_empty() && path_is_embedded(tpl, &data_path(tpl, som))
}
/// The fields rich text embeds, by the SOM path the layout gives them (a repeating subform's
/// first instance), with their template defaults: what a reset gives a floating field that has
/// no widget of its own.
pub fn embedded_fields(tpl: &Template) -> Vec<(String, String)> {
crate::data::fields_with_ids(tpl)
.into_iter()
.filter(|(id, _, _)| tpl.embedded.contains(*id))
.map(|(_, som, f)| (som, f.value.plain().unwrap_or_default()))
.collect()
}
/// Does `path` hold the data of a field embedded in rich text, in any instance of a repeating
/// subform? See [`field_is_embedded`].
pub fn path_is_embedded(tpl: &Template, path: &crate::DataPath) -> bool {
!tpl.embedded_paths.is_empty() && tpl.embedded_paths.contains(&path.iter().map(|(name, _)| name.clone()).collect::<Vec<_>>())
}
/// Give the field at `som` in `live`, and every other field bound to the same data node (a
/// field inside an area shares its node with one outside, since areas are left out of data
/// paths), the value [`form_tree`] would give it from `data` now, so a value change needs no
+165
View File
@@ -147,6 +147,171 @@ fn horizontally_scaled_text_is_written_with_tz_inside_its_own_graphics_state() {
/// The sample template parses without warnings into what the layout uses: its page areas,
/// page-number draws, rich-text title, field font, caption and widget border, check box,
/// date field, radio group, table columns and page break all come through.
/// Floating fields (XFA 3.3 p. 1222): the template knows which field ids rich text embeds,
/// whether in a draw, a field's value or its caption; and a field that shows the sheet number
/// (`sheet(this)+1`, however spaced) or the sheet count plays the page role, as every page is a
/// sheet of its own on screen. A script that reads `sheet(this)` alone plays none.
#[test]
fn the_template_knows_its_floating_fields_and_sheet_roles() {
let span = |id: &str| format!(r##"<span xfa:embed="#{id}" xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"/>"##);
let rich =
|id: &str| format!(r#"<exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>x {}</p></body></exData>"#, span(id));
let ready = |id: &str, script: &str| {
format!(
r#"<field id="{id}" presence="hidden" w="1in" h="0.2in"><ui><textEdit/></ui><event activity="ready" ref="$layout"><script contentType="application/x-javascript">{script}</script></event></field>"#
)
};
let tpl = format!(
r#"<template><subform name="form" layout="tb"><subform>
{}{}{}
<draw w="2in" h="0.3in"><value>{}</value></draw>
<field name="f" w="2in" h="0.3in"><ui><textEdit/></ui><caption><value>{}</value></caption><value>{}</value></field>
</subform></subform></template>"#,
ready("sheet", "this.rawValue = xfa.layout.sheet(this) + 1"),
ready("sheets", "this.rawValue = xfa.layout.sheetCount()"),
ready("bare", "this.rawValue = xfa.layout.sheet(this)"),
rich("inDraw"),
rich("inCaption"),
rich("inValue"),
);
let (tpl, _) = parse(&tpl).unwrap();
assert_eq!(tpl.embedded.iter().map(String::as_str).collect::<Vec<_>>(), ["inCaption", "inDraw", "inValue"]);
assert_eq!(tpl.page_roles, vec![("sheet".to_string(), PageRole::Number), ("sheets".to_string(), PageRole::Count)]);
}
/// An embedded sheet number is the page's own on every page, not a constant 1, and the sheet
/// count is the number of pages.
#[test]
fn an_embedded_sheet_number_counts_the_pages() {
let span = |id: &str| format!(r##"<span xfa:embed="#{id}" xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"/>"##);
let ready = |id: &str, script: &str| {
format!(
r#"<field id="{id}" presence="hidden" w="1in" h="0.2in"><ui><textEdit/></ui><event activity="ready" ref="$layout"><script contentType="application/x-javascript">{script}</script></event></field>"#
)
};
let tpl = format!(
r#"<template><subform name="form" layout="tb">
<pageSet><pageArea name="front"><contentArea x="0.5in" y="1in" w="7.5in" h="9in"/><medium short="8.5in" long="11in"/>
{}{}
<draw x="0.5in" y="0.2in" w="4in" h="0.3in"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>Sheet {} of {}</p></body></exData></value></draw>
</pageArea></pageSet>
<subform name="one" w="7in" h="6in"><draw w="2in" h="0.3in"><value><text>first</text></value></draw></subform>
<subform name="two" w="7in" h="6in"><draw w="2in" h="0.3in"><value><text>second</text></value></draw></subform>
</subform></template>"#,
ready("sheet", "this.rawValue = xfa.layout.sheet(this)+1"),
ready("sheets", "this.rawValue = xfa.layout.sheetCount()"),
span("sheet"),
span("sheets")
);
let form = layout_xml(&tpl).unwrap();
assert_eq!(form.pages.len(), 2);
for (i, page) in form.pages.iter().enumerate() {
let shown = texts(page).join(" ");
assert!(shown.contains(&format!("Sheet {} of 2", i + 1)), "page {}: {shown}", i + 1);
}
}
/// A FormCalc script names the field `$`, and a field may have a ready script that does
/// something else before the one that gives the sheet number: both still play the role.
#[test]
fn sheet_roles_are_read_from_formcalc_and_from_any_ready_script() {
let tpl = r#"<template><subform name="form" layout="tb">
<field id="fc" presence="hidden" w="1in" h="0.2in"><ui><textEdit/></ui><event activity="ready" ref="$layout"><script>$.rawValue = xfa.layout.sheet($) + 1</script></event></field>
<field id="fcCase" presence="hidden" w="1in" h="0.2in"><ui><textEdit/></ui><event activity="ready" ref="$layout"><script>$.rawValue = xfa.layout.Sheet($) + 1</script></event></field>
<field id="jsCase" presence="hidden" w="1in" h="0.2in"><ui><textEdit/></ui><event activity="ready" ref="$layout"><script contentType="application/x-javascript">this.rawValue = xfa.layout.Sheet(this)+1</script></event></field>
<field id="fcCount" presence="hidden" w="1in" h="0.2in"><ui><textEdit/></ui><event activity="ready" ref="$layout"><script>$.rawValue = xfa.layout.SheetCount()</script></event></field>
<field id="jsCount" presence="hidden" w="1in" h="0.2in"><ui><textEdit/></ui><event activity="ready" ref="$layout"><script contentType="application/x-javascript">this.rawValue = xfa.layout.SheetCount()</script></event></field>
<field id="second" presence="hidden" w="1in" h="0.2in"><ui><textEdit/></ui>
<event activity="ready" ref="$form"><script contentType="application/x-javascript">this.rawValue = 0;</script></event>
<event activity="ready" ref="$layout"><script contentType="application/x-javascript">this.rawValue = xfa.layout.sheet(this)+1</script></event></field>
</subform></template>"#;
let (tpl, _) = parse(tpl).unwrap();
// FormCalc names methods case-insensitively, JavaScript does not.
assert_eq!(
tpl.page_roles,
vec![
("fc".to_string(), PageRole::Number),
("fcCase".to_string(), PageRole::Number),
("fcCount".to_string(), PageRole::Count),
("second".to_string(), PageRole::Number)
]
);
}
/// The data paths of embedded fields are the ones scripts write: a page area's field under the
/// area, as master pages lay it out; a field in a repeating row by name, whatever its instance.
#[test]
fn embedded_fields_are_known_by_the_data_paths_scripts_write() {
let span = |id: &str| format!(r##"<span xfa:embed="#{id}" xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"/>"##);
let tpl = format!(
r#"<template><subform name="form" layout="tb">
<pageSet><pageArea name="front"><contentArea w="8in" h="10in"/><medium short="8.5in" long="11in"/>
<field name="stamp" id="stamp" presence="hidden" w="1in" h="0.2in"><ui><textEdit/></ui></field>
<draw w="3in" h="0.3in"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>Stamp {}</p></body></exData></value></draw>
</pageArea></pageSet>
<subform name="row" layout="tb"><occur min="1" max="-1"/>
<field name="amount" id="amount" presence="hidden" w="1in" h="0.2in"><ui><textEdit/></ui></field>
<draw w="3in" h="0.3in"><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>Amount {}</p></body></exData></value></draw>
</subform>
<field name="plain" w="1in" h="0.2in"><ui><textEdit/></ui></field>
</subform></template>"#,
span("stamp"),
span("amount")
);
let (tpl, _) = parse(&tpl).unwrap();
let names = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(tpl.embedded_paths.iter().cloned().collect::<Vec<_>>(), [names(&["form", "row", "amount"]), names(&["front", "stamp"])]);
assert!(crate::script::field_is_embedded(&tpl, "front[0].stamp[0]"), "a page area's field");
assert!(crate::script::field_is_embedded(&tpl, "form[0].row[3].amount[0]"), "any row");
assert!(!crate::script::field_is_embedded(&tpl, "form[0].plain[0]"));
}
/// Text embedding a field shows the value the data gives it: each row of a repeating subform
/// its own row's, a page area's field the value scripts write under the area, a multi-select
/// list its picks, a date in its display picture (XFA 3.3 p. 221) in every row; a row without
/// data shows the template's default, not the first row's value.
#[test]
fn embedded_text_shows_each_instances_data() {
let span = |id: &str| format!(r##"<span xfa:embed="#{id}" xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"/>"##);
let rich = |label: &str, id: &str| {
format!(r#"<exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>{label} {}</p></body></exData>"#, span(id))
};
let xml = format!(
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="1in" w="7.5in" h="9in"/><medium short="8.5in" long="11in"/>
<field name="stamp" id="stamp" presence="hidden" w="1in" h="0.2in"><ui><textEdit/></ui></field>
<draw x="0.5in" y="0.2in" w="4in" h="0.3in"><value>{}</value></draw>
</pageArea></pageSet>
<subform name="row" layout="tb"><occur min="1" max="-1" initial="3"/>
<field name="amount" id="amount" presence="hidden" w="1in" h="0.2in"><ui><textEdit/></ui><value><text>none</text></value></field>
<field name="due" id="due" presence="hidden" w="1in" h="0.2in"><ui><dateTimeEdit/></ui><format><picture>date{{MM/DD/YYYY}}</picture></format></field>
<draw w="4in" h="0.3in"><value>{}</value></draw>
<draw w="4in" h="0.3in"><value>{}</value></draw>
</subform>
<field name="picks" id="picks" presence="hidden" w="1in" h="0.6in"><ui><choiceList open="multiSelect"/></ui><items><text>A</text><text>B</text></items></field>
<draw w="4in" h="0.6in"><value>{}</value></draw>
</subform></template>
<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data>
<form><row><amount>10</amount><due>2026-11-04</due></row><row><amount>20</amount><due>2026-12-25</due></row><row/><picks><value>A</value><value>B</value></picks></form>
<front><stamp>Paid</stamp></front>
</xfa:data></xfa:datasets></xdp:xdp>"#,
rich("Stamp", "stamp"),
rich("Amount", "amount"),
rich("Due", "due"),
rich("Picks", "picks")
);
let (tpl, _) = parse(&xml).unwrap();
let data = parse_datasets(&xml).unwrap();
let form = crate::layout::layout(&tpl, Some(&data)).unwrap();
let all: Vec<String> = form.pages.iter().flat_map(texts).collect();
let joined = all.join(" | ");
for want in ["Stamp Paid", "Amount 10", "Amount 20", "Amount none", "Due 11/04/2026", "Due 12/25/2026", "Picks A"] {
assert!(joined.contains(want), "{want} in {joined}");
}
assert!(joined.contains('B'), "both picks: {joined}");
}
#[test]
fn the_template_parses_with_fonts_captions_and_rich_text() {
let (tpl, warnings) = parse(&template(2)).unwrap();
+2 -2
View File
@@ -5435,8 +5435,8 @@ milestone = "M12"
status = "partial"
commands = ["file.open"]
tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"]
evidence = ["crates/xfa/src/tests.rs::a_draw_turns_counter_clockwise_about_its_anchor", "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/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/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", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/testsLine truncated
notes = "A draw placed by position turns about its anchor (rotate); fields, subforms and draws in a flow don't. Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), 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; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a tLine truncated
evidence = ["crates/xfa/src/tests.rs::a_draw_turns_counter_clockwise_about_its_anchor", "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/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/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", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/testsLine truncated
notes = "A draw placed by position turns about its anchor (rotate); fields, subforms and draws in a flow don't. Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), 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; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a tLine truncated
[[feature]]
id = "form.xfa-formcalc"