Skip to main content

pdf_xfa/
appearance_bridge.rs

1//! FormCalc results -> PDF appearance streams.
2//!
3//! Connects FormCalc execution to appearance stream generation.
4//! Handles value formatting, conditional visibility, and caching.
5
6// Copyright (c) 2026 Innovation Trigger B.V.
7//
8// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
9// the PDFluent Commercial Licence. See the LICENSE file in this repository --
10// that file travels with the copy you received, which a URL does not.
11
12use crate::error::Result;
13use std::collections::HashMap;
14use std::io::Write as _;
15use xfa_layout_engine::form::{FieldKind, FormNodeId};
16use xfa_layout_engine::layout::{LayoutContent, LayoutDom, LayoutNode};
17
18/// Configuration for appearance stream generation.
19#[derive(Debug, Clone)]
20pub struct AppearanceConfig {
21    /// default_font.
22    pub default_font: String,
23    /// default_font_size.
24    pub default_font_size: f64,
25    /// border_width.
26    pub border_width: f64,
27    /// border_color.
28    pub border_color: [f64; 3],
29    /// background_color.
30    pub background_color: Option<[f64; 3]>,
31    /// text_color.
32    pub text_color: [f64; 3],
33    /// text_padding.
34    pub text_padding: f64,
35}
36
37impl Default for AppearanceConfig {
38    fn default() -> Self {
39        Self {
40            default_font: "Helvetica".to_string(),
41            default_font_size: 10.0,
42            border_width: 0.5,
43            border_color: [0.0, 0.0, 0.0],
44            background_color: Some([1.0, 1.0, 1.0]),
45            text_color: [0.0, 0.0, 0.0],
46            text_padding: 2.0,
47        }
48    }
49}
50
51/// A generated appearance stream.
52#[derive(Debug, Clone)]
53pub struct AppearanceStream {
54    /// content.
55    pub content: Vec<u8>,
56    /// bbox.
57    pub bbox: [f64; 4],
58    /// font_resources.
59    pub font_resources: Vec<(String, String)>,
60}
61
62/// Appearance stream cache with invalidation.
63pub struct AppearanceCache {
64    cache: HashMap<(usize, u64), AppearanceStream>,
65}
66
67impl AppearanceCache {
68    /// new.
69    pub fn new() -> Self {
70        Self {
71            cache: HashMap::new(),
72        }
73    }
74    /// get_or_generate.
75    pub fn get_or_generate(
76        &mut self,
77        node_id: FormNodeId,
78        value: &str,
79        width: f64,
80        height: f64,
81        config: &AppearanceConfig,
82    ) -> &AppearanceStream {
83        let key = (node_id.0, simple_hash(value));
84        self.cache
85            .entry(key)
86            .or_insert_with(|| field_appearance(value, width, height, config))
87    }
88    /// invalidate.
89    pub fn invalidate(&mut self, node_id: FormNodeId) {
90        self.cache.retain(|(id, _), _| *id != node_id.0);
91    }
92    /// clear.
93    pub fn clear(&mut self) {
94        self.cache.clear();
95    }
96}
97
98impl Default for AppearanceCache {
99    fn default() -> Self {
100        Self::new()
101    }
102}
103
104/// Generate appearance streams for an entire layout.
105pub fn generate_appearances(
106    layout: &LayoutDom,
107    config: &AppearanceConfig,
108) -> Result<Vec<PageAppearances>> {
109    let mut pages = Vec::new();
110    for page in &layout.pages {
111        let mut entries = Vec::new();
112        collect_appearances(&page.nodes, 0.0, 0.0, config, &mut entries);
113        pages.push(PageAppearances {
114            width: page.width,
115            height: page.height,
116            entries,
117        });
118    }
119    Ok(pages)
120}
121/// PageAppearances.
122
123#[derive(Debug)]
124pub struct PageAppearances {
125    /// width.
126    pub width: f64,
127    /// height.
128    pub height: f64,
129    /// entries.
130    pub entries: Vec<AppearanceEntry>,
131}
132/// AppearanceEntry.
133
134#[derive(Debug)]
135pub struct AppearanceEntry {
136    /// name.
137    pub name: String,
138    /// abs_x.
139    pub abs_x: f64,
140    /// abs_y.
141    pub abs_y: f64,
142    /// appearance.
143    pub appearance: AppearanceStream,
144}
145
146fn collect_appearances(
147    nodes: &[LayoutNode],
148    parent_x: f64,
149    parent_y: f64,
150    config: &AppearanceConfig,
151    result: &mut Vec<AppearanceEntry>,
152) {
153    for node in nodes {
154        let abs_x = node.rect.x + parent_x;
155        let abs_y = node.rect.y + parent_y;
156        let w = node.rect.width;
157        let h = node.rect.height;
158
159        let ap = match &node.content {
160            LayoutContent::Field {
161                value,
162                field_kind: FieldKind::Checkbox | FieldKind::Radio,
163                ..
164            } => {
165                let checked = checkbox_raw_value_is_checked(value);
166                Some(checkbox_appearance(checked, w, h))
167            }
168            LayoutContent::Field { value, .. } => Some(field_appearance(value, w, h, config)),
169            LayoutContent::Text(text) => Some(draw_appearance(text, w, h, config)),
170            LayoutContent::WrappedText {
171                lines, font_size, ..
172            } => Some(multiline_appearance(
173                lines,
174                *font_size,
175                font_size * 1.2,
176                w,
177                h,
178                config,
179            )),
180            LayoutContent::Image { .. } => None,
181            LayoutContent::Draw(_) => None,
182            LayoutContent::None => None,
183        };
184        if let Some(ap) = ap {
185            result.push(AppearanceEntry {
186                name: node.name.clone(),
187                abs_x,
188                abs_y,
189                appearance: ap,
190            });
191        }
192        if !node.children.is_empty() {
193            collect_appearances(&node.children, abs_x, abs_y, config, result);
194        }
195    }
196}
197/// field_appearance.
198pub fn field_appearance(
199    value: &str,
200    width: f64,
201    height: f64,
202    config: &AppearanceConfig,
203) -> AppearanceStream {
204    let mut ops = Vec::new();
205    if let Some(bg) = &config.background_color {
206        let _ = write!(
207            ops,
208            "{:.3} {:.3} {:.3} rg\n{:.2} {:.2} {:.2} {:.2} re\nf\n",
209            bg[0], bg[1], bg[2], 0.0, 0.0, width, height
210        );
211    }
212    if config.border_width > 0.0 {
213        let _ = write!(
214            ops,
215            "{:.2} w\n{:.3} {:.3} {:.3} RG\n{:.2} {:.2} {:.2} {:.2} re\nS\n",
216            config.border_width,
217            config.border_color[0],
218            config.border_color[1],
219            config.border_color[2],
220            0.0,
221            0.0,
222            width,
223            height
224        );
225    }
226    if !value.is_empty() {
227        let fs = config.default_font_size;
228        let p = config.text_padding;
229        let _ = write!(
230            ops,
231            "BT\n{:.3} {:.3} {:.3} rg\n/F1 {:.1} Tf\n{:.2} {:.2} Td\n({}) Tj\nET\n",
232            config.text_color[0],
233            config.text_color[1],
234            config.text_color[2],
235            fs,
236            p,
237            height - fs - p,
238            pdf_escape(value)
239        );
240        AppearanceStream {
241            content: ops,
242            bbox: [0.0, 0.0, width, height],
243            font_resources: vec![("F1".to_string(), config.default_font.clone())],
244        }
245    } else {
246        AppearanceStream {
247            content: ops,
248            bbox: [0.0, 0.0, width, height],
249            font_resources: vec![],
250        }
251    }
252}
253/// draw_appearance.
254pub fn draw_appearance(
255    text: &str,
256    width: f64,
257    height: f64,
258    config: &AppearanceConfig,
259) -> AppearanceStream {
260    let mut ops = Vec::new();
261    if let Some(bg) = &config.background_color {
262        let _ = write!(
263            ops,
264            "{:.3} {:.3} {:.3} rg\n{:.2} {:.2} {:.2} {:.2} re\nf\n",
265            bg[0], bg[1], bg[2], 0.0, 0.0, width, height
266        );
267    }
268    if !text.is_empty() {
269        let fs = config.default_font_size;
270        let p = config.text_padding;
271        let _ = write!(
272            ops,
273            "BT\n{:.3} {:.3} {:.3} rg\n/F1 {:.1} Tf\n{:.2} {:.2} Td\n({}) Tj\nET\n",
274            config.text_color[0],
275            config.text_color[1],
276            config.text_color[2],
277            fs,
278            p,
279            height - fs - p,
280            pdf_escape(text)
281        );
282        AppearanceStream {
283            content: ops,
284            bbox: [0.0, 0.0, width, height],
285            font_resources: vec![("F1".to_string(), config.default_font.clone())],
286        }
287    } else {
288        AppearanceStream {
289            content: ops,
290            bbox: [0.0, 0.0, width, height],
291            font_resources: vec![],
292        }
293    }
294}
295/// multiline_appearance.
296pub fn multiline_appearance(
297    lines: &[String],
298    font_size: f64,
299    line_height: f64,
300    width: f64,
301    height: f64,
302    config: &AppearanceConfig,
303) -> AppearanceStream {
304    let mut ops = Vec::new();
305    if let Some(bg) = &config.background_color {
306        let _ = write!(
307            ops,
308            "{:.3} {:.3} {:.3} rg\n{:.2} {:.2} {:.2} {:.2} re\nf\n",
309            bg[0], bg[1], bg[2], 0.0, 0.0, width, height
310        );
311    }
312    if config.border_width > 0.0 {
313        let _ = write!(
314            ops,
315            "{:.2} w\n{:.3} {:.3} {:.3} RG\n{:.2} {:.2} {:.2} {:.2} re\nS\n",
316            config.border_width,
317            config.border_color[0],
318            config.border_color[1],
319            config.border_color[2],
320            0.0,
321            0.0,
322            width,
323            height
324        );
325    }
326    if !lines.is_empty() {
327        let p = config.text_padding;
328        let _ = write!(
329            ops,
330            "BT\n{:.3} {:.3} {:.3} rg\n/F1 {:.1} Tf\n",
331            config.text_color[0], config.text_color[1], config.text_color[2], font_size
332        );
333        let start_y = height - font_size - p;
334        for (i, line) in lines.iter().enumerate() {
335            let ay = start_y - (i as f64 * line_height);
336            if ay < 0.0 {
337                break;
338            }
339            if i == 0 {
340                let _ = writeln!(ops, "{:.2} {:.2} Td", p, ay);
341            } else {
342                let _ = writeln!(ops, "{:.2} {:.2} Td", 0.0, -line_height);
343            }
344            let _ = writeln!(ops, "({}) Tj", pdf_escape(line));
345        }
346        ops.extend_from_slice(b"ET\n");
347        AppearanceStream {
348            content: ops,
349            bbox: [0.0, 0.0, width, height],
350            font_resources: vec![("F1".to_string(), config.default_font.clone())],
351        }
352    } else {
353        AppearanceStream {
354            content: ops,
355            bbox: [0.0, 0.0, width, height],
356            font_resources: vec![],
357        }
358    }
359}
360/// Resolve whether a checkbox or radio rawValue string represents a checked state.
361///
362/// Adobe XFA semantics (XFA 3.3 §11.2.1 / §17.8):
363/// - rawValue `"1"` → checked.
364/// - rawValue `"0"`, `""`, or absent (empty string from unbound field) → unchecked.
365/// - Any other non-empty, non-`"0"` value is treated as checked (custom on-value).
366///
367/// This function encodes the **default** Adobe binding contract used when no explicit
368/// `<items>` list is present on the template node.  When an explicit on/off value pair
369/// is available callers should compare against those values directly (see
370/// `render_bridge::is_check_button_checked`).
371pub fn checkbox_raw_value_is_checked(raw_value: &str) -> bool {
372    !raw_value.is_empty() && raw_value != "0"
373}
374
375/// checkbox_appearance.
376pub fn checkbox_appearance(checked: bool, width: f64, height: f64) -> AppearanceStream {
377    let mut ops = Vec::new();
378    let size = width.min(height);
379    let _ = write!(
380        ops,
381        "0.50 w\n0.000 0.000 0.000 RG\n0.00 0.00 {:.2} {:.2} re\nS\n",
382        size, size
383    );
384    if checked {
385        let pad = size * 0.2;
386        let _ = write!(ops,
387            "1.50 w\n0.000 0.000 0.000 RG\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\n",
388            pad, pad, size - pad, size - pad, size - pad, pad, pad, size - pad);
389    }
390    AppearanceStream {
391        content: ops,
392        bbox: [0.0, 0.0, size, size],
393        font_resources: vec![],
394    }
395}
396
397/// Apply XFA formatting patterns to a value.
398///
399/// Supports `num{...}` patterns per XFA Spec 3.3 §17.6:
400/// - `z` = digit, suppress leading zeros
401/// - `9` = digit, show leading zeros
402/// - `.` = decimal separator
403/// - `,` = grouping separator
404/// - Literal text is passed through
405pub fn format_value(value: &str, pattern: Option<&str>) -> String {
406    let Some(pattern) = pattern else {
407        return value.to_string();
408    };
409    if pattern.starts_with("num{") && pattern.ends_with('}') {
410        let inner = &pattern[4..pattern.len() - 1];
411        if let Ok(num) = value.parse::<f64>() {
412            format_numeric(num, inner)
413        } else {
414            value.to_string()
415        }
416    } else {
417        value.to_string()
418    }
419}
420
421/// Default formatting for `numericEdit` fields without an explicit
422/// `<format><picture>` pattern.  Strips trailing fractional zeros so
423/// that data values like `"3.00000000"` display as `"3"` and
424/// `"1.50"` displays as `"1.5"`.
425pub fn format_numeric_default(value: &str) -> String {
426    let trimmed = value.trim();
427    let Ok(num) = trimmed.parse::<f64>() else {
428        return value.to_string();
429    };
430    if num.fract() == 0.0 {
431        // Integer — drop all decimals
432        format!("{}", num as i64)
433    } else {
434        // Has meaningful decimals — strip trailing zeros
435        let s = format!("{}", num);
436        s.trim_end_matches('0').trim_end_matches('.').to_string()
437    }
438}
439
440/// Format a number according to an XFA numeric picture pattern.
441fn format_numeric(num: f64, pattern: &str) -> String {
442    let is_negative = num < 0.0;
443    let abs_num = num.abs();
444
445    // Split pattern on decimal point
446    let (int_pat, dec_pat) = match pattern.find('.') {
447        Some(pos) => (&pattern[..pos], Some(&pattern[pos + 1..])),
448        None => (pattern, None),
449    };
450
451    // Determine decimal places from pattern
452    let decimal_places = dec_pat
453        .map(|d| d.chars().filter(|c| *c == '9' || *c == 'z').count())
454        .unwrap_or(0);
455
456    // Round the number to the required decimal places
457    let factor = 10f64.powi(decimal_places as i32);
458    let rounded = (abs_num * factor).round() / factor;
459
460    // Split number into integer and fractional parts
461    let int_part = rounded.trunc() as u64;
462    let frac_part = ((rounded - rounded.trunc()) * factor).round() as u64;
463
464    // Format integer part: get raw digits
465    let int_str = int_part.to_string();
466
467    // Collect digit positions from pattern
468    let pat_digit_slots: Vec<char> = int_pat.chars().filter(|c| *c == 'z' || *c == '9').collect();
469    let num_slots = pat_digit_slots.len();
470
471    // Right-align digits into slots
472    let padded_len = num_slots.max(int_str.len());
473    let mut digits = vec![0u8; padded_len];
474    for (i, b) in int_str.bytes().rev().enumerate() {
475        digits[padded_len - 1 - i] = b - b'0';
476    }
477
478    // Build output by walking the pattern left-to-right
479    let mut int_result = String::new();
480    let mut seen_significant = false;
481
482    // First emit any extra digits that overflow the pattern
483    for d in digits.iter().take(padded_len.saturating_sub(num_slots)) {
484        int_result.push((b'0' + d) as char);
485        seen_significant = true;
486    }
487
488    let mut di = padded_len.saturating_sub(num_slots);
489    for ch in int_pat.chars() {
490        match ch {
491            'z' => {
492                let d = digits[di];
493                di += 1;
494                if d != 0 || seen_significant {
495                    int_result.push((b'0' + d) as char);
496                    seen_significant = true;
497                }
498            }
499            '9' => {
500                let d = digits[di];
501                di += 1;
502                int_result.push((b'0' + d) as char);
503                seen_significant = true;
504            }
505            ',' => {
506                // Only emit comma if we have seen significant digits
507                // and there are more digits to come
508                if seen_significant {
509                    int_result.push(',');
510                }
511            }
512            _ => int_result.push(ch),
513        }
514    }
515
516    // Format decimal part
517    let result_dec = if let Some(dp) = dec_pat {
518        let frac_str = format!("{:0>width$}", frac_part, width = decimal_places);
519        let frac_bytes: Vec<u8> = frac_str.bytes().map(|b| b - b'0').collect();
520        let mut dec_result = String::new();
521        let mut fi = 0;
522        for ch in dp.chars() {
523            match ch {
524                '9' | 'z' => {
525                    if fi < frac_bytes.len() {
526                        dec_result.push((b'0' + frac_bytes[fi]) as char);
527                        fi += 1;
528                    } else {
529                        dec_result.push('0');
530                    }
531                }
532                _ => dec_result.push(ch),
533            }
534        }
535        Some(dec_result)
536    } else {
537        None
538    };
539
540    // Assemble final result
541    let mut result = String::new();
542    if is_negative {
543        result.push('-');
544    }
545    if int_result.is_empty() {
546        result.push('0');
547    } else {
548        result.push_str(&int_result);
549    }
550    if let Some(dec) = result_dec {
551        result.push('.');
552        result.push_str(&dec);
553    }
554    result
555}
556
557fn pdf_escape(s: &str) -> String {
558    let mut r = String::with_capacity(s.len());
559    for c in s.chars() {
560        match c {
561            '(' => r.push_str("\\("),
562            ')' => r.push_str("\\)"),
563            '\\' => r.push_str("\\\\"),
564            _ => r.push(c),
565        }
566    }
567    r
568}
569
570fn simple_hash(s: &str) -> u64 {
571    let mut h: u64 = 5381;
572    for b in s.bytes() {
573        h = h.wrapping_mul(33).wrapping_add(b as u64);
574    }
575    h
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    #[test]
583    fn field_appearance_basic() {
584        let config = AppearanceConfig::default();
585        let ap = field_appearance("Hello", 100.0, 20.0, &config);
586        let content = String::from_utf8_lossy(&ap.content);
587        assert!(content.contains("(Hello) Tj"));
588    }
589
590    #[test]
591    fn field_appearance_empty() {
592        let config = AppearanceConfig::default();
593        let ap = field_appearance("", 100.0, 20.0, &config);
594        assert!(!String::from_utf8_lossy(&ap.content).contains("BT"));
595    }
596
597    #[test]
598    fn cache_hit() {
599        let mut cache = AppearanceCache::new();
600        let config = AppearanceConfig::default();
601        let _ = cache.get_or_generate(FormNodeId(0), "Hello", 100.0, 20.0, &config);
602        let _ = cache.get_or_generate(FormNodeId(0), "Hello", 100.0, 20.0, &config);
603        assert_eq!(cache.cache.len(), 1);
604    }
605
606    #[test]
607    fn cache_invalidate() {
608        let mut cache = AppearanceCache::new();
609        let config = AppearanceConfig::default();
610        let _ = cache.get_or_generate(FormNodeId(0), "A", 100.0, 20.0, &config);
611        let _ = cache.get_or_generate(FormNodeId(1), "B", 100.0, 20.0, &config);
612        cache.invalidate(FormNodeId(0));
613        assert_eq!(cache.cache.len(), 1);
614    }
615
616    #[test]
617    fn format_value_numeric() {
618        assert_eq!(format_value("42.5", Some("num{zzz.99}")), "42.50");
619        assert_eq!(format_value("hello", None), "hello");
620        // Integer formatting: suppress trailing decimals
621        assert_eq!(format_value("1.00000000", Some("num{z,zzz}")), "1");
622        assert_eq!(format_value("2.00000000", Some("num{z,zzz}")), "2");
623        assert_eq!(format_value("1234", Some("num{z,zzz}")), "1,234");
624        assert_eq!(format_value("0", Some("num{z,zzz}")), "0");
625        // Decimal formatting
626        assert_eq!(format_value("3.14159", Some("num{z.99}")), "3.14");
627        assert_eq!(format_value("0.5", Some("num{z.99}")), "0.50");
628        // Leading-zero patterns
629        assert_eq!(format_value("5", Some("num{999}")), "005");
630        assert_eq!(format_value("42", Some("num{999}")), "042");
631        // Negative
632        assert_eq!(format_value("-7.5", Some("num{z.99}")), "-7.50");
633        // Non-numeric value with num pattern
634        assert_eq!(format_value("abc", Some("num{z,zzz}")), "abc");
635    }
636
637    #[test]
638    fn checkbox_checked() {
639        let ap = checkbox_appearance(true, 12.0, 12.0);
640        let content = String::from_utf8_lossy(&ap.content);
641        assert!(content.contains("re\nS"));
642        assert!(content.contains("m\n"));
643    }
644}