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#"