From 2afbf55cd27cf9bec8c215f97802eee4b4a2a5d4 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 23:36:50 +0100 Subject: [PATCH] M12: an XFA draw placed by position turns about its anchor Nothing read rotate, so a turned line, picture or label was drawn as if it stood upright: part of #5561. XFA gives the angle in degrees counter-clockwise about the anchor, and Acrobat's flattened page for that issue's form puts the three turned lines exactly there, which the test holds it to. A turned draw is one item: a page break moves it whole, and what it holds is written unturned under one matrix, so text, pictures and borders need nothing of their own. The page count and link annotations look inside it, and its container grows to the box it stands in. Fields, subforms and draws in a flow don't turn yet. That issue's own form is static, so it already shows Acrobat's page: this is for dynamic forms. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/xfa/README.md | 5 +- crates/xfa/src/layout.rs | 21 +- crates/xfa/src/layout/common.rs | 6 + crates/xfa/src/layout/draw.rs | 24 ++- crates/xfa/src/layout/items.rs | 16 ++ crates/xfa/src/layout/measure.rs | 4 +- crates/xfa/src/model.rs | 23 +++ crates/xfa/src/parse.rs | 11 + crates/xfa/src/pdf.rs | 84 +++++++- crates/xfa/src/tests.rs | 339 +++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 11 files changed, 516 insertions(+), 21 deletions(-) diff --git a/crates/xfa/README.md b/crates/xfa/README.md index 2d08a74bb2..2ae840a8f0 100644 --- a/crates/xfa/README.md +++ b/crates/xfa/README.md @@ -32,7 +32,10 @@ that warning and keeps the existing layout and accepted widget values. page masters chosen by occurrence; a table's header row repeats after a break; presence `hidden` takes no space, `invisible` takes space; repeating subforms get their initial instances, in flowed content and in a top-to-bottom container inside positioned content (a - table in an area) alike; fields that compute the page number or count on layout show the numbers. + table in an area) alike; fields that compute the page number or count on layout show the numbers; + a draw placed by position turns about its anchor (`rotate`: degrees counter-clockwise, a multiple + of 90), as one item a page break doesn't cut, and its container grows to the box it stands in. A + field, a subform or a draw in a flow doesn't turn. - **PDF** (`pdf::write_form`): pages with content streams in the standard 14 fonts (Arial and friends → Helvetica, Courier New → Courier, serif faces → Times; nothing is embedded), JPEG XObjects, and widgets: text (multi-line, max length, alignment), date (Acrobat's `AFDate` diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 73c6bde90f..eab26c6dd4 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -32,7 +32,7 @@ use common::collect_values; pub(crate) use common::date_pattern; pub use common::formatted_value; pub(crate) use items::UNDERLINE_DROP; -pub use items::{Action, BorderShape, Form, Item, Page, Rounded, Widget, WidgetBorder, WidgetKind}; +pub use items::{Action, BorderShape, Form, Item, Page, Rounded, Turned, Widget, WidgetBorder, WidgetKind}; /// Most pages a form may lay out to. pub const MAX_PAGES: usize = 500; @@ -212,17 +212,22 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result let total = l.pages.len(); let count = total.to_string(); for p in &mut l.pages { - for it in &mut p.items { - if let Item::Text(s) = it - && s.text.contains(PAGE_COUNT_MARK) - { - s.text = s.text.replace(PAGE_COUNT_MARK, &count); - } - } + fill_page_count(&mut p.items, &count); } Ok(Form { pages: l.pages, fields: l.fields, warnings: l.warnings }) } +/// Puts the page count, known once every page is, where `items` left room for it. +fn fill_page_count(items: &mut [Item], count: &str) { + for it in items { + match it { + Item::Text(s) if s.text.contains(PAGE_COUNT_MARK) => s.text = s.text.replace(PAGE_COUNT_MARK, count), + Item::Turned(t) => fill_page_count(&mut t.items, count), + _ => {} + } + } +} + // Bytes of embedded field values copied out on this thread, for tests that bound layout work. #[cfg(test)] thread_local! { diff --git a/crates/xfa/src/layout/common.rs b/crates/xfa/src/layout/common.rs index 625f305a6b..54259ede47 100644 --- a/crates/xfa/src/layout/common.rs +++ b/crates/xfa/src/layout/common.rs @@ -51,6 +51,7 @@ pub(super) 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::Turned(t) => Some(t.extent), Item::Fill { .. } | Item::Line { .. } | Item::Rounded(_) => None, } } @@ -130,6 +131,11 @@ pub(super) fn moved(mut item: Item, dx: f64, dy: f64) -> Item { w.rect.x += dx; w.rect.y += dy; } + Item::Turned(t) => { + t.about = (t.about.0 + dx, t.about.1 + dy); + t.extent = (t.extent.0 + dy, t.extent.1 + dy); + t.items = std::mem::take(&mut t.items).into_iter().map(|i| moved(i, dx, dy)).collect(); + } } item } diff --git a/crates/xfa/src/layout/draw.rs b/crates/xfa/src/layout/draw.rs index f6520c1628..02659c812e 100644 --- a/crates/xfa/src/layout/draw.rs +++ b/crates/xfa/src/layout/draw.rs @@ -1,5 +1,5 @@ use super::common::clean_name; -use super::items::{Item, Rounded}; +use super::items::{Item, Rounded, Turned}; use super::{ Arc, Border, Ctx, Draw, ExclGroup, Face, Font, Layout, Layouter, MAX_DEPTH, MAX_IMAGE_BYTES, Node, Para, Presence, Rect, SomIndices, Stroke, Subform, Value, XfaError, data_path, text, @@ -35,7 +35,27 @@ impl Layouter<'_> { } let (w, h) = self.size_of(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1); let (x, y) = c.top_left(w, h); - self.draw_node(k, Rect::new(inner.x + x, inner.y + y, w, h), ctx, depth + 1, sib) + let rect = Rect::new(inner.x + x, inner.y + y, w, h); + let start = self.item_count(); + self.draw_node(k, rect, ctx, depth + 1, sib)?; + if c.rotate != 0 && matches!(k, Node::Draw(_)) { + let stands = k.standing_box(w, h); + let extent = (inner.y + stands.y, inner.y + stands.bottom()); + self.turn(start, (inner.x + c.x.unwrap_or(0.0), inner.y + c.y.unwrap_or(0.0)), c.rotate, extent); + } + Ok(()) + } + + /// Turn the items the last page got since `start`, a draw, about `about`. + fn turn(&mut self, start: usize, about: (f64, f64), quarters: u8, extent: (f64, f64)) { + let Some(page) = self.pages.last_mut().filter(|p| start < p.items.len()) else { return }; + let items = page.items.split_off(start); + page.items.push(Item::Turned(Box::new(Turned { about, quarters, extent, items }))); + // One item now, kept whole by what it is: the record of its items, the last one kept, + // would fall on the next. + if self.leaves.last().is_some_and(|leaf| leaf.0 == start) { + self.leaves.pop(); + } } pub(super) fn draw_node(&mut self, node: &Node, rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { diff --git a/crates/xfa/src/layout/items.rs b/crates/xfa/src/layout/items.rs index 8a68388684..be9499cb84 100644 --- a/crates/xfa/src/layout/items.rs +++ b/crates/xfa/src/layout/items.rs @@ -27,6 +27,22 @@ pub enum Item { content_type: String, }, Widget(Box), + /// A draw turned about its anchor. + Turned(Box), +} + +/// A draw turned about its anchor ([`Item::Turned`]). It stays one item, so a page break doesn't +/// cut it, and what it holds is written unturned under one matrix. +#[derive(Clone, Debug, PartialEq)] +pub struct Turned { + /// The point it turns about. + pub about: (f64, f64), + /// Quarter turns counter-clockwise, 1 to 3. + pub quarters: u8, + /// Top and bottom of its box once turned. + pub extent: (f64, f64), + /// The draw as it stands before turning. + pub items: Vec, } /// A box with rounded corners ([`Item::Rounded`]). diff --git a/crates/xfa/src/layout/measure.rs b/crates/xfa/src/layout/measure.rs index 2fffb3dfa0..32250ab02f 100644 --- a/crates/xfa/src/layout/measure.rs +++ b/crates/xfa/src/layout/measure.rs @@ -141,7 +141,7 @@ impl Layouter<'_> { Layout::Positioned => visible .map(|k| { let (w, h) = self.size_of(k, avail_w, ctx, depth + 1); - k.common().top_left(w, h).0 + w + k.standing_box(w, h).right() }) .fold(0.0, f64::max) .min(avail_w), @@ -297,7 +297,7 @@ impl Layouter<'_> { .map(|k| { let c = k.common(); let (w, h) = self.size_of(k, (inner_w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1); - c.top_left(w, h).1 + h + k.standing_box(w, h).bottom() }) .fold(0.0, f64::max) } diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index 0fac8db18e..23b685102c 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -334,6 +334,9 @@ pub struct Common { pub x: Option, pub y: Option, pub anchor: Anchor, + /// Quarter turns counter-clockwise about the anchor (`rotate`), 0 to 3. Only a draw placed by + /// position is turned. + pub rotate: u8, pub w: Option, pub h: Option, pub min_w: Option, @@ -366,6 +369,26 @@ impl Common { } impl Node { + /// The box a positioned object of `w` by `h` stands in, in its parent: from `x`, `y` and the + /// anchor, and for a draw, the only object that turns, turned about the anchor. + pub fn standing_box(&self, w: f64, h: f64) -> Rect { + let c = self.common(); + let (x, y) = c.top_left(w, h); + let upright = Rect::new(x, y, w, h); + if !matches!(self, Node::Draw(_)) { + return upright; + } + let (ax, ay) = (c.x.unwrap_or(0.0), c.y.unwrap_or(0.0)); + let (left, top, right, bottom) = (x - ax, y - ay, x + w - ax, y + h - ay); + // Counter-clockwise on a page whose y grows down, from the anchor. + match c.rotate { + 1 => Rect::new(ax + top, ay - right, h, w), + 2 => Rect::new(ax - right, ay - bottom, w, h), + 3 => Rect::new(ax - bottom, ay + left, h, w), + _ => upright, + } + } + pub fn common(&self) -> &Common { match self { Node::Subform(s) => &s.common, diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index fc114aebac..9ca6a7f3cd 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -198,6 +198,16 @@ fn parse_para(n: Option) -> ParaSpec { } } +/// `rotate` as quarter turns (XFA 3.3: degrees counter-clockwise, a multiple of 90, `deg` optional): +/// anything else leaves the object as it stands. +fn quarter_turns(rotate: Option<&str>) -> u8 { + let degrees = rotate.map(str::trim).map(|v| v.strip_suffix("deg").unwrap_or(v).trim_end()); + let turns = degrees.and_then(|v| v.parse::().ok()).map(|v| v.rem_euclid(360.0) / 90.0); + // Whole, which NaN and infinities aren't, and in range, so the cast is exact; a tiny negative + // angle rounds to a full turn. + turns.filter(|t| t.fract() == 0.0).map_or(0, |t| t as u8 % 4) +} + fn parse_common(n: XmlNode) -> Common { Common { name: n.attribute("name").map(str::to_string).filter(|s| !s.is_empty()), @@ -205,6 +215,7 @@ fn parse_common(n: XmlNode) -> Common { x: attr_measure(n, "x"), y: attr_measure(n, "y"), anchor: Anchor::parse(n.attribute("anchorType")), + rotate: quarter_turns(n.attribute("rotate")), w: attr_measure(n, "w"), h: attr_measure(n, "h"), min_w: attr_measure(n, "minW"), diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 0c21507abf..8e9b30c00d 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, Rounded, Widget, WidgetKind}; +use crate::layout::{Action, BorderShape, Form, Item, Page, Rounded, Turned, Widget, WidgetKind}; use crate::model::Rect; use crate::text::{Face, Family, Span}; @@ -567,12 +567,30 @@ impl Emitter<'_> { let h = page.height; // The link the text before ended in, which a next piece of it on the line widens. let mut last_link: Option = None; - for (i, item) in page.items.iter().enumerate() { + let steps = steps(&page.items); + let mut turn = None; + for (i, step) in steps.iter().enumerate() { + let item = match step { + Step::Item(item) => *item, + Step::Turn(t) => { + let matrix = turn_matrix(t, h); + turn = Some(matrix); + last_link = None; + content.extend(format!("q {} cm\n", matrix.map(n).join(" ")).bytes()); + continue; + } + Step::EndTurn => { + turn = None; + last_link = None; + content.extend_from_slice(b"Q\n"); + continue; + } + }; // A link's pieces join only across text and the underline drawn just before it: a fill, // picture, field or any other line (a rule) between them ends the link there. let keeps_link = match item { Item::Text(_) => true, - Item::Line { from, to, .. } => matches!(page.items.get(i + 1), Some(Item::Text(s)) + Item::Line { from, to, .. } => matches!(steps.get(i + 1), Some(Step::Item(Item::Text(s))) if (from.1 - to.1).abs() < 0.01 && (from.0 - s.x).abs() < 0.01 && (from.1 - (s.baseline + s.face.size * crate::layout::UNDERLINE_DROP)).abs() < 0.01), @@ -627,7 +645,7 @@ impl Emitter<'_> { ); content.extend(literal(&win_ansi(&s.text))); content.extend_from_slice(format!(" Tj{close}\n").as_bytes()); - if let Some(r) = self.text_link(s, h, page_ref, &mut last_link)? { + if let Some(r) = self.text_link(s, h, page_ref, &mut last_link, turn)? { annots.push(Object::Ref(r)); } } @@ -637,6 +655,8 @@ impl Emitter<'_> { xobjects.set(name.clone().into_bytes(), Object::Ref(r)); content.extend(format!("q {} 0 0 {} {} {} cm /{name} Do Q\n", n(rect.w), n(rect.h), n(rect.x), n(h - rect.bottom())).bytes()); } + // Opened out by `steps`. + Item::Turned(_) => {} Item::Widget(w) => { if let Some(r) = self.widget(w, page, page_ref)? { annots.push(Object::Ref(r)); @@ -675,12 +695,25 @@ impl Emitter<'_> { /// A span that goes on the link `last` ended in, on the same line, widens that annotation /// instead (a link whose text changes style partway is one link). An address PdfCraft /// wouldn't open from a document leaves the text as plain text, with one warning for the form. - fn text_link(&mut self, s: &Span, page_height: f64, page_ref: ObjRef, last: &mut Option) -> Result, XfaError> { + fn text_link( + &mut self, + s: &Span, + page_height: f64, + page_ref: ObjRef, + last: &mut Option, + turn: Option<[f64; 6]>, + ) -> Result, XfaError> { let piece = self.link_piece(s, page_height); - let Some((target, rect)) = piece else { + let Some((target, mut rect)) = piece else { *last = None; return Ok(None); }; + // Turned text is drawn by a matrix, and clicked where that puts it. Its pieces don't join: + // that goes by the line they stand on before turning. + if let Some(m) = turn { + rect = turned_rect(rect, m); + *last = None; + } if let Some(prev) = last.as_mut() && prev.uri == target && (prev.baseline - s.baseline).abs() < 0.01 @@ -765,6 +798,45 @@ fn write_button_action(action: Option<&Action>, d: &mut Dict) { } } +/// What a page's content is written from: its items, with a turned draw opened out. +enum Step<'a> { + Item(&'a Item), + Turn(&'a Turned), + EndTurn, +} + +fn steps(items: &[Item]) -> Vec> { + let mut out = Vec::with_capacity(items.len()); + for item in items { + if let Item::Turned(t) = item { + out.push(Step::Turn(t)); + out.extend(t.items.iter().map(Step::Item)); + out.push(Step::EndTurn); + } else { + out.push(Step::Item(item)); + } + } + out +} + +/// The matrix that turns `t` about its point, on a page `page_height` tall. +fn turn_matrix(t: &Turned, page_height: f64) -> [f64; 6] { + let (cos, sin) = match t.quarters { + 1 => (0.0, 1.0), + 2 => (-1.0, 0.0), + _ => (0.0, -1.0), + }; + let (x, y) = (t.about.0, page_height - t.about.1); + [cos, sin, -sin, cos, x - cos * x + sin * y, y - sin * x - cos * y] +} + +/// Rectangle `r`, `[left, bottom, right, top]`, under quarter-turn matrix `m`. +fn turned_rect(r: [f64; 4], m: [f64; 6]) -> [f64; 4] { + let at = |x: f64, y: f64| (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]); + let (a, b) = (at(r[0], r[1]), at(r[2], r[3])); + [a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)] +} + /// The link annotation a page's text last ended in: the next piece of the same link on the same /// line widens it. struct LinkPiece { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index e424be5441..e790144dbb 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -4145,3 +4145,342 @@ fn clear_password_values_clears_static_xfa_password_fields() { let packets = read_packets(&doc).unwrap().unwrap(); assert!(!packets.xdp.contains("hunter2"), "{}", packets.xdp); } + +/// A form with `draws` in a subform placed by position, on a letter page whose content area is a +/// quarter inch in, with `master` on the page itself. +fn form_of_draws(master: &str, draws: &str) -> String { + format!( + r#""# + ) +} + +/// A line from the top-left to the bottom-right of a box 0.7in × 0.4in, as #5561's form draws. +fn line_draw(x: &str, y: &str, rotate: &str) -> String { + format!(r#""#) +} + +/// `xdp` laid out into its document, and that document's first page. +fn written(xdp: &str) -> (Document, Dict) { + let mut doc = Document::open(Arc::new(shell(xdp))).unwrap(); + render_into(&mut doc).unwrap(); + let root = doc.get(doc.root().unwrap()); + let pages = doc.get(root.as_dict().unwrap().reference(b"Pages").unwrap()); + let kids = pages.as_dict().unwrap().get(b"Kids").map(|k| doc.resolve(k)).unwrap(); + let page = doc.get(kids.as_array().unwrap()[0].as_ref().unwrap()); + let page = page.as_dict().unwrap().clone(); + (doc, page) +} + +fn page_content(doc: &Document, page: &Dict) -> String { + let contents = doc.get(page.reference(b"Contents").unwrap()); + let Object::Stream(s) = &*contents else { panic!("not a stream") }; + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() +} + +/// The ends of each line `content` strokes, on the page: through the matrices around it. +fn stroked_lines(content: &str) -> Vec<[(f64, f64); 2]> { + let (mut stack, mut matrix) = (Vec::new(), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + let (mut numbers, mut from, mut lines): (Vec, _, _) = (Vec::new(), (0.0, 0.0), Vec::new()); + for token in content.split_whitespace() { + let at = |m: [f64; 6], x: f64, y: f64| (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]); + let last = |numbers: &[f64]| (numbers[numbers.len() - 2], numbers[numbers.len() - 1]); + match token { + "q" => stack.push(matrix), + "Q" => matrix = stack.pop().unwrap(), + "cm" => { + let n: [f64; 6] = numbers[numbers.len() - 6..].try_into().unwrap(); + let m = matrix; + matrix = [ + n[0] * m[0] + n[1] * m[2], + n[0] * m[1] + n[1] * m[3], + n[2] * m[0] + n[3] * m[2], + n[2] * m[1] + n[3] * m[3], + n[4] * m[0] + n[5] * m[2] + m[4], + n[4] * m[1] + n[5] * m[3] + m[5], + ]; + } + "m" => { + let (x, y) = last(&numbers); + from = at(matrix, x, y); + } + "l" => { + let (x, y) = last(&numbers); + lines.push([from, at(matrix, x, y)]); + } + _ => {} + } + match token.parse() { + Ok(v) => numbers.push(v), + Err(_) => numbers.clear(), + } + } + lines +} + +fn turned(page: &Page) -> Vec<&crate::layout::Turned> { + page.items.iter().filter_map(|i| if let Item::Turned(t) = i { Some(t.as_ref()) } else { None }).collect() +} + +/// A draw turns counter-clockwise about its anchor. The figures are where Acrobat put these lines +/// on the page it flattened for #5561's form: from the anchor by (+50.4, -28.8) unturned, and by +/// (+28.8, +50.4), (-50.4, +28.8) and (-28.8, -50.4) at 90, 180 and 270 degrees. +#[test] +fn a_draw_turns_counter_clockwise_about_its_anchor() { + let draws = [ + line_draw("1in", "0.95in", ""), + line_draw("3in", "0.95in", r#"rotate="90""#), + line_draw("5in", "0.95in", r#"rotate="180""#), + line_draw("7in", "0.95in", r#"rotate="270""#), + ] + .concat(); + let (doc, page) = written(&form_of_draws("", &draws)); + let lines = stroked_lines(&page_content(&doc, &page)); + assert_eq!(lines.len(), 4, "{lines:?}"); + let top = 792.0 - 18.0 - 0.95 * 72.0; + let acrobat = [((90.0, top), (50.4, -28.8)), ((234.0, top), (28.8, 50.4)), ((378.0, top), (-50.4, 28.8)), ((522.0, top), (-28.8, -50.4))]; + for ([from, to], (anchor, by)) in lines.iter().zip(acrobat) { + assert!((from.0 - anchor.0).abs() < 0.01 && (from.1 - anchor.1).abs() < 0.01, "{from:?} for {anchor:?}"); + let moved = (to.0 - from.0, to.1 - from.1); + assert!((moved.0 - by.0).abs() < 0.01 && (moved.1 - by.1).abs() < 0.01, "{moved:?} for {by:?}"); + } +} + +/// The anchor is the point it turns about, whichever corner that is: a box anchored at its bottom +/// right stands up and to the left of it, not where a top-left anchor would turn it. +#[test] +fn a_draw_turns_about_the_anchor_it_names() { + let draw = r#""#; + let (doc, page) = written(&form_of_draws("", draw)); + let lines = stroked_lines(&page_content(&doc, &page)); + let anchor = (18.0 + 216.0, 792.0 - 18.0 - 72.0); + // Its bottom right is 0.7in right and 0.4in down of its top left, here turned to 0.7in up and + // 0.4in right: so the top left is that far back from the anchor, and the line ends on it. + let [from, to] = lines[0]; + assert!((to.0 - anchor.0).abs() < 0.01 && (to.1 - anchor.1).abs() < 0.01, "{to:?} for {anchor:?}"); + assert!((from.0 - (anchor.0 - 28.8)).abs() < 0.01 && (from.1 - (anchor.1 - 50.4)).abs() < 0.01, "{from:?}"); +} + +/// Everything a draw shows turns with it, under one matrix: its text, its picture and its border, +/// rounded or not, each written as if it stood upright. +#[test] +fn text_pictures_and_borders_turn_with_their_draw() { + let picture = format!( + r#"{}"#, + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, tiny_png()) + ); + for (draw, shows) in [ + (r#"upright"#.to_string(), "(upright) Tj"), + (picture, " Do "), + (r#""#.to_string(), " l S"), + ( + r#""#.to_string(), + " c", + ), + (r#"x"#.to_string(), " re f"), + ] { + let (doc, page) = written(&form_of_draws("", &draw)); + let content = page_content(&doc, &page); + let open = content.find("0 1 -1 0 ").unwrap_or_else(|| panic!("no matrix for {draw}: {content}")); + let close = open + content[open..].find("\nQ\n").unwrap_or_else(|| panic!("no end for {draw}: {content}")); + assert!(content[open..close].contains(shows), "{shows} outside the matrix: {content}"); + } +} + +/// `rotate` as a multiple of 90 degrees, with a unit or not, past a full turn or below none; +/// anything else leaves the draw as it stands. +#[test] +fn rotate_reads_as_quarter_turns() { + let quarters = |rotate: &str| { + let form = layout_xml(&form_of_draws("", &line_draw("1in", "1in", &format!(r#"rotate="{rotate}""#)))).unwrap(); + turned(&form.pages[0]).first().map_or(0, |t| t.quarters) + }; + for (rotate, want) in [("90", 1), ("180", 2), ("270", 3), ("0", 0), ("360", 0), ("450", 1), ("-90", 3), ("90deg", 1), (" 90 ", 1), ("90.0", 1)] { + assert_eq!(quarters(rotate), want, "{rotate}"); + } + for rotate in ["45", "89.5", "135", "225", "abc", "", "deg", "NaN", "inf", "-inf"] { + assert_eq!(quarters(rotate), 0, "{rotate}"); + } + // Far past what a turn can be told apart at, or just under none: in range whatever it reads as. + for rotate in ["1e300", "-1e-300"] { + assert!(quarters(rotate) < 4, "{rotate}"); + } +} + +/// A turned draw is one item: a page break moves it whole, what it holds moving with the point it +/// turns about, and doesn't cut it, and the draws around it stay where they belong. +#[test] +fn a_page_break_moves_a_turned_draw_whole() { + let draws = format!( + r#"acrossfirst{}{}last"#, + [ + line_draw("3in", "12in", r#"rotate="90""#), + r#"sideways"#.to_string(), + ] + .concat(), + (2..8).map(|y| line_draw("1in", &format!("{y}in"), "")).collect::() + ); + let xdp = form_of_draws("", &draws).replace(r#"h="2.5in""#, r#"h="14in""#); + let form = layout_xml(&xdp).unwrap(); + assert!(form.pages.len() >= 2, "{}", form.pages.len()); + assert_eq!(texts(&form.pages[0]), ["first"]); + // Written after the turned draws, these lines still belong at the top of the block: what the + // break knew of the draw's many items mustn't carry over to those that now stand in their places. + let lines = form.pages[0].items.iter().filter(|i| matches!(i, Item::Line { .. })).count(); + assert_eq!(lines, 6, "{:?}", form.pages[0].items); + assert_eq!(turned(&form.pages[0]).len(), 0); + let moved = turned(&form.pages[1]); + assert_eq!(moved.len(), 2, "{:?}", form.pages[1].items); + let Item::Line { from, .. } = &moved[0].items[0] else { panic!("not a line") }; + assert_eq!(*from, moved[0].about, "the line moved with the point it turns about"); + // Turned 90 degrees, a box 0.7in wide stands 0.7in up from its anchor, on a second page. + assert!(moved[0].about.1 < 792.0, "{:?}", moved[0].about); + assert!((moved[0].extent.1 - moved[0].about.1).abs() < 0.01, "{:?}", moved[0]); + assert!((moved[0].about.1 - moved[0].extent.0 - 50.4).abs() < 0.01, "{:?}", moved[0]); + let moved_text = texts(&form.pages[1]); + assert!(moved_text.contains(&"last".to_string()) && moved_text.contains(&"across".to_string()), "{moved_text:?}"); +} + +/// What a page break goes by is the box as it stands once turned, whichever way, about whichever +/// corner: from a top-left anchor, it stands up, up or down of it; from a bottom-right one, the +/// box starts behind the anchor and the same turns take it the other way. +#[test] +fn a_turned_draw_knows_where_it_stands() { + let box_at = |anchor: &str, rotate: u32| { + let draw = format!( + r#""# + ); + let form = layout_xml(&form_of_draws("", &draw)).unwrap(); + let held = turned(&form.pages[0]); + let t = held.first().expect("turned"); + (t.extent.0 - t.about.1, t.extent.1 - t.about.1) + }; + let close = |got: (f64, f64), want: (f64, f64)| (got.0 - want.0).abs() < 0.01 && (got.1 - want.1).abs() < 0.01; + for (rotate, want) in [(90, (-50.4, 0.0)), (180, (-28.8, 0.0)), (270, (0.0, 50.4))] { + assert!(close(box_at("", rotate), want), "{rotate}: {:?}", box_at("", rotate)); + } + for (rotate, want) in [(90, (0.0, 50.4)), (180, (0.0, 28.8)), (270, (-50.4, 0.0))] { + let got = box_at(r#"anchorType="bottomRight""#, rotate); + assert!(close(got, want), "{rotate}: {got:?}"); + } +} + +/// The page count is filled in inside a turned draw too, as it comes last, when every page is known. +#[test] +fn the_page_count_reaches_a_turned_draw() { + let master = r##"

