Skip to main content

pdfluent_forms/
appearance.rs

1//! Default Appearance (DA) parsing and appearance stream generation (B.6).
2
3// Copyright (c) 2026 Innovation Trigger B.V.
4//
5// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
6// the PDFluent Commercial Licence. See the LICENSE file in this repository --
7// that file travels with the copy you received, which a URL does not.
8
9use crate::button::{button_kind, ButtonKind};
10use crate::choice::choice_kind;
11use crate::text::text_field_kind;
12use crate::tree::*;
13use std::io::Write as _;
14
15/// Parsed default appearance components.
16#[derive(Debug, Clone, Default)]
17pub struct DefaultAppearance {
18    /// Font name (PDF name without leading /).
19    pub font_name: Option<String>,
20    /// Font size (0 = auto-size).
21    pub font_size: f32,
22    /// Fill color components (gray, RGB, or CMYK).
23    pub color: Vec<f32>,
24    /// Color space operator: "g", "rg", or "k".
25    pub color_op: Option<String>,
26}
27
28/// Parse a /DA string like "0 0 0 rg /Helv 12 Tf".
29pub fn parse_da(da: &str) -> DefaultAppearance {
30    let mut result = DefaultAppearance::default();
31    let tokens: Vec<&str> = da.split_whitespace().collect();
32    let mut i = 0;
33    while i < tokens.len() {
34        match tokens[i] {
35            "Tf" if i >= 2 => {
36                result.font_size = tokens[i - 1].parse().unwrap_or(0.0);
37                let name = tokens[i - 2];
38                result.font_name = Some(name.strip_prefix('/').unwrap_or(name).to_string());
39            }
40            "g" if i >= 1 => {
41                if let Ok(g) = tokens[i - 1].parse::<f32>() {
42                    result.color = vec![g];
43                    result.color_op = Some("g".into());
44                }
45            }
46            "rg" if i >= 3 => {
47                if let (Ok(r), Ok(g), Ok(b)) = (
48                    tokens[i - 3].parse::<f32>(),
49                    tokens[i - 2].parse::<f32>(),
50                    tokens[i - 1].parse::<f32>(),
51                ) {
52                    result.color = vec![r, g, b];
53                    result.color_op = Some("rg".into());
54                }
55            }
56            "k" if i >= 4 => {
57                if let (Ok(c), Ok(m), Ok(y), Ok(k)) = (
58                    tokens[i - 4].parse::<f32>(),
59                    tokens[i - 3].parse::<f32>(),
60                    tokens[i - 2].parse::<f32>(),
61                    tokens[i - 1].parse::<f32>(),
62                ) {
63                    result.color = vec![c, m, y, k];
64                    result.color_op = Some("k".into());
65                }
66            }
67            _ => {}
68        }
69        i += 1;
70    }
71    result
72}
73
74/// Generate a raw PDF content stream for a text field appearance.
75pub fn generate_text_appearance(
76    tree: &FieldTree,
77    id: FieldId,
78    da: &DefaultAppearance,
79    text: &str,
80) -> Vec<u8> {
81    let node = tree.get(id);
82    let rect = node.rect.unwrap_or([0.0, 0.0, 100.0, 20.0]);
83    let width = rect[2] - rect[0];
84    let height = rect[3] - rect[1];
85    let quadding = tree.effective_quadding(id);
86    let flags = tree.effective_flags(id);
87    let font_name = da.font_name.as_deref().unwrap_or("Helv");
88    let font_size = if da.font_size > 0.0 {
89        da.font_size
90    } else {
91        (height - 2.0).clamp(4.0, 24.0)
92    };
93    let kind = text_field_kind(flags);
94    let mut buf = Vec::new();
95    let bw = node.border_style.as_ref().map(|b| b.width).unwrap_or(1.0);
96
97    if let Some(ref mk) = node.mk {
98        if let Some(ref bg) = mk.background_color {
99            write_color(&mut buf, bg, false);
100            let _ = writeln!(buf, "{} {} {} {} re f", 0.0, 0.0, width, height);
101        }
102        if let Some(ref bc) = mk.border_color {
103            write_color(&mut buf, bc, true);
104            let _ = writeln!(
105                buf,
106                "{} w {} {} {} {} re S",
107                bw,
108                bw / 2.0,
109                bw / 2.0,
110                width - bw,
111                height - bw
112            );
113        }
114    }
115
116    let margin = bw + 1.0;
117    let _ = writeln!(
118        buf,
119        "{} {} {} {} re W n",
120        margin,
121        margin,
122        width - margin * 2.0,
123        height - margin * 2.0
124    );
125    buf.extend_from_slice(b"BT\n");
126    if !da.color.is_empty() {
127        write_color(&mut buf, &da.color, false);
128    }
129    let _ = writeln!(buf, "/{} {} Tf", font_name, font_size);
130
131    let display_text = if flags.password() {
132        "*".repeat(text.len())
133    } else {
134        text.to_string()
135    };
136
137    match kind {
138        crate::text::TextFieldKind::Comb => {
139            if let Some(max_len) = tree.effective_max_len(id) {
140                let cell_w = width / max_len as f32;
141                for (i, ch) in display_text.chars().take(max_len as usize).enumerate() {
142                    let x = margin + cell_w * i as f32 + cell_w * 0.25;
143                    let y = margin + (height - margin * 2.0 - font_size) / 2.0;
144                    let _ = writeln!(buf, "{} {} Td ({}) Tj", x, y, escape_pdf_string_char(ch));
145                }
146            }
147        }
148        crate::text::TextFieldKind::Multiline => {
149            let leading = font_size * 1.2;
150            let _ = writeln!(buf, "{} TL", leading);
151            let _ = writeln!(buf, "{} {} Td", margin, height - margin - font_size);
152            for (i, line) in display_text.lines().enumerate() {
153                if i > 0 {
154                    buf.extend_from_slice(b"T*\n");
155                }
156                let _ = writeln!(buf, "({}) Tj", escape_pdf_string(line));
157            }
158        }
159        _ => {
160            let approx_w = display_text.len() as f32 * font_size * 0.5;
161            let x = match quadding {
162                Quadding::Center => margin + (width - margin * 2.0 - approx_w) / 2.0,
163                Quadding::Right => width - margin - approx_w,
164                Quadding::Left => margin,
165            }
166            .max(margin);
167            let y = margin + (height - margin * 2.0 - font_size) / 2.0;
168            let _ = writeln!(buf, "{} {} Td", x, y);
169            let _ = writeln!(buf, "({}) Tj", escape_pdf_string(&display_text));
170        }
171    }
172    buf.extend_from_slice(b"ET\n");
173    buf
174}
175
176/// Generate appearance stream for a checkbox.
177pub fn generate_checkbox_appearance(tree: &FieldTree, id: FieldId, checked: bool) -> Vec<u8> {
178    let node = tree.get(id);
179    let rect = node.rect.unwrap_or([0.0, 0.0, 12.0, 12.0]);
180    let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]);
181    let mut buf = Vec::new();
182    let _ = writeln!(buf, "1 g 0 0 {} {} re f", w, h);
183    let _ = writeln!(buf, "0 g 0.5 w 0 0 {} {} re S", w, h);
184    if checked {
185        let m = w * 0.15;
186        let _ = writeln!(
187            buf,
188            "0 g 1.5 w {} {} m {} {} l {} {} l S",
189            m,
190            h * 0.5,
191            w * 0.4,
192            m,
193            w - m,
194            h - m
195        );
196    }
197    buf
198}
199
200/// Generate appearance stream for a radio button.
201pub fn generate_radio_appearance(tree: &FieldTree, id: FieldId, selected: bool) -> Vec<u8> {
202    let node = tree.get(id);
203    let rect = node.rect.unwrap_or([0.0, 0.0, 12.0, 12.0]);
204    let size = (rect[2] - rect[0]).min(rect[3] - rect[1]);
205    let (cx, cy, r) = (size / 2.0, size / 2.0, size / 2.0 - 1.0);
206    let k = 0.5523_f32;
207    let mut buf = Vec::new();
208    write_circle(&mut buf, cx, cy, r, k, "1 g", "f");
209    write_circle(&mut buf, cx, cy, r, k, "0 g 0.5 w", "S");
210    if selected {
211        write_circle(&mut buf, cx, cy, r * 0.4, k, "0 g", "f");
212    }
213    buf
214}
215
216fn write_circle(buf: &mut Vec<u8>, cx: f32, cy: f32, r: f32, k: f32, prefix: &str, op: &str) {
217    let kr = k * r;
218    let _ = writeln!(
219        buf,
220        "{prefix} {cx} {bot} m {r1} {bot} {right} {b1} {right} {cy} c {right} {t1} {r1} {top} {cx} {top} c {l1} {top} {left} {t1} {left} {cy} c {left} {b1} {l1} {bot} {cx} {bot} c {op}",
221        prefix = prefix,
222        op = op,
223        cx = cx,
224        cy = cy,
225        bot = cy - r,
226        top = cy + r,
227        left = cx - r,
228        right = cx + r,
229        r1 = cx + kr,
230        l1 = cx - kr,
231        t1 = cy + kr,
232        b1 = cy - kr,
233    );
234}
235
236/// Generate appearance stream for a choice field.
237pub fn generate_choice_appearance(
238    tree: &FieldTree,
239    id: FieldId,
240    da: &DefaultAppearance,
241) -> Vec<u8> {
242    let node = tree.get(id);
243    let rect = node.rect.unwrap_or([0.0, 0.0, 150.0, 20.0]);
244    let (width, height) = (rect[2] - rect[0], rect[3] - rect[1]);
245    let flags = tree.effective_flags(id);
246    let kind = choice_kind(flags);
247    let font_name = da.font_name.as_deref().unwrap_or("Helv");
248    let font_size = if da.font_size > 0.0 {
249        da.font_size
250    } else {
251        (height - 4.0).clamp(4.0, 12.0)
252    };
253    let mut buf = Vec::new();
254    let _ = writeln!(buf, "1 g 0 0 {} {} re f", width, height);
255    let _ = writeln!(buf, "0 g 0.5 w 0 0 {} {} re S", width, height);
256    match kind {
257        crate::choice::ChoiceKind::ComboBox | crate::choice::ChoiceKind::EditableCombo => {
258            let selected = crate::choice::get_selection(tree, id);
259            let text = selected.first().map(|s| s.as_str()).unwrap_or("");
260            let arrow_w = height.min(20.0);
261            let _ = writeln!(
262                buf,
263                "0.9 g {} 0 {} {} re f",
264                width - arrow_w,
265                arrow_w,
266                height
267            );
268            let (ax, aw) = (width - arrow_w / 2.0, arrow_w * 0.25);
269            let _ = writeln!(
270                buf,
271                "0 g {} {} m {} {} l {} {} l f",
272                ax - aw,
273                height * 0.65,
274                ax + aw,
275                height * 0.65,
276                ax,
277                height * 0.35
278            );
279            buf.extend_from_slice(b"BT\n");
280            write_color(&mut buf, &da.color, false);
281            let _ = writeln!(buf, "/{} {} Tf", font_name, font_size);
282            let _ = writeln!(buf, "2 {} Td", (height - font_size) / 2.0);
283            let _ = writeln!(buf, "({}) Tj", escape_pdf_string(text));
284            buf.extend_from_slice(b"ET\n");
285        }
286        _ => {
287            // One BT block per row so a highlight can sit behind its own row
288            // and never over another row's text (#707).
289            let leading = font_size * 1.2;
290            let selected = crate::choice::get_selection(tree, id);
291            let top_idx = node.top_index.unwrap_or(0) as usize;
292            let _ = writeln!(buf, "q 1 1 {} {} re W n", width - 2.0, height - 2.0);
293            for (i, opt) in node.options.iter().skip(top_idx).enumerate() {
294                let row_top = height - 2.0 - i as f32 * leading;
295                if row_top <= 0.0 {
296                    break;
297                }
298                if selected.contains(&opt.export) || selected.contains(&opt.display) {
299                    let _ = writeln!(
300                        buf,
301                        "0.6 0.75 0.85 rg 1 {} {} {} re f",
302                        row_top - leading,
303                        width - 2.0,
304                        leading
305                    );
306                }
307                buf.extend_from_slice(b"BT\n");
308                write_color(&mut buf, &da.color, false);
309                let _ = writeln!(buf, "/{} {} Tf", font_name, font_size);
310                let _ = writeln!(
311                    buf,
312                    "3 {} Td",
313                    row_top - leading + (leading - font_size) / 2.0 + font_size * 0.2
314                );
315                let _ = writeln!(buf, "({}) Tj", escape_pdf_string(&opt.display));
316                buf.extend_from_slice(b"ET\n");
317            }
318            buf.extend_from_slice(b"Q\n");
319        }
320    }
321    buf
322}
323
324/// Generate the appropriate appearance stream for any field type.
325pub fn generate_appearance(tree: &FieldTree, id: FieldId) -> Option<Vec<u8>> {
326    let ft = tree.effective_field_type(id)?;
327    let da_str = tree.effective_da(id).unwrap_or("0 g /Helv 12 Tf");
328    let da = parse_da(da_str);
329    match ft {
330        FieldType::Text => {
331            let text = crate::text::get_text_value(tree, id).unwrap_or_default();
332            Some(generate_text_appearance(tree, id, &da, &text))
333        }
334        FieldType::Button => {
335            let flags = tree.effective_flags(id);
336            match button_kind(flags) {
337                ButtonKind::Checkbox => Some(generate_checkbox_appearance(
338                    tree,
339                    id,
340                    crate::button::is_checked(tree, id),
341                )),
342                ButtonKind::Radio => Some(generate_radio_appearance(
343                    tree,
344                    id,
345                    crate::button::is_checked(tree, id),
346                )),
347                ButtonKind::PushButton => {
348                    let caption = tree
349                        .get(id)
350                        .mk
351                        .as_ref()
352                        .and_then(|m| m.caption.as_deref())
353                        .unwrap_or("");
354                    Some(generate_text_appearance(tree, id, &da, caption))
355                }
356            }
357        }
358        FieldType::Choice => Some(generate_choice_appearance(tree, id, &da)),
359        FieldType::Signature => None,
360    }
361}
362
363fn write_color(buf: &mut Vec<u8>, color: &[f32], stroke: bool) {
364    let op = match (color.len(), stroke) {
365        (1, false) => "g",
366        (1, true) => "G",
367        (3, false) => "rg",
368        (3, true) => "RG",
369        (4, false) => "k",
370        (4, true) => "K",
371        _ => return,
372    };
373    for c in color {
374        let _ = write!(buf, "{} ", c);
375    }
376    let _ = writeln!(buf, "{}", op);
377}
378
379fn escape_pdf_string(s: &str) -> String {
380    s.chars()
381        .map(|ch| match ch {
382            '(' => "\\(".to_string(),
383            ')' => "\\)".to_string(),
384            '\\' => "\\\\".to_string(),
385            _ => ch.to_string(),
386        })
387        .collect()
388}
389
390fn escape_pdf_string_char(ch: char) -> String {
391    match ch {
392        '(' => "\\(".into(),
393        ')' => "\\)".into(),
394        '\\' => "\\\\".into(),
395        _ => ch.to_string(),
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use crate::flags::FieldFlags;
403
404    #[test]
405    fn test_parse_da_simple() {
406        let da = parse_da("0 g /Helv 12 Tf");
407        assert_eq!(da.font_name.as_deref(), Some("Helv"));
408        assert_eq!(da.font_size, 12.0);
409        assert_eq!(da.color, vec![0.0]);
410    }
411    #[test]
412    fn test_parse_da_rgb() {
413        let da = parse_da("0 0 1 rg /Cour 10 Tf");
414        assert_eq!(da.font_name.as_deref(), Some("Cour"));
415        assert_eq!(da.color, vec![0.0, 0.0, 1.0]);
416    }
417    #[test]
418    fn test_escape() {
419        assert_eq!(escape_pdf_string("a(b)"), "a\\(b\\)");
420    }
421    #[test]
422    fn test_checkbox_appearance() {
423        let mut tree = FieldTree::new();
424        let id = tree.alloc(FieldNode {
425            partial_name: "cb".into(),
426            alternate_name: None,
427            mapping_name: None,
428            field_type: Some(FieldType::Button),
429            flags: FieldFlags::empty(),
430            value: None,
431            default_value: None,
432            default_appearance: None,
433            quadding: None,
434            max_len: None,
435            options: vec![],
436            top_index: None,
437            rect: Some([0.0, 0.0, 12.0, 12.0]),
438            appearance_state: None,
439            on_state: None,
440            page_index: None,
441            parent: None,
442            children: vec![],
443            object_id: None,
444            has_actions: false,
445            mk: None,
446            border_style: None,
447        });
448        assert!(
449            generate_checkbox_appearance(&tree, id, true).len()
450                > generate_checkbox_appearance(&tree, id, false).len()
451        );
452    }
453}