Skip to main content

xfa_layout_engine/
types.rs

1//! Core types for XFA layout — Box Model, measurements, and layout primitives.
2//!
3//! Implements XFA 3.3 §4 (Box Model) types.
4
5/// Shared default horizontal text padding, applied per side when paragraph
6/// margins are not explicitly set.
7pub const DEFAULT_TEXT_PADDING: f64 = 0.0;
8
9/// A 2D point in layout coordinates (points, 1pt = 1/72 inch).
10#[derive(Debug, Clone, Copy, PartialEq, Default)]
11pub struct Point {
12    pub x: f64,
13    pub y: f64,
14}
15
16/// A 2D size in points.
17#[derive(Debug, Clone, Copy, PartialEq, Default)]
18pub struct Size {
19    pub width: f64,
20    pub height: f64,
21}
22
23/// An axis-aligned rectangle in layout space.
24#[derive(Debug, Clone, Copy, PartialEq, Default)]
25pub struct Rect {
26    pub x: f64,
27    pub y: f64,
28    pub width: f64,
29    pub height: f64,
30}
31
32impl Rect {
33    pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
34        Self {
35            x,
36            y,
37            width,
38            height,
39        }
40    }
41
42    pub fn right(&self) -> f64 {
43        self.x + self.width
44    }
45
46    pub fn bottom(&self) -> f64 {
47        self.y + self.height
48    }
49
50    /// Check whether a point (px, py) lies inside this rectangle.
51    pub fn contains(&self, px: f64, py: f64) -> bool {
52        px >= self.x && px <= self.right() && py >= self.y && py <= self.bottom()
53    }
54}
55
56/// Inset values (margins, padding) for the four sides.
57#[derive(Debug, Clone, Copy, PartialEq, Default)]
58pub struct Insets {
59    pub top: f64,
60    pub right: f64,
61    pub bottom: f64,
62    pub left: f64,
63}
64
65impl Insets {
66    pub fn uniform(value: f64) -> Self {
67        Self {
68            top: value,
69            right: value,
70            bottom: value,
71            left: value,
72        }
73    }
74
75    pub fn horizontal(&self) -> f64 {
76        self.left + self.right
77    }
78
79    pub fn vertical(&self) -> f64 {
80        self.top + self.bottom
81    }
82}
83
84/// A measurement with a unit, parsed from XFA attributes.
85///
86/// XFA Spec 3.3 §2.2 (p36-38) — Measurements:
87///   Absolute: in (inches, default), cm, mm, pt (1/72 inch).
88///   Relative (XFA 2.8+): em (em width in current font), % (percentage of space width).
89///   Note: bare numbers default to inches for dimensions but points for font sizes.
90#[derive(Debug, Clone, Copy, PartialEq)]
91pub struct Measurement {
92    pub value: f64,
93    pub unit: MeasurementUnit,
94}
95
96impl Measurement {
97    /// Convert this measurement to points (the internal unit).
98    ///
99    /// Note: `Em` and `Percent` are relative units that depend on the current
100    /// font context. Here we use a default 12pt font for em and approximate
101    /// percentage as a fraction of the default space width (~3pt at 12pt).
102    pub fn to_points(&self) -> f64 {
103        match self.unit {
104            MeasurementUnit::Points => self.value,
105            MeasurementUnit::Inches => self.value * 72.0,
106            MeasurementUnit::Centimeters => self.value * 72.0 / 2.54,
107            MeasurementUnit::Millimeters => self.value * 72.0 / 25.4,
108            MeasurementUnit::Em => self.value * 12.0, // default 12pt font
109            // XFA §2.2: % = percentage of space (U+0020) width in current font.
110            // Approximate: space width ≈ 25% of em → 3pt at 12pt default.
111            MeasurementUnit::Percent => self.value / 100.0 * 3.0,
112        }
113    }
114
115    /// Parse a measurement string like "10mm", "1in", "72pt", "2.5cm".
116    pub fn parse(s: &str) -> Option<Self> {
117        let s = s.trim();
118        if s.is_empty() {
119            return None;
120        }
121        // Find where the numeric part ends
122        let num_end = s
123            .find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-')
124            .unwrap_or(s.len());
125        let value: f64 = s[..num_end].parse().ok()?;
126        let unit_str = s[num_end..].trim();
127        let unit = match unit_str {
128            "" | "in" => MeasurementUnit::Inches,
129            "pt" => MeasurementUnit::Points,
130            "cm" => MeasurementUnit::Centimeters,
131            "mm" => MeasurementUnit::Millimeters,
132            "em" => MeasurementUnit::Em,
133            "%" => MeasurementUnit::Percent,
134            _ => return None,
135        };
136        Some(Measurement { value, unit })
137    }
138}
139
140impl Default for Measurement {
141    fn default() -> Self {
142        Self {
143            value: 0.0,
144            unit: MeasurementUnit::Points,
145        }
146    }
147}
148
149/// Units for measurements in XFA.
150///
151/// XFA Spec 3.3 §2.2 (p37) — Absolute: in, cm, mm, pt.
152/// Relative (XFA 2.8+): em, % (percentage of space width in current font).
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum MeasurementUnit {
155    Inches,
156    Centimeters,
157    Millimeters,
158    Points,
159    Em,
160    /// Percentage of the width of a space (U+0020) in the current font.
161    Percent,
162}
163
164/// Horizontal text alignment (XFA `<para hAlign>`).
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
166pub enum TextAlign {
167    /// Left-aligned (default).
168    #[default]
169    Left,
170    /// Centered.
171    Center,
172    /// Right-aligned.
173    Right,
174    /// Justified (treated as left for simple text rendering).
175    Justify,
176}
177
178/// Layout strategy for a container.
179///
180/// XFA Spec 3.3 §2.6 (p43) — Two layout strategies:
181///   Positioned: objects at fixed x,y coordinates (default for most containers).
182///   Flowing: objects placed sequentially — tb, lr-tb, rl-tb, table, row.
183///   pageArea always uses positioned layout only.
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
185pub enum LayoutStrategy {
186    /// Fixed x,y coordinates (default for subforms).
187    #[default]
188    Positioned,
189    /// Top-to-bottom flow (layout="tb").
190    TopToBottom,
191    /// Left-to-right, top-to-bottom wrapping (layout="lr-tb").
192    LeftToRightTB,
193    /// Right-to-left, top-to-bottom wrapping (layout="rl-tb").
194    RightToLeftTB,
195    /// Table layout (layout="table").
196    Table,
197    /// Row within a table (layout="row").
198    Row,
199}
200
201/// Vertical text alignment (XFA `<para vAlign>`).
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
203pub enum VerticalAlign {
204    #[default]
205    Top,
206    Middle,
207    Bottom,
208}
209
210/// Caption placement relative to content.
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
212pub enum CaptionPlacement {
213    #[default]
214    Left,
215    Top,
216    Right,
217    Bottom,
218    Inline,
219}
220
221/// The XFA Box Model for a form element.
222///
223/// XFA Spec 3.3 §2.6 (p49-50) — Nominal extent is w × h.
224/// Inside: margins → border inset → caption region → content region.
225/// The Nominal Content Region is the area after margins are applied.
226///
227/// §8 Growability (p275-276): a container is growable if it omits h and/or w:
228/// - h=✓ w=✓ → fixed, not growable (minH/maxH/minW/maxW ignored)
229/// - h=✓ w=∅ → growable along X only (minH/maxH ignored)
230/// - h=∅ w=✓ → growable along Y only (minW/maxW ignored)
231/// - h=∅ w=∅ → growable along both axes
232///   Default: minH=0, minW=0, maxH=infinity, maxW=infinity.
233///
234/// See spec figure "Relationship between nominal extent and borders,
235/// margins, captions, and content" (p50).
236///
237/// TODO(§2.6): border inset not modeled separately — currently merged with margins.
238#[derive(Debug, Clone, PartialEq, Default)]
239pub struct BoxModel {
240    /// Nominal width (None = growable).
241    pub width: Option<f64>,
242    /// Nominal height (None = growable).
243    pub height: Option<f64>,
244    /// Explicit x position (for positioned layout).
245    pub x: f64,
246    /// Explicit y position (for positioned layout).
247    pub y: f64,
248    /// Margins.
249    pub margins: Insets,
250    /// Border thickness (simplified to uniform for now).
251    pub border_width: f64,
252    /// Minimum width constraint.
253    pub min_width: f64,
254    /// Maximum width constraint.
255    pub max_width: f64,
256    /// Minimum height constraint.
257    pub min_height: f64,
258    /// Maximum height constraint.
259    pub max_height: f64,
260    /// Caption region.
261    pub caption: Option<Caption>,
262}
263
264/// A caption for a form field.
265#[derive(Debug, Clone, PartialEq)]
266pub struct Caption {
267    pub placement: CaptionPlacement,
268    /// Reserved space for the caption (None = auto).
269    pub reserve: Option<f64>,
270    pub text: String,
271}
272
273impl BoxModel {
274    /// The available content width after subtracting margins, borders, and caption.
275    pub fn content_width(&self) -> f64 {
276        let total = self.width.unwrap_or(self.max_width);
277        let mut available = total - self.margins.horizontal() - self.border_width * 2.0;
278        if let Some(ref cap) = self.caption {
279            if matches!(
280                cap.placement,
281                CaptionPlacement::Left | CaptionPlacement::Right
282            ) {
283                available -= cap.reserve.unwrap_or(0.0);
284            }
285        }
286        available.max(0.0)
287    }
288
289    /// The available content height after subtracting margins, borders, and caption.
290    pub fn content_height(&self) -> f64 {
291        let total = self.height.unwrap_or(self.max_height);
292        let mut available = total - self.margins.vertical() - self.border_width * 2.0;
293        if let Some(ref cap) = self.caption {
294            if matches!(
295                cap.placement,
296                CaptionPlacement::Top | CaptionPlacement::Bottom
297            ) {
298                available -= cap.reserve.unwrap_or(0.0);
299            }
300        }
301        available.max(0.0)
302    }
303
304    /// The outer extent (total bounding box).
305    pub fn outer_size(&self, content: Size) -> Size {
306        let mut w = content.width + self.margins.horizontal() + self.border_width * 2.0;
307        let mut h = content.height + self.margins.vertical() + self.border_width * 2.0;
308        if let Some(ref cap) = self.caption {
309            match cap.placement {
310                CaptionPlacement::Left | CaptionPlacement::Right => {
311                    w += cap.reserve.unwrap_or(0.0);
312                }
313                CaptionPlacement::Top | CaptionPlacement::Bottom => {
314                    h += cap.reserve.unwrap_or(0.0);
315                }
316                CaptionPlacement::Inline => {}
317            }
318        }
319        // Apply min/max constraints
320        if let Some(fixed_w) = self.width {
321            w = fixed_w;
322        } else {
323            w = w.clamp(self.min_width, self.max_width);
324        }
325        if let Some(fixed_h) = self.height {
326            h = fixed_h;
327        } else {
328            h = h.clamp(self.min_height, self.max_height);
329        }
330        Size {
331            width: w,
332            height: h,
333        }
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn measurement_parse() {
343        let m = Measurement::parse("10mm").unwrap();
344        assert_eq!(m.unit, MeasurementUnit::Millimeters);
345        assert!((m.to_points() - 28.3464).abs() < 0.01);
346
347        let m = Measurement::parse("72pt").unwrap();
348        assert_eq!(m.to_points(), 72.0);
349
350        let m = Measurement::parse("1in").unwrap();
351        assert_eq!(m.to_points(), 72.0);
352
353        let m = Measurement::parse("2.54cm").unwrap();
354        assert!((m.to_points() - 72.0).abs() < 0.01);
355    }
356
357    #[test]
358    fn box_model_content_area() {
359        let bm = BoxModel {
360            width: Some(200.0),
361            height: Some(100.0),
362            margins: Insets {
363                top: 5.0,
364                right: 10.0,
365                bottom: 5.0,
366                left: 10.0,
367            },
368            border_width: 1.0,
369            max_width: f64::MAX,
370            max_height: f64::MAX,
371            ..Default::default()
372        };
373        // content_width = 200 - 20 (margins) - 2 (border) = 178
374        assert_eq!(bm.content_width(), 178.0);
375        // content_height = 100 - 10 (margins) - 2 (border) = 88
376        assert_eq!(bm.content_height(), 88.0);
377    }
378
379    #[test]
380    fn box_model_with_caption() {
381        let bm = BoxModel {
382            width: Some(200.0),
383            height: Some(100.0),
384            caption: Some(Caption {
385                placement: CaptionPlacement::Left,
386                reserve: Some(50.0),
387                text: "Label".to_string(),
388            }),
389            max_width: f64::MAX,
390            max_height: f64::MAX,
391            ..Default::default()
392        };
393        // content_width = 200 - 0 (margins) - 0 (border) - 50 (caption) = 150
394        assert_eq!(bm.content_width(), 150.0);
395    }
396
397    #[test]
398    fn outer_size_applies_constraints() {
399        let bm = BoxModel {
400            min_width: 100.0,
401            min_height: 50.0,
402            max_width: 500.0,
403            max_height: 300.0,
404            ..Default::default()
405        };
406        let s = bm.outer_size(Size {
407            width: 10.0,
408            height: 10.0,
409        });
410        assert_eq!(s.width, 100.0); // clamped to min
411        assert_eq!(s.height, 50.0); // clamped to min
412    }
413
414    #[test]
415    fn outer_size_fixed() {
416        let bm = BoxModel {
417            width: Some(200.0),
418            height: Some(100.0),
419            max_width: f64::MAX,
420            max_height: f64::MAX,
421            ..Default::default()
422        };
423        let s = bm.outer_size(Size {
424            width: 50.0,
425            height: 50.0,
426        });
427        assert_eq!(s.width, 200.0); // fixed
428        assert_eq!(s.height, 100.0); // fixed
429    }
430
431    #[test]
432    fn insets_helpers() {
433        let i = Insets {
434            top: 1.0,
435            right: 2.0,
436            bottom: 3.0,
437            left: 4.0,
438        };
439        assert_eq!(i.horizontal(), 6.0);
440        assert_eq!(i.vertical(), 4.0);
441    }
442}