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}