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    /// Caption's own typeface (XFA `<caption><font typeface="...">`). The caption
307    /// styles its label text independently of the field's `<font>`; when present
308    /// it must win, otherwise an Arial caption on a Times field renders serif.
309    pub font_family: Option<String>,
310    /// Caption's own font size in points, if specified on its `<font>`.
311    pub font_size: Option<f64>,
312}
313
314impl BoxModel {
315    /// The available content width after subtracting margins, borders, and caption.
316    pub fn content_width(&self) -> f64 {
317        let total = self.width.unwrap_or(self.max_width);
318        let mut available = total - self.margins.horizontal() - self.border_width * 2.0;
319        if let Some(ref cap) = self.caption {
320            if matches!(
321                cap.placement,
322                CaptionPlacement::Left | CaptionPlacement::Right
323            ) {
324                available -= cap.reserve.unwrap_or(0.0);
325            }
326        }
327        available.max(0.0)
328    }
329
330    /// The available content height after subtracting margins, borders, and caption.
331    pub fn content_height(&self) -> f64 {
332        let total = self.height.unwrap_or(self.max_height);
333        let mut available = total - self.margins.vertical() - self.border_width * 2.0;
334        if let Some(ref cap) = self.caption {
335            if matches!(
336                cap.placement,
337                CaptionPlacement::Top | CaptionPlacement::Bottom
338            ) {
339                available -= cap.reserve.unwrap_or(0.0);
340            }
341        }
342        available.max(0.0)
343    }
344
345    /// The outer extent (total bounding box).
346    pub fn outer_size(&self, content: Size) -> Size {
347        let mut w = content.width + self.margins.horizontal() + self.border_width * 2.0;
348        let mut h = content.height + self.margins.vertical() + self.border_width * 2.0;
349        if let Some(ref cap) = self.caption {
350            match cap.placement {
351                CaptionPlacement::Left | CaptionPlacement::Right => {
352                    w += cap.reserve.unwrap_or(0.0);
353                }
354                CaptionPlacement::Top | CaptionPlacement::Bottom => {
355                    h += cap.reserve.unwrap_or(0.0);
356                }
357                CaptionPlacement::Inline => {}
358            }
359        }
360        // Apply min/max constraints
361        if let Some(fixed_w) = self.width {
362            w = fixed_w;
363        } else {
364            w = w.clamp(self.min_width, self.max_width);
365        }
366        if let Some(fixed_h) = self.height {
367            h = fixed_h;
368        } else {
369            h = h.clamp(self.min_height, self.max_height);
370        }
371        Size {
372            width: w,
373            height: h,
374        }
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn measurement_parse() {
384        let m = Measurement::parse("10mm").unwrap();
385        assert_eq!(m.unit, MeasurementUnit::Millimeters);
386        assert!((m.to_points() - 28.3464).abs() < 0.01);
387
388        let m = Measurement::parse("72pt").unwrap();
389        assert_eq!(m.to_points(), 72.0);
390
391        let m = Measurement::parse("1in").unwrap();
392        assert_eq!(m.to_points(), 72.0);
393
394        let m = Measurement::parse("2.54cm").unwrap();
395        assert!((m.to_points() - 72.0).abs() < 0.01);
396    }
397
398    #[test]
399    fn box_model_content_area() {
400        let bm = BoxModel {
401            width: Some(200.0),
402            height: Some(100.0),
403            margins: Insets {
404                top: 5.0,
405                right: 10.0,
406                bottom: 5.0,
407                left: 10.0,
408            },
409            border_width: 1.0,
410            max_width: f64::MAX,
411            max_height: f64::MAX,
412            ..Default::default()
413        };
414        // content_width = 200 - 20 (margins) - 2 (border) = 178
415        assert_eq!(bm.content_width(), 178.0);
416        // content_height = 100 - 10 (margins) - 2 (border) = 88
417        assert_eq!(bm.content_height(), 88.0);
418    }
419
420    #[test]
421    fn box_model_with_caption() {
422        let bm = BoxModel {
423            width: Some(200.0),
424            height: Some(100.0),
425            caption: Some(Caption {
426                placement: CaptionPlacement::Left,
427                reserve: Some(50.0),
428                text: "Label".to_string(),
429                font_family: None,
430                font_size: None,
431            }),
432            max_width: f64::MAX,
433            max_height: f64::MAX,
434            ..Default::default()
435        };
436        // content_width = 200 - 0 (margins) - 0 (border) - 50 (caption) = 150
437        assert_eq!(bm.content_width(), 150.0);
438    }
439
440    #[test]
441    fn outer_size_applies_constraints() {
442        let bm = BoxModel {
443            min_width: 100.0,
444            min_height: 50.0,
445            max_width: 500.0,
446            max_height: 300.0,
447            ..Default::default()
448        };
449        let s = bm.outer_size(Size {
450            width: 10.0,
451            height: 10.0,
452        });
453        assert_eq!(s.width, 100.0); // clamped to min
454        assert_eq!(s.height, 50.0); // clamped to min
455    }
456
457    #[test]
458    fn outer_size_fixed() {
459        let bm = BoxModel {
460            width: Some(200.0),
461            height: Some(100.0),
462            max_width: f64::MAX,
463            max_height: f64::MAX,
464            ..Default::default()
465        };
466        let s = bm.outer_size(Size {
467            width: 50.0,
468            height: 50.0,
469        });
470        assert_eq!(s.width, 200.0); // fixed
471        assert_eq!(s.height, 100.0); // fixed
472    }
473
474    #[test]
475    fn insets_helpers() {
476        let i = Insets {
477            top: 1.0,
478            right: 2.0,
479            bottom: 3.0,
480            left: 4.0,
481        };
482        assert_eq!(i.horizontal(), 6.0);
483        assert_eq!(i.vertical(), 4.0);
484    }
485}