M12: an XFA page counts for its page area even when all its master fields are hidden

Each page laying the form out writes names the page area it was made from
(PCXfaArea), and page_area_uses counts those pages, so a page whose only
master field a script hid keeps its master nodes in the script model and a
later script can show them again (review). A file laid out before pages
were named is still read from its generated fields. Adds two tests from
wireless89's review: page-local and shared master values through relayout,
save, reopen and print, and the page whose last master widget is hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
This commit is contained in:
mtlcl410
2026-10-31 07:20:51 +08:00
co-authored by Claude Opus 5.5
parent 74930f3929
commit e5faac8e52
6 changed files with 147 additions and 6 deletions
+116
View File
@@ -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(
"</subform></template>",
r#"<field name="restore" w="100pt" h="25pt"><ui><button/></ui>
<event activity="click"><script contentType="application/x-javascript">xfa.resolveNode("master[2].PrintButton1").presence = "visible";</script></event></field></subform></template>"#,
);
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("</pageArea>", r#"
<field name="stamp" x="36pt" y="24pt" w="160pt" h="25pt"><ui><textEdit/></ui></field>
<field name="shared" x="200pt" y="24pt" w="150pt" h="25pt"><ui><textEdit/></ui><bind match="global"/></field>
<field name="mark" x="360pt" y="24pt" w="80pt" h="25pt"><ui><button/></ui>
<event activity="click"><script contentType="application/x-javascript">stamp.rawValue = "MASTER_B_CLICK";</script></event>
</field>
</pageArea>"#)
.replace(r#"<subform name="page1" layout="tb">"#, r#"<subform name="page1" layout="tb">
<field name="more" w="20pt" h="20pt"><ui><checkButton/></ui><items><integer>1</integer><integer>0</integer></items><value><integer>1</integer></value>
<event activity="change"><script contentType="application/x-javascript">xfa.resolveNode("form.page3").presence = this.rawValue == 1 ? "visible" : "hidden";</script></event>
</field>"#);
format!(
r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">{template}
<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><master><shared>GLOBAL_VALUE</shared></master></xfa:data></xfa:datasets></xdp:xdp>"#
)
}
+2
View File
@@ -135,6 +135,8 @@ pub struct Page {
pub width: f64,
pub height: f64,
pub items: Vec<Item>,
/// The name of the page area the page was made from (`None` for an unnamed one).
pub area: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq)]
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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,
+8
View File
@@ -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));
}
+19 -4
View File
@@ -594,17 +594,32 @@ pub fn fields_by_som(doc: &Document) -> HashMap<String, String> {
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<String, usize> {
let mut out: HashMap<String, usize> = 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::<usize>().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);