M12: a flowed XFA subform a page break cuts gets a closed border on each page, its margins repeated

This commit is contained in:
Eric Kaden
2026-10-23 21:54:55 -04:00
parent f5e74991ed
commit d7da5dfe29
3 changed files with 146 additions and 26 deletions
+113 -21
View File
@@ -188,6 +188,21 @@ struct FlowMark {
items: usize,
}
/// A flowed subform being laid out ([`Layouter::frames`]): where it sits across, its margins and
/// border, its part on the content area the flow is in, and its parts on earlier ones (page
/// index, the page's first item of the part, the part's box).
struct Frame {
x: f64,
w: f64,
margin: Margin,
border: Option<Border>,
part: FlowMark,
parts: Vec<(usize, usize, Rect)>,
/// The flow left a part's content area other than at a break between children (an item cut
/// across pages, a table's own break): the border is left out.
lost: bool,
}
struct Cursor {
area: usize,
content: usize,
@@ -222,6 +237,9 @@ struct Layouter<'t> {
/// The fields and draws of the block being placed: (first item, one past the last, top,
/// bottom) on the last page. A page break keeps each whole (see `split_block`).
leaves: Vec<(usize, usize, f64, f64)>,
/// The flowed subforms being laid out, outermost first: a break between their children
/// closes their parts on one content area and opens new ones on the next.
frames: Vec<Frame>,
}
/// How many instances of a repeating subform to lay out: what the data holds once it holds
@@ -267,6 +285,7 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result<Form, XfaError>
measured: RefCell::new(HashMap::new()),
measures: Cell::new(0),
leaves: Vec::new(),
frames: Vec::new(),
};
let ctx = Ctx { font: Font::default(), para: Para::default(), som: String::new() };
l.new_page()?;
@@ -613,32 +632,98 @@ impl Layouter<'_> {
Some(FlowMark { page: self.pages.len(), area: c.area, content: c.content, y: c.y, items: self.pages.last().map_or(0, |p| p.items.len()) })
}
/// Draw a flowed subform's border `b` (edges and fill) round what it took since `start`, `x`
/// and `w` wide, under the content drawn since: once its content is placed, its height is
/// known. A subform that didn't stay in one content area is drawn without its border, and the
/// layout says so.
fn draw_flowed_border(&mut self, b: &Border, start: Option<FlowMark>, x: f64, w: f64) -> Result<(), XfaError> {
let Some(s) = start else { return Ok(()) };
/// Whether the flow is still in the content area `m` was taken in.
fn on_area(&self, m: &FlowMark) -> bool {
self.pages.len() == m.page && self.cur.as_ref().is_some_and(|c| c.area == m.area && c.content == m.content)
}
/// Whether page `page` (an index) got anything from item `at` on.
fn has_items_from(&self, page: usize, at: usize) -> bool {
self.pages.get(page).is_some_and(|p| p.items.len() > at)
}
/// Before placing a child `h` tall inside flowed subforms: when it doesn't fit what is left
/// of the content area but would fit a fresh one, break there ([`Self::break_frames`]).
fn break_before(&mut self, h: f64) -> Result<(), XfaError> {
let (rect, y, placed) = self.cursor();
if self.frames.is_empty() || !placed || y + h <= rect.bottom() + 0.01 || h > rect.h + 0.01 {
return Ok(());
}
self.break_frames()
}
/// Break the flowed subforms being laid out ([`Self::frames`]) across content areas, as
/// another viewer breaks such a section on a synthetic form: each one's part here ends after
/// its content and its bottom margin (the innermost first, each inside the next), the flow
/// moves to the next content area, and each starts a new part there after its top margin
/// (the outermost first).
fn break_frames(&mut self) -> Result<(), XfaError> {
let (rect, mut y, _) = self.cursor();
let page = self.pages.len().saturating_sub(1);
let here: Vec<bool> = self.frames.iter().map(|f| self.on_area(&f.part)).collect();
let filled: Vec<bool> = self.frames.iter().map(|f| self.has_items_from(page, f.part.items)).collect();
for ((f, here), filled) in self.frames.iter_mut().zip(here).zip(filled).rev() {
y = (y + f.margin.bottom).min(rect.bottom());
if !here {
f.lost = true;
} else if filled && y - f.part.y >= 0.01 {
f.parts.push((page, f.part.items, Rect::new(f.x, f.part.y, f.w, y - f.part.y)));
}
}
self.next_area()?;
for i in 0..self.frames.len() {
let Some(mark) = self.flow_mark() else { break };
let Some(f) = self.frames.get_mut(i) else { break };
f.part = mark;
let top = f.margin.top;
self.advance(top);
}
Ok(())
}
/// Close flowed subform `f` once its content is placed: its last part ends where the flow
/// is, and its border (edges and fill) is drawn round each part, closed on every side, under
/// that part's content. One whose flow left a content area other than at a break between its
/// children (an item of its own cut across pages) is drawn without its border, and the layout
/// says so.
fn finish_frame(&mut self, mut f: Frame) -> Result<(), XfaError> {
let Some(b) = f.border.take() else { return Ok(()) };
if b.fill().is_none() && b.visible_edges() == [false; 4] {
return Ok(());
}
let (_, end, _) = self.cursor();
let same_area = self.cur.as_ref().is_some_and(|c| c.area == s.area && c.content == s.content);
if self.pages.len() != s.page || !same_area {
self.warn_once("a bordered section that doesn't fit in one content area is drawn without its border");
let page = self.pages.len().saturating_sub(1);
if !self.on_area(&f.part) {
f.lost = true;
} else if self.has_items_from(page, f.part.items) && end - f.part.y >= 0.01 {
f.parts.push((page, f.part.items, Rect::new(f.x, f.part.y, f.w, end - f.part.y)));
}
if f.lost {
self.warn_once("a bordered section whose own item a page break cuts is drawn without its border");
return Ok(());
}
if end - s.y < 0.01 {
return Ok(());
// The last part first: a part's place on its page stays where it was found.
for (page, at, rect) in f.parts.into_iter().rev() {
self.insert_border(&b, rect, page, at)?;
}
Ok(())
}
/// Draw border `b` round `rect` on page `page` (an index), under what that page got from item
/// `at` on.
fn insert_border(&mut self, b: &Border, rect: Rect, page: usize, at: usize) -> Result<(), XfaError> {
let before = self.pages.last().map_or(0, |p| p.items.len());
self.draw_border(b, Rect::new(x, s.y, w, end - s.y))?;
// Moved back to where the subform started, so its content is drawn over them.
if let Some(p) = self.pages.last_mut() {
let added = p.items.len().saturating_sub(before);
if let Some(tail) = p.items.get_mut(s.items..) {
tail.rotate_right(added.min(tail.len()));
self.draw_border(b, rect)?;
let drawn: Vec<Item> = match self.pages.last_mut() {
Some(p) => {
let from = before.min(p.items.len());
p.items.drain(from..).collect()
}
None => return Ok(()),
};
if let Some(target) = self.pages.get_mut(page) {
let at = at.min(target.items.len());
target.items.splice(at..at, drawn);
}
Ok(())
}
@@ -841,8 +926,14 @@ impl Layouter<'_> {
continue;
}
let m = sf.common.margin;
// Where the subform starts: its border and fill go round what it takes from here.
let start = self.flow_mark();
// Its border and fill go round what it takes from here, a part on each content area.
let framed = match self.flow_mark() {
Some(part) => {
self.frames.push(Frame { x, w, margin: m, border: sf.common.border.clone(), part, parts: Vec::new(), lost: false });
true
}
None => false,
};
self.advance(m.top);
let inner_x = x + m.left;
let inner_w = (w - m.horizontal()).max(1.0);
@@ -862,13 +953,14 @@ impl Layouter<'_> {
continue;
}
let (cw, ch) = self.size_of(child, inner_w, &ctx, depth + 1);
self.break_before(ch)?;
self.flow_block(child, inner_x, (cw, ch), &ctx, depth + 1, csib)?;
}
}
}
self.advance(m.bottom);
if let Some(b) = &sf.common.border {
self.draw_flowed_border(b, start, x, w)?;
if framed && let Some(frame) = self.frames.pop() {
self.finish_frame(frame)?;
}
}
if sf.break_after_page {
+32 -4
View File
@@ -2049,8 +2049,10 @@ fn a_multi_line_field_without_a_height_grows_to_hold_its_text() {
}
/// A flowed subform without a fixed height gets its border and fill round all it takes, margins
/// included, drawn under its content; a flowed table too. One that doesn't fit in one content
/// area keeps no border, and the layout says so.
/// included, drawn under its content; a flowed table too. One a page break cuts between its
/// children gets a closed border round its part on each page, its bottom margin ending the first
/// part and its top margin starting the next, as another viewer draws it on a synthetic form. One
/// whose own item a page break cuts keeps no border, and the layout says so.
#[test]
fn a_flowed_subform_is_drawn_with_its_border_under_its_content() {
let field = |name: &str| format!(r#"<field name="{name}" w="3in" h="0.5in"><ui><textEdit/></ui></field>"#);
@@ -2076,10 +2078,36 @@ fn a_flowed_subform_is_drawn_with_its_border_under_its_content() {
let table = r#"<subform name="grid" layout="table" columnWidths="2in 2in" w="4in"><border><edge/></border><subform name="row" layout="row"><draw name="c1" h="0.4in"><value><text>One</text></value></draw><draw name="c2" h="0.4in"><value><text>Two</text></value></draw></subform></subform>"#;
let form = lay(table);
assert_eq!(form.pages[0].items.iter().filter(|i| matches!(i, Item::Line { .. })).count(), 4, "{:?}", form.pages[0].items);
// Thirty fields don't fit one page: no border, and one warning.
// Thirty fields: 19 fit the first page after the top margin, 11 go to the second.
let many: String = (0..30).map(|i| field(&format!("f{i}"))).collect();
let form = lay(&boxed(&many));
assert!(form.pages.len() >= 2);
assert_eq!(form.pages.len(), 2);
let fills = |page: usize| -> Vec<Rect> {
form.pages[page].items.iter().filter_map(|i| if let Item::Fill { rect, .. } = i { Some(*rect) } else { None }).collect()
};
let (first, second) = (fills(0), fills(1));
assert_eq!((first.len(), second.len()), (1, 1), "a part on each page");
// Page 1: from the top (36) past 0.1in of margin and 19 fields, then 0.2in of margin.
assert!((first[0].y - 36.0).abs() < 0.01 && (first[0].bottom() - (36.0 + 7.2 + 19.0 * 36.0 + 14.4)).abs() < 0.01, "{:?}", first[0]);
// Page 2: the top margin again, 11 fields, the bottom margin.
assert!((second[0].y - 36.0).abs() < 0.01 && (second[0].bottom() - (36.0 + 7.2 + 11.0 * 36.0 + 14.4)).abs() < 0.01, "{:?}", second[0]);
let lines = |page: usize| form.pages[page].items.iter().filter(|i| matches!(i, Item::Line { .. })).count();
assert_eq!((lines(0), lines(1)), (4, 4), "each part's border is closed");
let f19 = form.pages[1].items.iter().find_map(|i| if let Item::Widget(w) = i { (w.name == "f19").then_some(w.rect) } else { None }).unwrap();
assert!((f19.y - 43.2).abs() < 0.01, "the second part starts after the top margin: {}", f19.y);
assert!(form.warnings.is_empty(), "{:?}", form.warnings);
// The margins come back without a border too.
let bare = format!(r#"<subform name="box" layout="tb" w="7.5in"><margin topInset="0.1in" bottomInset="0.2in"/>{many}</subform>"#);
let form = lay(&bare);
let f19 = form.pages[1].items.iter().find_map(|i| if let Item::Widget(w) = i { (w.name == "f19").then_some(w.rect) } else { None }).unwrap();
assert!((f19.y - 43.2).abs() < 0.01, "{}", f19.y);
// A text of its own longer than a page, cut across pages: no border, one warning.
let long = format!(
r#"<draw name="long" w="7in"><font typeface="Arial" size="10pt"/><value><text>{}</text></value></draw>"#,
"A line of the long text.\n".repeat(80)
);
let form = lay(&boxed(&(field("a") + &long)));
assert!(form.pages.len() >= 2, "the long text runs onto another page");
assert!(form.pages.iter().all(|p| !p.items.iter().any(|i| matches!(i, Item::Fill { .. }))), "no fill drawn");
assert_eq!(form.warnings.iter().filter(|w| w.contains("bordered section")).count(), 1, "{:?}", form.warnings);
}
+1 -1
View File
@@ -5250,7 +5250,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/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/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content"]
notes = "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 that fit one content area (one that runs on past it is drawn without its border, with a layout warning), captions, rich text, JPEG/PNG/GIF/BMP/TIFF 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. 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 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. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #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 = "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; one whose own item a break cuts is drawn without its border, with a layout warning), captions, rich text, JPEG/PNG/GIF/BMP/TIFF 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. 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 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. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #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"