Skip to main content

oxidize_pdf/forms/
choice_widget.rs

1//! Choice field widgets (ComboBox and ListBox) for PDF forms
2//!
3//! This module implements ISO 32000-1 Section 12.7.4.4 (Choice Fields)
4//! including combo boxes (dropdowns) and list boxes with single or multi-select.
5
6use crate::annotations::{Annotation, AnnotationType};
7use crate::error::Result;
8use crate::forms::{ComboBox, ListBox};
9use crate::geometry::Rectangle;
10use crate::graphics::Color;
11use crate::objects::{Dictionary, Object, Stream};
12use crate::text::Font;
13use std::fmt::Write;
14
15/// Widget annotation for choice fields (ComboBox and ListBox)
16#[derive(Debug, Clone)]
17pub struct ChoiceWidget {
18    /// Widget rectangle
19    pub rect: Rectangle,
20    /// Border color
21    pub border_color: Color,
22    /// Border width
23    pub border_width: f64,
24    /// Background color
25    pub background_color: Option<Color>,
26    /// Text color
27    pub text_color: Color,
28    /// Font
29    pub font: Font,
30    /// Font size
31    pub font_size: f64,
32    /// Highlight color for selected items
33    pub highlight_color: Option<Color>,
34}
35
36impl Default for ChoiceWidget {
37    fn default() -> Self {
38        Self {
39            rect: Rectangle::from_position_and_size(0.0, 0.0, 100.0, 20.0),
40            border_color: Color::rgb(0.0, 0.0, 0.0),
41            border_width: 1.0,
42            background_color: Some(Color::rgb(1.0, 1.0, 1.0)),
43            text_color: Color::rgb(0.0, 0.0, 0.0),
44            font: Font::Helvetica,
45            font_size: 10.0,
46            highlight_color: Some(Color::rgb(0.8, 0.8, 1.0)),
47        }
48    }
49}
50
51impl ChoiceWidget {
52    /// Create a new choice widget
53    pub fn new(rect: Rectangle) -> Self {
54        Self {
55            rect,
56            ..Default::default()
57        }
58    }
59
60    /// Set border color
61    pub fn with_border_color(mut self, color: Color) -> Self {
62        self.border_color = color;
63        self
64    }
65
66    /// Set border width
67    pub fn with_border_width(mut self, width: f64) -> Self {
68        self.border_width = width;
69        self
70    }
71
72    /// Set background color
73    pub fn with_background_color(mut self, color: Option<Color>) -> Self {
74        self.background_color = color;
75        self
76    }
77
78    /// Set text color
79    pub fn with_text_color(mut self, color: Color) -> Self {
80        self.text_color = color;
81        self
82    }
83
84    /// Set font
85    pub fn with_font(mut self, font: Font) -> Self {
86        self.font = font;
87        self
88    }
89
90    /// Set font size
91    pub fn with_font_size(mut self, size: f64) -> Self {
92        self.font_size = size;
93        self
94    }
95
96    /// Set highlight color for selected items
97    pub fn with_highlight_color(mut self, color: Option<Color>) -> Self {
98        self.highlight_color = color;
99        self
100    }
101
102    /// Create appearance stream for a combo box
103    fn create_combobox_appearance(&self, combo: &ComboBox) -> String {
104        let mut stream = String::new();
105
106        // Save graphics state
107        writeln!(&mut stream, "q").expect("Writing to string should never fail");
108
109        // Draw background if specified — routed through the shared
110        // NaN-sanitising helpers (issues #220 + #221). The previous
111        // emitter forced RGB output via `.r()/.g()/.b()` regardless of
112        // the colour's native space; the helper now preserves the
113        // native space (`rg` / `g` / `k`) which is strictly more correct.
114        if let Some(bg_color) = &self.background_color {
115            crate::graphics::color::write_fill_color(&mut stream, *bg_color);
116            writeln!(
117                &mut stream,
118                "0 0 {} {} re",
119                self.rect.width(),
120                self.rect.height()
121            )
122            .expect("Writing to string should never fail");
123            writeln!(&mut stream, "f").expect("Writing to string should never fail");
124        }
125
126        // Draw border
127        crate::graphics::color::write_stroke_color(&mut stream, self.border_color);
128        writeln!(&mut stream, "{} w", self.border_width)
129            .expect("Writing to string should never fail");
130        writeln!(
131            &mut stream,
132            "0 0 {} {} re",
133            self.rect.width(),
134            self.rect.height()
135        )
136        .expect("Writing to string should never fail");
137        writeln!(&mut stream, "S").expect("Writing to string should never fail");
138
139        // Draw dropdown arrow on the right
140        let arrow_x = self.rect.width() - 15.0;
141        let arrow_y = self.rect.height() / 2.0;
142        crate::graphics::color::write_fill_color(
143            &mut stream,
144            crate::graphics::Color::Rgb(0.3, 0.3, 0.3),
145        );
146        writeln!(&mut stream, "{} {} m", arrow_x, arrow_y + 3.0)
147            .expect("Writing to string should never fail");
148        writeln!(&mut stream, "{} {} l", arrow_x + 8.0, arrow_y + 3.0)
149            .expect("Writing to string should never fail");
150        writeln!(&mut stream, "{} {} l", arrow_x + 4.0, arrow_y - 3.0)
151            .expect("Writing to string should never fail");
152        writeln!(&mut stream, "f").expect("Writing to string should never fail");
153
154        // Draw selected text if any
155        if let Some(selected_idx) = combo.selected {
156            if let Some((_, display_text)) = combo.options.get(selected_idx) {
157                writeln!(&mut stream, "BT").expect("Writing to string should never fail");
158                writeln!(
159                    &mut stream,
160                    "/{} {} Tf",
161                    self.font.pdf_name(),
162                    self.font_size
163                )
164                .expect("Writing to string should never fail");
165                crate::graphics::color::write_fill_color(&mut stream, self.text_color);
166                writeln!(
167                    &mut stream,
168                    "2 {} Td",
169                    (self.rect.height() - self.font_size) / 2.0
170                )
171                .expect("Writing to string should never fail");
172                writeln!(&mut stream, "({}) Tj", escape_pdf_string(display_text))
173                    .expect("Writing to string should never fail");
174                writeln!(&mut stream, "ET").expect("Writing to string should never fail");
175            }
176        }
177
178        // Restore graphics state
179        writeln!(&mut stream, "Q").expect("Writing to string should never fail");
180
181        stream
182    }
183
184    /// Create appearance stream for a list box
185    fn create_listbox_appearance(&self, listbox: &ListBox) -> String {
186        let mut stream = String::new();
187
188        // Save graphics state
189        writeln!(&mut stream, "q").expect("Writing to string should never fail");
190
191        // Draw background — sanitised via shared helper (issues #220 + #221).
192        if let Some(bg_color) = &self.background_color {
193            crate::graphics::color::write_fill_color(&mut stream, *bg_color);
194            writeln!(
195                &mut stream,
196                "0 0 {} {} re",
197                self.rect.width(),
198                self.rect.height()
199            )
200            .expect("Writing to string should never fail");
201            writeln!(&mut stream, "f").expect("Writing to string should never fail");
202        }
203
204        // Draw border
205        crate::graphics::color::write_stroke_color(&mut stream, self.border_color);
206        writeln!(&mut stream, "{} w", self.border_width)
207            .expect("Writing to string should never fail");
208        writeln!(
209            &mut stream,
210            "0 0 {} {} re",
211            self.rect.width(),
212            self.rect.height()
213        )
214        .expect("Writing to string should never fail");
215        writeln!(&mut stream, "S").expect("Writing to string should never fail");
216
217        // Calculate visible items
218        let item_height = self.font_size + 4.0;
219        let visible_items = (self.rect.height() / item_height) as usize;
220
221        // Draw items
222
223        for (idx, (_, display_text)) in listbox.options.iter().enumerate().take(visible_items) {
224            let y_pos = self.rect.height() - ((idx + 1) as f64 * item_height);
225
226            // Draw highlight for selected items — sanitised via shared helper.
227            if listbox.selected.contains(&idx) {
228                if let Some(highlight) = &self.highlight_color {
229                    crate::graphics::color::write_fill_color(&mut stream, *highlight);
230                    writeln!(
231                        &mut stream,
232                        "0 {} {} {} re",
233                        y_pos,
234                        self.rect.width(),
235                        item_height
236                    )
237                    .expect("Writing to string should never fail");
238                    writeln!(&mut stream, "f").expect("Writing to string should never fail");
239                }
240            }
241
242            // Draw text
243            writeln!(&mut stream, "BT").expect("Writing to string should never fail");
244            writeln!(
245                &mut stream,
246                "/{} {} Tf",
247                self.font.pdf_name(),
248                self.font_size
249            )
250            .expect("Writing to string should never fail");
251            crate::graphics::color::write_fill_color(&mut stream, self.text_color);
252            writeln!(&mut stream, "2 {} Td", y_pos + 2.0)
253                .expect("Writing to string should never fail");
254            writeln!(&mut stream, "({}) Tj", escape_pdf_string(display_text))
255                .expect("Writing to string should never fail");
256            writeln!(&mut stream, "ET").expect("Writing to string should never fail");
257        }
258
259        // Draw scrollbar if needed — sanitised via shared helper.
260        if listbox.options.len() > visible_items {
261            crate::graphics::color::write_fill_color(&mut stream, Color::gray(0.7));
262            let scrollbar_x = self.rect.width() - 10.0;
263            writeln!(&mut stream, "{} 0 8 {} re", scrollbar_x, self.rect.height())
264                .expect("Writing to string should never fail");
265            writeln!(&mut stream, "f").expect("Writing to string should never fail");
266
267            // Draw scroll thumb
268            crate::graphics::color::write_fill_color(&mut stream, Color::gray(0.4));
269            let thumb_height =
270                (visible_items as f64 / listbox.options.len() as f64) * self.rect.height();
271            writeln!(
272                &mut stream,
273                "{} {} 8 {} re",
274                scrollbar_x,
275                self.rect.height() - thumb_height,
276                thumb_height
277            )
278            .expect("Writing to string should never fail");
279            writeln!(&mut stream, "f").expect("Writing to string should never fail");
280        }
281
282        // Restore graphics state
283        writeln!(&mut stream, "Q").expect("Writing to string should never fail");
284
285        stream
286    }
287}
288
289/// Create a widget annotation for a combo box
290pub fn create_combobox_widget(combo: &ComboBox, widget: &ChoiceWidget) -> Result<Annotation> {
291    let mut annotation = Annotation::new(AnnotationType::Widget, widget.rect);
292
293    // Set field reference
294    let mut field_dict = combo.to_dict();
295
296    // Add widget-specific entries
297    field_dict.set(
298        "Rect",
299        Object::Array(vec![
300            Object::Real(widget.rect.lower_left.x),
301            Object::Real(widget.rect.lower_left.y),
302            Object::Real(widget.rect.upper_right.x),
303            Object::Real(widget.rect.upper_right.y),
304        ]),
305    );
306
307    // Create appearance stream
308    let appearance_content = widget.create_combobox_appearance(combo);
309    let appearance_stream = create_appearance_stream(
310        appearance_content.as_bytes(),
311        widget.rect.width(),
312        widget.rect.height(),
313    );
314
315    // Create appearance dictionary
316    let mut ap_dict = Dictionary::new();
317    let mut n_dict = Dictionary::new();
318    n_dict.set(
319        "default",
320        Object::Stream(
321            appearance_stream.dictionary().clone(),
322            appearance_stream.data().to_vec(),
323        ),
324    );
325    ap_dict.set("N", Object::Dictionary(n_dict));
326    field_dict.set("AP", Object::Dictionary(ap_dict));
327
328    // Set default appearance string — sanitised via shared helper
329    // (issues #220 + #221). Emits the colour's native space, not always RGB.
330    let da = format!(
331        "/{} {} Tf {}",
332        widget.font.pdf_name(),
333        widget.font_size,
334        crate::graphics::color::fill_color_op(widget.text_color),
335    );
336    field_dict.set("DA", Object::String(da));
337
338    // Set the field dictionary as the annotation's dictionary
339    annotation.set_field_dict(field_dict);
340
341    Ok(annotation)
342}
343
344/// Create a widget annotation for a list box
345pub fn create_listbox_widget(listbox: &ListBox, widget: &ChoiceWidget) -> Result<Annotation> {
346    let mut annotation = Annotation::new(AnnotationType::Widget, widget.rect);
347
348    // Set field reference
349    let mut field_dict = listbox.to_dict();
350
351    // Add widget-specific entries
352    field_dict.set(
353        "Rect",
354        Object::Array(vec![
355            Object::Real(widget.rect.lower_left.x),
356            Object::Real(widget.rect.lower_left.y),
357            Object::Real(widget.rect.upper_right.x),
358            Object::Real(widget.rect.upper_right.y),
359        ]),
360    );
361
362    // Create appearance stream
363    let appearance_content = widget.create_listbox_appearance(listbox);
364    let appearance_stream = create_appearance_stream(
365        appearance_content.as_bytes(),
366        widget.rect.width(),
367        widget.rect.height(),
368    );
369
370    // Create appearance dictionary
371    let mut ap_dict = Dictionary::new();
372    let mut n_dict = Dictionary::new();
373    n_dict.set(
374        "default",
375        Object::Stream(
376            appearance_stream.dictionary().clone(),
377            appearance_stream.data().to_vec(),
378        ),
379    );
380    ap_dict.set("N", Object::Dictionary(n_dict));
381    field_dict.set("AP", Object::Dictionary(ap_dict));
382
383    // Set default appearance string — sanitised via shared helper
384    // (issues #220 + #221). Emits the colour's native space, not always RGB.
385    let da = format!(
386        "/{} {} Tf {}",
387        widget.font.pdf_name(),
388        widget.font_size,
389        crate::graphics::color::fill_color_op(widget.text_color),
390    );
391    field_dict.set("DA", Object::String(da));
392
393    // Set the field dictionary as the annotation's dictionary
394    annotation.set_field_dict(field_dict);
395
396    Ok(annotation)
397}
398
399/// Helper function to escape PDF strings
400fn escape_pdf_string(s: &str) -> String {
401    s.chars()
402        .map(|c| match c {
403            '(' => "\\(".to_string(),
404            ')' => "\\)".to_string(),
405            '\\' => "\\\\".to_string(),
406            _ => c.to_string(),
407        })
408        .collect()
409}
410
411/// Create an appearance stream
412fn create_appearance_stream(content: &[u8], width: f64, height: f64) -> Stream {
413    let mut dict = Dictionary::new();
414    dict.set("Type", Object::Name("XObject".to_string()));
415    dict.set("Subtype", Object::Name("Form".to_string()));
416    dict.set(
417        "BBox",
418        Object::Array(vec![
419            Object::Integer(0),
420            Object::Integer(0),
421            Object::Real(width),
422            Object::Real(height),
423        ]),
424    );
425
426    Stream::with_dictionary(dict, content.to_vec())
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use crate::geometry::Point;
433
434    #[test]
435    fn test_choice_widget_creation() {
436        let rect = Rectangle::new(Point::new(100.0, 100.0), Point::new(200.0, 120.0));
437        let widget = ChoiceWidget::new(rect.clone());
438
439        assert_eq!(widget.rect, rect);
440        assert_eq!(widget.font_size, 10.0);
441    }
442
443    #[test]
444    fn test_choice_widget_builder() {
445        let rect = Rectangle::new(Point::new(0.0, 0.0), Point::new(100.0, 30.0));
446        let widget = ChoiceWidget::new(rect)
447            .with_border_color(Color::rgb(1.0, 0.0, 0.0))
448            .with_font_size(12.0)
449            .with_font(Font::HelveticaBold);
450
451        assert_eq!(widget.border_color, Color::rgb(1.0, 0.0, 0.0));
452        assert_eq!(widget.font_size, 12.0);
453        assert_eq!(widget.font, Font::HelveticaBold);
454    }
455
456    #[test]
457    fn test_combobox_widget_creation() {
458        let combo = ComboBox::new("country")
459            .add_option("US", "United States")
460            .add_option("CA", "Canada")
461            .with_selected(0);
462
463        let rect = Rectangle::new(Point::new(100.0, 100.0), Point::new(250.0, 125.0));
464        let widget = ChoiceWidget::new(rect);
465
466        let annotation = create_combobox_widget(&combo, &widget);
467        assert!(annotation.is_ok());
468    }
469
470    #[test]
471    fn test_listbox_widget_creation() {
472        let listbox = ListBox::new("languages")
473            .add_option("en", "English")
474            .add_option("es", "Spanish")
475            .add_option("fr", "French")
476            .with_selected(vec![0, 2]);
477
478        let rect = Rectangle::new(Point::new(100.0, 100.0), Point::new(200.0, 200.0));
479        let widget = ChoiceWidget::new(rect);
480
481        let annotation = create_listbox_widget(&listbox, &widget);
482        assert!(annotation.is_ok());
483    }
484
485    #[test]
486    fn test_escape_pdf_string() {
487        assert_eq!(escape_pdf_string("Hello"), "Hello");
488        assert_eq!(escape_pdf_string("Hello (World)"), "Hello \\(World\\)");
489        assert_eq!(escape_pdf_string("Path\\to\\file"), "Path\\\\to\\\\file");
490    }
491}