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            let leading = font_size * 1.2;
288            let selected = crate::choice::get_selection(tree, id);
289            let top_idx = node.top_index.unwrap_or(0) as usize;
290            let visible = (height / leading).floor() as usize;
291            buf.extend_from_slice(b"BT\n");
292            write_color(&mut buf, &da.color, false);
293            let _ = writeln!(buf, "/{} {} Tf", font_name, font_size);
294            let _ = writeln!(buf, "{} TL", leading);
295            let y_start = height - 2.0 - font_size;
296            let _ = writeln!(buf, "2 {} Td", y_start);
297            for (i, opt) in node.options.iter().skip(top_idx).take(visible).enumerate() {
298                if selected.contains(&opt.export) || selected.contains(&opt.display) {
299                    buf.extend_from_slice(b"ET\n");
300                    let _ = writeln!(
301                        buf,
302                        "0.6 0.75 0.95 rg 0 {} {} {} re f",
303                        y_start - i as f32 * leading - 1.0,
304                        width,
305                        leading
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!(buf, "2 {} Td", y_start - i as f32 * leading);
311                }
312                if i > 0 {
313                    buf.extend_from_slice(b"T*\n");
314                }
315                let _ = writeln!(buf, "({}) Tj", escape_pdf_string(&opt.display));
316            }
317            buf.extend_from_slice(b"ET\n");
318        }
319    }
320    buf
321}
322
323/// Generate the appropriate appearance stream for any field type.
324pub fn generate_appearance(tree: &FieldTree, id: FieldId) -> Option<Vec<u8>> {
325    let ft = tree.effective_field_type(id)?;
326    let da_str = tree.effective_da(id).unwrap_or("0 g /Helv 12 Tf");
327    let da = parse_da(da_str);
328    match ft {
329        FieldType::Text => {
330            let text = crate::text::get_text_value(tree, id).unwrap_or_default();
331            Some(generate_text_appearance(tree, id, &da, &text))
332        }
333        FieldType::Button => {
334            let flags = tree.effective_flags(id);
335            match button_kind(flags) {
336                ButtonKind::Checkbox => Some(generate_checkbox_appearance(
337                    tree,
338                    id,
339                    crate::button::is_checked(tree, id),
340                )),
341                ButtonKind::Radio => Some(generate_radio_appearance(
342                    tree,
343                    id,
344                    crate::button::is_checked(tree, id),
345                )),
346                ButtonKind::PushButton => {
347                    let caption = tree
348                        .get(id)
349                        .mk
350                        .as_ref()
351                        .and_then(|m| m.caption.as_deref())
352                        .unwrap_or("");
353                    Some(generate_text_appearance(tree, id, &da, caption))
354                }
355            }
356        }
357        FieldType::Choice => Some(generate_choice_appearance(tree, id, &da)),
358        FieldType::Signature => None,
359    }
360}
361
362fn write_color(buf: &mut Vec<u8>, color: &[f32], stroke: bool) {
363    let op = match (color.len(), stroke) {
364        (1, false) => "g",
365        (1, true) => "G",
366        (3, false) => "rg",
367        (3, true) => "RG",
368        (4, false) => "k",
369        (4, true) => "K",
370        _ => return,
371    };
372    for c in color {
373        let _ = write!(buf, "{} ", c);
374    }
375    let _ = writeln!(buf, "{}", op);
376}
377
378fn escape_pdf_string(s: &str) -> String {
379    s.chars()
380        .map(|ch| match ch {
381            '(' => "\\(".to_string(),
382            ')' => "\\)".to_string(),
383            '\\' => "\\\\".to_string(),
384            _ => ch.to_string(),
385        })
386        .collect()
387}
388
389fn escape_pdf_string_char(ch: char) -> String {
390    match ch {
391        '(' => "\\(".into(),
392        ')' => "\\)".into(),
393        '\\' => "\\\\".into(),
394        _ => ch.to_string(),
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use crate::flags::FieldFlags;
402
403    #[test]
404    fn test_parse_da_simple() {
405        let da = parse_da("0 g /Helv 12 Tf");
406        assert_eq!(da.font_name.as_deref(), Some("Helv"));
407        assert_eq!(da.font_size, 12.0);
408        assert_eq!(da.color, vec![0.0]);
409    }
410    #[test]
411    fn test_parse_da_rgb() {
412        let da = parse_da("0 0 1 rg /Cour 10 Tf");
413        assert_eq!(da.font_name.as_deref(), Some("Cour"));
414        assert_eq!(da.color, vec![0.0, 0.0, 1.0]);
415    }
416    #[test]
417    fn test_escape() {
418        assert_eq!(escape_pdf_string("a(b)"), "a\\(b\\)");
419    }
420    #[test]
421    fn test_checkbox_appearance() {
422        let mut tree = FieldTree::new();
423        let id = tree.alloc(FieldNode {
424            partial_name: "cb".into(),
425            alternate_name: None,
426            mapping_name: None,
427            field_type: Some(FieldType::Button),
428            flags: FieldFlags::empty(),
429            value: None,
430            default_value: None,
431            default_appearance: None,
432            quadding: None,
433            max_len: None,
434            options: vec![],
435            top_index: None,
436            rect: Some([0.0, 0.0, 12.0, 12.0]),
437            appearance_state: None,
438            on_state: None,
439            page_index: None,
440            parent: None,
441            children: vec![],
442            object_id: None,
443            has_actions: false,
444            mk: None,
445            border_style: None,
446        });
447        assert!(
448            generate_checkbox_appearance(&tree, id, true).len()
449                > generate_checkbox_appearance(&tree, id, false).len()
450        );
451    }
452}