of

"##; + let form = layout_xml(&form_of_draws(master, &line_draw("1in", "1in", ""))).unwrap(); + let held = turned(&form.pages[0]); + assert_eq!(held.len(), 1, "{:?}", form.pages[0].items); + let text: Vec<&str> = held[0].items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s.text.as_str()) } else { None }).collect(); + assert_eq!(text.concat().trim(), "of 1", "{text:?}"); +} + +/// A link in turned text is clicked where the text is drawn, whichever way it turned: the box it +/// has unturned, turned about the draw's anchor. +#[test] +fn a_link_turns_with_its_text() { + let rect_at = |rotate: &str| { + let draw = format!( + r#"

a link in turned text

"# + ); + let (doc, page) = written(&form_of_draws("", &draw)); + let annots = page.get(b"Annots").map(|a| doc.resolve(a)).expect("the link"); + let annot = doc.get(annots.as_array().unwrap()[0].as_ref().unwrap()); + let rect: Vec = annot.as_dict().unwrap().get(b"Rect").unwrap().as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + rect + }; + let upright = rect_at(""); + let (ax, ay) = (18.0 + 72.0, 792.0 - 18.0 - 216.0); + let (left, bottom, right, top) = (upright[0] - ax, upright[1] - ay, upright[2] - ax, upright[3] - ay); + assert!(left >= -0.01 && top <= 0.01 && right - left > 3.0 * (top - bottom), "it stands right and down of its anchor: {upright:?}"); + for (rotate, want) in [ + ("90", [ax - top, ay + left, ax - bottom, ay + right]), + ("180", [ax - right, ay - top, ax - left, ay - bottom]), + ("270", [ax + bottom, ay - right, ax + top, ay - left]), + ] { + let got = rect_at(&format!(r#"rotate="{rotate}""#)); + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 0.01), "{rotate}: {got:?} for {want:?}"); + } +} + +/// Only a draw placed by position turns: a field, and a draw in a flow, stand as they did. +#[test] +fn only_positioned_draws_turn() { + let field = r#""#; + let form = layout_xml(&form_of_draws("", field)).unwrap(); + assert!(turned(&form.pages[0]).is_empty()); + assert_eq!(widgets(&form.pages[0]).len(), 1); + let flowed = form_of_draws("", &line_draw("1in", "1in", r#"rotate="90""#)) + .replace(r#""#, r#""#); + let form = layout_xml(&flowed).unwrap(); + assert!(turned(&form.pages[0]).is_empty(), "{:?}", form.pages[0].items); + assert!(form.pages[0].items.iter().any(|i| matches!(i, Item::Line { .. })), "it is still drawn"); + // A draw that shows nothing has nothing to turn. + let form = layout_xml(&form_of_draws("", &line_draw("1in", "1in", r#"rotate="90" presence="invisible""#))).unwrap(); + assert!(form.pages[0].items.is_empty(), "{:?}", form.pages[0].items); +} + +/// A block carried over to a content area further across takes a turned draw with it, point and +/// items both. +#[test] +fn a_turned_draw_moves_across_with_its_content_area() { + let xdp = form_of_draws("", &line_draw("1in", "6in", r#"rotate="90""#)) + .replace( + r#""#, + r#""#, + ) + .replace(r#"h="2.5in""#, r#"h="7in""#); + let form = layout_xml(&xdp).unwrap(); + let moved = form.pages.iter().flat_map(turned).next().expect("turned"); + let Item::Line { from, .. } = &moved.items[0] else { panic!("not a line") }; + assert_eq!(*from, moved.about); + assert!((moved.about.0 - (4.0 + 1.0) * 72.0).abs() < 0.01, "{:?}", moved.about); +} + +/// A positioned subform with no height grows to hold what it holds as it stands, so what comes next +/// neither runs through a turned draw nor leaves the room its upright box would have had. +#[test] +fn a_container_grows_to_a_turned_draw_as_it_stands() { + let next = r#"next"#; + let baseline = |rotate: &str| { + let xdp = form_of_draws("", &line_draw("1in", "1in", rotate)).replace(r#" h="2.5in""#, "").replace("", next); + let form = layout_xml(&xdp).unwrap(); + assert_eq!(form.pages.len(), 1); + form.pages[0].items.iter().find_map(|i| if let Item::Text(s) = i { Some(s.baseline) } else { None }).expect("the next block") + }; + // Upright the box ends 0.4in below the anchor, and the next block starts there. + let upright = baseline(""); + for (rotate, bottom) in [("90", 0.0), ("180", 0.0), ("270", 50.4)] { + let got = baseline(&format!(r#"rotate="{rotate}""#)) - (upright - 28.8); + assert!((got - bottom).abs() < 0.01, "{rotate}: the next block starts {got} below the anchor, not {bottom}"); + } +} + +/// A subform with no width is as wide as what it holds as it stands, whichever way that turned, +/// so a neighbour in a row doesn't stand off from it or over it. +#[test] +fn a_container_is_as_wide_as_a_turned_draw_stands() { + let row = |rotate: &str| { + let xdp = form_of_draws( + "", + &format!( + r#"{}beside"#, + line_draw("1in", "1in", rotate) + ), + ) + .replace(r#""#, r#""#); + let form = layout_xml(&xdp).unwrap(); + form.pages[0].items.iter().find_map(|i| if let Item::Text(s) = i { Some(s.x) } else { None }).expect("the neighbour") + }; + let upright = row(""); + // Upright it ends 0.7in right of the anchor; turned, 0.4in right, at the anchor, and at it again. + for (rotate, right) in [("90", 28.8), ("180", 0.0), ("270", 0.0)] { + let got = row(&format!(r#"rotate="{rotate}""#)) - (upright - 50.4); + assert!((got - right).abs() < 0.01, "{rotate}: the neighbour stands {got} right of the anchor, not {right}"); + } + // From a bottom-right anchor the box starts behind it, and the same turns take it the other way. + for (rotate, right) in [("90", 0.0), ("180", 50.4), ("270", 28.8)] { + let got = row(&format!(r#"anchorType="bottomRight" rotate="{rotate}""#)) - (upright - 50.4); + assert!((got - right).abs() < 0.01, "{rotate}: the neighbour stands {got} right of the anchor, not {right}"); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0b6f658dd6..eecaf4f56b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5320,8 +5320,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/js/src/tests.rs::message_box_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_in_an_xfa_script_reads_app_alerts_arguments", "crates/engine/src/tests.rs::xfa_message_boxes_keep_their_title_icon_and_buttons", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout"] -notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), 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). xfa.host.messageBox keeps its title, icon and buttons, and app.alert in an XFA script reads its own argument order; every message box still answers OK (1) (#4291)." +evidence = ["crates/xfa/src/tests.rs::a_draw_turns_counter_clockwise_about_its_anchor", "crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/js/src/tests.rs::message_box_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_in_an_xfa_script_reads_app_alerts_arguments", "crates/engine/src/tests.rs::xfa_message_boxes_keep_their_title_icon_and_buttons", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout"] +notes = "A draw placed by position turns about its anchor (rotate); fields, subforms and draws in a flow don't. Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), 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). xfa.host.messageBox keeps its title, icon and buttons, and app.alert in an XFA script reads its own argument order; every message box still answers OK (1) (#4291)." [[feature]] id = "form.xfa-formcalc"