diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 1bcf3801c3..46ab4e82d8 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -9386,3 +9386,119 @@ fn repeated_backdrop_artwork_survives_background_undo_redo_and_both_save_modes()
s.apply(rewritten, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap();
black(&s, rewritten);
}
+
+/// A page still exists after its only master widget is hidden: its master nodes stay in the
+/// script model, so a later script can address them again (test from wireless89's review of
+/// #5368).
+#[test]
+fn xfa_page_master_instance_survives_hiding_its_last_widget() {
+ let template = pdfcraft_xfa::fixtures::repeated_print_button_template()
+ .replace(r#"xfa.host.print(1, "0", (xfa.host.numPages -1).toString(), 0, 0, 0, 0, 0);"#, r#"this.presence = "hidden";"#)
+ .replace(
+ "",
+ r#"
+"#,
+ );
+ let (tpl, _) = pdfcraft_xfa::parse(&template).unwrap();
+ let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100);
+ let id = s.open("hidden-master.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap();
+ let out = s.run_javascript(id, "", Some("PrintButton1_3")).unwrap();
+ assert_eq!(out.error, None, "{out:?}");
+ let cos = &s.get(id).unwrap().editor.as_ref().unwrap().cos;
+ assert_eq!(pdfcraft_organize::page_count(cos).unwrap(), 3, "the body still fills three pages");
+ assert!(!s.get(id).unwrap().form.iter().any(|f| f.name == "PrintButton1_3"));
+ let uses = pdfcraft_xfa::page_area_uses(cos, &tpl);
+ let restored = s.run_javascript(id, "", Some("restore")).unwrap();
+ assert_eq!((uses.get("master").copied(), restored.error), (Some(3), None), "the third page master remains addressable by scripts");
+}
+
+/// Page-local values and a globally bound control survive relayout, disappearing and
+/// reappearing pages, incremental saves and vector printing (test from wireless89's review of #5368).
+#[test]
+fn xfa_master_values_survive_page_changes_save_reopen_and_print() {
+ fn name(s: &Session, id: DocId, som: &str) -> String {
+ let d = s.get(id).unwrap();
+ pdfcraft_xfa::fields_by_som(&d.editor.as_ref().unwrap().cos).get(som).unwrap_or_else(|| panic!("missing {som}")).clone()
+ }
+ fn set(s: &mut Session, id: DocId, som: &str, text: &str) {
+ let n = name(s, id, som);
+ s.apply(id, Edit::SetFieldValue { name: n, value: FieldValue::Text(text.into()) }).unwrap();
+ }
+ fn check(s: &Session, id: DocId, expected: &[&str]) {
+ let d = s.get(id).unwrap();
+ // Inspect the actual PDF tree: refreshing the cached view after XFA relayout is
+ // a separate issue (#4604), not part of this master-value regression.
+ assert_eq!(pdfcraft_organize::page_count(&d.editor.as_ref().unwrap().cos).unwrap(), expected.len());
+ let by_som = pdfcraft_xfa::fields_by_som(&d.editor.as_ref().unwrap().cos);
+ assert_eq!(by_som.keys().filter(|som| som.ends_with(".stamp[0]")).count(), expected.len());
+ for (page, value) in expected.iter().enumerate() {
+ for (field, expected) in [("stamp", *value), ("shared", "GLOBAL_VALUE")] {
+ let n = name(s, id, &format!("master[{page}].{field}[0]"));
+ assert_eq!(d.form.iter().find(|f| f.name == n).unwrap().value, [expected], "page {page}: {field}");
+ }
+ }
+ }
+ let template = master_round_trip_review_template();
+ let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100);
+ let mut id = s.open("masters.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap();
+ assert_eq!(s.get(id).unwrap().info.pages.len(), 3);
+ for (page, value) in ["MASTER_A", "MASTER_B", "MASTER_C"].iter().enumerate() {
+ set(&mut s, id, &format!("master[{page}].stamp[0]"), value);
+ }
+ check(&s, id, &["MASTER_A", "MASTER_B", "MASTER_C"]);
+ let mark = name(&s, id, "master[1].mark[0]");
+ let out = s.run_javascript(id, "throw new Error('AcroForm fallback ran')", Some(&mark)).unwrap();
+ assert_eq!(out.error, None, "{out:?}");
+ check(&s, id, &["MASTER_A", "MASTER_B_CLICK", "MASTER_C"]);
+ let more = name(&s, id, "form[0].page1[0].more[0]");
+ s.apply(id, Edit::SetFieldValue { name: more.clone(), value: FieldValue::Check(false) }).unwrap();
+ assert!(s.take_js_output(id).errors.is_empty());
+ check(&s, id, &["MASTER_A", "MASTER_B_CLICK"]);
+ s.undo(id).unwrap();
+ check(&s, id, &["MASTER_A", "MASTER_B_CLICK", "MASTER_C"]);
+ s.redo(id).unwrap();
+ check(&s, id, &["MASTER_A", "MASTER_B_CLICK"]);
+ s.apply(id, Edit::SetFieldValue { name: more, value: FieldValue::Check(true) }).unwrap();
+ check(&s, id, &["MASTER_A", "MASTER_B_CLICK", "MASTER_C"]);
+ for cycle in 0..3 {
+ let saved = s.save_bytes(id).unwrap();
+ let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap();
+ let data = pdfcraft_xfa::data_of(&cos).unwrap();
+ for (page, value) in ["MASTER_A", "MASTER_B_CLICK", "MASTER_C"].iter().enumerate() {
+ assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path(&format!("master[{page}].stamp[0]"))), Some(*value), "cycle {cycle}: page {page}");
+ }
+ assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("master[0].shared[0]")), Some("GLOBAL_VALUE"));
+ id = s.open("again.pdf", None, saved, None).unwrap();
+ assert!(s.take_js_output(id).errors.is_empty());
+ check(&s, id, &["MASTER_A", "MASTER_B_CLICK", "MASTER_C"]);
+ }
+ let printed = s.print_pdf(id, &print::Settings { pages: vec![0, 1, 2], ..Default::default() }).unwrap();
+ let pid = s.open("printed.pdf", None, Arc::new(printed), None).unwrap();
+ let texts = page_texts(&s, pid);
+ assert_eq!(texts.len(), 3);
+ for (page, value) in ["MASTER_A", "MASTER_B_CLICK", "MASTER_C"].iter().enumerate() {
+ assert!(texts[page].contains(value) && texts[page].contains("GLOBAL_VALUE"), "page {page}: {:?}", texts[page]);
+ for other in ["MASTER_A", "MASTER_B_CLICK", "MASTER_C"].iter().filter(|other| *other != value) {
+ assert!(!texts[page].contains(other), "page {page} contains another page's value: {other}");
+ }
+ }
+}
+fn master_round_trip_review_template() -> String {
+ let template = pdfcraft_xfa::fixtures::repeated_print_button_template()
+ .replace(r#"name="PrintButton1" x="36pt""#, r#"name="PrintButton1" x="480pt""#)
+ .replace("", r#"
+
+
+
+
+
+ "#)
+ .replace(r#""#, r#"
+ 101
+
+ "#);
+ format!(
+ r#"{template}
+GLOBAL_VALUE"#
+ )
+}
diff --git a/crates/xfa/src/layout/items.rs b/crates/xfa/src/layout/items.rs
index 8a68388684..b3e1803879 100644
--- a/crates/xfa/src/layout/items.rs
+++ b/crates/xfa/src/layout/items.rs
@@ -135,6 +135,8 @@ pub struct Page {
pub width: f64,
pub height: f64,
pub items: Vec,
+ /// The name of the page area the page was made from (`None` for an unnamed one).
+ pub area: Option,
}
#[derive(Clone, Debug, Default, PartialEq)]
diff --git a/crates/xfa/src/layout/pages.rs b/crates/xfa/src/layout/pages.rs
index 5bc7a13fbe..4b7f7f3ef2 100644
--- a/crates/xfa/src/layout/pages.rs
+++ b/crates/xfa/src/layout/pages.rs
@@ -97,7 +97,7 @@ impl Layouter<'_> {
if let Some(u) = self.uses.get_mut(idx) {
*u += 1;
}
- self.pages.push(Page { width: area.width, height: area.height, items: Vec::new() });
+ self.pages.push(Page { width: area.width, height: area.height, items: Vec::new(), area: area.name.clone() });
let content = area.content.first().copied().unwrap_or(Rect::new(0.0, 0.0, area.width, area.height));
self.cur = Some(Cursor { area: idx, content: 0, rect: content, y: content.y, placed: false });
// Master page content: positioned draws and fields. The page area's own SOM index counts
diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs
index 3afe4f11f8..d01d88ede5 100644
--- a/crates/xfa/src/lib.rs
+++ b/crates/xfa/src/lib.rs
@@ -99,7 +99,7 @@ pub use data::{
pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, formatted_value, layout};
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, NODE_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape};
+pub use pdf::{AREA_KEY, 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,
diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs
index 0c21507abf..72d8c033c9 100644
--- a/crates/xfa/src/pdf.rs
+++ b/crates/xfa/src/pdf.rs
@@ -36,6 +36,11 @@ pub const CLICK_KEY: &[u8] = b"PCXfaClick";
/// fields they hold.
pub const NODE_KEY: &[u8] = b"PCXfaNode";
+/// Private key on each page laying the form out writes: the name of the page area the page was
+/// made from. The n-th page with a name is that area's `[n-1]` instance, which scripts address
+/// even when no field on the page is left to show it (see `page_area_uses`).
+pub const AREA_KEY: &[u8] = b"PCXfaArea";
+
pub struct Written {
pub pages: usize,
pub fields: usize,
@@ -663,6 +668,9 @@ impl Emitter<'_> {
pd.set(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, page.width, page.height].iter().map(|v| Object::Real(*v)).collect()));
pd.set(b"Resources".to_vec(), Object::Dict(res));
pd.set(b"Contents".to_vec(), Object::Ref(contents));
+ if let Some(area) = &page.area {
+ pd.set(AREA_KEY.to_vec(), Object::String(PdfString::text(area)));
+ }
if !annots.is_empty() {
pd.set(b"Annots".to_vec(), Object::Array(annots));
}
diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs
index d0452533dc..04158bac00 100644
--- a/crates/xfa/src/script.rs
+++ b/crates/xfa/src/script.rs
@@ -594,17 +594,32 @@ pub fn fields_by_som(doc: &Document) -> HashMap {
out
}
-/// How many pages the layout made from each named page area of `tpl`, as the generated fields
-/// of `doc` show it: the master content of the n-th page from area `master` is `master[n-1]`.
-/// Areas no generated field names are left out (one page's worth, to [`form_tree_paged`]).
+/// How many pages the layout made from each named page area of `tpl`: the master content of the
+/// n-th page from area `master` is `master[n-1]`. Each page the layout wrote names its area
+/// ([`crate::AREA_KEY`]), so a page whose master fields are all hidden still counts; a file laid
+/// out before pages were named shows it by its generated fields. Areas neither shows are left out
+/// (one page's worth, to [`form_tree_paged`]).
pub fn page_area_uses(doc: &Document, tpl: &Template) -> HashMap {
let mut out: HashMap = HashMap::new();
let Some(ps) = &tpl.root.page_set else { return out };
+ let is_area = |name: &str| tpl.root.common.name.as_deref() != Some(name) && ps.areas.iter().any(|a| a.name.as_deref() == Some(name));
+ // The flat page list laying the form out writes.
+ let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|c| c.get(b"Pages")).map(|p| doc.resolve(p)));
+ let kids =
+ pages.and_then(|p| p.as_dict().and_then(|d| d.get(b"Kids")).map(|k| doc.resolve(k))).and_then(|k| k.as_array().cloned()).unwrap_or_default();
+ for page in kids.iter().filter_map(Object::as_ref).take(crate::layout::MAX_PAGES) {
+ let page = doc.get(page);
+ let Some(name) = page.as_dict().and_then(|d| d.get(crate::AREA_KEY)).and_then(Object::as_string).map(PdfString::to_text) else { continue };
+ if is_area(&name) {
+ let pages = out.entry(name).or_insert(0);
+ *pages = pages.saturating_add(1).min(crate::layout::MAX_PAGES);
+ }
+ }
for som in fields_by_som(doc).keys() {
let first = som.split('.').next().unwrap_or_default();
let Some((name, rest)) = first.split_once('[') else { continue };
let Some(index) = rest.strip_suffix(']').and_then(|i| i.parse::().ok()) else { continue };
- if tpl.root.common.name.as_deref() == Some(name) || !ps.areas.iter().any(|a| a.name.as_deref() == Some(name)) {
+ if !is_area(name) {
continue;
}
let pages = out.entry(name.to_string()).or_insert(1);