mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 18:31:10 -06:00
fix(xfa): a page area's occur min gets its pages after the body (#2574)
A page area with <occur min="1" max="1"> that the body never reached was never laid out, so the public ISCIII form lost the page that holds its finishing instructions and the TERMINAR MEMORIA button once the repeated copies over the body pages were removed. PageArea records occur min (0 without an <occur>). When the body has been laid out, an area used fewer times than its minimum gets the pages it still needs, in template order, after the body's pages. The form is 7 pages with the once-only page last, which is what Acrobat Pro shows.
This commit is contained in:
+1
-1
@@ -159,7 +159,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
## Progress log
|
||||
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
|
||||
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
|
||||
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `<occur>` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. Synthetic regression test; the page area's `occur min` (a once-only page of its own) is still not laid out, recorded in `form.xfa-dynamic`. Estimates unchanged.
|
||||
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `<occur>` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of the public ISCIII form, with its button, is page 7 of 7). Acrobat Pro shows the form as 7 pages with that page last, as here; how `relevant="-print"` treats it when printing is still unchecked. Synthetic regression tests. Estimates unchanged.
|
||||
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
|
||||
- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged.
|
||||
- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor).
|
||||
|
||||
@@ -232,6 +232,7 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result<Form, XfaError>
|
||||
content: vec![Rect::new(36.0, 36.0, 540.0, 720.0)],
|
||||
items: Vec::new(),
|
||||
occur_max: None,
|
||||
occur_min: 0,
|
||||
}],
|
||||
};
|
||||
let mut values = HashMap::new();
|
||||
@@ -261,6 +262,7 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result<Form, XfaError>
|
||||
if tpl.root.common.presence.occupies() {
|
||||
l.flow_subform(&root_node, None, &ctx, 0, 0)?;
|
||||
}
|
||||
l.satisfy_page_area_minimums()?;
|
||||
l.check_measures()?;
|
||||
let total = l.pages.len();
|
||||
let count = total.to_string();
|
||||
@@ -497,11 +499,28 @@ impl Layouter<'_> {
|
||||
// ───────────────────────────────────────────────────────────────────────── pages
|
||||
|
||||
fn new_page(&mut self) -> Result<(), XfaError> {
|
||||
let idx =
|
||||
(0..self.areas.len()).find(|&i| self.areas[i].occur_max.is_none_or(|m| self.uses[i] < m)).unwrap_or(self.areas.len().saturating_sub(1));
|
||||
self.new_page_of(idx)
|
||||
}
|
||||
|
||||
/// Once the body has been laid out, give every page area that has been used fewer times than
|
||||
/// its `occur min` the pages it still needs, in template order, after the body's pages.
|
||||
fn satisfy_page_area_minimums(&mut self) -> Result<(), XfaError> {
|
||||
let areas = Rc::clone(&self.areas);
|
||||
for (idx, area) in areas.iter().enumerate() {
|
||||
while self.uses.get(idx).is_some_and(|u| *u < area.occur_min) {
|
||||
self.new_page_of(idx)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start a page from page area `idx`.
|
||||
fn new_page_of(&mut self, idx: usize) -> Result<(), XfaError> {
|
||||
if self.pages.len() >= MAX_PAGES {
|
||||
return Err(XfaError::TooLarge(format!("the form lays out to more than {MAX_PAGES} pages")));
|
||||
}
|
||||
let idx =
|
||||
(0..self.areas.len()).find(|&i| self.areas[i].occur_max.is_none_or(|m| self.uses[i] < m)).unwrap_or(self.areas.len().saturating_sub(1));
|
||||
let areas = Rc::clone(&self.areas);
|
||||
let Some(area) = areas.get(idx) else {
|
||||
return Err(XfaError::Malformed("the template has no page area".into()));
|
||||
|
||||
@@ -680,6 +680,9 @@ pub struct PageArea {
|
||||
pub items: Vec<Node>,
|
||||
/// How many pages may use this master (`None`: unbounded).
|
||||
pub occur_max: Option<usize>,
|
||||
/// How many pages must use this master: a layout that ends with fewer adds pages for it.
|
||||
/// Zero without an `<occur>`.
|
||||
pub occur_min: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
|
||||
@@ -529,9 +529,11 @@ fn parse_page_set(n: XmlNode, warnings: &mut Vec<String>, depth: usize) -> PageS
|
||||
content.push(Rect::new(0.0, 0.0, width, height));
|
||||
}
|
||||
let items = pa.children().filter(|c| c.is_element()).filter_map(|c| parse_node(c, warnings, depth + 1)).collect();
|
||||
// A page area with no `<occur>` has no limit (a subform's default of one would exhaust it).
|
||||
let occur_max = child(pa, "occur").and_then(|o| parse_occur(Some(o)).max);
|
||||
areas.push(PageArea { name: pa.attribute("name").map(str::to_string), width, height, content, items, occur_max });
|
||||
// A page area with no `<occur>` has no limit and no minimum (a subform's default of one
|
||||
// would exhaust it, and demand a page for every master).
|
||||
let occur = child(pa, "occur").map(|o| parse_occur(Some(o)));
|
||||
let (occur_min, occur_max) = occur.map_or((0, None), |o| (o.min, o.max));
|
||||
areas.push(PageArea { name: pa.attribute("name").map(str::to_string), width, height, content, items, occur_max, occur_min });
|
||||
}
|
||||
// Nested page sets: take their areas too.
|
||||
for ps in children(n, "pageSet") {
|
||||
|
||||
+33
-3
@@ -1225,9 +1225,39 @@ fn page_area_without_occur_serves_every_body_page() {
|
||||
);
|
||||
let form = layout_xml(&xdp).unwrap();
|
||||
let soms: Vec<Vec<String>> = form.pages.iter().map(|p| widgets(p).iter().map(|w| w.som.clone()).collect()).collect();
|
||||
assert_eq!(form.pages.len(), 4, "{soms:?}");
|
||||
let buttons = soms.iter().flatten().filter(|s| s.contains("PrintButton")).count();
|
||||
assert_eq!(buttons, 0, "the single-use page area is not drawn over body pages: {soms:?}");
|
||||
let has_button = |page: &Vec<String>| page.iter().any(|s| s.contains("PrintButton"));
|
||||
// Four body pages, then the once-only page: its button is on that page alone, not over the body.
|
||||
assert_eq!(form.pages.len(), 5, "{soms:?}");
|
||||
assert!(soms[..4].iter().all(|p| !has_button(p)), "the single-use page area is not drawn over body pages: {soms:?}");
|
||||
assert_eq!(soms[4].iter().filter(|s| s.contains("PrintButton")).count(), 1, "the required page is laid out once: {soms:?}");
|
||||
}
|
||||
|
||||
/// A page area's `occur min` is a page it must have even when the body never reaches it, and a
|
||||
/// body that already used it does not get another. An area with no `<occur>` needs none.
|
||||
#[test]
|
||||
fn page_area_minimum_adds_pages_after_the_body_only_when_unused() {
|
||||
let area = |name: &str, occur: &str| {
|
||||
format!(
|
||||
r#"<pageArea name="{name}">{occur}<contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea>"#
|
||||
)
|
||||
};
|
||||
let pages = |areas: String| {
|
||||
let xdp = 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>{areas}</pageSet><field name="f" w="2in" h="0.3in"><ui><textEdit/></ui></field></subform>
|
||||
</template></xdp:xdp>"##
|
||||
);
|
||||
layout_xml(&xdp).unwrap().pages.len()
|
||||
};
|
||||
// The body is one page; the required second master adds one, a third with min 2 adds two.
|
||||
assert_eq!(pages(area("A", "") + &area("B", r#"<occur min="1" max="1"/>"#)), 2);
|
||||
assert_eq!(pages(area("A", "") + &area("B", r#"<occur min="1" max="1"/>"#) + &area("C", r#"<occur min="2" max="3"/>"#)), 4);
|
||||
// An area the body already used (it is first, so page 1 is its own) adds nothing.
|
||||
assert_eq!(pages(area("A", r#"<occur min="1" max="1"/>"#)), 1);
|
||||
assert_eq!(pages(area("A", r#"<occur min="1" max="1"/>"#) + &area("B", "")), 1);
|
||||
// No `<occur>` and `min="0"` need no page.
|
||||
assert_eq!(pages(area("A", "") + &area("B", r#"<occur min="0" max="1"/>"#)), 1);
|
||||
}
|
||||
|
||||
/// Areas are named in SOM expressions but are not data scopes; unnamed subforms and areas have
|
||||
|
||||
@@ -5221,7 +5221,7 @@ status = "partial"
|
||||
commands = ["file.open"]
|
||||
tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"]
|
||||
evidence = ["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/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/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/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/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/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/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"]
|
||||
notes = "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), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, 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; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, 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 toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: a page area's occur min (a once-only page area such as a closing instructions page is not laid out as a page of its own, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
|
||||
notes = "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 shows the public ISCIII form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, 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; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, 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 toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: the master's relevant=\"-print\" (how Acrobat prints a page area marked not relevant for print is unchecked, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
|
||||
|
||||
[[feature]]
|
||||
id = "form.xfa-formcalc"
|
||||
|
||||
Reference in New Issue
Block a user