Skip to main content

rpdfium_doc/
form_control.rs

1//! Form controls (widgets) — visual representations of form fields on pages.
2//!
3//! A `FormControl` represents a single widget annotation (/Subtype /Widget)
4//! associated with a form field, storing its position and appearance state.
5
6use rpdfium_core::Rect;
7use rpdfium_parser::ObjectId;
8
9use crate::icon_fit::IconFit;
10
11/// Highlighting mode for a widget annotation (ISO 32000-2 Table 188).
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum HighlightingMode {
14    /// No highlighting (N).
15    None,
16    /// Invert the contents of the annotation rectangle (I, default).
17    #[default]
18    Invert,
19    /// Invert the border of the annotation (O).
20    Outline,
21    /// Push effect (P).
22    Push,
23    /// Same as Invert (T, for toggle — used by checkboxes).
24    Toggle,
25}
26
27impl HighlightingMode {
28    /// Parse from a PDF name string (`/H` key).
29    pub fn from_name(name: &str) -> Self {
30        match name {
31            "N" => Self::None,
32            "I" => Self::Invert,
33            "O" => Self::Outline,
34            "P" => Self::Push,
35            "T" => Self::Toggle,
36            _ => Self::Invert,
37        }
38    }
39}
40
41/// Text/icon positioning mode for a widget annotation (ISO 32000-2 Table 189).
42///
43/// Determines the relative placement of the caption text and icon within
44/// a button widget.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub enum TextPosition {
47    /// Caption only; no icon displayed (TP=0, default).
48    #[default]
49    CaptionOnly = 0,
50    /// Icon only; no caption displayed (TP=1).
51    IconOnly = 1,
52    /// Caption below the icon (TP=2).
53    CaptionBelow = 2,
54    /// Caption above the icon (TP=3).
55    CaptionAbove = 3,
56    /// Caption to the right of the icon (TP=4).
57    CaptionRight = 4,
58    /// Caption to the left of the icon (TP=5).
59    CaptionLeft = 5,
60    /// Caption overlaid directly on the icon (TP=6).
61    Overlaid = 6,
62}
63
64impl TextPosition {
65    /// Parse from the `/TP` integer value in an MK dictionary.
66    pub fn from_value(v: u32) -> Self {
67        match v {
68            0 => Self::CaptionOnly,
69            1 => Self::IconOnly,
70            2 => Self::CaptionBelow,
71            3 => Self::CaptionAbove,
72            4 => Self::CaptionRight,
73            5 => Self::CaptionLeft,
74            6 => Self::Overlaid,
75            _ => Self::CaptionOnly,
76        }
77    }
78}
79
80/// A visual widget (control) for a form field.
81#[derive(Debug, Clone)]
82pub struct FormControl {
83    /// The fully qualified name of the associated form field.
84    pub field_name: String,
85    /// The annotation rectangle on the page.
86    pub rect: Rect,
87    /// The current appearance state (e.g., "Yes", "Off").
88    pub appearance_state: Option<String>,
89    /// Zero-based page index, if known.
90    pub page_index: Option<usize>,
91    /// Highlighting mode (`/H`).
92    pub highlighting_mode: HighlightingMode,
93    /// Rotation in degrees from `/MK` → `/R` (0, 90, 180, 270).
94    pub rotation: u32,
95    /// Border color from `/MK` → `/BC`.
96    pub border_color: Option<Vec<f32>>,
97    /// Background color from `/MK` → `/BG`.
98    pub background_color: Option<Vec<f32>>,
99    /// Normal caption from `/MK` → `/CA`.
100    pub caption: Option<String>,
101    /// Rollover caption from `/MK` → `/RC`.
102    pub rollover_caption: Option<String>,
103    /// Alternate (down) caption from `/MK` → `/AC`.
104    pub alt_caption: Option<String>,
105    /// Default appearance string (`/DA`).
106    pub default_appearance: Option<String>,
107    /// Normal icon stream reference (`/MK` → `/I`).
108    pub normal_icon: Option<ObjectId>,
109    /// Rollover icon stream reference (`/MK` → `/RI`).
110    pub rollover_icon: Option<ObjectId>,
111    /// Down (alternate) icon stream reference (`/MK` → `/IX`).
112    pub down_icon: Option<ObjectId>,
113    /// Icon fit parameters (`/MK` → `/IF`).
114    pub icon_fit: Option<IconFit>,
115    /// Text/icon positioning (`/MK` → `/TP`).
116    pub text_position: TextPosition,
117}
118
119impl FormControl {
120    /// Returns the annotation rectangle on the page.
121    ///
122    /// Corresponds to `CPDF_FormControl::GetRect()` in PDFium.
123    pub fn rect(&self) -> Rect {
124        self.rect
125    }
126
127    /// Deprecated — use [`rect()`](Self::rect) — no public `FPDFFormControl_GetRect` API.
128    #[deprecated(
129        note = "use `rect()` — there is no public FPDF_* API (internal `CPDF_FormControl::GetRect`)"
130    )]
131    #[inline]
132    pub fn get_rect(&self) -> Rect {
133        self.rect()
134    }
135
136    /// Returns the current checked appearance state name (e.g., `"Yes"`, `"Off"`).
137    ///
138    /// Corresponds to `CPDF_FormControl::GetCheckedAPState()` in PDFium.
139    pub fn checked_ap_state(&self) -> Option<&str> {
140        self.appearance_state.as_deref()
141    }
142
143    /// Deprecated — use [`checked_ap_state()`](Self::checked_ap_state) — no public FPDF_* API.
144    #[deprecated(
145        note = "use `checked_ap_state()` — there is no public FPDF_* API (internal `CPDF_FormControl::GetCheckedAPState`)"
146    )]
147    #[inline]
148    pub fn get_checked_ap_state(&self) -> Option<&str> {
149        self.checked_ap_state()
150    }
151
152    /// Returns the export value for a radio/checkbox button option.
153    ///
154    /// For checkbox and radio button controls, returns the appearance state
155    /// name used when the button is in its on state. Corresponds to
156    /// `CPDF_FormControl::GetExportValue()` in PDFium.
157    pub fn export_value(&self) -> Option<&str> {
158        self.appearance_state.as_deref()
159    }
160
161    /// Deprecated — use [`export_value()`](Self::export_value) — no public FPDF_* API.
162    #[deprecated(
163        note = "use `export_value()` — there is no public FPDF_* API (internal `CPDF_FormControl::GetExportValue`)"
164    )]
165    #[inline]
166    pub fn get_export_value(&self) -> Option<&str> {
167        self.export_value()
168    }
169
170    /// Returns the highlighting mode for this widget.
171    ///
172    /// Corresponds to `CPDF_FormControl::GetHighlightingMode()` in PDFium.
173    pub fn highlighting_mode(&self) -> HighlightingMode {
174        self.highlighting_mode
175    }
176
177    /// Deprecated — use [`highlighting_mode()`](Self::highlighting_mode) — no public FPDF_* API.
178    #[deprecated(
179        note = "use `highlighting_mode()` — there is no public FPDF_* API (internal `CPDF_FormControl::GetHighlightingMode`)"
180    )]
181    #[inline]
182    pub fn get_highlighting_mode(&self) -> HighlightingMode {
183        self.highlighting_mode()
184    }
185
186    /// Returns the rotation angle in degrees (0, 90, 180, or 270).
187    ///
188    /// Corresponds to `CPDF_FormControl::GetRotation()` in PDFium.
189    pub fn rotation(&self) -> u32 {
190        self.rotation
191    }
192
193    /// Deprecated — use [`rotation()`](Self::rotation) — no public FPDF_* API.
194    #[deprecated(
195        note = "use `rotation()` — there is no public FPDF_* API (internal `CPDF_FormControl::GetRotation`)"
196    )]
197    #[inline]
198    pub fn get_rotation(&self) -> u32 {
199        self.rotation()
200    }
201
202    /// Returns the normal (default) caption string for this widget.
203    ///
204    /// Corresponds to `CPDF_FormControl::GetNormalCaption()` in PDFium.
205    pub fn normal_caption(&self) -> Option<&str> {
206        self.caption.as_deref()
207    }
208
209    /// Deprecated — use [`normal_caption()`](Self::normal_caption) — no public FPDF_* API.
210    #[deprecated(
211        note = "use `normal_caption()` — there is no public FPDF_* API (internal `CPDF_FormControl::GetNormalCaption`)"
212    )]
213    #[inline]
214    pub fn get_normal_caption(&self) -> Option<&str> {
215        self.normal_caption()
216    }
217
218    /// Returns the rollover (hover) caption string for this widget.
219    ///
220    /// Corresponds to `CPDF_FormControl::GetRolloverCaption()` in PDFium.
221    pub fn rollover_caption(&self) -> Option<&str> {
222        self.rollover_caption.as_deref()
223    }
224
225    /// Deprecated — use [`rollover_caption()`](Self::rollover_caption) — no public FPDF_* API.
226    #[deprecated(
227        note = "use `rollover_caption()` — there is no public FPDF_* API (internal `CPDF_FormControl::GetRolloverCaption`)"
228    )]
229    #[inline]
230    pub fn get_rollover_caption(&self) -> Option<&str> {
231        self.rollover_caption()
232    }
233
234    /// Returns the down (pressed) caption string for this widget.
235    ///
236    /// Corresponds to `CPDF_FormControl::GetDownCaption()` in PDFium.
237    pub fn down_caption(&self) -> Option<&str> {
238        self.alt_caption.as_deref()
239    }
240
241    /// Deprecated — use [`down_caption()`](Self::down_caption) — no public FPDF_* API.
242    #[deprecated(
243        note = "use `down_caption()` — there is no public FPDF_* API (internal `CPDF_FormControl::GetDownCaption`)"
244    )]
245    #[inline]
246    pub fn get_down_caption(&self) -> Option<&str> {
247        self.down_caption()
248    }
249
250    /// Returns the text/icon position for this widget (TP value, 0–6).
251    ///
252    /// Corresponds to `CPDF_FormControl::GetTextPosition()` in PDFium.
253    pub fn text_position(&self) -> TextPosition {
254        self.text_position
255    }
256
257    /// Deprecated — use [`text_position()`](Self::text_position) — no public FPDF_* API.
258    #[deprecated(
259        note = "use `text_position()` — there is no public FPDF_* API (internal `CPDF_FormControl::GetTextPosition`)"
260    )]
261    #[inline]
262    pub fn get_text_position(&self) -> TextPosition {
263        self.text_position()
264    }
265
266    /// Returns `true` if the control's appearance state is "Yes" (checked).
267    pub fn is_checked(&self) -> bool {
268        self.appearance_state.as_deref() == Some("Yes")
269    }
270
271    /// Returns `true` if the default appearance state is "Yes".
272    ///
273    /// Note: We use `/AS` for the current state. Default checked state
274    /// would typically come from `/DV` on the parent field.
275    ///
276    /// Tier-1 primary: calls another rpdfium function (`is_checked()`), so
277    /// `#[inline]` must not be applied.
278    pub fn is_default_checked(&self) -> bool {
279        // Upstream uses the /DV from the parent field; here we check /AS
280        // which is the initial state parsed from the PDF.
281        self.is_checked()
282    }
283
284    /// Returns the border color as a packed ARGB value (`0xAARRGGBB`), or `None`
285    /// if no border color is defined.
286    ///
287    /// Supports grayscale (1 component) and RGB (3 component) color arrays.
288    /// Returns `None` for CMYK (4 components) or an empty array.
289    ///
290    /// Corresponds to `CPDF_FormControl::GetColorARGB()` in PDFium (nIndex=0).
291    pub fn border_color_argb(&self) -> Option<u32> {
292        color_components_to_argb(self.border_color.as_deref())
293    }
294
295    /// Returns the background color as a packed ARGB value (`0xAARRGGBB`), or `None`
296    /// if no background color is defined.
297    ///
298    /// Supports grayscale (1 component) and RGB (3 component) color arrays.
299    /// Returns `None` for CMYK (4 components) or an empty array.
300    ///
301    /// Corresponds to `CPDF_FormControl::GetColorARGB()` in PDFium (nIndex=1).
302    pub fn background_color_argb(&self) -> Option<u32> {
303        color_components_to_argb(self.background_color.as_deref())
304    }
305}
306
307/// Convert PDF color component array (0.0–1.0) to a packed ARGB `u32`.
308///
309/// Supports 1-component (grayscale) and 3-component (RGB). Returns `None`
310/// for other component counts (e.g., 4-component CMYK or empty).
311fn color_components_to_argb(components: Option<&[f32]>) -> Option<u32> {
312    let c = components?;
313    let to_byte = |f: f32| (f.clamp(0.0, 1.0) * 255.0).round() as u32;
314    let argb = match c.len() {
315        1 => {
316            let g = to_byte(c[0]);
317            0xFF00_0000 | (g << 16) | (g << 8) | g
318        }
319        3 => {
320            let r = to_byte(c[0]);
321            let g = to_byte(c[1]);
322            let b = to_byte(c[2]);
323            0xFF00_0000 | (r << 16) | (g << 8) | b
324        }
325        _ => return None,
326    };
327    Some(argb)
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    fn make_control() -> FormControl {
335        FormControl {
336            field_name: "checkbox1".to_string(),
337            rect: Rect {
338                left: 10.0,
339                bottom: 20.0,
340                right: 30.0,
341                top: 40.0,
342            },
343            appearance_state: Some("Yes".to_string()),
344            page_index: Some(0),
345            highlighting_mode: HighlightingMode::Invert,
346            rotation: 0,
347            border_color: None,
348            background_color: None,
349            caption: None,
350            rollover_caption: None,
351            alt_caption: None,
352            default_appearance: None,
353            normal_icon: None,
354            rollover_icon: None,
355            down_icon: None,
356            icon_fit: None,
357            text_position: TextPosition::default(),
358        }
359    }
360
361    #[test]
362    fn test_form_control_creation() {
363        let ctrl = make_control();
364        assert_eq!(ctrl.field_name, "checkbox1");
365        assert_eq!(ctrl.rect.left, 10.0);
366        assert_eq!(ctrl.appearance_state.as_deref(), Some("Yes"));
367        assert_eq!(ctrl.page_index, Some(0));
368    }
369
370    #[test]
371    fn test_form_control_no_page_index() {
372        let mut ctrl = make_control();
373        ctrl.field_name = "text1".to_string();
374        ctrl.rect = Rect {
375            left: 0.0,
376            bottom: 0.0,
377            right: 100.0,
378            top: 20.0,
379        };
380        ctrl.appearance_state = None;
381        ctrl.page_index = None;
382        assert!(ctrl.page_index.is_none());
383        assert!(ctrl.appearance_state.is_none());
384    }
385
386    #[test]
387    fn test_is_checked() {
388        let ctrl = make_control();
389        assert!(ctrl.is_checked());
390    }
391
392    #[test]
393    fn test_is_not_checked() {
394        let mut ctrl = make_control();
395        ctrl.appearance_state = Some("Off".to_string());
396        assert!(!ctrl.is_checked());
397    }
398
399    #[test]
400    fn test_is_checked_none() {
401        let mut ctrl = make_control();
402        ctrl.appearance_state = None;
403        assert!(!ctrl.is_checked());
404    }
405
406    #[test]
407    fn test_highlighting_mode_from_name() {
408        assert_eq!(HighlightingMode::from_name("N"), HighlightingMode::None);
409        assert_eq!(HighlightingMode::from_name("I"), HighlightingMode::Invert);
410        assert_eq!(HighlightingMode::from_name("O"), HighlightingMode::Outline);
411        assert_eq!(HighlightingMode::from_name("P"), HighlightingMode::Push);
412        assert_eq!(HighlightingMode::from_name("T"), HighlightingMode::Toggle);
413        assert_eq!(
414            HighlightingMode::from_name("Unknown"),
415            HighlightingMode::Invert
416        );
417    }
418
419    #[test]
420    fn test_form_control_with_mk_data() {
421        let mut ctrl = make_control();
422        ctrl.rotation = 90;
423        ctrl.border_color = Some(vec![1.0, 0.0, 0.0]);
424        ctrl.background_color = Some(vec![1.0, 1.0, 1.0]);
425        ctrl.caption = Some("Click me".to_string());
426        ctrl.rollover_caption = Some("Hover text".to_string());
427        ctrl.alt_caption = Some("Down text".to_string());
428        ctrl.highlighting_mode = HighlightingMode::Push;
429
430        assert_eq!(ctrl.rotation, 90);
431        assert_eq!(ctrl.border_color.as_ref().unwrap().len(), 3);
432        assert_eq!(ctrl.caption.as_deref(), Some("Click me"));
433        assert_eq!(ctrl.rollover_caption.as_deref(), Some("Hover text"));
434        assert_eq!(ctrl.alt_caption.as_deref(), Some("Down text"));
435        assert_eq!(ctrl.highlighting_mode, HighlightingMode::Push);
436    }
437
438    #[test]
439    fn test_text_position_from_value() {
440        assert_eq!(TextPosition::from_value(0), TextPosition::CaptionOnly);
441        assert_eq!(TextPosition::from_value(1), TextPosition::IconOnly);
442        assert_eq!(TextPosition::from_value(2), TextPosition::CaptionBelow);
443        assert_eq!(TextPosition::from_value(3), TextPosition::CaptionAbove);
444        assert_eq!(TextPosition::from_value(4), TextPosition::CaptionRight);
445        assert_eq!(TextPosition::from_value(5), TextPosition::CaptionLeft);
446        assert_eq!(TextPosition::from_value(6), TextPosition::Overlaid);
447        assert_eq!(TextPosition::from_value(99), TextPosition::CaptionOnly);
448    }
449
450    #[test]
451    fn test_text_position_default() {
452        assert_eq!(TextPosition::default(), TextPosition::CaptionOnly);
453    }
454
455    #[test]
456    fn test_form_control_with_icon_data() {
457        let mut ctrl = make_control();
458        ctrl.normal_icon = Some(ObjectId::new(10, 0));
459        ctrl.rollover_icon = Some(ObjectId::new(11, 0));
460        ctrl.down_icon = Some(ObjectId::new(12, 0));
461        ctrl.text_position = TextPosition::IconOnly;
462
463        assert_eq!(ctrl.normal_icon, Some(ObjectId::new(10, 0)));
464        assert_eq!(ctrl.rollover_icon, Some(ObjectId::new(11, 0)));
465        assert_eq!(ctrl.down_icon, Some(ObjectId::new(12, 0)));
466        assert_eq!(ctrl.text_position, TextPosition::IconOnly);
467    }
468
469    #[test]
470    fn test_border_color_argb_rgb_red() {
471        let mut ctrl = make_control();
472        ctrl.border_color = Some(vec![1.0, 0.0, 0.0]);
473        assert_eq!(ctrl.border_color_argb(), Some(0xFFFF0000));
474    }
475
476    #[test]
477    fn test_background_color_argb_grayscale() {
478        let mut ctrl = make_control();
479        ctrl.background_color = Some(vec![1.0]); // white
480        assert_eq!(ctrl.background_color_argb(), Some(0xFFFFFFFF));
481    }
482
483    #[test]
484    fn test_background_color_argb_black_grayscale() {
485        let mut ctrl = make_control();
486        ctrl.background_color = Some(vec![0.0]); // black
487        assert_eq!(ctrl.background_color_argb(), Some(0xFF000000));
488    }
489
490    #[test]
491    fn test_border_color_argb_none_for_cmyk() {
492        let mut ctrl = make_control();
493        ctrl.border_color = Some(vec![0.1, 0.2, 0.3, 0.4]); // CMYK — not supported
494        assert_eq!(ctrl.border_color_argb(), None);
495    }
496
497    #[test]
498    fn test_color_argb_none_when_absent() {
499        let ctrl = make_control();
500        // make_control sets border_color and background_color to None
501        assert_eq!(ctrl.border_color_argb(), None);
502        assert_eq!(ctrl.background_color_argb(), None);
503    }
504
505    #[test]
506    fn test_border_color_argb_rgb_green() {
507        let mut ctrl = make_control();
508        ctrl.border_color = Some(vec![0.0, 1.0, 0.0]);
509        assert_eq!(ctrl.border_color_argb(), Some(0xFF00FF00));
510    }
511}