From 2912b21d33bd09766f302ca39302b5c69d3d53cb Mon Sep 17 00:00:00 2001 From: EULA <90616501+EULArule@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:17:21 -0400 Subject: [PATCH] XFA: single-block forms paginate, open in their saved state, and show their signatures (#1098) * XFA: forms in one wrapped block lay out across pages, with their rows and growing fields A dynamic form whose body is a single block that can't be flowed (left-to-right wrapped, positioned or fixed-height) laid out to one page, the rest drawn past the bottom edge with no warning. - A block taller than a content area is spread over the following ones. Breaks fall between fields, draws and lines of text; fills and lines are cut at the break; a field or draw moves whole, border and all. - Repeating subforms in a left-to-right wrapped container get the instances the data holds, as in the other layouts (only the first was drawn). - A multi-line text field without a fixed height grows to hold its text. Past the height of a content area it is drawn as text that continues across pages (not editable; reported as a warning) instead of one widget cut off. - Text too long for the fixed height it was given is drawn smaller, down to 70%, rather than over what lies below (the standard faces are wider than the template's). - Nested measurements carry their SOM path, so instance counts and field values are found below the first level. Synthetic tests cover each. Run: fmt, clippy and tests for pdfcraft-xfa, and the pdfcraft-engine tests; the workspace-wide gates were not run. Co-Authored-By: Claude Opus 5.5 * XFA: a form opens in the state it was saved in, and wrapped sections are as tall as their content The form packet's saved presence, access and captions are applied to the template, so rows a script showed before saving appear, locked fields stay locked and a button keeps the label it was saved with. A flowed subform no longer stretches to a leftover minH: a public form whose own form packet records 3 pages laid out as 4 and now lays out as 3. Also: a text field without a border of its own has no box, and a CR LF pair is one line break. Co-Authored-By: Claude Opus 5.5 * XFA: a signed signature box shows the signature the file holds A laid-out signature box takes the value and the appearance of the signed field of the same SOM name that the signing application left in the AcroForm, and is read-only. The engine no longer redraws a signed box as an empty one, on open or on a script's re-layout. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/lib.rs | 12 +- crates/engine/src/tests.rs | 19 ++ crates/engine/src/xfa.rs | 4 +- crates/xfa/src/fixtures.rs | 33 ++++ crates/xfa/src/layout.rs | 366 ++++++++++++++++++++++++++++++++----- crates/xfa/src/parse.rs | 84 +++++++++ crates/xfa/src/pdf.rs | 67 +++++++ crates/xfa/src/tests.rs | 364 ++++++++++++++++++++++++++++++++++++ crates/xfa/src/text.rs | 4 +- 9 files changed, 906 insertions(+), 47 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4242ee286d..26f14fe6d7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2051,10 +2051,20 @@ pub struct Session { /// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { let report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; + xfa_redraw(doc)?; + Ok(report) +} + +/// Give the fields of a laid-out XFA form their appearances. A signed signature box keeps the +/// picture its signer's application drew. +pub(crate) fn xfa_redraw(doc: &mut pdfcraft_cos::Document) -> Result<(), String> { for f in pdfcraft_forms::fields(doc) { + if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) { + continue; + } pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?; } - Ok(report) + Ok(()) } /// The form's fields as the XFA data layer wants them. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6e92eab950..9babc4fbb5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2226,6 +2226,25 @@ fn dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally() { assert_eq!(d2.form.iter().find(|f| f.name == "answer").unwrap().value, vec!["N".to_string()]); } +#[test] +fn a_signed_xfa_signature_box_shows_the_picture_the_file_holds() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("signed.pdf", None, Arc::new(pdfcraft_xfa::fixtures::signed_shell()), None).expect("opens"); + let doc = s.get(id).unwrap(); + assert_eq!(doc.xfa.as_ref().map(|x| (x.pages, x.fields)), Some((1, 2))); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + let at = |x: u32, y: u32| -> [u8; 4] { + let i = ((y * out.width + x) * 4) as usize; + out.rgba[i..i + 4].try_into().unwrap() + }; + // The signed box (3in by 1in from half an inch in) is filled with the signature's blue + // picture; the unsigned one below it is not. + assert_eq!(at(36 + 108, 36 + 36), [0, 0, 255, 255]); + assert_ne!(at(36 + 108, 36 + 72 + 36), [0, 0, 255, 255]); +} + #[test] fn an_xfa_form_without_a_template_opens_with_its_placeholder_and_a_warning() { let bytes = Arc::new(pdfcraft_xfa::fixtures::shell("")); diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 029fcb7009..b0f4c0b318 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -452,9 +452,7 @@ impl<'a, 'b> Runner<'a, 'b> { self.flush()?; let report = pdfcraft_xfa::rerender(self.doc, self.tpl).map_err(|e| e.to_string())?; self.console(report.warnings); - for f in pdfcraft_forms::fields(self.doc) { - pdfcraft_forms::redraw_field(self.doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?; - } + crate::xfa_redraw(self.doc)?; self.by_som = None; self.fields = None; self.cached = None; diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index f91cc7c509..a6c79a2a0c 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -324,3 +324,36 @@ pub fn shell(xdp: &str) -> Vec { ]; assemble(objs) } + +/// A dynamic form with two signature boxes of which `sign` has been signed: the file holds the +/// field a signing application leaves (named by its SOM path, its value a signature dictionary) +/// and the picture drawn in the box, a solid blue rectangle. +pub fn signed_shell() -> Vec { + let xdp = r##" + + + +"##; + let picture = "0 0 1 rg 0 0 100 40 re f"; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [6 0 R] /SigFlags 3 >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".to_vec(), + b"<< /Length 44 >>\nstream\nBT /F1 12 Tf 72 700 Td (Please wait...) Tj ET\nendstream".to_vec(), + format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()).into_bytes(), + b"<< /T (form[0]) /Kids [7 0 R] >>".to_vec(), + b"<< /T (body[0]) /Parent 6 0 R /Kids [8 0 R] >>".to_vec(), + b"<< /T (sign[0]) /Parent 7 0 R /FT /Sig /V 9 0 R /Type /Annot /Subtype /Widget /Rect [0 0 0 0] /AP << /N 10 0 R >> >>".to_vec(), + b"<< /Type /Sig /Name (A Signer) >>".to_vec(), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 40] /Length {} >>\nstream\n{picture}\nendstream", picture.len()).into_bytes(), + ]; + assemble(objs) +} diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 615afbb0e5..13712e910b 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -200,6 +200,9 @@ struct Layouter<'t> { measured: RefCell>, /// Measurements made so far (see [`MAX_MEASURES`]). measures: Cell, + /// 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)>, } /// How many instances of a repeating subform to lay out: what the data holds once it holds @@ -248,6 +251,7 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result items: 0, measured: RefCell::new(HashMap::new()), measures: Cell::new(0), + leaves: Vec::new(), }; let ctx = Ctx { font: Font::default(), para: Para::default(), som: String::new() }; l.new_page()?; @@ -312,6 +316,75 @@ fn splittable(node: &Node) -> bool { matches!(node, Node::Subform(s) if matches!(s.layout, Layout::Tb | Layout::Table) && s.common.h.is_none()) } +/// Top and bottom of an item a page break must not cut (text, a picture, a field); `None` +/// for fills and lines, which are cut at the break. +fn solid_extent(item: &Item) -> Option<(f64, f64)> { + match item { + Item::Text(s) => Some((s.baseline - s.face.size * 0.8, s.baseline + s.face.size * 0.2)), + Item::Image { rect, .. } => Some((rect.y, rect.bottom())), + Item::Widget(w) => Some((w.rect.y, w.rect.bottom())), + Item::Fill { .. } | Item::Line { .. } => None, + } +} + +/// `item` as the part above `cut` and the part below: solid items go whole to the side their +/// top is on, fills and lines crossing the cut are cut in two. +fn divide(item: Item, cut: f64) -> (Option, Option) { + if let Some((top, _)) = solid_extent(&item) { + return if top < cut - 0.01 { (Some(item), None) } else { (None, Some(item)) }; + } + match item { + Item::Fill { rect, color } => { + if rect.bottom() <= cut + 0.01 { + (Some(Item::Fill { rect, color }), None) + } else if rect.y >= cut - 0.01 { + (None, Some(Item::Fill { rect, color })) + } else { + let above = Rect::new(rect.x, rect.y, rect.w, cut - rect.y); + let below = Rect::new(rect.x, cut, rect.w, rect.bottom() - cut); + (Some(Item::Fill { rect: above, color }), Some(Item::Fill { rect: below, color })) + } + } + Item::Line { from, to, width, color, dashed } => { + let (lo, hi) = if from.1 <= to.1 { (from, to) } else { (to, from) }; + if lo.1 >= cut { + (None, Some(Item::Line { from, to, width, color, dashed })) + } else if hi.1 <= cut { + (Some(Item::Line { from, to, width, color, dashed }), None) + } else { + // lo.1 < cut < hi.1, so the divisor is positive. + let t = (cut - lo.1) / (hi.1 - lo.1); + let mid = (lo.0 + (hi.0 - lo.0) * t, cut); + (Some(Item::Line { from: lo, to: mid, width, color, dashed }), Some(Item::Line { from: mid, to: hi, width, color, dashed })) + } + } + other => (Some(other), None), + } +} + +/// `item` moved by (`dx`, `dy`). +fn moved(mut item: Item, dx: f64, dy: f64) -> Item { + match &mut item { + Item::Fill { rect, .. } | Item::Image { rect, .. } => { + rect.x += dx; + rect.y += dy; + } + Item::Line { from, to, .. } => { + *from = (from.0 + dx, from.1 + dy); + *to = (to.0 + dx, to.1 + dy); + } + Item::Text(s) => { + s.x += dx; + s.baseline += dy; + } + Item::Widget(w) => { + w.rect.x += dx; + w.rect.y += dy; + } + } + item +} + fn is_row(node: &Node) -> bool { matches!(node, Node::Subform(s) if s.layout == Layout::Row) } @@ -491,6 +564,143 @@ impl Layouter<'_> { Ok(true) } + /// Place `node` as one block of size (`w`, `h`) at the cursor: in this content area when it fits, + /// in the next when it fits there, and across as many as it takes when it is taller than a + /// 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 { + self.make_room(h)?; + } + let (rect, y, _) = self.cursor(); + let first = self.pages.last().map_or(0, |p| p.items.len()); + self.leaves.clear(); + self.draw_node(node, Rect::new(x, y, w, h), ctx, depth, sib)?; + let page_h = self.pages.last().map_or(f64::INFINITY, |p| p.height); + // What was drawn, when it is lower than the block's own height says (content that + // outgrows a fixed height) and ends off the page. + let drawn = self + .pages + .last() + .and_then(|p| p.items.get(first..)) + .unwrap_or_default() + .iter() + .filter_map(solid_extent) + .map(|e| e.1) + .fold(y + h, f64::max); + if y + h <= rect.bottom() + 0.01 && drawn <= page_h + 0.01 { + self.leaves.clear(); + self.place(h); + return Ok(()); + } + self.split_block(first, y, drawn) + } + + /// Remember that the items the last page got since `start` are one field or draw at + /// `rect`, to be kept together by a page break. + fn keep_whole(&mut self, start: usize, rect: Rect) { + let end = self.pages.last().map_or(0, |p| p.items.len()); + if end > start { + self.leaves.push((start, end, rect.y, rect.bottom())); + } + } + + fn item_count(&self) -> usize { + self.pages.last().map_or(0, |p| p.items.len()) + } + + /// 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> { + let items: Vec = match self.pages.last_mut() { + Some(p) if first <= p.items.len() => p.items.split_off(first), + _ => return Ok(()), + }; + // Each item with the extent of the field or draw it belongs to: such a leaf moves + // whole (its border with its text), unless it is taller than a content area. + let area_h = self.cursor().0.h; + let mut extents: Vec> = vec![None; items.len()]; + for (start, end, top, bottom) in std::mem::take(&mut self.leaves) { + if bottom - top <= area_h + 0.01 + && let Some(slots) = start.checked_sub(first).and_then(|s| extents.get_mut(s..end.saturating_sub(first))) + { + slots.fill(Some((top, bottom))); + } + } + let mut rest: Vec<(Item, Option<(f64, f64)>)> = items.into_iter().zip(extents).collect(); + let origin_x = self.cursor().0.x; + // An item's place on the current page is its drawn place moved by (dx, -shift). + let (mut dx, mut shift) = (0.0, 0.0); + let mut seg_top = top; + let mut first_segment = true; + loop { + let (rect, _, placed) = self.cursor(); + let limit = rect.bottom() + shift; + let mut cut = limit; + if rect.h < 1.0 { + // No room to split into: leave the rest here. + cut = f64::INFINITY; + } else { + // The lowest break at or above the bottom that crosses no solid item: going + // through them from the lowest top up, a break moved to an item's top can't + // cross one already passed. + let mut solid: Vec<(f64, f64)> = + rest.iter().filter_map(|(item, kept)| kept.or_else(|| solid_extent(item))).filter(|e| e.0 < limit - 0.01).collect(); + solid.sort_by(|a, b| b.0.total_cmp(&a.0)); + for (t, b) in solid { + if t < cut - 0.01 && b > cut + 0.01 { + cut = t; + } + } + if cut <= seg_top + 0.01 { + // An item taller than what is left. Start it in a fresh area if this one + // already holds something; otherwise cut through it. + cut = if first_segment && placed && seg_top > rect.y + 0.01 { seg_top } else { limit }; + } + } + let mut here = Vec::new(); + let mut below = Vec::new(); + for (item, kept) in rest { + if let Some((top, _)) = kept { + if top < cut - 0.01 { + here.push(item) + } else { + below.push((item, kept)) + } + continue; + } + match divide(item, cut) { + (Some(a), None) => here.push(a), + (None, Some(b)) => below.push((b, None)), + (Some(a), Some(b)) => { + here.push(a); + below.push((b, None)); + } + (None, None) => {} + } + } + if let Some(p) = self.pages.last_mut() { + p.items.extend(here.into_iter().map(|it| moved(it, dx, -shift))); + } + if below.is_empty() { + if let Some(c) = &mut self.cur { + c.y = (end - shift).min(c.rect.bottom()).max(c.y); + c.placed = true; + } + return Ok(()); + } + rest = below; + seg_top = cut; + first_segment = false; + self.next_area()?; + let (rect, _, _) = self.cursor(); + shift = cut - rect.y; + dx = rect.x - origin_x; + } + } + fn page_has_flow(&self) -> bool { self.cur.as_ref().is_some_and(|c| c.placed || c.content > 0) } @@ -518,10 +728,7 @@ impl Layouter<'_> { if !matches!(sf.layout, Layout::Tb | Layout::Table) || sf.common.h.is_some() { // Not splittable: one block. let (_, h) = self.size_of(node, w, ctx_parent, depth); - self.make_room(h)?; - let (_, y, _) = self.cursor(); - self.draw_node(node, Rect::new(x, y, w, h), ctx_parent, depth, sib + i)?; - self.place(h); + self.flow_block(node, x, (w, h), ctx_parent, depth, sib + i)?; continue; } let m = sf.common.margin; @@ -543,10 +750,7 @@ impl Layouter<'_> { continue; } let (cw, ch) = self.size_of(child, inner_w, &ctx, depth + 1); - self.make_room(ch)?; - let (_, y, _) = self.cursor(); - self.draw_node(child, Rect::new(inner_x, y, cw, ch), &ctx, depth + 1, csib)?; - self.place(ch); + self.flow_block(child, inner_x, (cw, ch), &ctx, depth + 1, csib)?; } } } @@ -605,10 +809,7 @@ impl Layouter<'_> { continue; } let (cw, ch) = self.size_of(child, inner_w, ctx, depth + 1); - self.make_room(ch)?; - let (_, y, _) = self.cursor(); - self.draw_node(child, Rect::new(x, y, cw, ch), ctx, depth + 1, csib)?; - self.place(ch); + self.flow_block(child, x, (cw, ch), ctx, depth + 1, csib)?; } Ok(()) } @@ -662,10 +863,11 @@ impl Layouter<'_> { fn measure(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) { let c = node.common(); - // The container's own SOM, under which its repeating children count their instances. - // Measurements are cached per template node, so instance 0 stands for all of them. - let som = ctx.with_som(c, 0).som; - let ctx = ctx.child(c); + // The node's own SOM: its repeating children count their instances under it, and a + // growing text field finds its value at it. Measurements are cached per template node, + // so instance 0 stands for all of them. + let ctx = ctx.with_som(c, 0); + let som = ctx.som.clone(); // A container without a width is as wide as its content (a radio group beside its // question, say); a leaf without one takes what is available. let w = match c.w { @@ -697,7 +899,7 @@ impl Layouter<'_> { .filter(|k| k.common().presence.occupies()) .map(|k| self.size_of(k, inner_w, &ctx, depth + 1).1 * self.instances(k, &som) as f64) .sum(), - Layout::LrTb | Layout::RlTb => self.packed_height(&s.children, inner_w, &ctx, depth), + Layout::LrTb | Layout::RlTb => self.pack_instances(&s.children, inner_w, &ctx, depth, &som).1, Layout::Table => { let cols = self.columns(s, inner_w); s.children @@ -715,7 +917,13 @@ impl Layouter<'_> { content + c.margin.vertical() } }; - (w, clamp_opt(h, c.min_h, c.max_h)) + // A flowed subform is as tall as what it holds: other viewers do not stretch one to + // the `minH` a designer left on it. + let min_h = match node { + Node::Subform(s) if c.h.is_none() && s.layout != Layout::Positioned => None, + _ => c.min_h, + }; + (w, clamp_opt(h, min_h, c.max_h)) } /// The natural width of a container's content within `avail_w`. @@ -787,7 +995,7 @@ impl Layouter<'_> { let lines = if f.multiline { 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)), - _ => text::line_height(font.size) * lines + 4.0 + f.ui_margin.vertical(), + _ => (text::line_height(font.size) * lines).max(self.grown_text_height(f, inner_w, ctx)) + 4.0 + f.ui_margin.vertical(), }; match self.caption_reserve(f, inner_w, ctx) { Some((Placement::Top | Placement::Bottom, r)) => ui + r, @@ -807,6 +1015,25 @@ 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. + fn grown_text_height(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> f64 { + if f.ui != Ui::TextEdit || !f.multiline || f.common.h.is_some() || f.common.name.is_none() { + 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 Some(value) = value.filter(|t| !t.trim().is_empty()) else { return 0.0 }; + // Beside a caption the text has what the caption leaves; 2pt of padding on each side. + let beside = match self.caption_reserve(f, inner_w, ctx) { + Some((Placement::Top | Placement::Bottom, _)) | None => 0.0, + Some((_, r)) => r, + }; + let w = (inner_w - beside - f.ui_margin.horizontal() - 4.0).max(1.0); + let font = ctx.font.apply(&f.common.font); + text::measure_height(&text::plain(value.trim_end()), w, &ctx.para, &Face::of(&font), &|_| String::new()) + } + fn group_height(&self, g: &ExclGroup, inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { let nodes: Vec = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); match g.layout { @@ -828,8 +1055,10 @@ impl Layouter<'_> { .fold(0.0, f64::max) } - /// Left-to-right packing: rows of children, wrapped at the available width. - fn pack(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> (Vec<(usize, Rect)>, f64) { + /// Left-to-right packing: rows of children, wrapped at the available width, as (child, + /// instance, place). A repeating subform is packed once per instance it has under the + /// container at `som`. + fn pack_instances(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize, som: &str) -> (Vec<(usize, usize, Rect)>, f64) { let mut out = Vec::new(); let (mut x, mut y, mut line_h) = (0.0, 0.0, 0.0); for (i, k) in children.iter().enumerate() { @@ -837,18 +1066,26 @@ impl Layouter<'_> { continue; } let (w, h) = self.size_of(k, inner_w, ctx, depth + 1); - if x > 0.0 && x + w > inner_w + 0.05 { - y += line_h; - x = 0.0; - line_h = 0.0; + for instance in 0..self.instances(k, som) { + if x > 0.0 && x + w > inner_w + 0.05 { + y += line_h; + x = 0.0; + line_h = 0.0; + } + out.push((i, instance, Rect::new(x, y, w, h))); + x += w; + line_h = line_h.max(h); } - out.push((i, Rect::new(x, y, w, h))); - x += w; - line_h = line_h.max(h); } (out, y + line_h) } + /// [`Self::pack_instances`] for children that don't repeat (the fields of a group). + fn pack(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> (Vec<(usize, Rect)>, f64) { + let (placed, h) = self.pack_instances(children, inner_w, ctx, depth, ""); + (placed.into_iter().map(|(i, _, r)| (i, r)).collect(), h) + } + fn packed_height(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { self.pack(children, inner_w, ctx, depth).1 } @@ -923,8 +1160,21 @@ impl Layouter<'_> { return Ok(()); } match node { - Node::Draw(d) => self.draw_draw(d, rect, ctx), - Node::Field(f) => self.draw_field(f, rect, ctx, None, sib), + Node::Draw(d) => { + let start = self.item_count(); + self.draw_draw(d, rect, ctx)?; + // Text and pictures stay with their border; a bare line or rectangle is cut. + if matches!(d.value, Value::Text(_) | Value::Rich(_) | Value::Image { .. }) { + self.keep_whole(start, rect); + } + Ok(()) + } + Node::Field(f) => { + let start = self.item_count(); + self.draw_field(f, rect, ctx, None, sib)?; + self.keep_whole(start, rect); + Ok(()) + } Node::ExclGroup(g) => self.draw_group(g, rect, ctx, depth, sib), Node::Area(a) => { let ctx = ctx.with_som(&a.common, sib); @@ -962,11 +1212,13 @@ impl Layouter<'_> { } } Layout::LrTb | Layout::RlTb => { - let (placed, _) = self.pack(&s.children, inner.w, &ctx, depth); - for (i, r) in placed { + // A repeating subform here (rows of fields under a heading) gets its + // instances, as in the other layouts. + let (placed, _) = self.pack_instances(&s.children, inner.w, &ctx, depth, &ctx.som); + for (i, instance, r) in placed { if let Some(k) = s.children.get(i) { let x = if s.layout == Layout::RlTb { inner.right() - r.x - r.w } else { inner.x + r.x }; - self.draw_node(k, Rect::new(x, inner.y + r.y, r.w, r.h), &ctx, depth + 1, sib_index(&s.children, i))?; + self.draw_node(k, Rect::new(x, inner.y + r.y, r.w, r.h), &ctx, depth + 1, sib_index(&s.children, i) + instance)?; } } } @@ -1039,7 +1291,20 @@ impl Layouter<'_> { Value::Rich(r) => r.clone(), _ => return Ok(()), }; - let block = text::layout(&rich, rect, para, &Face::of(font), &|id| self.embed(id)); + let face = Face::of(font); + // Text that doesn't fit the height it was given (the template's face is narrower than + // the standard one drawn in its place, so it wraps to more lines) is drawn smaller, + // down to 70%, rather than over what lies below. + let fits = |k: f64| text::measure_height(&rich, rect.w / k, para, &face, &|id| self.embed(id)) <= rect.h / k + 1.0; + let scale = if rect.h < 4.0 || fits(1.0) { 1.0 } else { [0.95, 0.9, 0.85, 0.8, 0.75].into_iter().find(|k| fits(*k)).unwrap_or(0.7) }; + let mut block = text::layout(&rich, Rect::new(rect.x, rect.y, rect.w / scale, rect.h / scale), para, &face, &|id| self.embed(id)); + if scale < 1.0 { + for s in &mut block.spans { + s.x = rect.x + (s.x - rect.x) * scale; + s.baseline = rect.y + (s.baseline - rect.y) * scale; + s.face.size *= scale; + } + } self.push_block(block) } @@ -1248,6 +1513,25 @@ impl Layouter<'_> { // Shown as text, not as a field. return self.draw_value_text(&f.value, ui.inset(&f.ui_margin), &fctx.font, &fctx.para); } + // A text field that grew past what a content area holds can't be one widget: its text + // is drawn as lines the page breaks fall between, and stays what the data holds. + if f.ui == Ui::TextEdit && f.multiline && f.common.h.is_none() && rect.h > self.cursor().0.h + 0.01 { + let value = + self.data.and_then(|d| d.value_at(&data_path(self.tpl, &som_path()))).filter(|t| !t.trim().is_empty()).or_else(|| f.value.plain()); + if let Some(value) = value.filter(|t| !t.trim().is_empty()) { + let name = f.common.name.clone().unwrap_or_else(|| "a text field".into()); + self.warn_once(format!("{name}: its text is longer than a page; it is shown across pages and can't be edited here")); + let m = Margin { + top: f.ui_margin.top + 2.0, + bottom: f.ui_margin.bottom + 2.0, + left: f.ui_margin.left + 2.0, + right: f.ui_margin.right + 2.0, + }; + let para = Para { v_align: VAlign::Top, ..fctx.para.clone() }; + let block = text::layout(&text::plain(value.trim_end()), ui.inset(&m), ¶, &Face::of(&fctx.font), &|_| String::new()); + return self.push_block(block); + } + } let face = match (&f.ui, &f.caption) { // Buttons show their caption in the caption's font. (Ui::Button, Some(cap)) => Face::of(&ctx.font.apply(&cap.font)), @@ -1354,16 +1638,14 @@ impl Layouter<'_> { } } -/// The widget's own border from the `` border element. Without one, text fields and check -/// boxes get a solid box, as Designer draws them. +/// The widget's own border from the `` border element. Without one, a check box gets a +/// solid box, as Designer draws it, and any other field has no box of its own. fn widget_border(b: Option<&Border>, check: bool) -> WidgetBorder { let Some(b) = b else { - return WidgetBorder { - color: Some([0.0, 0.0, 0.0]), - fill: if check { Some([1.0, 1.0, 1.0]) } else { None }, - shape: BorderShape::Full, - width: 0.5, - }; + if !check { + return WidgetBorder { color: None, fill: None, shape: BorderShape::None, width: 0.0 }; + } + return WidgetBorder { color: Some([0.0, 0.0, 0.0]), fill: Some([1.0, 1.0, 1.0]), shape: BorderShape::Full, width: 0.5 }; }; let vis = b.visible_edges(); let shape = match vis { diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 0bf9008157..d3111f375c 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -662,7 +662,91 @@ pub fn parse(xml: &str) -> Result<(Template, Vec), XfaError> { // A page set may sit beside the root subform. root_sf.page_set = child(template, "pageSet").map(|ps| parse_page_set(ps, &mut warnings, 0)); } + // The form packet beside the template holds the state the form was saved in. + let saved = root + .descendants() + .find(|n| n.is_element() && n.tag_name().name() == "form" && n.tag_name().namespace().is_some_and(|ns| ns.contains("xfa-form"))); + if let Some(saved_root) = saved.and_then(|f| children(f, "subform").next()) { + if let Some(p) = saved_root.attribute("presence") { + root_sf.common.presence = Presence::parse(Some(p)); + } + apply_saved_state(&mut root_sf.children, saved_root, 0); + } let mut page_roles = Vec::new(); collect_page_roles(&[Node::Subform(Box::new(root_sf.clone()))], &mut page_roles, 0); Ok((Template { root: root_sf, page_roles }, warnings)) } + +fn parse_access(a: Option<&str>) -> Option { + Some(match a? { + "readOnly" => Access::ReadOnly, + "protected" => Access::Protected, + "nonInteractive" => Access::NonInteractive, + "open" => Access::Open, + _ => return None, + }) +} + +/// Apply the saved state of a form packet (`saved`: the element matching the container whose +/// children `nodes` are) to the template: the `presence` and `access` a script gave an object +/// before the form was saved, which is restored on opening. Objects are matched by +/// name and position among the same-named; of the instances of a repeating subform only the +/// first is read, as the template has it once. +fn apply_saved_state(nodes: &mut [Node], saved: XmlNode, depth: usize) { + if depth > MAX_DEPTH { + return; + } + let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); + for c in saved.children().filter(|c| c.is_element()) { + if !matches!(c.tag_name().name(), "subform" | "subformSet" | "area" | "field" | "draw" | "exclGroup") { + continue; + } + let Some(name) = c.attribute("name").filter(|n| !n.is_empty()) else { continue }; + let nth = { + let k = seen.entry(name).or_insert(0); + *k += 1; + *k - 1 + }; + let same: Vec = nodes.iter().enumerate().filter(|(_, n)| n.common().name.as_deref() == Some(name)).map(|(i, _)| i).collect(); + // More saved objects of the name than the template has: further instances. + let Some(target) = same.get(nth).copied().and_then(|i| nodes.get_mut(i)) else { continue }; + let presence = c.attribute("presence").map(|p| Presence::parse(Some(p))); + match target { + Node::Subform(s) => { + if let Some(p) = presence { + s.common.presence = p; + } + apply_saved_state(&mut s.children, c, depth + 1); + } + Node::Area(a) => { + if let Some(p) = presence { + a.common.presence = p; + } + apply_saved_state(&mut a.children, c, depth + 1); + } + Node::Field(f) => { + if let Some(p) = presence { + f.common.presence = p; + } + if let Some(a) = parse_access(c.attribute("access")) { + f.access = a; + } + // A caption a script changed (a button's label, say) is saved as plain text. + let text = child(c, "caption").and_then(|k| child(k, "value")).and_then(|v| child(v, "text")).and_then(|t| t.text()); + if let (Some(caption), Some(text)) = (f.caption.as_mut(), text) { + caption.value = Value::Text(text.to_string()); + } + } + Node::Draw(d) => { + if let Some(p) = presence { + d.common.presence = p; + } + } + Node::ExclGroup(g) => { + if let Some(p) = presence { + g.common.presence = p; + } + } + } + } +} diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 811ac326ee..44896d407c 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -176,6 +176,63 @@ struct Emitter<'a> { radio_selected: HashMap, field_count: usize, warnings: Vec, + /// The signatures the file holds, by the SOM path of the field signed. + signed: HashMap, +} + +/// A signature field of the file that has been signed. +struct Signed { + /// Its `V`: the signature dictionary (a reference, as the file has it). + value: Object, + /// Its widget's `AP`: what the signer's application drew in the box. + appearance: Option, +} + +/// Most fields looked at for signatures. +const MAX_FIELDS_SEARCHED: usize = 20_000; +/// Deepest field nesting followed. +const MAX_FIELD_DEPTH: usize = 32; + +/// The signed signature fields the document came with, by full name. Signing applications name +/// a dynamic form's fields by their SOM path (`form1[0].page1[0].sign[0]`), which is how a +/// laid-out signature box finds its signature. Fields of an earlier layout pass are not read. +fn signed_fields(doc: &Document, catalog: &Dict) -> HashMap { + let mut out = HashMap::new(); + let Some(acro) = catalog.get(b"AcroForm").map(|a| doc.resolve(a)) else { return out }; + let Some(fields) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)) else { return out }; + let mut stack: Vec<(Object, String, bool, usize)> = + fields.as_array().into_iter().flatten().map(|f| (f.clone(), String::new(), false, 0)).collect(); + let mut seen = 0usize; + while let Some((field, prefix, parent_is_sig, depth)) = stack.pop() { + seen += 1; + if seen > MAX_FIELDS_SEARCHED { + break; + } + let field = doc.resolve(&field); + let Some(d) = field.as_dict() else { continue }; + if d.contains(SOM_KEY) { + continue; + } + let name = match d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|t| t.to_text())) { + Some(t) if prefix.is_empty() => t, + Some(t) => format!("{prefix}.{t}"), + None => prefix, + }; + let is_sig = match d.name(b"FT") { + Some(ft) => ft == b"Sig", + None => parent_is_sig, + }; + let kids: Vec = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + if is_sig && let Some(value) = d.get(b"V").filter(|v| doc.resolve(v).as_dict().is_some()) { + // The appearance is on the field when it is its own widget, or on its one widget. + let appearance = d.get(b"AP").cloned().or_else(|| kids.iter().find_map(|k| doc.resolve(k).as_dict().and_then(|k| k.get(b"AP").cloned()))); + out.entry(name.clone()).or_insert(Signed { value: value.clone(), appearance }); + } + if depth < MAX_FIELD_DEPTH { + stack.extend(kids.into_iter().map(|k| (k, name.clone(), is_sig, depth + 1))); + } + } + out } impl Emitter<'_> { @@ -304,6 +361,14 @@ impl Emitter<'_> { } WidgetKind::Signature => { d.set(b"FT".to_vec(), Object::name("Sig")); + // Signed already: the box shows the signature the file holds and is done with. + if let Some(signed) = self.signed.get(&w.som) { + d.set(b"V".to_vec(), signed.value.clone()); + if let Some(ap) = &signed.appearance { + d.set(b"AP".to_vec(), ap.clone()); + } + ff |= 1; + } } WidgetKind::CheckBox { on, .. } => { d.set(b"FT".to_vec(), Object::name("Btn")); @@ -610,6 +675,7 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result }; // Pictures embedded by earlier passes, from the pages this pass replaces. let images = earlier_images(doc, pages_ref); + let signed = signed_fields(doc, &catalog); let mut em = Emitter { doc, images, @@ -618,6 +684,7 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result radio_selected: HashMap::new(), field_count: 0, warnings: Vec::new(), + signed, }; let mut kids = Vec::new(); for page in &form.pages { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index a690e7f3c6..1ef3216e2d 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1109,3 +1109,367 @@ fn data_paths_leave_areas_out() { assert_eq!(path("other[0].box[0].rest[0]"), "other[0].box[0].rest[0]"); assert_eq!(data_path(&tpl, ""), Vec::new()); } + +/// A form of one page area (7.5in × 10in of content from 0.5in) around `body`. +fn one_area_form(root_attrs: &str, body: &str) -> String { + format!( + r##" + + + +"## + ) +} + +/// Every field is inside the content area of its page, and the fields come in template order. +fn assert_paginated(form: &Form, fields: usize, min_pages: usize) { + assert!(form.pages.len() >= min_pages, "{} pages", form.pages.len()); + let mut names = Vec::new(); + for (i, p) in form.pages.iter().enumerate() { + for w in widgets(p) { + assert!( + w.rect.y >= 36.0 - 0.01 && w.rect.bottom() <= 36.0 + 720.0 + 0.01, + "page {}: {} is outside the content area: {:?}", + i + 1, + w.name, + w.rect + ); + names.push(w.som.clone()); + } + } + assert_eq!(names.len(), fields, "{names:?}"); + let expected: Vec = (0..fields).map(|i| format!("f{i}[0]")).collect(); + for (n, e) in names.iter().zip(&expected) { + assert!(n.ends_with(e), "{n} where {e} belongs: {names:?}"); + } +} + +#[test] +fn a_positioned_subform_taller_than_a_page_continues_on_the_next_pages() { + // 30 fields 0.9in apart: 27in of positioned content, where a content area holds 10in. + let fields: String = + (0..30).map(|i| format!(r#""#, i as f64 * 0.9)).collect(); + // As the only child of a flowed root … + let form = layout_xml(&one_area_form( + r#"layout="tb""#, + &format!(r#"{fields}"#), + )) + .unwrap(); + assert_paginated(&form, 30, 3); + // … its fill is cut at the breaks, a piece on every page, none past the content area. + for (i, p) in form.pages.iter().enumerate() { + let fills: Vec<&Rect> = p.items.iter().filter_map(|it| if let Item::Fill { rect, .. } = it { Some(rect) } else { None }).collect(); + assert!(!fills.is_empty(), "page {}: no fill", i + 1); + for r in fills { + assert!(r.bottom() <= 36.0 + 720.0 + 0.01, "page {}: fill {r:?}", i + 1); + } + } + // … after other content on the page … + let form = layout_xml(&one_area_form( + r#"layout="tb""#, + &format!(r#"Head{fields}"#), + )) + .unwrap(); + assert_paginated(&form, 30, 3); + assert!(!widgets(&form.pages[0]).is_empty(), "the block starts on the page the heading is on"); + // … and as the root itself (positioned is the default layout). + let form = layout_xml(&one_area_form("", &fields)).unwrap(); + assert_paginated(&form, 30, 3); +} + +#[test] +fn wrapped_and_fixed_height_blocks_taller_than_a_page_are_split_too() { + let fields: String = (0..60).map(|i| format!(r#""#)).collect(); + // Left-to-right wrapping: 30 lines of 0.8in. + let form = layout_xml(&one_area_form(r#"layout="tb""#, &format!(r#"{fields}"#))).unwrap(); + assert_paginated(&form, 60, 3); + // Top-to-bottom with a fixed height the content outgrows. + let form = + layout_xml(&one_area_form(r#"layout="tb""#, &format!(r#"{fields}"#))).unwrap(); + assert_paginated(&form, 60, 5); +} + +#[test] +fn a_block_that_fits_a_page_is_still_moved_whole() { + let block = |n: usize| { + format!(r#""#) + }; + let form = layout_xml(&one_area_form(r#"layout="tb""#, &format!("{}{}{}", block(0), block(1), block(2)))).unwrap(); + assert_eq!(form.pages.len(), 3); + for p in &form.pages { + let w = widgets(p); + assert_eq!(w.len(), 1); + assert!((w[0].rect.y - (36.0 + 360.0)).abs() < 0.01, "{:?}", w[0].rect); + } +} + +#[test] +fn a_field_taller_than_a_page_is_cut_through_without_looping() { + let body = r#""#; + let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap(); + let all: Vec<&Widget> = form.pages.iter().flat_map(widgets).collect(); + assert_eq!(all.len(), 2); + assert!(form.pages.len() <= 4, "{} pages", form.pages.len()); +} + +/// A form built the way Designer builds a flowed one: everything in one left-to-right wrapped +/// body, sections of a heading over repeating rows of fields, and a text field that grows. +fn wrapped_form(data: &str) -> String { + let body = r#" + + Entries + + + + + + + + + "#; + let form = one_area_form(r#"layout="tb""#, body); + let packet = + format!("{data}\n"); + form.replace("", &packet) +} + +#[test] +fn repeating_rows_in_a_wrapped_container_get_the_instances_the_data_holds() { + let rows: String = (0..4).map(|i| format!("what {i}{i}why {i}")).collect(); + let form = layout_xml(&wrapped_form(&format!("
{rows}
n
"))).unwrap(); + let all: Vec<&Widget> = form.pages.iter().flat_map(widgets).collect(); + let whats: Vec<(&str, Option<&str>, f64)> = + all.iter().filter(|w| w.som.ends_with(".what[0]")).map(|w| (w.som.as_str(), w.value.as_deref(), w.rect.y)).collect(); + assert_eq!(whats.len(), 4, "{whats:?}"); + for (i, (som, value, _)) in whats.iter().enumerate() { + assert_eq!(*som, format!("form[0].body[0].section[0].row[{i}].what[0]")); + assert_eq!(*value, Some(format!("what {i}").as_str())); + } + // One under another, the section growing with them: what follows comes after the last. + assert!(whats.windows(2).all(|p| (p[1].2 - p[0].2 - 72.0).abs() < 0.01), "{whats:?}"); + let notes = all.iter().find(|w| w.name == "notes").unwrap(); + assert!(notes.rect.y >= whats[3].2 + 72.0 - 0.01, "{:?} after {whats:?}", notes.rect); + // Without data a row is there once. + let form = layout_xml(&wrapped_form("
")).unwrap(); + assert_eq!(form.pages.iter().flat_map(widgets).filter(|w| w.name.starts_with("what")).count(), 1); +} + +#[test] +fn a_multi_line_field_without_a_height_grows_to_hold_its_text() { + let place = |data: &str| { + let form = layout_xml(&wrapped_form(data)).unwrap(); + let on = |name: &str| { + form.pages.iter().enumerate().find_map(|(i, p)| widgets(p).into_iter().find(|w| w.name == name).map(|w| (i, w.rect))).unwrap() + }; + (on("notes"), on("after")) + }; + // Little text: the minimum height. + let ((_, notes), (_, after)) = place("Short.
"); + assert!((notes.h - 108.0).abs() < 0.01, "{notes:?}"); + assert!((after.y - notes.bottom()).abs() < 0.01); + // Forty lines: the field holds them, and what follows moves down. + let long: String = (0..40).map(|i| format!("Line {i} of the notes.\n")).collect(); + let ((notes_page, grown), (after_page, after)) = place(&format!("
{long}
")); + assert!(grown.h > 40.0 * 11.0 && grown.h < 720.0, "{grown:?}"); + assert_eq!(notes_page, after_page); + assert!((after.y - grown.bottom()).abs() < 0.01, "{after:?} after {grown:?}"); + // A fixed height stays what it is. + let fixed = wrapped_form(&format!("
{long}
")) + .replace(r#"minH="1.5in"> + Head + + + "#; + let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap(); + assert_paginated(&form, 2, 2); + let below_head = |it: &&Item| match it { + Item::Fill { rect, .. } => rect.bottom() > 36.0 + 432.0 + 1.0, + Item::Line { from, to, .. } => from.1.max(to.1) > 36.0 + 432.0 + 1.0, + _ => false, + }; + assert_eq!(form.pages[0].items.iter().filter(below_head).count(), 0, "{:?}", form.pages[0].items); + assert!( + form.pages[1].items.iter().any(|it| matches!(it, Item::Fill { rect, .. } if (rect.y - 36.0).abs() < 0.01 && (rect.h - 360.0).abs() < 0.01)) + ); +} + +#[test] +fn text_too_long_for_its_fixed_height_is_drawn_smaller_instead_of_over_what_follows() { + let words = "certification statement wording ".repeat(12); + let body = format!( + r#"{words} + Short"# + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + let spans: Vec<&crate::text::Span> = form.pages[0].items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s) } else { None }).collect(); + let (short, note): (Vec<_>, Vec<_>) = spans.into_iter().partition(|s| s.text == "Short"); + // Three lines at 8pt need 27.6pt; the box is 21.6pt high. + assert!(note.iter().all(|s| s.face.size < 8.0 && s.face.size >= 5.6), "{:?}", note.iter().map(|s| s.face.size).collect::>()); + assert!(note.iter().all(|s| s.baseline <= 36.0 + 21.6 + 0.5), "{:?}", note.iter().map(|s| s.baseline).collect::>()); + assert!(note.iter().all(|s| s.x + s.face.width(&s.text) <= 36.0 + 540.0 + 0.5)); + // Text that fits keeps its size. + assert!((short[0].face.size - 8.0).abs() < 1e-9); +} + +#[test] +fn a_text_field_that_outgrows_a_page_continues_on_the_next_as_text() { + let long: String = (0..150).map(|i| format!("Line {i} of the notes.\n")).collect(); + let form = layout_xml(&wrapped_form(&format!("
{long}end
"))).unwrap(); + assert!(form.pages.len() >= 3, "{} pages", form.pages.len()); + assert!(form.warnings.iter().any(|w| w.starts_with("notes: its text is longer than a page")), "{:?}", form.warnings); + // Every line is there, in order, inside the content area of its page; no widget holds it. + let mut lines = Vec::new(); + for (i, p) in form.pages.iter().enumerate() { + for it in &p.items { + if let Item::Text(s) = it + && s.text.starts_with("Line ") + { + assert!(s.baseline > 36.0 && s.baseline <= 36.0 + 720.0 + 0.01, "page {}: {:?} at {}", i + 1, s.text, s.baseline); + lines.push(s.text.clone()); + } + } + assert!(widgets(p).iter().all(|w| w.name != "notes")); + } + assert_eq!(lines, (0..150).map(|i| format!("Line {i} of the notes.")).collect::>()); + // What follows comes after the last line, on the last page. + let last = form.pages.last().unwrap(); + let after = widgets(last).into_iter().find(|w| w.name == "after").unwrap(); + let last_line = last.items.iter().filter_map(|it| if let Item::Text(s) = it { Some(s.baseline) } else { None }).fold(0.0, f64::max); + assert!(after.rect.y >= last_line - 0.01 && after.rect.bottom() <= 36.0 + 720.0 + 0.01, "{:?} after {last_line}", after.rect); +} + +#[test] +fn the_form_packet_restores_the_presence_and_access_the_form_was_saved_with() { + // Three rows hidden in the template; a script showed the first two before the form was + // saved, and locked a field. + let row = |n: usize, presence: &str| { + format!( + r#""# + ) + }; + let body = format!( + r#"{}{}{}{}"#, + row(0, ""), + row(1, r#" presence="hidden""#), + row(2, r#" presence="hidden""#), + row(3, r#" presence="hidden""#) + ); + let template = one_area_form(r#"layout="tb""#, &body); + let visible = |xdp: &str| -> Vec { layout_xml(xdp).unwrap().pages.iter().flat_map(widgets).map(|w| w.som.clone()).collect() }; + assert_eq!(visible(&template), ["form[0].body[0].row0[0].who[0]", "form[0].body[0].rep[0].x[0]"]); + let saved = r#"
+ + + + + + + + + + + + +"#; + let xdp = template.replace("", saved); + assert_eq!( + visible(&xdp), + ["form[0].body[0].row0[0].who[0]", "form[0].body[0].row1[0].who[0]", "form[0].body[0].row2[0].who[0]", "form[0].body[0].rep[0].x[0]"], + "a later instance of a repeating subform says nothing about the template's one" + ); + let form = layout_xml(&xdp).unwrap(); + let locked: Vec = form.pages.iter().flat_map(widgets).map(|w| w.read_only).collect(); + assert_eq!(locked, [true, true, false, false]); +} + +#[test] +fn a_flowed_subform_is_as_tall_as_its_content_whatever_its_minimum_height() { + // A bar with a stale 3in `minH` holds one 0.2in line; the row after it follows directly. + let body = |bar: &str| { + format!( + r#"Attachments"# + ) + }; + let top = |bar: &str| { + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body(bar))).unwrap(); + let w = widgets(&form.pages[0]); + w[0].rect.y + }; + let flowed = top(r#"layout="lr-tb""#); + assert!((top(r#"layout="tb""#) - flowed).abs() < 0.01); + // A positioned subform keeps its minimum: nothing else says how tall it is. + assert!((top("") - flowed - (216.0 - 14.4)).abs() < 0.01, "{} against {flowed}", top("")); +} + +#[test] +fn the_form_packet_restores_a_caption_a_script_changed() { + let body = r#"