diff --git a/crates/xfa/src/layout/flow.rs b/crates/xfa/src/layout/flow.rs index a455050a17..ba8f886f83 100644 --- a/crates/xfa/src/layout/flow.rs +++ b/crates/xfa/src/layout/flow.rs @@ -21,7 +21,12 @@ impl Layouter<'_> { // A field a page break may split starts here, and what doesn't fit goes on in the next // content area (XFA 3.3 pp. 291–292), rather than the whole field moving there. let splits = may_split && matches!(node, Node::Field(f) if f.splits_at_page_break()); - if !splits && 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 !splits && !flows_on && y + h > rect.bottom() + 0.01 && h <= rect.h + 0.01 { self.make_room(h, 0.0)?; } let (rect, y, _) = self.cursor(); @@ -65,7 +70,7 @@ impl Layouter<'_> { self.leaves.clear(); return self.place_block(node, x, (w, h), ctx, depth, sib, false); } - return self.split_block(first, y, drawn); + return self.split_block(first, y, drawn, flows_on); }; // The field breaks into pieces, each with the field's border all round (a border's // default `break="close"`, XFA 3.3 pp. 629–630): the border drawn round the whole field @@ -84,7 +89,7 @@ impl Layouter<'_> { _ => None, }; self.splitting = Some(Splitting { som, rect: Rect::new(x, y, w, h), pieces: Vec::new() }); - let split = self.split_block(first, y, drawn); + let split = self.split_block(first, y, drawn, false); let pieces = self.splitting.take().map(|s| s.pieces).unwrap_or_default(); split?; if let Some(b) = border { @@ -123,8 +128,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(()), @@ -281,15 +289,21 @@ impl Layouter<'_> { rest = below; seg_top = cut; first_segment = false; - let (rect, y) = if field.is_some() { - // A field breaks the flowed subforms round it as a field moving on whole does: - // each closes its part at the break and opens a new one, after its top margin, - // in the next content area. + let (rect, y) = if field.is_some() || flows_on { + // A field breaks the flowed subforms round it as a field moving on whole does, and + // so does a flowing subform: each closes its part at the break, after its bottom + // margin, and opens a new one, after its top margin, in the next content area. + let area_h = self.cursor().0.h; if let Some(c) = &mut self.cur { c.y = (cut - shift).min(c.rect.bottom()).max(c.y); - c.placed = true; + if field.is_some() { + c.placed = true; + } } - self.break_frames(0.0, false)?; + // A flowing subform's rest that fits an area has the top margins give way to it, + // as `make_room` does. + let left = (end - cut).max(0.0); + self.break_frames(if flows_on && left <= area_h + 0.01 { left } else { 0.0 }, false)?; let (rect, y, _) = self.cursor(); (rect, y) } else { diff --git a/crates/xfa/src/layout/measure.rs b/crates/xfa/src/layout/measure.rs index 902b8b71b0..a2f0f0380e 100644 --- a/crates/xfa/src/layout/measure.rs +++ b/crates/xfa/src/layout/measure.rs @@ -257,7 +257,10 @@ impl Layouter<'_> { /// `ctx` is the field's own and `outer_para` the paragraph around it, which its caption takes. fn field_height(&self, f: &Field, inner_w: f64, ctx: &Ctx, outer_para: &Para) -> 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. @@ -310,12 +313,14 @@ 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) and `outer_para` the paragraph around it. - /// Zero for anything else. + /// 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, outer_para: &Para) -> 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 5c8d80847b..ee084f05b9 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -5407,6 +5407,195 @@ fn fields_by_som_of(mut doc: Document, fields: Vec) -> std::collections: fields_by_som(&doc) } +/// 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)); +} + +/// 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"); +} + +/// 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(); + // 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 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.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]); +} + +/// 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 {