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