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