Skip to main content

normordis_pdf/elements/
form.rs

1use serde::{Deserialize, Serialize};
2
3use super::{Element, LayoutMode, RenderContext, RenderResult};
4use crate::{
5    layout::{FixedBox, OverflowPolicy},
6    styles::RgbColor,
7};
8
9// ── FieldRect ─────────────────────────────────────────────────────────────────
10
11/// Position and size of a form field on the page (mm from bottom-left corner).
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct FieldRect {
14    pub x_mm: f64,
15    pub y_mm: f64,
16    pub width_mm: f64,
17    pub height_mm: f64,
18}
19
20// ── Field definitions ─────────────────────────────────────────────────────────
21
22/// A single-line or multi-line text input field.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct TextFieldDef {
25    pub name: String,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub default_value: Option<String>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub tooltip: Option<String>,
30    pub multiline: bool,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub max_length: Option<u32>,
33    pub readonly: bool,
34    pub required: bool,
35    pub rect: FieldRect,
36    pub font_size: f64,
37}
38
39/// A boolean checkbox field.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct CheckBoxDef {
42    pub name: String,
43    pub checked_by_default: bool,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub tooltip: Option<String>,
46    pub rect: FieldRect,
47}
48
49/// One option in a radio button group.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct RadioButtonDef {
52    /// Group name — all buttons with the same `group_name` are mutually exclusive.
53    pub group_name: String,
54    /// Value this button represents when selected.
55    pub value: String,
56    pub selected_by_default: bool,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub tooltip: Option<String>,
59    pub rect: FieldRect,
60}
61
62/// A drop-down selection field.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ComboBoxDef {
65    pub name: String,
66    pub options: Vec<String>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub default_value: Option<String>,
69    pub editable: bool,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub tooltip: Option<String>,
72    pub rect: FieldRect,
73    pub font_size: f64,
74}
75
76/// A scrollable list selection field.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct ListBoxDef {
79    pub name: String,
80    pub options: Vec<String>,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub default_value: Option<String>,
83    pub multi_select: bool,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub tooltip: Option<String>,
86    pub rect: FieldRect,
87    pub font_size: f64,
88}
89
90// ── FormField ─────────────────────────────────────────────────────────────────
91
92/// An interactive AcroForm field.
93///
94/// Note: full AcroForm interactivity requires v2.0.0 (pdf-writer). This version
95/// renders a visible placeholder rectangle with the field name label.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97#[serde(tag = "field_type", rename_all = "snake_case")]
98pub enum FormField {
99    TextField(TextFieldDef),
100    CheckBox(CheckBoxDef),
101    RadioButton(RadioButtonDef),
102    ComboBox(ComboBoxDef),
103    ListBox(ListBoxDef),
104}
105
106impl FormField {
107    fn rect(&self) -> &FieldRect {
108        match self {
109            FormField::TextField(d) => &d.rect,
110            FormField::CheckBox(d) => &d.rect,
111            FormField::RadioButton(d) => &d.rect,
112            FormField::ComboBox(d) => &d.rect,
113            FormField::ListBox(d) => &d.rect,
114        }
115    }
116
117    fn name(&self) -> &str {
118        match self {
119            FormField::TextField(d) => &d.name,
120            FormField::CheckBox(d) => &d.name,
121            FormField::RadioButton(d) => &d.group_name,
122            FormField::ComboBox(d) => &d.name,
123            FormField::ListBox(d) => &d.name,
124        }
125    }
126}
127
128impl Element for FormField {
129    fn layout_mode(&self) -> LayoutMode {
130        let r = self.rect();
131        LayoutMode::Fixed(FixedBox {
132            x_mm: r.x_mm,
133            y_mm: r.y_mm,
134            width_mm: r.width_mm,
135            height_mm: r.height_mm,
136            z_index: 0,
137            overflow: OverflowPolicy::Clip,
138            border: None,
139            background: None,
140            padding_mm: 0.0,
141            ua_role: None,
142            ua_alt: None,
143        })
144    }
145
146    fn estimated_height_mm(&self) -> f64 {
147        0.0
148    }
149
150    fn render(&self, ctx: &mut RenderContext) -> crate::Result<RenderResult> {
151        let r = self.rect();
152        let name = self.name().to_string();
153
154        let (fill_r, fill_g, fill_b) = match self {
155            FormField::TextField(_) | FormField::ComboBox(_) | FormField::ListBox(_) =>
156                (0.93_f64, 0.96_f64, 1.0_f64),
157            FormField::CheckBox(_) | FormField::RadioButton(_) =>
158                (1.0_f64, 1.0_f64, 1.0_f64),
159        };
160
161        let fill = RgbColor { r: fill_r, g: fill_g, b: fill_b };
162        let stroke = RgbColor { r: 0.4, g: 0.4, b: 0.8 };
163        ctx.backend.draw_rect_stroked(
164            r.x_mm, r.y_mm, r.width_mm, r.height_mm,
165            &fill, &stroke, 0.5,
166        )?;
167
168        match self {
169            FormField::CheckBox(d) if d.checked_by_default => {
170                render_checkmark(ctx, r)?;
171            }
172            FormField::RadioButton(d) if d.selected_by_default => {
173                render_radio_dot(ctx, r)?;
174            }
175            _ => {}
176        }
177
178        // Render field name label inside the box.
179        if let Some(font_ref) = ctx.get_font_ref(false, false) {
180            let label_fs = 7.0_f64;
181            let label_color = RgbColor { r: 0.3, g: 0.3, b: 0.6 };
182            let text_x = r.x_mm + 1.5 * 25.4 / 72.0;
183            let text_y = r.y_mm + r.height_mm / 2.0 - label_fs * 25.4 / 72.0 / 2.0;
184            ctx.draw_text(&name, text_x, text_y, label_fs, font_ref, &label_color)?;
185        }
186
187        Ok(RenderResult::done())
188    }
189}
190
191fn render_checkmark(ctx: &mut RenderContext, r: &FieldRect) -> crate::Result<()> {
192    let cx = r.x_mm + r.width_mm * 0.25;
193    let cy = r.y_mm + r.height_mm * 0.5;
194    let cr = r.width_mm.min(r.height_mm) * 0.3;
195    let color = RgbColor { r: 0.0, g: 0.5, b: 0.0 };
196    let width_pt = 1.0_f32;
197    // Two-segment tick mark
198    ctx.backend.draw_line(cx - cr * 0.3, cy, cx, cy - cr * 0.5, width_pt, &color)?;
199    ctx.backend.draw_line(cx, cy - cr * 0.5, cx + cr, cy + cr * 0.8, width_pt, &color)?;
200    Ok(())
201}
202
203fn render_radio_dot(ctx: &mut RenderContext, r: &FieldRect) -> crate::Result<()> {
204    let dot_size = r.width_mm.min(r.height_mm) * 0.4;
205    let dx = r.x_mm + r.width_mm / 2.0 - r.width_mm * 0.2;
206    let dy = r.y_mm + r.height_mm / 2.0 - r.height_mm * 0.2;
207    let color = RgbColor { r: 0.2, g: 0.2, b: 0.8 };
208    ctx.backend.draw_rect(dx, dy, dot_size, dot_size, &color)?;
209    Ok(())
210}