diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index a57ce3d9cf..cb485bd8d2 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -192,6 +192,8 @@ impl Ctx { Ctx { font: self.font.apply(&c.font), para: self.para.apply(&c.para), som: self.som.clone() } } + /// [`Self::child`] for object `c`, its SOM path extended by its name and `index` among the + /// siblings of that name (an unnamed object adds nothing). fn with_som(&self, c: &Common, index: usize) -> Ctx { let mut out = self.child(c); if let Some(n) = &c.name { @@ -214,6 +216,33 @@ struct Measured { size: (f64, f64), } +/// A point in the flow ([`Layouter::flow_mark`]): the page and content area, the height +/// reached there, and how many items the page had then. +#[derive(Clone, Copy, Debug)] +struct FlowMark { + page: usize, + area: usize, + content: usize, + y: f64, + 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, + part: FlowMark, + parts: Vec<(usize, usize, Rect)>, + /// Something inside didn't fit its content area: an item cut across areas, or one placed past + /// the area's bottom (a table row taller than an area, an overflow leader and the row after it + /// that don't fit together). The border is left out ([`Layouter::finish_frame`]). + lost: bool, +} + struct Cursor { area: usize, content: usize, @@ -248,6 +277,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, } /// How many instances of a repeating subform to lay out: what the data holds once it holds @@ -293,6 +325,7 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result 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()?; @@ -647,6 +680,8 @@ impl Layouter<'_> { self.new_page() } + /// The current content area, how far down it the flow is, and whether anything has been + /// placed in it; before the first page, an empty letter-size page from its top. fn cursor(&self) -> (Rect, f64, bool) { match &self.cur { Some(c) => (c.rect, c.y, c.placed), @@ -654,6 +689,111 @@ impl Layouter<'_> { } } + /// Where the flow is now ([`FlowMark`]); `None` before the first page. + fn flow_mark(&self) -> Option { + let c = self.cur.as_ref()?; + 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()) }) + } + + /// 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) + } + + /// 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). The top margins take only what the `need` points of what comes next + /// leave of the new area: a child that fits a fresh area but not after the margins would + /// otherwise run past its bottom, out of its border. `to_page` moves to a new page instead (a + /// child's own page break), which breaks the subforms around it the same way. + fn break_frames(&mut self, need: f64, to_page: bool) -> Result<(), XfaError> { + let (rect, mut y, _) = self.cursor(); + let page = self.pages.len().saturating_sub(1); + let here: Vec = self.frames.iter().map(|f| self.on_area(&f.part)).collect(); + let filled: Vec = 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))); + } + } + if to_page { + self.new_page()?; + } else { + self.next_area()?; + } + let (rect, y, _) = self.cursor(); + let mut room = (rect.bottom() - y - need).max(0.0); + 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; + // `max` before `min`: a NaN margin counts as none and leaves `room` a number. + let top = f.margin.top.max(0.0).min(room); + room = (room - top).max(0.0); + 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 holding something that didn't fit its content area ([`Frame::lost`]) + /// 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 (area, end, _) = self.cursor(); + // The bottom margin stays in the content area, as at a break; content that itself ran + // past it has already cost the section its border ([`Self::overflowed`]). + let end = end.min(area.bottom()).max(end - f.margin.bottom); + 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 holding an item that doesn't fit on its page is drawn without its border"); + 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)?; + let drawn: Vec = 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(()) + } + /// Move the cursor down (margins and spacing: the area still counts as empty). fn advance(&mut self, dy: f64) { if let Some(c) = &mut self.cur { @@ -667,16 +807,36 @@ impl Layouter<'_> { c.y += dy.max(0.0); c.placed = true; } + let (_, y, _) = self.cursor(); + self.overflowed(y); } - /// Make room for `h`: stay, or move to the next content area / page. Returns whether a - /// break happened. - fn make_room(&mut self, h: f64) -> Result { + /// Something was placed down to `bottom`: past the content area's bottom, the flowed subforms + /// it is inside can't hold it in a part, and are drawn without their border. + fn overflowed(&mut self, bottom: f64) { + let (rect, _, _) = self.cursor(); + if bottom > rect.bottom() + 0.01 { + for f in &mut self.frames { + f.lost = true; + } + } + } + + /// Make room for `h`, with `after` more drawn before it should it move (a table's overflow + /// leader): stay, or move to the next content area / page. Returns whether a break happened. + /// Something that fits a fresh area breaks the flowed subforms it is in with it + /// ([`Self::break_frames`]), a table's rows included; one taller than an area just moves on, + /// and those subforms are drawn without their border ([`Self::finish_frame`]). + fn make_room(&mut self, h: f64, after: f64) -> Result { let (rect, y, placed) = self.cursor(); if y + h <= rect.bottom() + 0.01 || !placed { return Ok(false); } - self.next_area()?; + if h <= rect.h + 0.01 { + self.break_frames(h + after, false)?; + } else { + self.next_area()?; + } Ok(true) } @@ -686,7 +846,7 @@ impl Layouter<'_> { 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 { - self.make_room(h)?; + self.make_room(h, 0.0)?; } let (rect, y, _) = self.cursor(); let first = self.pages.last().map_or(0, |p| p.items.len()); @@ -805,6 +965,7 @@ impl Layouter<'_> { c.y = (end - shift).min(c.rect.bottom()).max(c.y); c.placed = true; } + self.overflowed(end - shift); return Ok(()); } rest = below; @@ -831,7 +992,7 @@ impl Layouter<'_> { let Node::Subform(sf) = node else { return Ok(()) }; self.check_measures()?; if sf.break_before_page && self.page_has_flow() { - self.new_page()?; + self.break_frames(0.0, true)?; } // Data adds instances of a repeating subform (rows added in another viewer). let instances = self.instances(node, &ctx.som); @@ -852,6 +1013,14 @@ impl Layouter<'_> { continue; } let m = sf.common.margin; + // 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); @@ -876,9 +1045,12 @@ impl Layouter<'_> { } } self.advance(m.bottom); + if framed && let Some(frame) = self.frames.pop() { + self.finish_frame(frame)?; + } } if sf.break_after_page { - self.new_page()?; + self.break_frames(0.0, true)?; } Ok(()) } @@ -895,6 +1067,12 @@ impl Layouter<'_> { let cols = self.columns(sf, inner_w); let leader = sf.overflow_leader.as_ref().and_then(|name| sf.children.iter().position(|c| c.common().name.as_deref() == Some(name) && is_row(c))); + // The overflow leader starts each part after the first, before the row that moved. + let lead = leader.and_then(|li| match sf.children.get(li) { + Some(Node::Subform(l)) => Some((li, l.as_ref())), + _ => None, + }); + let lead_h = lead.map_or(0.0, |(_, l)| self.row_height(l, &cols, inner_w, ctx, depth + 1)); let sibs = SomIndices::of(&sf.children); for (ci, child) in sf.children.iter().enumerate() { if !child.common().presence.occupies() { @@ -909,15 +1087,13 @@ impl Layouter<'_> { self.warn_truncated(child, &ctx.som, instances); for inst in 0..instances { let h = self.row_height(row, &cols, inner_w, ctx, depth + 1); - if self.make_room(h)? - && let Some(li) = leader - && li != ci - && let Some(Node::Subform(l)) = sf.children.get(li) + let led = lead.filter(|(li, _)| *li != ci); + if self.make_room(h, led.map_or(0.0, |_| lead_h))? + && let Some((li, l)) = led { - let lh = self.row_height(l, &cols, inner_w, ctx, depth + 1); let (_, y, _) = self.cursor(); - self.draw_row(l, &cols, Rect::new(x, y, inner_w, lh), ctx, depth + 1, sibs.at(li))?; - self.place(lh); + self.draw_row(l, &cols, Rect::new(x, y, inner_w, lead_h), ctx, depth + 1, sibs.at(li))?; + self.place(lead_h); } let (_, y, _) = self.cursor(); self.draw_row(row, &cols, Rect::new(x, y, inner_w, h), ctx, depth + 1, csib + inst)?; diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 176e1c4ee0..8a9bff88d3 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2238,6 +2238,8 @@ fn repeating_rows_in_a_wrapped_container_get_the_instances_the_data_holds() { assert_eq!(form.pages.iter().flat_map(widgets).filter(|w| w.name.starts_with("what")).count(), 1); } +/// A multi-line field with a minimum height and no fixed one grows to hold its text, moving +/// what follows down; one with a fixed height keeps it. #[test] fn a_multi_line_field_without_a_height_grows_to_hold_its_text() { let place = |data: &str| { @@ -2363,6 +2365,199 @@ fn rounded_boxes_are_written_as_curved_paths() { assert!(edge.starts_with("q 1 0 0 RG 2 w 37 685 m 37 738 l ") && edge.ends_with(" 179 685 l S Q") && !edge.contains(" h "), "{edge}"); } +/// 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 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 +/// holding an item that doesn't fit on its page 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#""#); + let boxed = |inner: &str| { + format!( + r#"{inner}"# + ) + }; + let lay = |body: &str| { + layout_xml(&one_area_form(r#"layout="tb""#, &format!(r#"{body}"#))).unwrap() + }; + // Two half-inch fields between a tenth and a fifth of an inch of margin: 1.3in from the top. + let form = lay(&boxed(&(field("a") + &field("b")))); + let items = &form.pages[0].items; + let fill = items.iter().position(|i| matches!(i, Item::Fill { .. })).expect("the subform's fill"); + let Some(Item::Fill { rect, .. }) = items.get(fill) else { panic!("item {fill} is the fill") }; + assert!((rect.y - 36.0).abs() < 0.01 && (rect.h - 1.3 * 72.0).abs() < 0.01 && (rect.w - 540.0).abs() < 0.01, "{rect:?}"); + assert_eq!(items.iter().filter(|i| matches!(i, Item::Line { .. })).count(), 4, "its four edges"); + let first_widget = items.iter().position(|i| matches!(i, Item::Widget(_))).unwrap(); + assert!(fill < first_widget, "the fill is under the fields"); + assert!(form.warnings.is_empty(), "{:?}", form.warnings); + // A flowed table's border too. + let table = r#"OneTwo"#; + 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: 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_eq!(form.pages.len(), 2); + 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 (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); + // A bottom margin that would run past the content area stops at its edge: 39 quarter-inch + // fields end at 738, and 0.4in more would reach 766.8, past 756. + let quarter: String = (0..39).map(|i| format!(r#""#)).collect(); + let low = format!(r#"{quarter}"#); + let form = lay(&low); + let edge = form.pages[0].items.iter().find_map(|i| if let Item::Fill { rect, .. } = i { Some(rect.bottom()) } else { None }).unwrap(); + assert!((edge - 756.0).abs() < 0.01, "the box ends at the content area's bottom: {edge}"); + // The margins come back without a border too. + let bare = format!(r#"{many}"#); + 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#"{}"#, + "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); + // A field that fits a fresh page (720pt) but not after the 7.2pt top margin: the margin + // gives way, so the field ends at the content area's bottom, inside the second part. + let tall = r#""#; + let form = lay(&boxed(&(field("a") + tall))); + assert_eq!(form.pages.len(), 2); + let tall = form.pages[1].items.iter().find_map(|i| if let Item::Widget(w) = i { (w.name == "tall").then_some(w.rect) } else { None }).unwrap(); + assert!(tall.bottom() <= 756.01, "the field stays in the content area: {tall:?}"); + let part = form.pages[1].items.iter().find_map(|i| if let Item::Fill { rect, .. } = i { Some(*rect) } else { None }).expect("the second part"); + assert!(part.y <= tall.y + 0.01 && part.bottom() >= tall.bottom() - 0.01, "the border holds the field: {part:?} {tall:?}"); + assert!(form.warnings.is_empty(), "{:?}", form.warnings); + // A section that starts near a page's bottom, where its first field doesn't fit: the field + // moves to the next page whole, and the border starts there with it, none left behind. + let spacer = r#""#; + let form = lay(&format!("{spacer}{}", boxed(&(field("a") + &field("b"))))); + assert_eq!(form.pages.len(), 2); + let fills = |page: usize| -> Vec { + form.pages[page].items.iter().filter_map(|i| if let Item::Fill { rect, .. } = i { Some(*rect) } else { None }).collect() + }; + assert!(fills(0).is_empty(), "no border left behind on page 1: {:?}", fills(0)); + let moved = fills(1); + assert!(moved.len() == 1 && (moved[0].y - 36.0).abs() < 0.01 && (moved[0].h - (7.2 + 2.0 * 36.0 + 14.4)).abs() < 0.01, "{moved:?}"); + assert!(form.warnings.is_empty(), "{:?}", form.warnings); + // The same field first in the section, at the top of the page: after the section's own top + // margin it runs past the bottom, so the section has no border round it, and says so. + let form = lay(&boxed(r#""#)); + 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); +} + +/// A flowed table a page break cuts between its rows gets a closed border round its part on +/// each page, as a flowed subform does: its margins repeat, and its overflow leader starts the +/// second part after the top margin. +#[test] +fn a_flowed_table_cut_between_its_rows_keeps_its_border_on_each_page() { + let row = |name: &str, text: &str| { + format!( + r#"{text}-"# + ) + }; + let rows: String = (0..40).map(|i| row(&format!("r{i}"), &format!("Row {i}"))).collect(); + let table = format!( + r#"{}{rows}"#, + row("head", "Head") + ); + let body = format!(r#"{table}"#); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + assert_eq!(form.pages.len(), 2); + 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"); + assert_eq!((lines(0), lines(1)), (4, 4), "each part's border is closed"); + // Page 1: 0.1in of margin, the leader and 32 rows of 0.3in fill the area to its bottom. + assert!((first[0].y - 36.0).abs() < 0.01 && (first[0].bottom() - 756.0).abs() < 0.01, "{:?}", first[0]); + // Page 2: the top margin again, the leader, the other 8 rows, the bottom margin. + assert!((second[0].y - 36.0).abs() < 0.01 && (second[0].bottom() - (36.0 + 7.2 + 9.0 * 21.6 + 14.4)).abs() < 0.01, "{:?}", second[0]); + let head = form.pages[1].items.iter().find_map(|i| if let Item::Text(t) = i { (t.text == "Head").then_some(t.baseline) } else { None }); + assert!(head.is_some_and(|b| b > 43.2 && b < 64.8), "the leader starts the second part after its top margin: {head:?}"); + assert!(form.warnings.is_empty(), "{:?}", form.warnings); +} + +/// A flowed table's overflow leader and the row that moved are given room together on the new +/// page; a leader and row too tall for any page, and a first row taller than its page, cost the +/// table its border, with the warning, rather than a border round what runs off the page. +#[test] +fn a_tables_leader_and_oversized_rows_fit_its_border_or_lose_it() { + let row = |name: &str, h: &str| { + format!(r#"{name}"#) + }; + let table = |rows: &str| { + format!( + r#"{}{rows}"#, + row("head", "0.3in") + ) + }; + let lay = |rows: &str| layout_xml(&one_area_form(r#"layout="tb""#, &table(rows))).unwrap(); + let fills = |form: &Form, page: usize| -> Vec { + form.pages[page].items.iter().filter_map(|i| if let Item::Fill { rect, .. } = i { Some(*rect) } else { None }).collect() + }; + let small: String = (0..10).map(|i| row(&format!("r{i}"), "0.3in")).collect(); + // A 695pt row after ten small ones moves; it and the 21.6pt leader leave 3.4pt of the 720pt + // page for the top margin, so the row ends at the content area's bottom, in the border. + let form = lay(&(small.clone() + &row("big", "695pt"))); + assert_eq!(form.pages.len(), 2); + let part = fills(&form, 1); + assert!(part.len() == 1 && (part[0].y - 36.0).abs() < 0.01 && (part[0].bottom() - 756.0).abs() < 0.01, "{part:?}"); + assert!(form.warnings.is_empty(), "{:?}", form.warnings); + // 710pt and the leader don't fit any page: no border, one warning. + let form = lay(&(small + &row("big", "710pt"))); + assert!(form.pages.iter().enumerate().all(|(p, _)| fills(&form, p).is_empty()), "no fill drawn"); + assert_eq!(form.warnings.iter().filter(|w| w.contains("bordered section")).count(), 1, "{:?}", form.warnings); + // A first row taller than the page: no border round it, one warning. + let form = lay(&row("tall", "800pt")); + assert!(fills(&form, 0).is_empty(), "{:?}", fills(&form, 0)); + assert_eq!(form.warnings.iter().filter(|w| w.contains("bordered section")).count(), 1, "{:?}", form.warnings); +} + +/// A child's own page break (breakBefore, breakAfter) breaks the flowed subform around it as a +/// break between children does: a closed part on each page, no warning. +#[test] +fn a_childs_page_break_keeps_the_border_around_it_on_each_page() { + let field = |name: &str| format!(r#""#); + let body = format!( + r#"{}{}{}{}"#, + field("a"), + field("b"), + field("c"), + field("d") + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + assert_eq!(form.pages.len(), 3, "a, then b and c, then d"); + for (page, fields) in [(0, 1.0), (1, 2.0), (2, 1.0)] { + let fills: Vec = + form.pages[page].items.iter().filter_map(|i| if let Item::Fill { rect, .. } = i { Some(*rect) } else { None }).collect(); + let lines = form.pages[page].items.iter().filter(|i| matches!(i, Item::Line { .. })).count(); + assert_eq!((fills.len(), lines), (1, 4), "page {}: one closed part", page + 1); + // The top margin, its fields, the bottom margin. + assert!((fills[0].y - 36.0).abs() < 0.01 && (fills[0].h - (7.2 + fields * 36.0 + 14.4)).abs() < 0.01, "page {}: {:?}", page + 1, fills[0]); + } + assert!(form.warnings.is_empty(), "{:?}", form.warnings); +} + #[test] fn a_page_break_keeps_a_fields_border_with_the_field() { // 6in of heading, then a bordered 5in field: it doesn't fit the 10in area and moves to diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cf3d07fe6f..5291dc4e64 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5249,8 +5249,8 @@ milestone = "M12" 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/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths"] -notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. 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, captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), 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; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; 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; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +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/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content"] +notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. 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, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), 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; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; 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; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc"