From be00c1300e0250e9d49200562a023379c3481172 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:07:09 +0800 Subject: [PATCH 1/6] M12: an empty multi-line XFA field with a minimum height starts at that height An empty multi-line text field was laid out three lines tall (38.5pt in the default 10pt font) before its minimum height applied, so a growable table cell (minH, no h; XFA 3.3 p. 731) could never be as short as its own minimum. Texas HHS Form 1060's activity table, ten rows of such cells at minH 7.62mm, came out at 38.5pt a row instead of 21.6pt and ran onto a third page; readers fit the form on two. Such a field now starts at its minimum (one line, or its text, below it) and still grows with its text. A multi-line field without a minimum keeps its three lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK --- crates/xfa/src/layout/measure.rs | 5 ++++- crates/xfa/src/tests.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/xfa/src/layout/measure.rs b/crates/xfa/src/layout/measure.rs index 2fffb3dfa0..f7106d5890 100644 --- a/crates/xfa/src/layout/measure.rs +++ b/crates/xfa/src/layout/measure.rs @@ -209,7 +209,10 @@ impl Layouter<'_> { /// that grows) plus a caption above or below it, or the taller of the two for one beside it. fn field_height(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> f64 { let font = ctx.font.apply(&f.common.font); - let lines = if f.multiline { 3.0 } else { 1.0 }; + // An empty multi-line field is three lines tall, unless it gives a minimum height (`minH`, + // XFA 3.3 p. 731): then that is where it starts, as a growable table cell does, and one + // line below it; its text, when there is more, still grows it. + let lines = if f.multiline && f.common.min_h.is_none() { 3.0 } else { 1.0 }; let ui = match f.ui { Ui::CheckButton => f.check_size.unwrap_or(10.0).max(text::line_height(font.size)), // A button shows its caption: as tall as the caption's text in its own font. diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index ca129abceb..c16b56b4de 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -4089,3 +4089,32 @@ fn clear_password_values_clears_static_xfa_password_fields() { let packets = read_packets(&doc).unwrap().unwrap(); assert!(!packets.xdp.contains("hunter2"), "{}", packets.xdp); } + +/// An empty multi-line field with a minimum height and no height (a growable table cell, as a +/// public form's activity list has) starts at its minimum, not three lines; text it can't hold +/// grows it; one without a minimum stays three lines tall. +#[test] +fn an_empty_multiline_field_with_a_minimum_height_starts_there() { + let xml = |cell: &str| { + format!( + r#""# + ) + }; + let height = |cell: &str, value: Option<&str>| { + let t = crate::parse(&xml(cell)).unwrap().0; + let data = value.map(|v| { + parse_datasets(&format!( + "
{v}
" + )) + .unwrap() + }); + let form = crate::layout::layout(&t, data.as_ref()).unwrap(); + widgets(&form.pages[0]).first().map(|w| w.rect.h + 2.0 * 0.508 * 72.0 / 25.4).unwrap() + }; + assert!((height(r#"minH="7.62mm""#, None) - 21.6).abs() < 0.01, "{}", height(r#"minH="7.62mm""#, None)); + assert!(height(r#"minH="7.62mm""#, Some(&"word ".repeat(80))) > 40.0, "text grows it"); + assert!(height("", None) > 38.0, "three lines without a minimum: {}", height("", None)); +} From c09ce0b204c757134399fa946d7b23949dbbba64 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:02:23 +0800 Subject: [PATCH 2/6] M12: a growable lr-tb XFA subform is split at a page break, not moved whole A flowing lr-tb or rl-tb subform without a height, laid out as one block, moved whole to the next page when it didn't fit what was left of this one. A subform is splittable "in margins and where consensus exists among contained objects" (XFA 3.3 p. 1520), and readers start it on the page and carry the rest over. It now does the same, as a block taller than a page already did. One with a height still moves whole. With the row-height fix before it, a public evaluation form's sections shrank enough to fit a page, so they moved whole and left page 1 nearly empty (3 pages, where readers lay the form out on 2). It is 2 again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK --- crates/xfa/src/layout/flow.rs | 7 ++++++- crates/xfa/src/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/xfa/src/layout/flow.rs b/crates/xfa/src/layout/flow.rs index 8f656d7eaf..d83842c26b 100644 --- a/crates/xfa/src/layout/flow.rs +++ b/crates/xfa/src/layout/flow.rs @@ -8,7 +8,12 @@ impl Layouter<'_> { /// content area (a positioned or fixed-height subform holding more than a page). fn flow_block(&mut self, node: &Node, x: f64, (w, h): (f64, f64), ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { let (rect, y, _) = self.cursor(); - if y + h > rect.bottom() + 0.01 && h <= rect.h + 0.01 { + // A growable flowing subform (`lr-tb`/`rl-tb`, no `h`) is split where its objects allow + // ("splittable: in margins and where consensus exists among contained objects", XFA 3.3 + // p. 1520), so it starts here and the rest goes on in the next content area, as one + // taller than a content area does. Anything else that fits one moves there whole. + let flows_on = matches!(node, Node::Subform(s) if matches!(s.layout, Layout::LrTb | Layout::RlTb) && s.common.h.is_none()); + if !flows_on && y + h > rect.bottom() + 0.01 && h <= rect.h + 0.01 { self.make_room(h, 0.0)?; } let (rect, y, _) = self.cursor(); diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index c16b56b4de..cfef0a372c 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -4118,3 +4118,28 @@ fn an_empty_multiline_field_with_a_minimum_height_starts_there() { assert!(height(r#"minH="7.62mm""#, Some(&"word ".repeat(80))) > 40.0, "text grows it"); assert!(height("", None) > 38.0, "three lines without a minimum: {}", height("", None)); } + +/// A growable `lr-tb` subform that doesn't fit what is left of the page is split there (XFA 3.3 +/// p. 1520), its first rows on this page and the rest on the next, as a public evaluation form's +/// sections are; one with a height, a positioned one, still moves whole. +#[test] +fn a_growable_lr_tb_subform_is_split_at_the_page_break() { + let xml = |attrs: &str| { + let rows: String = (0..6).map(|i| format!(r#""#)).collect(); + format!( + r#""# + ) + }; + let pages_of = |attrs: &str| { + let t = crate::parse(&xml(attrs)).unwrap().0; + let form = crate::layout::layout(&t, None).unwrap(); + (0..6) + .map(|i| form.pages.iter().position(|p| widgets(p).iter().any(|w| w.som == format!("form[0].s[0].f{i}[0]"))).unwrap_or(9)) + .collect::>() + }; + assert_eq!(pages_of(r#"layout="lr-tb""#), [0, 0, 0, 0, 1, 1], "split at the break"); + assert_eq!(pages_of(r#"layout="lr-tb" h="6in""#), [1, 1, 1, 1, 1, 1], "a fixed height moves whole"); +} From ae73e95c7ea731fcd20cb174eb63df21799e8a7d Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:58:59 +0800 Subject: [PATCH 3/6] M12: test where an XFA row group lands at a page break, and table rows at their minimum height A growable lr-tb row group that fits stays, one taller than a page goes on over pages, an rl-tb one splits as lr-tb does, and one with a height or a positioned one moves whole. A table of empty multi-line cells with a minimum height fits as many rows on a page as that height allows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/xfa/src/tests.rs | 74 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index cfef0a372c..4ddeba9cfb 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -4143,3 +4143,77 @@ fn a_growable_lr_tb_subform_is_split_at_the_page_break() { assert_eq!(pages_of(r#"layout="lr-tb""#), [0, 0, 0, 0, 1, 1], "split at the break"); assert_eq!(pages_of(r#"layout="lr-tb" h="6in""#), [1, 1, 1, 1, 1, 1], "a fixed height moves whole"); } + +/// Where a row group lands against a page break, for each case the splitting rule tells apart: +/// a growable `lr-tb`/`rl-tb` one that fits what is left stays whole on this page, one that +/// doesn't is split at the break, and one taller than a whole page goes on over pages; one with +/// a height, and a positioned one, move whole to the next page when they fit there. +#[test] +fn a_row_group_against_a_page_break_splits_only_when_growable_and_flowing() { + // A 6in field, then the group of `n` 1in rows (7in wide, one to a line) in a 10in area. + let pages_of = |attrs: &str, n: usize, positioned: bool| { + let rows: String = (0..n) + .map(|i| { + if positioned { + format!(r#""#) + } else { + format!(r#""#) + } + }) + .collect(); + let xml = format!( + r#""# + ); + let t = crate::parse(&xml).unwrap().0; + let form = crate::layout::layout(&t, None).unwrap(); + (0..n) + .map(|i| form.pages.iter().position(|p| widgets(p).iter().any(|w| w.som == format!("form[0].s[0].f{i}[0]"))).unwrap_or(99)) + .collect::>() + }; + // Fits the 4in left: stays on this page, whole. + assert_eq!(pages_of(r#"layout="lr-tb""#, 3, false), [0, 0, 0], "a group that fits stays"); + // Taller than a page (14in): starts here, 4 rows, then 10 on the next page. + let mut over = vec![0; 4]; + over.extend([1; 10]); + assert_eq!(pages_of(r#"layout="lr-tb""#, 14, false), over, "a group taller than a page goes on over pages"); + // Right-to-left rows split at the break as left-to-right ones do. + assert_eq!(pages_of(r#"layout="rl-tb""#, 6, false), [0, 0, 0, 0, 1, 1], "rl-tb splits at the break"); + // A height, or a positioned layout: moved whole when it fits the next page. + assert_eq!(pages_of(r#"layout="lr-tb" h="6in""#, 6, false), [1; 6], "a fixed height moves whole"); + assert_eq!(pages_of("", 6, true), [1; 6], "a positioned group moves whole"); +} + +/// The rows of a table whose cells are empty multi-line fields with a minimum height and no +/// height start at that minimum (XFA 3.3 p. 731), so a table taller than a page puts as many of +/// them on it as that height allows, and the rest on the next; cells with a height keep it. +#[test] +fn table_rows_of_minimum_height_cells_fill_the_page_at_that_height() { + let pages = |cell: &str, n: usize| { + let rows: String = (0..n) + .map(|i| format!(r#""#)) + .collect(); + let xml = format!( + r#""# + ); + let t = crate::parse(&xml).unwrap().0; + let form = crate::layout::layout(&t, None).unwrap(); + let per_page: Vec = form.pages.iter().map(|p| widgets(p).iter().filter(|w| w.som.ends_with(".c[0]")).count()).collect(); + let first_h = form.pages.first().and_then(|p| widgets(p).first().map(|w| w.rect.h)).unwrap_or(0.0); + (per_page, first_h) + }; + // 0.4in (28.8pt) rows: 25 to a 10in page. At three lines (38.5pt), 18 would fit. + let (per_page, h) = pages(r#"minH="0.4in""#, 30); + assert_eq!(per_page, [25, 5], "row height {h}"); + // A table that fits keeps all its rows on the page, each at the minimum. + let (per_page, h) = pages(r#"minH="0.4in""#, 10); + assert_eq!(per_page, [10]); + assert!(h <= 28.8 + 0.01, "a row starts at its minimum: {h}"); + // Cells with a height: 0.5in rows, 20 to a page. + assert_eq!(pages(r#"h="0.5in""#, 30).0, [20, 10]); +} From 6f5c0d5c9cca02209e9c1f34a36a6409c30c2ae2 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:10:59 +0800 Subject: [PATCH 4/6] M12: the XFA table-row test checks a row is exactly at its minimum height Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/xfa/src/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 4ddeba9cfb..0416a283f9 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -4213,7 +4213,7 @@ fn table_rows_of_minimum_height_cells_fill_the_page_at_that_height() { // A table that fits keeps all its rows on the page, each at the minimum. let (per_page, h) = pages(r#"minH="0.4in""#, 10); assert_eq!(per_page, [10]); - assert!(h <= 28.8 + 0.01, "a row starts at its minimum: {h}"); + assert!((h - 28.8).abs() < 0.01, "a row starts at its minimum: {h}"); // Cells with a height: 0.5in rows, 20 to a page. assert_eq!(pages(r#"h="0.5in""#, 30).0, [20, 10]); } From 8cc5b81e8db3a23e8fb82dfb4d75b5ac37b814a3 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:04:34 +0800 Subject: [PATCH 5/6] M12: the table row test checks every cell's height, not only the first table_rows_of_minimum_height_cells_fill_the_page_at_that_height read the height of the first widget on the first page only, and counted only the c cells per page. A shorter last row leaves both the counts and that one height as they were (checked: with the last row at minH 0.3in the old assertions pass). It now collects the c and d cells of every row on every page and asserts each is 28.8pt, with 60 and 20 cells. Review on #5124 (CodeRabbit, crates/xfa/src/tests.rs). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/xfa/src/tests.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 0416a283f9..f4b3ff4ea5 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -4204,16 +4204,20 @@ fn table_rows_of_minimum_height_cells_fill_the_page_at_that_height() { let t = crate::parse(&xml).unwrap().0; let form = crate::layout::layout(&t, None).unwrap(); let per_page: Vec = form.pages.iter().map(|p| widgets(p).iter().filter(|w| w.som.ends_with(".c[0]")).count()).collect(); - let first_h = form.pages.first().and_then(|p| widgets(p).first().map(|w| w.rect.h)).unwrap_or(0.0); - (per_page, first_h) + // Every cell of every row, on every page: a shorter `d` cell, or a shorter row further + // down, leaves the counts above as they are. + let heights: Vec = + form.pages.iter().flat_map(|p| widgets(p)).filter(|w| w.som.ends_with(".c[0]") || w.som.ends_with(".d[0]")).map(|w| w.rect.h).collect(); + (per_page, heights) }; // 0.4in (28.8pt) rows: 25 to a 10in page. At three lines (38.5pt), 18 would fit. let (per_page, h) = pages(r#"minH="0.4in""#, 30); - assert_eq!(per_page, [25, 5], "row height {h}"); + assert_eq!(per_page, [25, 5], "row heights {h:?}"); + assert!(h.len() == 60 && h.iter().all(|c| (c - 28.8).abs() < 0.01), "every cell starts at its minimum: {h:?}"); // A table that fits keeps all its rows on the page, each at the minimum. let (per_page, h) = pages(r#"minH="0.4in""#, 10); assert_eq!(per_page, [10]); - assert!((h - 28.8).abs() < 0.01, "a row starts at its minimum: {h}"); + assert!(h.len() == 20 && h.iter().all(|c| (c - 28.8).abs() < 0.01), "every cell starts at its minimum: {h:?}"); // Cells with a height: 0.5in rows, 20 to a page. assert_eq!(pages(r#"h="0.5in""#, 30).0, [20, 10]); } From 31db4dbbfcd1225b646c0b59a5621d8ffe01c993 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:15:33 +0800 Subject: [PATCH 6/6] M12: a split row group keeps its section's border; an unnamed growable field grows A growable lr-tb/rl-tb subform split at a content area's bottom now breaks the flowed subforms round it there (break_frames), as a break between their children does, so a bordered or filled section keeps a closed part on each page instead of losing its border. An unnamed growable multi-line field is measured with its template text. A nameless field takes no part in data binding (XFA 3.3 p. 194), and its SOM context ends in its parent, so its data is not looked up. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/xfa/src/layout/flow.rs | 27 +++++++++++---- crates/xfa/src/layout/measure.rs | 9 +++-- crates/xfa/src/tests.rs | 57 ++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 9 deletions(-) diff --git a/crates/xfa/src/layout/flow.rs b/crates/xfa/src/layout/flow.rs index d83842c26b..cdbb461455 100644 --- a/crates/xfa/src/layout/flow.rs +++ b/crates/xfa/src/layout/flow.rs @@ -37,7 +37,7 @@ impl Layouter<'_> { self.place(h); return Ok(()); } - self.split_block(first, y, drawn) + self.split_block(first, y, drawn, flows_on) } /// Remember that the items the last page got since `start` are one field or draw at @@ -56,8 +56,11 @@ impl Layouter<'_> { /// Spread the items the last page holds from index `first` on (a block drawn from `top` to /// `end`, past the bottom of its content area) over this and the following content areas. /// Breaks fall between text, pictures and fields, never through one that fits in an area; - /// fills and lines are cut at the break. - fn split_block(&mut self, first: usize, top: f64, end: f64) -> Result<(), XfaError> { + /// fills and lines are cut at the break. A flowing subform (`flows_on`: one [`Self::flow_block`] + /// splits where it would otherwise move whole) breaks the flowed subforms round it there too + /// ([`Self::break_frames`]), so they keep their border; anything else just moves on, and + /// those subforms are drawn without theirs ([`Self::finish_frame`]). + fn split_block(&mut self, first: usize, top: f64, end: f64, flows_on: bool) -> Result<(), XfaError> { let items: Vec = match self.pages.last_mut() { Some(p) if first <= p.items.len() => p.items.split_off(first), _ => return Ok(()), @@ -139,9 +142,21 @@ impl Layouter<'_> { rest = below; seg_top = cut; first_segment = false; - self.next_area()?; - let (rect, _, _) = self.cursor(); - shift = cut - rect.y; + if flows_on { + // Each subform round it ends its part at the cut, after its bottom margin, and + // starts a new one after its top margin, where the rest goes on. A rest that fits + // an area has the top margins give way to it, as `make_room` does. + let (rect, _, _) = self.cursor(); + if let Some(c) = &mut self.cur { + c.y = (cut - shift).min(c.rect.bottom()).max(c.y); + } + let rest = (end - cut).max(0.0); + self.break_frames(if rest <= rect.h + 0.01 { rest } else { 0.0 }, false)?; + } else { + self.next_area()?; + } + let (rect, y, _) = self.cursor(); + shift = cut - if flows_on { y } else { rect.y }; dx = rect.x - origin_x; } } diff --git a/crates/xfa/src/layout/measure.rs b/crates/xfa/src/layout/measure.rs index 3e73884ff1..a6e9b48fd6 100644 --- a/crates/xfa/src/layout/measure.rs +++ b/crates/xfa/src/layout/measure.rs @@ -262,12 +262,15 @@ impl Layouter<'_> { /// The height the text of a multi-line text field takes (its value in the data, else the /// template's), for a field without a fixed height, which grows to hold its text. `ctx` - /// is the field's own (its SOM ends in the field). Zero for anything else. + /// is the field's own (its SOM ends in the field). Zero for anything else. A nameless field + /// takes no part in data binding (XFA 3.3 p. 194), and its `ctx` ends in its parent: its + /// text is the template's. fn grown_text_height(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> f64 { - if !f.grows_with_text() || f.common.name.is_none() { + if !f.grows_with_text() { return 0.0; } - let value = self.data.and_then(|d| d.value_at(&data_path(self.tpl, &ctx.som))).filter(|t| !t.trim().is_empty()).or_else(|| f.value.plain()); + let bound = f.common.name.as_ref().and(self.data); + let value = bound.and_then(|d| d.value_at(&data_path(self.tpl, &ctx.som))).filter(|t| !t.trim().is_empty()).or_else(|| f.value.plain()); let Some(value) = value.filter(|t| !t.trim().is_empty()) else { return 0.0 }; // Beside a caption the text has what the caption leaves; a widget's text has 2pt of // padding on each side, a non-interactive field's (drawn as text) none, so it wraps diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 36e169a435..4dca485fdf 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -4278,6 +4278,63 @@ fn table_rows_of_minimum_height_cells_fill_the_page_at_that_height() { assert_eq!(pages(r#"h="0.5in""#, 30).0, [20, 10]); } +/// An unnamed growable multi-line field grows to hold its text as a named one does, with a +/// minimum height or without. A nameless field takes no part in data binding (XFA 3.3 p. 194), +/// so the text it shows, and the layout measures, is the template's. +#[test] +fn an_unnamed_growable_field_grows_to_hold_its_text() { + let lay = |name: &str, min_h: &str| { + let body = format!( + r#"{}"#, + "word ".repeat(80) + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + let ws = widgets(&form.pages[0]); + let field = ws.iter().find(|w| w.name != "after").map(|w| w.rect.h).unwrap(); + let after = ws.iter().find(|w| w.name == "after").map(|w| w.rect.y).unwrap(); + (field, after) + }; + for min_h in ["", r#"minH="0.3in""#] { + let named = lay(r#"name="a""#, min_h); + assert!(named.0 > 100.0, "the named field grows: {named:?}"); + let unnamed = lay("", min_h); + assert!( + (unnamed.0 - named.0).abs() < 0.01 && (unnamed.1 - named.1).abs() < 0.01, + "{min_h}: unnamed {unnamed:?}, named {named:?} (field height, next field's top)" + ); + } +} + +/// A growable `lr-tb` subform split at a page break inside a bordered, filled `tb` section breaks +/// that section with it, as a break between the section's children does: a closed part on each +/// page, its bottom margin ending the first and its top margin starting the second, no warning. +#[test] +fn a_split_lr_tb_subform_keeps_the_border_of_the_section_round_it() { + let rows: String = (0..6).map(|i| format!(r#""#)).collect(); + let body = format!( + r#"{rows}"# + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + let rows_on: Vec = + (0..6).map(|i| form.pages.iter().position(|p| widgets(p).iter().any(|w| w.name == format!("f{i}"))).unwrap_or(9)).collect(); + // After 0.1in of margin and the 6in field, three 1in rows fit the 10in area. + assert_eq!(rows_on, [0, 0, 0, 1, 1, 1]); + let fills = |page: usize| -> Vec { + form.pages[page].items.iter().filter_map(|i| if let Item::Fill { rect, .. } = i { Some(*rect) } else { None }).collect() + }; + let lines = |page: usize| form.pages[page].items.iter().filter(|i| matches!(i, Item::Line { .. })).count(); + let (first, second) = (fills(0), fills(1)); + assert_eq!((first.len(), second.len()), (1, 1), "a part on each page: {:?}", form.warnings); + assert_eq!((lines(0), lines(1)), (4, 4), "each part's border is closed"); + // Page 1: the top margin, the field, three rows, the bottom margin. + assert!((first[0].y - 36.0).abs() < 0.01 && (first[0].bottom() - (36.0 + 7.2 + 432.0 + 216.0 + 14.4)).abs() < 0.01, "{:?}", first[0]); + // Page 2: the top margin again, the other three rows, the bottom margin. + assert!((second[0].y - 36.0).abs() < 0.01 && (second[0].bottom() - (36.0 + 7.2 + 216.0 + 14.4)).abs() < 0.01, "{:?}", second[0]); + let f3 = widgets(&form.pages[1]).into_iter().find(|w| w.name == "f3").map(|w| w.rect).unwrap(); + assert!((f3.y - 43.2).abs() < 0.01, "the second part starts after the top margin: {f3:?}"); + assert!(form.warnings.is_empty(), "{:?}", form.warnings); +} + /// A form with `draws` in a subform placed by position, on a letter page whose content area is a /// quarter inch in, with `master` on the page itself. fn form_of_draws(master: &str, draws: &str) -> String {