diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index f6eb99452..a57ce3d9c 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -44,13 +44,45 @@ pub(crate) const UNDERLINE_DROP: f64 = 0.1; #[derive(Clone, Debug, PartialEq)] pub enum Item { - Fill { rect: Rect, color: [f64; 3] }, - Line { from: (f64, f64), to: (f64, f64), width: f64, color: [f64; 3], dashed: bool }, + Fill { + rect: Rect, + color: [f64; 3], + }, + Line { + from: (f64, f64), + to: (f64, f64), + width: f64, + color: [f64; 3], + dashed: bool, + }, + /// A box with rounded (or inverted) corners: its fill, and its edge drawn inside it. + Rounded(Box), Text(Span), - Image { rect: Rect, data: Arc>, content_type: String }, + Image { + rect: Rect, + data: Arc>, + content_type: String, + }, Widget(Box), } +/// A box with rounded corners ([`Item::Rounded`]). +#[derive(Clone, Debug, PartialEq)] +pub struct Rounded { + pub rect: Rect, + /// Top-left, top-right, bottom-right and bottom-left radii, each at most half the box's + /// shorter side. + pub radii: [f64; 4], + /// Corners whose curve goes into the box rather than around it, in the same order. + pub inverted: [bool; 4], + pub fill: Option<[f64; 3]>, + /// The edge: its width, colour and whether it is dashed. + pub edge: Option<(f64, [f64; 3], bool)>, + /// A page break cut the box here: that side has no edge, and its corners are square. + pub open_top: bool, + pub open_bottom: bool, +} + #[derive(Clone, Debug, PartialEq)] pub enum WidgetKind { Text, @@ -339,7 +371,7 @@ fn solid_extent(item: &Item) -> Option<(f64, f64)> { 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::Fill { .. } | Item::Line { .. } | Item::Rounded(_) => None, } } @@ -374,6 +406,23 @@ fn divide(item: Item, cut: f64) -> (Option, Option) { (Some(Item::Line { from: lo, to: mid, width, color, dashed }), Some(Item::Line { from: mid, to: hi, width, color, dashed })) } } + Item::Rounded(r) => { + if r.rect.bottom() <= cut + 0.01 { + (Some(Item::Rounded(r)), None) + } else if r.rect.y >= cut - 0.01 { + (None, Some(Item::Rounded(r))) + } else { + // Each part keeps the corners on its side, rounded no more than its own height + // allows, and is open where it was cut. + let [tl, tr, br, bl] = r.radii; + let above = Rect::new(r.rect.x, r.rect.y, r.rect.w, cut - r.rect.y); + let below = Rect::new(r.rect.x, cut, r.rect.w, r.rect.bottom() - cut); + let fit = |v: f64, h: f64| v.min(h.max(0.0)); + let upper = Rounded { rect: above, radii: [fit(tl, above.h), fit(tr, above.h), 0.0, 0.0], open_bottom: true, ..(*r).clone() }; + let lower = Rounded { rect: below, radii: [0.0, 0.0, fit(br, below.h), fit(bl, below.h)], open_top: true, ..*r }; + (Some(Item::Rounded(Box::new(upper))), Some(Item::Rounded(Box::new(lower)))) + } + } other => (Some(other), None), } } @@ -385,6 +434,10 @@ fn moved(mut item: Item, dx: f64, dy: f64) -> Item { rect.x += dx; rect.y += dy; } + Item::Rounded(r) => { + r.rect.x += dx; + r.rect.y += dy; + } Item::Line { from, to, .. } => { *from = (from.0 + dx, from.1 + dy); *to = (to.0 + dx, to.1 + dy); @@ -1408,11 +1461,35 @@ impl Layouter<'_> { } } + /// Draw border `b` around `rect`: its fill, then each shown edge as a line inside the box, or + /// the whole box as one rounded shape when its corners are rounded. fn draw_border(&mut self, b: &Border, rect: Rect) -> Result<(), XfaError> { + let vis = b.visible_edges(); + // Rounded corners: the box is one shape, filled and edged along its curves, when its + // edges are all alike (all shown and the same, or all hidden). Edges that differ keep + // square corners. + let radii = b.radii(rect.w, rect.h); + let alike = b.edges.iter().all(|e| *e == b.edges[0]); + if radii.iter().any(|r| *r > 0.0) && (vis == [true; 4] && alike || vis == [false; 4]) { + let e = b.edges[0]; + let edge = vis[0].then(|| { + let color = if e.stroke == Stroke::Relief { [0.55, 0.55, 0.55] } else { e.color.0 }; + (e.thickness.clamp(0.1, 20.0), color, matches!(e.stroke, Stroke::Dashed | Stroke::Dotted)) + }); + let shape = Rounded { + rect, + radii, + inverted: b.corners.map(|c| c.inverted), + fill: b.fill().map(|f| f.0), + edge, + open_top: false, + open_bottom: false, + }; + return self.push(Item::Rounded(Box::new(shape))); + } if let Some(fill) = b.fill() { self.push(Item::Fill { rect, color: fill.0 })?; } - let vis = b.visible_edges(); let (x0, y0, x1, y1) = (rect.x, rect.y, rect.right(), rect.bottom()); // Top, right, bottom, left; lines sit inside the box. let segs = [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x0, y1), (x1, y1)), ((x0, y0), (x0, y1))]; diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index 964388f5a..77c07c704 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -125,11 +125,22 @@ impl Edge { } } -/// A box border: four edges (top, right, bottom, left) and a fill. +/// One corner of a border: how far it rounds the box (`radius`), and whether the curve goes +/// into the box (`inverted`) rather than around it. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Corner { + pub presence: Presence, + pub radius: f64, + pub inverted: bool, +} + +/// A box border: four edges (top, right, bottom, left), four corners (top-left, top-right, +/// bottom-right, bottom-left) and a fill. #[derive(Clone, Debug, Default, PartialEq)] pub struct Border { pub presence: Presence, pub edges: [Edge; 4], + pub corners: [Corner; 4], pub fill: Option, } @@ -144,6 +155,15 @@ impl Border { pub fn fill(&self) -> Option { if self.presence == Presence::Visible { self.fill } else { None } } + + /// How far each corner (top-left, top-right, bottom-right, bottom-left) rounds a box `w` by + /// `h`: its radius when the corner is shown, no more than half the box's shorter side, and 0 + /// for a hidden corner or a hidden border. + pub fn radii(&self, w: f64, h: f64) -> [f64; 4] { + let most = (w.min(h) / 2.0).max(0.0); + let shown = self.presence == Presence::Visible; + self.corners.map(|c| if shown && c.presence == Presence::Visible && c.radius.is_finite() { c.radius.clamp(0.0, most) } else { 0.0 }) + } } /// Font properties as written on an element; `None` inherits from the ancestors. @@ -581,7 +601,7 @@ pub enum Value { slope_up: bool, edge: Edge, }, - Rectangle(Border), + Rectangle(Box), } impl Value { diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 362bde994..919b354d0 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -146,7 +146,23 @@ fn parse_border(n: Option) -> Option { 3 => [e(0), e(1), e(2), e(1)], _ => [e(0), e(1), e(2), e(3)], }; - Some(Border { presence: Presence::parse(n.attribute("presence")), edges: four, fill: parse_fill(child(n, "fill")) }) + Some(Border { presence: Presence::parse(n.attribute("presence")), edges: four, corners: parse_corners(n), fill: parse_fill(child(n, "fill")) }) +} + +/// The `` children of a border or rectangle, top-left, top-right, bottom-right then +/// bottom-left: one applies to all four, and a corner not given is the last one given. Without +/// any, the corners are square. +fn parse_corners(n: XmlNode) -> [Corner; 4] { + let given: Vec = children(n, "corner") + .take(4) + .map(|c| Corner { + presence: Presence::parse(c.attribute("presence")), + radius: attr_measure(c, "radius").filter(|r| r.is_finite()).unwrap_or(0.0).max(0.0), + inverted: c.attribute("inverted") == Some("1"), + }) + .collect(); + let Some(last) = given.last().copied() else { return [Corner::default(); 4] }; + std::array::from_fn(|i| given.get(i).copied().unwrap_or(last)) } /// ``: typeface, size, weight, posture, underline, fill color and horizontal scale. @@ -389,7 +405,7 @@ fn parse_value(n: Option, warnings: &mut Vec) -> Value { "rectangle" => { let mut b = parse_border(Some(c)).unwrap_or_default(); b.fill = parse_fill(child(c, "fill")); - return Value::Rectangle(b); + return Value::Rectangle(Box::new(b)); } "date" | "time" | "dateTime" | "integer" | "decimal" | "float" | "boolean" => return Value::Text(text_of(c)), _ => {} diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 5a16307b7..0135207c4 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -8,7 +8,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use pdfcraft_fonts::{literal, win_ansi}; use crate::XfaError; -use crate::layout::{Action, BorderShape, Form, Item, Page, Widget, WidgetKind}; +use crate::layout::{Action, BorderShape, Form, Item, Page, Rounded, Widget, WidgetKind}; use crate::model::Rect; use crate::text::{Face, Family, Span}; @@ -129,6 +129,77 @@ fn all_faces() -> Vec<(&'static str, &'static str)> { out } +/// The content that draws rounded box `r` on a page `page_h` tall: its fill along its curves, +/// then its edge inside it. A side a page break cut has no edge. +pub(crate) fn rounded(r: &Rounded, page_h: f64) -> String { + let mut c = String::new(); + if let Some(color) = r.fill { + c.push_str(&format!("{} rg {}f\n", rgb(color), outline(r.rect, r.radii, r.inverted, (false, false), page_h))); + } + if let Some((w, color, dashed)) = r.edge { + // The edge's centre line, half its width inside the box, so the edge stays in: a corner + // curving around the box tightens by as much, and one curving into it widens. + let inset = Rect::new(r.rect.x + w / 2.0, r.rect.y + w / 2.0, (r.rect.w - w).max(0.0), (r.rect.h - w).max(0.0)); + let most = inset.w.min(inset.h) / 2.0; + let radii: [f64; 4] = std::array::from_fn(|i| match (r.radii[i], r.inverted[i]) { + (v, _) if v <= 0.0 => 0.0, + (v, true) => (v + w / 2.0).min(most), + (v, false) => (v - w / 2.0).clamp(0.0, most), + }); + let path = outline(inset, radii, r.inverted, (r.open_top, r.open_bottom), page_h); + c.push_str(&format!("q {} RG {} w {}{path}S Q\n", rgb(color), n(w), if dashed { "[2 2] 0 d " } else { "" })); + } + c +} + +/// The path around `rect` (top-left based, on a page `page_h` tall) with the corners `radii` +/// (top-left, top-right, bottom-right, bottom-left), each curving around the box or, when +/// `inverted`, into it. `open` leaves out the top or bottom side (and doesn't close the path): +/// those are where a page break cut the box. Ends with a space, for the paint operator. +fn outline(rect: Rect, radii: [f64; 4], inverted: [bool; 4], open: (bool, bool), page_h: f64) -> String { + let (x0, y0, x1, y1) = (rect.x, rect.y, rect.right(), rect.bottom()); + let pt = |q: (f64, f64)| format!("{} {}", n(q.0), n(page_h - q.1)); + // Corner `i`: its point, then where its curve starts and ends, going clockwise. + let corner = |i: usize| { + let r = radii.get(i).copied().unwrap_or(0.0); + match i { + 0 => ((x0, y0), (x0, y0 + r), (x0 + r, y0)), + 1 => ((x1, y0), (x1 - r, y0), (x1, y0 + r)), + 2 => ((x1, y1), (x1, y1 - r), (x1 - r, y1)), + _ => ((x0, y1), (x0 + r, y1), (x0, y1 - r)), + } + }; + // Corner `i`'s curve, from its start: a quarter circle as one Bézier curve. + let curve = |i: usize| { + if radii.get(i).copied().unwrap_or(0.0) <= 0.0 { + return String::new(); + } + let (p, a, b) = corner(i); + const K: f64 = 0.5523; + let (c1, c2) = if inverted.get(i).copied().unwrap_or(false) { + // Centred on the corner itself. + ((a.0 + (b.0 - p.0) * K, a.1 + (b.1 - p.1) * K), (b.0 + (a.0 - p.0) * K, b.1 + (a.1 - p.1) * K)) + } else { + ((a.0 + (p.0 - a.0) * K, a.1 + (p.1 - a.1) * K), (b.0 + (p.0 - b.0) * K, b.1 + (p.1 - b.1) * K)) + }; + format!("{} {} {} c ", pt(c1), pt(c2), pt(b)) + }; + // Along corners `from`, joining them with straight sides. + let along = |from: &[usize]| { + let mut s = String::new(); + for &i in from { + s.push_str(&format!("{} l {}", pt(corner(i).1), curve(i))); + } + s + }; + match open { + (false, false) => format!("{} m {}{}{}{}h ", pt(corner(0).1), curve(0), along(&[1]), along(&[2]), along(&[3])), + (true, false) => format!("{} m {}{} l ", pt((x1, y0)), along(&[2, 3]), pt((x0, y0))), + (false, true) => format!("{} m {}{} l ", pt((x0, y1)), along(&[0, 1]), pt((x1, y1))), + (true, true) => format!("{} m {} l {} m {} l ", pt((x0, y0)), pt((x0, y1)), pt((x1, y0)), pt((x1, y1))), + } +} + /// A PDF appearance-state name for a check button's on value. fn state_name(on: &str) -> String { let s: String = on.trim().chars().map(|c| if c.is_whitespace() || "()<>[]{}/%#".contains(c) || c.is_control() { '_' } else { c }).collect(); @@ -619,6 +690,7 @@ impl Emitter<'_> { Item::Fill { rect, color } => { content.extend(format!("{} rg {} {} {} {} re f\n", rgb(*color), n(rect.x), n(h - rect.bottom()), n(rect.w), n(rect.h)).bytes()); } + Item::Rounded(r) => content.extend(rounded(r, h).bytes()), Item::Line { from, to, width, color, dashed } => { content.extend( format!( diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 21bb2af9d..176e1c4ee 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2265,6 +2265,104 @@ fn a_multi_line_field_without_a_height_grows_to_hold_its_text() { assert!((notes.rect.h - 108.0).abs() < 0.01, "{:?}", notes.rect); } +/// The rounded boxes laid out on `page`, in order. +fn rounded(page: &Page) -> Vec<&crate::layout::Rounded> { + page.items.iter().filter_map(|i| if let Item::Rounded(r) = i { Some(r.as_ref()) } else { None }).collect() +} + +/// A border or rectangle with rounded corners is one shape, filled and edged along its curves: +/// one corner applies to all four, a corner not given repeats the last one given, a radius is at +/// most half the box's shorter side, and inverted corners are kept. Hidden corners, and edges +/// that differ from each other, keep the square box. +#[test] +fn rounded_corners_make_a_border_one_rounded_shape() { + let draw = |name: &str, y: f64, border: &str| { + format!(r#"{name}{border}"#) + }; + let body = [ + draw("round", 0.0, r#""#), + draw("mixed", 1.2, r#""#), + draw("huge", 2.4, r#""#), + draw("hiddenCorner", 3.6, r#""#), + draw("unlikeEdges", 4.8, r#""#), + draw("fillOnly", 6.0, r#""#), + r#""#.to_string(), + ] + .concat(); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &format!(r#"{body}"#))) + .unwrap(); + let shapes = rounded(&form.pages[0]); + let at = |y: f64| shapes.iter().copied().find(|r| (r.rect.y - 36.0 - y * 72.0).abs() < 0.01); + let round = at(0.0).expect("the rounded border is one shape"); + assert_eq!((round.rect.w, round.rect.h, round.radii), (144.0, 72.0, [18.0; 4])); + assert_eq!((round.fill, round.edge), (Some([230.0 / 255.0; 3]), Some((1.0, [1.0, 0.0, 0.0], false)))); + let mixed = at(1.2).expect("the box with two corners given"); + assert_eq!((mixed.radii, mixed.inverted), ([7.2, 14.4, 14.4, 14.4], [false, true, true, true])); + assert_eq!(at(2.4).expect("the huge radius").radii, [36.0; 4], "at most half the shorter side"); + assert!(at(3.6).is_none() && at(4.8).is_none(), "hidden corners and unlike edges keep a square box"); + let fill_only = at(6.0).expect("a fill with hidden edges is rounded too"); + assert_eq!((fill_only.fill.is_some(), fill_only.edge), (true, None)); + let shape = at(7.2).expect("a rectangle drawn as a value"); + assert!((shape.radii[0] - 5.08 * 72.0 / 25.4).abs() < 1e-9 && shape.edge.is_some()); + // The square boxes are drawn as before: edges as lines. + let lines_at = |y: f64| { + form.pages[0] + .items + .iter() + .filter(|i| matches!(i, Item::Line { from, .. } if from.1 >= 36.0 + y * 72.0 - 1.0 && from.1 <= 36.0 + (y + 1.0) * 72.0 + 1.0)) + .count() + }; + assert_eq!((lines_at(3.6), lines_at(4.8)), (4, 2)); +} + +/// A rounded box a page break cuts keeps its rounded corners on each page, square where it was +/// cut, and its edge stops there instead of closing across the cut. +#[test] +fn a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page() { + // A 12-inch box in a 10-inch content area: it is cut at the area's bottom. + let body = r#"Note"#; + let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap(); + assert_eq!(form.pages.len(), 2); + let (first, last) = (rounded(&form.pages[0]), rounded(&form.pages[1])); + assert_eq!((first.len(), last.len()), (1, 1), "{first:?} {last:?}"); + assert_eq!((first[0].radii, first[0].open_top, first[0].open_bottom), ([18.0, 18.0, 0.0, 0.0], false, true)); + assert_eq!((last[0].radii, last[0].open_top, last[0].open_bottom), ([0.0, 0.0, 18.0, 18.0], true, false)); + assert!((first[0].rect.h + last[0].rect.h - 12.0 * 72.0).abs() < 0.01, "the two parts make the whole box"); +} + +/// A rounded box is written as one path: filled along its curves, then edged half its width +/// inside, the curves as quarter circles. An inverted corner curves into the box, and the edge +/// of a box a page break cut doesn't close across the cut. +#[test] +fn rounded_boxes_are_written_as_curved_paths() { + use crate::layout::Rounded; + // A 2-by-1-inch box at the page's top left, on a page 792pt tall. + let shape = |inverted: [bool; 4], edge: bool, open: (bool, bool)| Rounded { + rect: Rect::new(36.0, 36.0, 144.0, 72.0), + radii: [18.0; 4], + inverted, + fill: Some([0.9, 0.9, 0.9]), + edge: edge.then_some((2.0, [1.0, 0.0, 0.0], false)), + open_top: open.0, + open_bottom: open.1, + }; + let content = |r: Rounded| crate::pdf::rounded(&r, 792.0); + let ops = content(shape([false; 4], true, (false, false))); + // The fill: from the top-left corner's start, a quarter circle to (54, 36), closed. + assert!(ops.contains("0.9 0.9 0.9 rg 36 738 m 36 747.941 44.059 756 54 756 c "), "{ops}"); + assert!(ops.contains(" h f\n"), "{ops}"); + // The edge: 1pt inside, the corners 1pt tighter. + assert!(ops.contains("q 1 0 0 RG 2 w 37 738 m 37 747.389 44.611 755 54 755 c "), "{ops}"); + assert!(ops.contains(" h S Q\n"), "{ops}"); + // Inverted: the curve bends towards the box's inside. + let inv = content(shape([true; 4], false, (false, false))); + assert!(inv.contains("36 738 m 45.941 738 54 746.059 54 756 c "), "{inv}"); + // Cut at the bottom: the edge runs from the bottom left round the top and back down, open. + let cut = content(Rounded { radii: [18.0, 18.0, 0.0, 0.0], ..shape([false; 4], true, (false, true)) }); + let edge = cut.lines().find(|l| l.contains(" RG ")).unwrap_or_default(); + 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}"); +} + #[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 1d7fce3e7..cf3d07fe6 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"] -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, 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"] +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)" [[feature]] id = "form.xfa-formcalc"