M12: lay a dynamic XFA form out once after its open-time scripts

On opening, the engine laid the form out again after every initialize or
calculate script that changed presence, access or instances. On a large form
one layout takes about the whole 3 s open budget, so the remaining initialize
scripts were skipped, and that half-initialized form was what saving kept: each
later opening hid more of it, and a page shown at the first opening, filled in,
was hidden and its data reset by the next one.

The scripts read the live form (template, data and overrides), not the pages,
and XFA 3.3 fires initialize after data binding, before layout (pp. 388, 390,
407). On opening, a needed layout is now recorded and done once in finish().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
This commit is contained in:
mtlcl410
2026-10-31 13:57:32 +08:00
co-authored by Claude Opus 5.5
parent f137a289ba
commit bc5aff5ff6
3 changed files with 69 additions and 4 deletions
+47
View File
@@ -6529,6 +6529,53 @@ fn the_xfa_open_budget_stops_the_calculations_it_has_no_time_for() {
assert_eq!(pdfcraft_js::xfa_session::engines_started(), engines, "no script ran, so no engine started");
}
/// Opening lays the form out once after its initialize scripts, not after each one that
/// hides a subform (XFA 3.3 pp. 388, 390: initialize fires once data binding is complete;
/// the layout process has not run yet). Laying it out after every script spent the opening's
/// time on the first few, and a form whose initialize scripts hide its optional pages was
/// saved half initialized: the next opening hid more of it, and reset the data typed into
/// what it hid.
#[test]
fn xfa_initialize_scripts_that_hide_subforms_lay_the_form_out_once() {
// Shaped like a form with optional extra pages: a check box, then more sections than one
// event may lay the form out again for, each hiding itself while the box is clear.
let sections = 60;
let mut body = String::from(
r#"<field name="wanted" w="0.3in" h="0.3in"><ui><checkButton/></ui><items><integer>1</integer><integer>0</integer></items><value><integer>0</integer></value></field>"#,
);
for n in 0..sections {
body.push_str(&format!(
r#"<subform name="extra{n}" layout="tb"><event activity="initialize"><script contentType="application/x-javascript">if (wanted.rawValue == 0) this.presence = "hidden";</script></event><field name="note" w="3in" h="0.3in"><ui><textEdit/></ui></field></subform>"#
));
}
let tpl = 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"><occur max="-1"/><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
{body}
</subform>
</template>
</xdp:xdp>
"#
);
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100);
let id = s.open("extras.pdf", None, bytes, None).expect("opens");
let out = s.take_js_output(id);
assert!(out.errors.is_empty(), "{:?}", out.errors);
let names = |s: &Session, id| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::<Vec<_>>();
let opened = names(&s, id);
assert!(opened.iter().all(|n| !n.contains("note")), "every extra section is hidden on opening: {opened:?}");
assert!(opened.iter().any(|n| n.contains("wanted")), "{opened:?}");
// The saved form opens as it was saved.
let saved = s.save_bytes(id).unwrap();
let again = s.open("again.pdf", None, saved, None).expect("reopens");
assert!(s.take_js_output(again).errors.is_empty());
assert_eq!(names(&s, again), opened);
}
/// The scripts of one event share the form as the engine holds it: a failed script's
/// half-done writes reach nobody, and a script reads the saved value a choice list was given
/// by its shown text.
+20 -2
View File
@@ -256,6 +256,11 @@ struct Runner<'a, 'b> {
skipped: usize,
relayouts: usize,
relayouts_skipped: bool,
/// Lay the form out once, when the event ends, rather than after every script that changed
/// its structure (opening: see [`on_open`]).
defer_relayout: bool,
/// A deferred layout is owed.
relayout_owed: bool,
dropped_output: usize,
truncated_warned: bool,
data: Option<pdfcraft_xfa::DataNode>,
@@ -291,6 +296,8 @@ impl<'a, 'b> Runner<'a, 'b> {
skipped: 0,
relayouts: 0,
relayouts_skipped: false,
defer_relayout: false,
relayout_owed: false,
dropped_output: 0,
truncated_warned: false,
data,
@@ -689,7 +696,11 @@ impl<'a, 'b> Runner<'a, 'b> {
self.error(format!("{} ({}): {e}", ev.som, ev.activity));
}
if self.apply_effects(ev, &o.effects)? {
self.relayout()?;
if self.defer_relayout {
self.relayout_owed = true;
} else {
self.relayout()?;
}
}
Ok(Some(o))
}
@@ -719,9 +730,10 @@ 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.relayout_owed {
self.relayouts = 0;
self.relayouts_skipped = false;
self.relayout_owed = false;
self.relayout()?;
}
self.flush()?;
@@ -825,6 +837,12 @@ pub(crate) fn relayout_changed_text(
/// 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);
// The scripts read the live form (template, data and overrides), not the pages, so the
// pages are laid out once, after the last of them. Laying a large form out after every
// script that hides a subform spent the time limit on the first few, and the form was kept
// half initialized: a page its initialize scripts hide stayed open to typing until the
// next opening hid it and reset its data.
r.defer_relayout = true;
let events: Vec<ScriptEvent> = r.live().events.iter().filter(|e| e.activity == "initialize" || e.activity == "docReady").cloned().collect();
for ev in &events {
r.run(ev, "")?;
File diff suppressed because one or more lines are too long