Merge #5124: M12: XFA table rows start at their minimum height, and a growable row group splits at a page break (form.xfa-dynamic)

# Conflicts:
#	crates/xfa/src/layout/flow.rs
#	crates/xfa/src/layout/measure.rs
#	crates/xfa/src/tests.rs
This commit is contained in:
bflatastic committed 2026-11-09 18:35:21 +07:00
commit 629d211506
3 files changed
+223 -15

No files matched your search

+25 -11
View File
@@ -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<Item> = 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 {
+9 -4
View File
@@ -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
+189
View File
@@ -5407,6 +5407,195 @@ fn fields_by_som_of(mut doc: Document, fields: Vec<Object>) -> 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#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
<pageSet><pageArea name="p"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<field name="a" w="2in" {cell}><ui><textEdit multiLine="1"/></ui><margin topInset="0.508mm" bottomInset="0.508mm"/></field>
</subform></template>"#
)
};
let height = |cell: &str, value: Option<&str>| {
let t = crate::parse(&xml(cell)).unwrap().0;
let data = value.map(|v| {
parse_datasets(&format!(
"<xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data><form><a>{v}</a></form></xfa:data></xfa:datasets>"
))
.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#"<field name="f{i}" w="7in" h="1in"/>"#)).collect();
format!(
r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
<pageSet><pageArea name="p"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<field name="tall" w="7in" h="6in"/><subform name="s" w="7.5in" {attrs}>{rows}</subform>
</subform></template>"#
)
};
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::<Vec<_>>()
};
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#"<field name="f{i}" y="{i}in" w="7in" h="1in"/>"#)
} else {
format!(r#"<field name="f{i}" w="7in" h="1in"/>"#)
}
})
.collect();
let xml = format!(
r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
<pageSet><pageArea name="p"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<field name="tall" w="7in" h="6in"/><subform name="s" w="7.5in" {attrs}>{rows}</subform>
</subform></template>"#
);
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::<Vec<_>>()
};
// 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#"<subform name="r{i}" layout="row"><field name="c" {cell}><ui><textEdit multiLine="1"/></ui></field><field name="d" {cell}><ui><textEdit multiLine="1"/></ui></field></subform>"#))
.collect();
let xml = format!(
r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
<pageSet><pageArea name="p"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<subform name="t" layout="table" columnWidths="2in 2in" w="4in">{rows}</subform>
</subform></template>"#
);
let t = crate::parse(&xml).unwrap().0;
let form = crate::layout::layout(&t, None).unwrap();
let per_page: Vec<usize> = 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<f64> =
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#"<subform name="s" layout="tb" w="7.5in"><field {name} w="2in" {min_h}><ui><textEdit multiLine="1"/></ui><value><text>{}</text></value></field><field name="after" w="2in" h="0.3in"/></subform>"#,
"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#"<field name="f{i}" w="7in" h="1in"/>"#)).collect();
let body = format!(
r#"<subform name="box" layout="tb" w="7.5in"><border><edge/><fill><color value="230,230,230"/></fill></border><margin topInset="0.1in" bottomInset="0.2in"/><field name="tall" w="7in" h="6in"/><subform name="s" layout="lr-tb" w="7.5in">{rows}</subform></subform>"#
);
let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
let rows_on: Vec<usize> =
(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<Rect> {
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 {