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