Skip to main content

oxidize_pdf/forms/
appearance.rs

1//! Appearance streams for form fields according to ISO 32000-1 Section 12.7.3.3
2//!
3//! This module provides appearance stream generation for interactive form fields,
4//! ensuring visual representation of field content and states.
5
6use crate::error::{PdfError, Result};
7use crate::forms::{BorderStyle, DefaultAppearance, FieldType, Widget};
8use crate::graphics::Color;
9use crate::objects::{Dictionary, Object, Stream};
10use crate::text::{escape_pdf_string_literal, Font, TextEncoding};
11use std::collections::{HashMap, HashSet};
12
13/// Emit a `(text) Tj` operator for a built-in PDF base-14 font.
14///
15/// ISO 32000-1 §9.6.2.2 specifies that the standard 14 Type1 fonts use
16/// StandardEncoding by default; when a /DA string references one, viewers
17/// apply WinAnsi encoding for the subset of base-14 fonts that name Latin
18/// glyphs (Helvetica, Times, Courier). The string-literal bytes in the `Tj`
19/// operator must therefore be *WinAnsi-encoded*, not UTF-8.
20///
21/// Fails explicitly (`PdfError::EncodingError`) when `text` contains any
22/// codepoint WinAnsi cannot represent — the caller (typically
23/// `Document::fill_field`) must propagate this so the user sees the real
24/// cause instead of receiving a silently-garbled `/AP` stream.
25///
26/// Custom (Type0/CID) fonts and symbolic fonts (Symbol, ZapfDingbats) are
27/// rejected here — they follow separate content-stream encodings that live
28/// outside the WinAnsi path.
29fn emit_tj_for_builtin(content: &mut String, text: &str, font: &Font) -> Result<()> {
30    if font.is_custom() {
31        return Err(PdfError::EncodingError(format!(
32            "Custom Type0/CID fonts are not yet supported in form-field appearance \
33             streams (font: {:?}). Track: https://github.com/bzsanti/oxidizePdf/issues/212",
34            font.pdf_name(),
35        )));
36    }
37    if font.is_symbolic() {
38        return Err(PdfError::EncodingError(format!(
39            "Symbolic fonts ({:?}) are not supported for form-field text — their \
40             encoding depends on glyph names, not Unicode codepoints",
41            font.pdf_name(),
42        )));
43    }
44
45    // Built-in non-symbolic → WinAnsi strict. Any codepoint WinAnsi can't
46    // represent fails the operation instead of emitting `?` or raw UTF-8.
47    let bytes = TextEncoding::WinAnsiEncoding
48        .encode_strict(text)
49        .map_err(|ch| {
50            PdfError::EncodingError(format!(
51                "Value contains character {:?} (U+{:04X}) which cannot be encoded \
52                 in WinAnsiEncoding used by built-in PDF font {}. Register a Type0 \
53                 font via `Document::add_font_from_bytes` and attach it to the field; \
54                 see https://github.com/bzsanti/oxidizePdf/issues/212",
55                ch,
56                ch as u32,
57                font.pdf_name(),
58            ))
59        })?;
60
61    content.push_str(&format!("({}) Tj\n", escape_pdf_string_literal(&bytes)));
62    Ok(())
63}
64
65/// Emit a `<HHHH...> Tj` operator for a custom Type0/CID font.
66///
67/// Each Unicode codepoint is resolved to its glyph index via the font's cmap
68/// (`Font::glyph_mapping.char_to_glyph`) and written as a 4-hex-digit code —
69/// exactly what a `/Encoding /Identity-H` font expects on the content-stream
70/// side. The chars are recorded into `used_chars` so the caller can merge
71/// them into `Document::used_characters_by_font` for the subsetter (matches
72/// the infrastructure introduced for issue #204).
73///
74/// Fails with `PdfError::EncodingError` when the font has no glyph for a
75/// codepoint — silent fallback to `.notdef` would leave the user with
76/// invisible-or-blank glyphs.
77fn emit_tj_for_custom(
78    content: &mut String,
79    text: &str,
80    font_name: &str,
81    custom_font: &crate::fonts::Font,
82    used_chars: &mut HashSet<char>,
83) -> Result<()> {
84    use std::fmt::Write;
85    let mut hex = String::with_capacity(text.len() * 4);
86    for ch in text.chars() {
87        let gid = custom_font.glyph_mapping.char_to_glyph(ch).ok_or_else(|| {
88            PdfError::EncodingError(format!(
89                "Custom font {:?} has no glyph for character {:?} (U+{:04X}); \
90                 the font's cmap does not cover this codepoint",
91                font_name, ch, ch as u32,
92            ))
93        })?;
94        write!(&mut hex, "{:04X}", gid).expect("writing to String cannot fail");
95        used_chars.insert(ch);
96    }
97    content.push_str(&format!("<{}> Tj\n", hex));
98    Ok(())
99}
100
101/// Appearance states for form fields
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
103pub enum AppearanceState {
104    /// Normal appearance (default state)
105    Normal,
106    /// Rollover appearance (mouse hover)
107    Rollover,
108    /// Down appearance (mouse pressed)
109    Down,
110}
111
112impl AppearanceState {
113    /// Get the PDF name for this state
114    pub fn pdf_name(&self) -> &'static str {
115        match self {
116            AppearanceState::Normal => "N",
117            AppearanceState::Rollover => "R",
118            AppearanceState::Down => "D",
119        }
120    }
121}
122
123/// Appearance stream for a form field
124#[derive(Debug, Clone)]
125pub struct AppearanceStream {
126    /// The content stream data
127    pub content: Vec<u8>,
128    /// Resources dictionary (fonts, colors, etc.)
129    pub resources: Dictionary,
130    /// Bounding box for the appearance
131    pub bbox: [f64; 4],
132}
133
134impl AppearanceStream {
135    /// Create a new appearance stream
136    pub fn new(content: Vec<u8>, bbox: [f64; 4]) -> Self {
137        Self {
138            content,
139            resources: Dictionary::new(),
140            bbox,
141        }
142    }
143
144    /// Set resources dictionary
145    pub fn with_resources(mut self, resources: Dictionary) -> Self {
146        self.resources = resources;
147        self
148    }
149
150    /// Convert to a Stream object
151    pub fn to_stream(&self) -> Stream {
152        let mut dict = Dictionary::new();
153        dict.set("Type", Object::Name("XObject".to_string()));
154        dict.set("Subtype", Object::Name("Form".to_string()));
155
156        // Set bounding box
157        let bbox_array = vec![
158            Object::Real(self.bbox[0]),
159            Object::Real(self.bbox[1]),
160            Object::Real(self.bbox[2]),
161            Object::Real(self.bbox[3]),
162        ];
163        dict.set("BBox", Object::Array(bbox_array));
164
165        // Set resources
166        if !self.resources.is_empty() {
167            dict.set("Resources", Object::Dictionary(self.resources.clone()));
168        }
169
170        // Create stream with dictionary
171        Stream::with_dictionary(dict, self.content.clone())
172    }
173}
174
175/// Outcome of an appearance-stream build — carries the stream plus the
176/// per-font character obligations the generator recorded. Callers
177/// (typically `Document::fill_field`) merge `used_chars_by_font` into
178/// `Document::used_characters_by_font` so the writer's font subsetter
179/// covers every codepoint referenced from any `/AP` stream (preserves
180/// the #204 invariant).
181///
182/// Named struct instead of a tuple so adding further outputs (e.g.
183/// computed bbox overrides, resource hints) doesn't become a breaking
184/// API change. The map is empty for built-in fonts that don't need
185/// subsetting.
186#[derive(Debug, Clone)]
187pub struct FieldAppearanceResult {
188    /// The rendered appearance stream ready to attach to a widget.
189    pub stream: AppearanceStream,
190    /// Characters consumed from each custom Type0 font, keyed by font
191    /// name. Empty when every emission went through the built-in path.
192    pub used_chars_by_font: HashMap<String, HashSet<char>>,
193}
194
195/// Appearance dictionary for a form field
196#[derive(Debug, Clone)]
197pub struct AppearanceDictionary {
198    /// Appearance streams by state
199    appearances: HashMap<AppearanceState, AppearanceStream>,
200    /// Down appearances for different values (checkboxes, radio buttons)
201    down_appearances: HashMap<String, AppearanceStream>,
202}
203
204impl AppearanceDictionary {
205    /// Create a new appearance dictionary
206    pub fn new() -> Self {
207        Self {
208            appearances: HashMap::new(),
209            down_appearances: HashMap::new(),
210        }
211    }
212
213    /// Set appearance for a specific state
214    pub fn set_appearance(&mut self, state: AppearanceState, stream: AppearanceStream) {
215        self.appearances.insert(state, stream);
216    }
217
218    /// Set down appearance for a specific value
219    pub fn set_down_appearance(&mut self, value: String, stream: AppearanceStream) {
220        self.down_appearances.insert(value, stream);
221    }
222
223    /// Get appearance for a state
224    pub fn get_appearance(&self, state: AppearanceState) -> Option<&AppearanceStream> {
225        self.appearances.get(&state)
226    }
227
228    /// Convert to PDF dictionary
229    pub fn to_dict(&self) -> Dictionary {
230        let mut dict = Dictionary::new();
231
232        // Add appearances by state
233        for (state, stream) in &self.appearances {
234            let stream_obj = stream.to_stream();
235            dict.set(
236                state.pdf_name(),
237                Object::Stream(stream_obj.dictionary().clone(), stream_obj.data().to_vec()),
238            );
239        }
240
241        // Add down appearances if any
242        if !self.down_appearances.is_empty() {
243            let mut down_dict = Dictionary::new();
244            for (value, stream) in &self.down_appearances {
245                let stream_obj = stream.to_stream();
246                down_dict.set(
247                    value,
248                    Object::Stream(stream_obj.dictionary().clone(), stream_obj.data().to_vec()),
249                );
250            }
251            dict.set("D", Object::Dictionary(down_dict));
252        }
253
254        dict
255    }
256}
257
258impl Default for AppearanceDictionary {
259    fn default() -> Self {
260        Self::new()
261    }
262}
263
264/// Trait for generating appearance streams for different field types
265pub trait AppearanceGenerator {
266    /// Generate appearance stream for the field
267    fn generate_appearance(
268        &self,
269        widget: &Widget,
270        value: Option<&str>,
271        state: AppearanceState,
272    ) -> Result<AppearanceStream>;
273}
274
275/// Text field appearance generator
276pub struct TextFieldAppearance {
277    /// Font to use
278    pub font: Font,
279    /// Font size
280    pub font_size: f64,
281    /// Text color
282    pub text_color: Color,
283    /// Justification (0=left, 1=center, 2=right)
284    pub justification: i32,
285    /// Multiline text
286    pub multiline: bool,
287}
288
289impl Default for TextFieldAppearance {
290    fn default() -> Self {
291        Self {
292            font: Font::Helvetica,
293            font_size: 12.0,
294            text_color: Color::black(),
295            justification: 0,
296            multiline: false,
297        }
298    }
299}
300
301impl AppearanceGenerator for TextFieldAppearance {
302    fn generate_appearance(
303        &self,
304        widget: &Widget,
305        value: Option<&str>,
306        state: AppearanceState,
307    ) -> Result<AppearanceStream> {
308        let result = self.generate_appearance_with_font(widget, value, state, None)?;
309        Ok(result.stream)
310    }
311}
312
313impl TextFieldAppearance {
314    /// Generate the appearance honouring an optional pre-resolved custom
315    /// (Type0/CID) font. Returns a [`FieldAppearanceResult`] carrying both
316    /// the stream and any characters that the Type0 path consumed from the
317    /// font (empty for the built-in path).
318    ///
319    /// The caller is responsible for supplying `custom_font` when
320    /// `self.font == Font::Custom(_)`. If `self.font` is custom but
321    /// `custom_font` is `None`, an explicit `PdfError::EncodingError`
322    /// signals "font not registered on the Document" (a common user
323    /// mistake distinct from the generator not supporting custom fonts).
324    pub fn generate_appearance_with_font(
325        &self,
326        widget: &Widget,
327        value: Option<&str>,
328        _state: AppearanceState,
329        custom_font: Option<&crate::fonts::Font>,
330    ) -> Result<FieldAppearanceResult> {
331        let width = widget.rect.upper_right.x - widget.rect.lower_left.x;
332        let height = widget.rect.upper_right.y - widget.rect.lower_left.y;
333
334        let mut content = String::new();
335        let mut used_chars_per_font: HashMap<String, HashSet<char>> = HashMap::new();
336
337        // Save graphics state
338        content.push_str("q\n");
339
340        // Draw background if specified
341        if let Some(bg_color) = &widget.appearance.background_color {
342            crate::graphics::color::write_fill_color(&mut content, *bg_color);
343            content.push_str(&format!("0 0 {width} {height} re f\n"));
344        }
345
346        // Draw border
347        if let Some(border_color) = &widget.appearance.border_color {
348            crate::graphics::color::write_stroke_color(&mut content, *border_color);
349            content.push_str(&format!("{} w\n", widget.appearance.border_width));
350
351            match widget.appearance.border_style {
352                BorderStyle::Solid => {
353                    content.push_str(&format!("0 0 {width} {height} re S\n"));
354                }
355                BorderStyle::Dashed => {
356                    content.push_str("[3 2] 0 d\n");
357                    content.push_str(&format!("0 0 {width} {height} re S\n"));
358                }
359                BorderStyle::Beveled | BorderStyle::Inset => {
360                    // Simplified beveled/inset border
361                    content.push_str(&format!("0 0 {width} {height} re S\n"));
362                }
363                BorderStyle::Underline => {
364                    content.push_str(&format!("0 0 m {width} 0 l S\n"));
365                }
366            }
367        }
368
369        // Draw text if value is provided
370        if let Some(text) = value {
371            // Set text color
372            crate::graphics::color::write_fill_color(&mut content, self.text_color);
373
374            // Begin text
375            content.push_str("BT\n");
376            content.push_str(&format!(
377                "/{} {} Tf\n",
378                self.font.pdf_name(),
379                self.font_size
380            ));
381
382            // Calculate text position
383            let padding = 2.0;
384            let text_y = (height - self.font_size) / 2.0 + self.font_size * 0.3;
385
386            let text_x = match self.justification {
387                1 => width / 2.0,     // Center (would need text width calculation)
388                2 => width - padding, // Right
389                _ => padding,         // Left
390            };
391
392            content.push_str(&format!("{text_x} {text_y} Td\n"));
393
394            // Dispatch on the font kind.
395            //
396            // `(true, Some)`  → Custom Type0/CID font with a resolved font
397            //                   handle. Emits hex-CID Tj and records used
398            //                   chars (#204 subsetter invariant).
399            // `(true, None)`  → `/DA` names a custom font the Document does
400            //                   not have registered. Fail fast with a
401            //                   diagnostic that points the caller at the
402            //                   likely mistake; silently falling through to
403            //                   `emit_tj_for_builtin` would produce an
404            //                   opaque "Custom fonts not supported" error
405            //                   from a different code path, confusing
406            //                   anyone who mistyped a font name.
407            // `(false, _)`    → Built-in Type1 path, WinAnsi strict.
408            match (self.font.is_custom(), custom_font) {
409                (true, Some(cf)) => {
410                    let font_name = self.font.pdf_name();
411                    let entry = used_chars_per_font.entry(font_name.clone()).or_default();
412                    emit_tj_for_custom(&mut content, text, &font_name, cf, entry)?;
413                }
414                (true, None) => {
415                    return Err(PdfError::EncodingError(format!(
416                        "Font {:?} is marked as Custom but was not found in the \
417                         document registry; call Document::add_font_from_bytes with \
418                         this name before fill_field/save. See issue #212.",
419                        self.font.pdf_name(),
420                    )));
421                }
422                (false, _) => {
423                    emit_tj_for_builtin(&mut content, text, &self.font)?;
424                }
425            }
426
427            // End text
428            content.push_str("ET\n");
429        }
430
431        // Restore graphics state
432        content.push_str("Q\n");
433
434        // Create resources dictionary — the /Font entry differs by path:
435        // - Built-in: emit the current Type1 inline dict (correct as-is).
436        // - Custom Type0: emit a placeholder entry whose key matches the
437        //   font name; the writer's /AP externalisation pass rewrites it
438        //   to an indirect Reference to the document-level Type0 object
439        //   (see issue #212 Phase 3).
440        let mut resources = Dictionary::new();
441        let mut font_dict = Dictionary::new();
442        if self.font.is_custom() {
443            // Placeholder — real reference is wired in at write-time.
444            let mut placeholder = Dictionary::new();
445            placeholder.set("Type", Object::Name("Font".to_string()));
446            placeholder.set("Subtype", Object::Name("Type0".to_string()));
447            placeholder.set("BaseFont", Object::Name(self.font.pdf_name()));
448            placeholder.set("Encoding", Object::Name("Identity-H".to_string()));
449            font_dict.set(self.font.pdf_name(), Object::Dictionary(placeholder));
450        } else {
451            let mut font_res = Dictionary::new();
452            font_res.set("Type", Object::Name("Font".to_string()));
453            font_res.set("Subtype", Object::Name("Type1".to_string()));
454            font_res.set("BaseFont", Object::Name(self.font.pdf_name()));
455            font_dict.set(self.font.pdf_name(), Object::Dictionary(font_res));
456        }
457        resources.set("Font", Object::Dictionary(font_dict));
458
459        let stream = AppearanceStream::new(content.into_bytes(), [0.0, 0.0, width, height])
460            .with_resources(resources);
461
462        Ok(FieldAppearanceResult {
463            stream,
464            used_chars_by_font: used_chars_per_font,
465        })
466    }
467}
468
469/// Checkbox appearance generator
470pub struct CheckBoxAppearance {
471    /// Check mark style
472    pub check_style: CheckStyle,
473    /// Check color
474    pub check_color: Color,
475}
476
477/// Style of check mark
478#[derive(Debug, Clone, Copy)]
479pub enum CheckStyle {
480    /// Check mark (✓)
481    Check,
482    /// Cross (✗)
483    Cross,
484    /// Square (■)
485    Square,
486    /// Circle (●)
487    Circle,
488    /// Star (★)
489    Star,
490}
491
492impl Default for CheckBoxAppearance {
493    fn default() -> Self {
494        Self {
495            check_style: CheckStyle::Check,
496            check_color: Color::black(),
497        }
498    }
499}
500
501impl AppearanceGenerator for CheckBoxAppearance {
502    fn generate_appearance(
503        &self,
504        widget: &Widget,
505        value: Option<&str>,
506        _state: AppearanceState,
507    ) -> Result<AppearanceStream> {
508        let width = widget.rect.upper_right.x - widget.rect.lower_left.x;
509        let height = widget.rect.upper_right.y - widget.rect.lower_left.y;
510        let is_checked = value.is_some_and(|v| v == "Yes" || v == "On" || v == "true");
511
512        let mut content = String::new();
513
514        // Save graphics state
515        content.push_str("q\n");
516
517        // Draw background
518        if let Some(bg_color) = &widget.appearance.background_color {
519            crate::graphics::color::write_fill_color(&mut content, *bg_color);
520            content.push_str(&format!("0 0 {width} {height} re f\n"));
521        }
522
523        // Draw border
524        if let Some(border_color) = &widget.appearance.border_color {
525            crate::graphics::color::write_stroke_color(&mut content, *border_color);
526            content.push_str(&format!("{} w\n", widget.appearance.border_width));
527            content.push_str(&format!("0 0 {width} {height} re S\n"));
528        }
529
530        // Draw check mark if checked
531        if is_checked {
532            // Set check color
533            crate::graphics::color::write_fill_color(&mut content, self.check_color);
534
535            let inset = width * 0.2;
536
537            match self.check_style {
538                CheckStyle::Check => {
539                    // Draw check mark path
540                    content.push_str(&format!("{} {} m\n", inset, height * 0.5));
541                    content.push_str(&format!("{} {} l\n", width * 0.4, inset));
542                    content.push_str(&format!("{} {} l\n", width - inset, height - inset));
543                    content.push_str("3 w S\n");
544                }
545                CheckStyle::Cross => {
546                    // Draw X
547                    content.push_str(&format!("{inset} {inset} m\n"));
548                    content.push_str(&format!("{} {} l\n", width - inset, height - inset));
549                    content.push_str(&format!("{} {inset} m\n", width - inset));
550                    content.push_str(&format!("{inset} {} l\n", height - inset));
551                    content.push_str("2 w S\n");
552                }
553                CheckStyle::Square => {
554                    // Draw filled square
555                    content.push_str(&format!(
556                        "{inset} {inset} {} {} re f\n",
557                        width - 2.0 * inset,
558                        height - 2.0 * inset
559                    ));
560                }
561                CheckStyle::Circle => {
562                    // Draw filled circle (simplified)
563                    let cx = width / 2.0;
564                    let cy = height / 2.0;
565                    let r = (width.min(height) - 2.0 * inset) / 2.0;
566
567                    // Use Bézier curves to approximate circle
568                    let k = 0.552284749831;
569                    content.push_str(&format!("{} {} m\n", cx + r, cy));
570                    content.push_str(&format!(
571                        "{} {} {} {} {} {} c\n",
572                        cx + r,
573                        cy + k * r,
574                        cx + k * r,
575                        cy + r,
576                        cx,
577                        cy + r
578                    ));
579                    content.push_str(&format!(
580                        "{} {} {} {} {} {} c\n",
581                        cx - k * r,
582                        cy + r,
583                        cx - r,
584                        cy + k * r,
585                        cx - r,
586                        cy
587                    ));
588                    content.push_str(&format!(
589                        "{} {} {} {} {} {} c\n",
590                        cx - r,
591                        cy - k * r,
592                        cx - k * r,
593                        cy - r,
594                        cx,
595                        cy - r
596                    ));
597                    content.push_str(&format!(
598                        "{} {} {} {} {} {} c\n",
599                        cx + k * r,
600                        cy - r,
601                        cx + r,
602                        cy - k * r,
603                        cx + r,
604                        cy
605                    ));
606                    content.push_str("f\n");
607                }
608                CheckStyle::Star => {
609                    // Draw 5-pointed star (simplified)
610                    let cx = width / 2.0;
611                    let cy = height / 2.0;
612                    let r = (width.min(height) - 2.0 * inset) / 2.0;
613
614                    // Star points (simplified)
615                    for i in 0..5 {
616                        let angle = std::f64::consts::PI * 2.0 * i as f64 / 5.0
617                            - std::f64::consts::PI / 2.0;
618                        let x = cx + r * angle.cos();
619                        let y = cy + r * angle.sin();
620
621                        if i == 0 {
622                            content.push_str(&format!("{x} {y} m\n"));
623                        } else {
624                            content.push_str(&format!("{x} {y} l\n"));
625                        }
626                    }
627                    content.push_str("f\n");
628                }
629            }
630        }
631
632        // Restore graphics state
633        content.push_str("Q\n");
634
635        let stream = AppearanceStream::new(content.into_bytes(), [0.0, 0.0, width, height]);
636
637        Ok(stream)
638    }
639}
640
641/// Radio button appearance generator
642pub struct RadioButtonAppearance {
643    /// Button color when selected
644    pub selected_color: Color,
645}
646
647impl Default for RadioButtonAppearance {
648    fn default() -> Self {
649        Self {
650            selected_color: Color::black(),
651        }
652    }
653}
654
655impl AppearanceGenerator for RadioButtonAppearance {
656    fn generate_appearance(
657        &self,
658        widget: &Widget,
659        value: Option<&str>,
660        _state: AppearanceState,
661    ) -> Result<AppearanceStream> {
662        let width = widget.rect.upper_right.x - widget.rect.lower_left.x;
663        let height = widget.rect.upper_right.y - widget.rect.lower_left.y;
664        let is_selected = value.is_some_and(|v| v == "Yes" || v == "On" || v == "true");
665
666        let mut content = String::new();
667
668        // Save graphics state
669        content.push_str("q\n");
670
671        // Draw background circle
672        if let Some(bg_color) = &widget.appearance.background_color {
673            crate::graphics::color::write_fill_color(&mut content, *bg_color);
674        } else {
675            crate::graphics::color::write_fill_color(&mut content, Color::Gray(1.0));
676        }
677
678        let cx = width / 2.0;
679        let cy = height / 2.0;
680        let r = width.min(height) / 2.0 - widget.appearance.border_width;
681
682        // Draw outer circle
683        let k = 0.552284749831;
684        content.push_str(&format!("{} {} m\n", cx + r, cy));
685        content.push_str(&format!(
686            "{} {} {} {} {} {} c\n",
687            cx + r,
688            cy + k * r,
689            cx + k * r,
690            cy + r,
691            cx,
692            cy + r
693        ));
694        content.push_str(&format!(
695            "{} {} {} {} {} {} c\n",
696            cx - k * r,
697            cy + r,
698            cx - r,
699            cy + k * r,
700            cx - r,
701            cy
702        ));
703        content.push_str(&format!(
704            "{} {} {} {} {} {} c\n",
705            cx - r,
706            cy - k * r,
707            cx - k * r,
708            cy - r,
709            cx,
710            cy - r
711        ));
712        content.push_str(&format!(
713            "{} {} {} {} {} {} c\n",
714            cx + k * r,
715            cy - r,
716            cx + r,
717            cy - k * r,
718            cx + r,
719            cy
720        ));
721        content.push_str("f\n");
722
723        // Draw border
724        if let Some(border_color) = &widget.appearance.border_color {
725            crate::graphics::color::write_stroke_color(&mut content, *border_color);
726            content.push_str(&format!("{} w\n", widget.appearance.border_width));
727
728            content.push_str(&format!("{} {} m\n", cx + r, cy));
729            content.push_str(&format!(
730                "{} {} {} {} {} {} c\n",
731                cx + r,
732                cy + k * r,
733                cx + k * r,
734                cy + r,
735                cx,
736                cy + r
737            ));
738            content.push_str(&format!(
739                "{} {} {} {} {} {} c\n",
740                cx - k * r,
741                cy + r,
742                cx - r,
743                cy + k * r,
744                cx - r,
745                cy
746            ));
747            content.push_str(&format!(
748                "{} {} {} {} {} {} c\n",
749                cx - r,
750                cy - k * r,
751                cx - k * r,
752                cy - r,
753                cx,
754                cy - r
755            ));
756            content.push_str(&format!(
757                "{} {} {} {} {} {} c\n",
758                cx + k * r,
759                cy - r,
760                cx + r,
761                cy - k * r,
762                cx + r,
763                cy
764            ));
765            content.push_str("S\n");
766        }
767
768        // Draw inner dot if selected
769        if is_selected {
770            crate::graphics::color::write_fill_color(&mut content, self.selected_color);
771
772            let inner_r = r * 0.4;
773            content.push_str(&format!("{} {} m\n", cx + inner_r, cy));
774            content.push_str(&format!(
775                "{} {} {} {} {} {} c\n",
776                cx + inner_r,
777                cy + k * inner_r,
778                cx + k * inner_r,
779                cy + inner_r,
780                cx,
781                cy + inner_r
782            ));
783            content.push_str(&format!(
784                "{} {} {} {} {} {} c\n",
785                cx - k * inner_r,
786                cy + inner_r,
787                cx - inner_r,
788                cy + k * inner_r,
789                cx - inner_r,
790                cy
791            ));
792            content.push_str(&format!(
793                "{} {} {} {} {} {} c\n",
794                cx - inner_r,
795                cy - k * inner_r,
796                cx - k * inner_r,
797                cy - inner_r,
798                cx,
799                cy - inner_r
800            ));
801            content.push_str(&format!(
802                "{} {} {} {} {} {} c\n",
803                cx + k * inner_r,
804                cy - inner_r,
805                cx + inner_r,
806                cy - k * inner_r,
807                cx + inner_r,
808                cy
809            ));
810            content.push_str("f\n");
811        }
812
813        // Restore graphics state
814        content.push_str("Q\n");
815
816        let stream = AppearanceStream::new(content.into_bytes(), [0.0, 0.0, width, height]);
817
818        Ok(stream)
819    }
820}
821
822/// Push button appearance generator
823pub struct PushButtonAppearance {
824    /// Button label
825    pub label: String,
826    /// Label font
827    pub font: Font,
828    /// Font size
829    pub font_size: f64,
830    /// Text color
831    pub text_color: Color,
832}
833
834impl Default for PushButtonAppearance {
835    fn default() -> Self {
836        Self {
837            label: String::new(),
838            font: Font::Helvetica,
839            font_size: 12.0,
840            text_color: Color::black(),
841        }
842    }
843}
844
845impl AppearanceGenerator for PushButtonAppearance {
846    fn generate_appearance(
847        &self,
848        widget: &Widget,
849        _value: Option<&str>,
850        state: AppearanceState,
851    ) -> Result<AppearanceStream> {
852        let width = widget.rect.upper_right.x - widget.rect.lower_left.x;
853        let height = widget.rect.upper_right.y - widget.rect.lower_left.y;
854
855        let mut content = String::new();
856
857        // Save graphics state
858        content.push_str("q\n");
859
860        // Draw background with different colors for different states
861        let bg_color = match state {
862            AppearanceState::Down => Color::gray(0.8),
863            AppearanceState::Rollover => Color::gray(0.95),
864            AppearanceState::Normal => widget
865                .appearance
866                .background_color
867                .unwrap_or(Color::gray(0.9)),
868        };
869
870        crate::graphics::color::write_fill_color(&mut content, bg_color);
871        content.push_str(&format!("0 0 {width} {height} re f\n"));
872
873        // Draw beveled border for button appearance
874        if matches!(widget.appearance.border_style, BorderStyle::Beveled) {
875            // Light edge (top and left)
876            content.push_str("0.9 G\n");
877            content.push_str("2 w\n");
878            content.push_str(&format!("0 {height} m {width} {height} l\n"));
879            content.push_str(&format!("{width} {height} l {width} 0 l S\n"));
880
881            // Dark edge (bottom and right)
882            content.push_str("0.3 G\n");
883            content.push_str(&format!("0 0 m {width} 0 l\n"));
884            content.push_str(&format!("0 0 l 0 {height} l S\n"));
885        } else {
886            // Regular border
887            if let Some(border_color) = &widget.appearance.border_color {
888                crate::graphics::color::write_stroke_color(&mut content, *border_color);
889                content.push_str(&format!("{} w\n", widget.appearance.border_width));
890                content.push_str(&format!("0 0 {width} {height} re S\n"));
891            }
892        }
893
894        // Draw label text
895        if !self.label.is_empty() {
896            crate::graphics::color::write_fill_color(&mut content, self.text_color);
897
898            content.push_str("BT\n");
899            content.push_str(&format!(
900                "/{} {} Tf\n",
901                self.font.pdf_name(),
902                self.font_size
903            ));
904
905            // Center text (simplified - would need actual text width calculation)
906            let text_x = width / 4.0; // Approximate centering
907            let text_y = (height - self.font_size) / 2.0 + self.font_size * 0.3;
908
909            content.push_str(&format!("{text_x} {text_y} Td\n"));
910
911            // Same contract as TextFieldAppearance: built-in Type1 → WinAnsi
912            // strict encoding, explicit error for anything outside the
913            // repertoire. Applies to the button label.
914            emit_tj_for_builtin(&mut content, &self.label, &self.font)?;
915
916            content.push_str("ET\n");
917        }
918
919        // Restore graphics state
920        content.push_str("Q\n");
921
922        // Create resources dictionary
923        let mut resources = Dictionary::new();
924
925        // Add font resource
926        let mut font_dict = Dictionary::new();
927        let mut font_res = Dictionary::new();
928        font_res.set("Type", Object::Name("Font".to_string()));
929        font_res.set("Subtype", Object::Name("Type1".to_string()));
930        font_res.set("BaseFont", Object::Name(self.font.pdf_name()));
931        font_dict.set(self.font.pdf_name(), Object::Dictionary(font_res));
932        resources.set("Font", Object::Dictionary(font_dict));
933
934        let stream = AppearanceStream::new(content.into_bytes(), [0.0, 0.0, width, height])
935            .with_resources(resources);
936
937        Ok(stream)
938    }
939}
940
941/// Appearance generator for ComboBox fields
942#[derive(Debug, Clone)]
943pub struct ComboBoxAppearance {
944    /// Font for text
945    pub font: Font,
946    /// Font size
947    pub font_size: f64,
948    /// Text color
949    pub text_color: Color,
950    /// Selected option
951    pub selected_text: Option<String>,
952    /// Show dropdown arrow
953    pub show_arrow: bool,
954}
955
956impl Default for ComboBoxAppearance {
957    fn default() -> Self {
958        Self {
959            font: Font::Helvetica,
960            font_size: 12.0,
961            text_color: Color::black(),
962            selected_text: None,
963            show_arrow: true,
964        }
965    }
966}
967
968impl AppearanceGenerator for ComboBoxAppearance {
969    fn generate_appearance(
970        &self,
971        widget: &Widget,
972        value: Option<&str>,
973        state: AppearanceState,
974    ) -> Result<AppearanceStream> {
975        let result = self.generate_appearance_with_font(widget, value, state, None)?;
976        Ok(result.stream)
977    }
978}
979
980impl ComboBoxAppearance {
981    /// Parallel to [`TextFieldAppearance::generate_appearance_with_font`].
982    /// When `self.font == Font::Custom(name)` AND `custom_font` is supplied,
983    /// emits a hex-CID `<HHHH...> Tj` with a Type0 placeholder resource; the
984    /// writer rewrites the placeholder into an indirect Reference to the
985    /// document-level font object. Same contract on the
986    /// `Custom + None` case: fails fast with a "font not registered"
987    /// `PdfError::EncodingError` instead of silently falling back.
988    pub fn generate_appearance_with_font(
989        &self,
990        widget: &Widget,
991        value: Option<&str>,
992        _state: AppearanceState,
993        custom_font: Option<&crate::fonts::Font>,
994    ) -> Result<FieldAppearanceResult> {
995        let width = widget.rect.upper_right.x - widget.rect.lower_left.x;
996        let height = widget.rect.upper_right.y - widget.rect.lower_left.y;
997
998        let mut content = String::new();
999        let mut used_chars_per_font: HashMap<String, HashSet<char>> = HashMap::new();
1000
1001        // Draw background
1002        crate::graphics::color::write_fill_color(&mut content, Color::white());
1003        content.push_str(&format!("0 0 {} {} re\n", width, height));
1004        content.push_str("f\n");
1005
1006        // Draw border
1007        if let Some(ref border_color) = widget.appearance.border_color {
1008            crate::graphics::color::write_stroke_color(&mut content, *border_color);
1009            content.push_str(&format!("{} w\n", widget.appearance.border_width));
1010            content.push_str(&format!("0 0 {} {} re\n", width, height));
1011            content.push_str("S\n");
1012        }
1013
1014        // Draw dropdown arrow if enabled
1015        if self.show_arrow {
1016            let arrow_x = width - 15.0;
1017            let arrow_y = height / 2.0;
1018            crate::graphics::color::write_fill_color(&mut content, Color::gray(0.5));
1019            content.push_str(&format!("{} {} m\n", arrow_x, arrow_y + 3.0));
1020            content.push_str(&format!("{} {} l\n", arrow_x + 8.0, arrow_y + 3.0));
1021            content.push_str(&format!("{} {} l\n", arrow_x + 4.0, arrow_y - 3.0));
1022            content.push_str("f\n");
1023        }
1024
1025        // Draw selected text
1026        let text_to_show = value.or(self.selected_text.as_deref());
1027        if let Some(text) = text_to_show {
1028            content.push_str("BT\n");
1029            content.push_str(&format!(
1030                "/{} {} Tf\n",
1031                self.font.pdf_name(),
1032                self.font_size
1033            ));
1034            crate::graphics::color::write_fill_color(&mut content, self.text_color);
1035            content.push_str(&format!("5 {} Td\n", (height - self.font_size) / 2.0));
1036
1037            // Same dispatch as `TextFieldAppearance::generate_appearance_with_font`
1038            // — see that function for the rationale on the explicit
1039            // `(true, None)` arm. Combo boxes reach this path when the field's
1040            // `/DA` picks a custom font and the user renders the selected value.
1041            match (self.font.is_custom(), custom_font) {
1042                (true, Some(cf)) => {
1043                    let font_name = self.font.pdf_name();
1044                    let entry = used_chars_per_font.entry(font_name.clone()).or_default();
1045                    emit_tj_for_custom(&mut content, text, &font_name, cf, entry)?;
1046                }
1047                (true, None) => {
1048                    return Err(PdfError::EncodingError(format!(
1049                        "Font {:?} is marked as Custom but was not found in the \
1050                         document registry; call Document::add_font_from_bytes with \
1051                         this name before fill_field/save. See issue #212.",
1052                        self.font.pdf_name(),
1053                    )));
1054                }
1055                (false, _) => emit_tj_for_builtin(&mut content, text, &self.font)?,
1056            }
1057            content.push_str("ET\n");
1058        }
1059
1060        // Resources dict parallels the TextField path: custom → Type0
1061        // placeholder (writer-rewritten), builtin → Type1 inline.
1062        let mut resources = Dictionary::new();
1063        let mut font_dict = Dictionary::new();
1064        if self.font.is_custom() {
1065            let mut placeholder = Dictionary::new();
1066            placeholder.set("Type", Object::Name("Font".to_string()));
1067            placeholder.set("Subtype", Object::Name("Type0".to_string()));
1068            placeholder.set("BaseFont", Object::Name(self.font.pdf_name()));
1069            placeholder.set("Encoding", Object::Name("Identity-H".to_string()));
1070            font_dict.set(self.font.pdf_name(), Object::Dictionary(placeholder));
1071        } else {
1072            let mut font_res = Dictionary::new();
1073            font_res.set("Type", Object::Name("Font".to_string()));
1074            font_res.set("Subtype", Object::Name("Type1".to_string()));
1075            font_res.set("BaseFont", Object::Name(self.font.pdf_name()));
1076            font_dict.set(self.font.pdf_name(), Object::Dictionary(font_res));
1077        }
1078        resources.set("Font", Object::Dictionary(font_dict));
1079
1080        let bbox = [0.0, 0.0, width, height];
1081        let stream = AppearanceStream::new(content.into_bytes(), bbox).with_resources(resources);
1082        Ok(FieldAppearanceResult {
1083            stream,
1084            used_chars_by_font: used_chars_per_font,
1085        })
1086    }
1087}
1088
1089/// Appearance generator for ListBox fields
1090#[derive(Debug, Clone)]
1091pub struct ListBoxAppearance {
1092    /// Font for text
1093    pub font: Font,
1094    /// Font size
1095    pub font_size: f64,
1096    /// Text color
1097    pub text_color: Color,
1098    /// Background color for selected items
1099    pub selection_color: Color,
1100    /// Options to display
1101    pub options: Vec<String>,
1102    /// Selected indices
1103    pub selected: Vec<usize>,
1104    /// Item height
1105    pub item_height: f64,
1106}
1107
1108impl Default for ListBoxAppearance {
1109    fn default() -> Self {
1110        Self {
1111            font: Font::Helvetica,
1112            font_size: 12.0,
1113            text_color: Color::black(),
1114            selection_color: Color::rgb(0.2, 0.4, 0.8),
1115            options: Vec::new(),
1116            selected: Vec::new(),
1117            item_height: 16.0,
1118        }
1119    }
1120}
1121
1122impl AppearanceGenerator for ListBoxAppearance {
1123    fn generate_appearance(
1124        &self,
1125        widget: &Widget,
1126        _value: Option<&str>,
1127        _state: AppearanceState,
1128    ) -> Result<AppearanceStream> {
1129        let width = widget.rect.upper_right.x - widget.rect.lower_left.x;
1130        let height = widget.rect.upper_right.y - widget.rect.lower_left.y;
1131
1132        let mut content = String::new();
1133
1134        // Draw background
1135        crate::graphics::color::write_fill_color(&mut content, Color::white());
1136        content.push_str(&format!("0 0 {} {} re\n", width, height));
1137        content.push_str("f\n");
1138
1139        // Draw border
1140        if let Some(ref border_color) = widget.appearance.border_color {
1141            crate::graphics::color::write_stroke_color(&mut content, *border_color);
1142            content.push_str(&format!("{} w\n", widget.appearance.border_width));
1143            content.push_str(&format!("0 0 {} {} re\n", width, height));
1144            content.push_str("S\n");
1145        }
1146
1147        // Draw list items
1148        let mut y = height - self.item_height;
1149        for (index, option) in self.options.iter().enumerate() {
1150            if y < 0.0 {
1151                break; // Stop if we've filled the visible area
1152            }
1153
1154            // Draw selection background if selected
1155            if self.selected.contains(&index) {
1156                crate::graphics::color::write_fill_color(&mut content, self.selection_color);
1157                content.push_str(&format!("0 {} {} {} re\n", y, width, self.item_height));
1158                content.push_str("f\n");
1159            }
1160
1161            // Draw text
1162            content.push_str("BT\n");
1163            content.push_str(&format!(
1164                "/{} {} Tf\n",
1165                self.font.pdf_name(),
1166                self.font_size
1167            ));
1168
1169            // Use white text for selected items, regular colour for others
1170            if self.selected.contains(&index) {
1171                crate::graphics::color::write_fill_color(&mut content, Color::white());
1172            } else {
1173                crate::graphics::color::write_fill_color(&mut content, self.text_color);
1174            }
1175
1176            content.push_str(&format!("5 {} Td\n", y + 2.0));
1177
1178            emit_tj_for_builtin(&mut content, option, &self.font)?;
1179            content.push_str("ET\n");
1180
1181            y -= self.item_height;
1182        }
1183
1184        let bbox = [0.0, 0.0, width, height];
1185        Ok(AppearanceStream::new(content.into_bytes(), bbox))
1186    }
1187}
1188
1189/// Generate default appearance stream for a field type.
1190///
1191/// Kept for API stability — delegates to [`generate_field_appearance`] with
1192/// no `/DA` override and no custom-font context, which is the only behaviour
1193/// this function could ever offer. Prefer the richer signature of
1194/// [`generate_field_appearance`] when you need non-WinAnsi values or a
1195/// per-field font.
1196pub fn generate_default_appearance(
1197    field_type: FieldType,
1198    widget: &Widget,
1199    value: Option<&str>,
1200) -> Result<AppearanceStream> {
1201    Ok(generate_field_appearance(field_type, widget, value, None, None)?.stream)
1202}
1203
1204/// Generate an appearance stream honouring an optional typed `/DA` and an
1205/// optional resolved custom font.
1206///
1207/// Returns both the stream and the set of characters consumed from the
1208/// custom font (empty for built-in fonts) — callers (typically
1209/// `Document::fill_field`) merge the latter into
1210/// `Document::used_characters_by_font` so the font subsetter emits a subset
1211/// that covers the appearance content (same invariant as issue #204).
1212///
1213/// Dispatch:
1214/// - `default_appearance.font == Font::Custom(name)` AND `custom_font` is
1215///   `Some(...)` → **Type0/CID path**. Content stream uses hex glyph-index
1216///   Tj, resources dict carries a placeholder `/Type0` entry that the
1217///   writer rewrites to an indirect Reference to the document-level font
1218///   object (see [`writer::pdf_writer`]).
1219/// - Anything else → **built-in / WinAnsi path** (existing behaviour, now
1220///   with strict encoding so non-WinAnsi values fail explicitly instead of
1221///   being silently corrupted).
1222pub fn generate_field_appearance(
1223    field_type: FieldType,
1224    widget: &Widget,
1225    value: Option<&str>,
1226    default_appearance: Option<&DefaultAppearance>,
1227    custom_font: Option<&crate::fonts::Font>,
1228) -> Result<FieldAppearanceResult> {
1229    match field_type {
1230        FieldType::Text => {
1231            let mut generator = TextFieldAppearance::default();
1232            if let Some(da) = default_appearance {
1233                generator.font = da.font.clone();
1234                generator.font_size = da.font_size;
1235                generator.text_color = da.color.clone();
1236            }
1237            generator.generate_appearance_with_font(
1238                widget,
1239                value,
1240                AppearanceState::Normal,
1241                custom_font,
1242            )
1243        }
1244        FieldType::Button => {
1245            // Default button appearance (checkbox-style) does not consume a
1246            // user-supplied `/DA` today — the button glyphs are synthesised.
1247            let generator = CheckBoxAppearance::default();
1248            let stream = generator.generate_appearance(widget, value, AppearanceState::Normal)?;
1249            Ok(FieldAppearanceResult {
1250                stream,
1251                used_chars_by_font: HashMap::new(),
1252            })
1253        }
1254        FieldType::Choice => {
1255            let mut generator = ComboBoxAppearance::default();
1256            if let Some(da) = default_appearance {
1257                generator.font = da.font.clone();
1258                generator.font_size = da.font_size;
1259                generator.text_color = da.color.clone();
1260            }
1261            generator.generate_appearance_with_font(
1262                widget,
1263                value,
1264                AppearanceState::Normal,
1265                custom_font,
1266            )
1267        }
1268        FieldType::Signature => {
1269            let width = widget.rect.upper_right.x - widget.rect.lower_left.x;
1270            let height = widget.rect.upper_right.y - widget.rect.lower_left.y;
1271            Ok(FieldAppearanceResult {
1272                stream: AppearanceStream::new(b"q\nQ\n".to_vec(), [0.0, 0.0, width, height]),
1273                used_chars_by_font: HashMap::new(),
1274            })
1275        }
1276    }
1277}
1278
1279#[cfg(test)]
1280mod tests {
1281    use super::*;
1282    use crate::geometry::{Point, Rectangle};
1283
1284    #[test]
1285    fn test_appearance_state_names() {
1286        assert_eq!(AppearanceState::Normal.pdf_name(), "N");
1287        assert_eq!(AppearanceState::Rollover.pdf_name(), "R");
1288        assert_eq!(AppearanceState::Down.pdf_name(), "D");
1289    }
1290
1291    #[test]
1292    fn test_appearance_stream_creation() {
1293        let content = b"q\n1 0 0 RG\n0 0 100 50 re S\nQ\n";
1294        let stream = AppearanceStream::new(content.to_vec(), [0.0, 0.0, 100.0, 50.0]);
1295
1296        assert_eq!(stream.content, content);
1297        assert_eq!(stream.bbox, [0.0, 0.0, 100.0, 50.0]);
1298        assert!(stream.resources.is_empty());
1299    }
1300
1301    #[test]
1302    fn test_appearance_stream_with_resources() {
1303        let mut resources = Dictionary::new();
1304        resources.set("Font", Object::Name("F1".to_string()));
1305
1306        let content = b"BT\n/F1 12 Tf\n(Test) Tj\nET\n";
1307        let stream = AppearanceStream::new(content.to_vec(), [0.0, 0.0, 100.0, 50.0])
1308            .with_resources(resources.clone());
1309
1310        assert_eq!(stream.resources, resources);
1311    }
1312
1313    #[test]
1314    fn test_appearance_dictionary() {
1315        let mut app_dict = AppearanceDictionary::new();
1316
1317        let normal_stream = AppearanceStream::new(b"normal".to_vec(), [0.0, 0.0, 10.0, 10.0]);
1318        let down_stream = AppearanceStream::new(b"down".to_vec(), [0.0, 0.0, 10.0, 10.0]);
1319
1320        app_dict.set_appearance(AppearanceState::Normal, normal_stream);
1321        app_dict.set_appearance(AppearanceState::Down, down_stream);
1322
1323        assert!(app_dict.get_appearance(AppearanceState::Normal).is_some());
1324        assert!(app_dict.get_appearance(AppearanceState::Down).is_some());
1325        assert!(app_dict.get_appearance(AppearanceState::Rollover).is_none());
1326    }
1327
1328    #[test]
1329    fn test_text_field_appearance() {
1330        let widget = Widget::new(Rectangle {
1331            lower_left: Point { x: 0.0, y: 0.0 },
1332            upper_right: Point { x: 200.0, y: 30.0 },
1333        });
1334
1335        let generator = TextFieldAppearance::default();
1336        let result =
1337            generator.generate_appearance(&widget, Some("Test Text"), AppearanceState::Normal);
1338
1339        assert!(result.is_ok());
1340        let stream = result.unwrap();
1341        assert_eq!(stream.bbox, [0.0, 0.0, 200.0, 30.0]);
1342
1343        let content = String::from_utf8_lossy(&stream.content);
1344        assert!(content.contains("BT"));
1345        assert!(content.contains("(Test Text) Tj"));
1346        assert!(content.contains("ET"));
1347    }
1348
1349    #[test]
1350    fn test_checkbox_appearance_checked() {
1351        let widget = Widget::new(Rectangle {
1352            lower_left: Point { x: 0.0, y: 0.0 },
1353            upper_right: Point { x: 20.0, y: 20.0 },
1354        });
1355
1356        let generator = CheckBoxAppearance::default();
1357        let result = generator.generate_appearance(&widget, Some("Yes"), AppearanceState::Normal);
1358
1359        assert!(result.is_ok());
1360        let stream = result.unwrap();
1361        let content = String::from_utf8_lossy(&stream.content);
1362
1363        // Should contain check mark drawing commands
1364        assert!(content.contains(" m"));
1365        assert!(content.contains(" l"));
1366        assert!(content.contains(" S"));
1367    }
1368
1369    #[test]
1370    fn test_checkbox_appearance_unchecked() {
1371        let widget = Widget::new(Rectangle {
1372            lower_left: Point { x: 0.0, y: 0.0 },
1373            upper_right: Point { x: 20.0, y: 20.0 },
1374        });
1375
1376        let generator = CheckBoxAppearance::default();
1377        let result = generator.generate_appearance(&widget, Some("No"), AppearanceState::Normal);
1378
1379        assert!(result.is_ok());
1380        let stream = result.unwrap();
1381        let content = String::from_utf8_lossy(&stream.content);
1382
1383        // Should not contain complex drawing for check mark
1384        assert!(content.contains("q"));
1385        assert!(content.contains("Q"));
1386    }
1387
1388    #[test]
1389    fn test_radio_button_appearance() {
1390        let widget = Widget::new(Rectangle {
1391            lower_left: Point { x: 0.0, y: 0.0 },
1392            upper_right: Point { x: 20.0, y: 20.0 },
1393        });
1394
1395        let generator = RadioButtonAppearance::default();
1396        let result = generator.generate_appearance(&widget, Some("Yes"), AppearanceState::Normal);
1397
1398        assert!(result.is_ok());
1399        let stream = result.unwrap();
1400        let content = String::from_utf8_lossy(&stream.content);
1401
1402        // Should contain circle drawing commands (Bézier curves)
1403        assert!(
1404            content.contains(" c"),
1405            "Content should contain curve commands"
1406        );
1407        assert!(
1408            content.contains("f\n"),
1409            "Content should contain fill commands"
1410        );
1411    }
1412
1413    #[test]
1414    fn test_push_button_appearance() {
1415        let mut generator = PushButtonAppearance::default();
1416        generator.label = "Click Me".to_string();
1417
1418        let widget = Widget::new(Rectangle {
1419            lower_left: Point { x: 0.0, y: 0.0 },
1420            upper_right: Point { x: 100.0, y: 30.0 },
1421        });
1422
1423        let result = generator.generate_appearance(&widget, None, AppearanceState::Normal);
1424
1425        assert!(result.is_ok());
1426        let stream = result.unwrap();
1427        let content = String::from_utf8_lossy(&stream.content);
1428
1429        assert!(content.contains("(Click Me) Tj"));
1430        assert!(!stream.resources.is_empty());
1431    }
1432
1433    #[test]
1434    fn test_push_button_states() {
1435        let generator = PushButtonAppearance::default();
1436        let widget = Widget::new(Rectangle {
1437            lower_left: Point { x: 0.0, y: 0.0 },
1438            upper_right: Point { x: 100.0, y: 30.0 },
1439        });
1440
1441        // Test different states produce different appearances
1442        let normal = generator
1443            .generate_appearance(&widget, None, AppearanceState::Normal)
1444            .unwrap();
1445        let down = generator
1446            .generate_appearance(&widget, None, AppearanceState::Down)
1447            .unwrap();
1448        let rollover = generator
1449            .generate_appearance(&widget, None, AppearanceState::Rollover)
1450            .unwrap();
1451
1452        // Content should be different for different states (different background colors)
1453        assert_ne!(normal.content, down.content);
1454        assert_ne!(normal.content, rollover.content);
1455        assert_ne!(down.content, rollover.content);
1456    }
1457
1458    #[test]
1459    fn test_check_styles() {
1460        let widget = Widget::new(Rectangle {
1461            lower_left: Point { x: 0.0, y: 0.0 },
1462            upper_right: Point { x: 20.0, y: 20.0 },
1463        });
1464
1465        // Test different check styles
1466        for style in [
1467            CheckStyle::Check,
1468            CheckStyle::Cross,
1469            CheckStyle::Square,
1470            CheckStyle::Circle,
1471            CheckStyle::Star,
1472        ] {
1473            let mut generator = CheckBoxAppearance::default();
1474            generator.check_style = style;
1475
1476            let result =
1477                generator.generate_appearance(&widget, Some("Yes"), AppearanceState::Normal);
1478
1479            assert!(result.is_ok(), "Failed for style {:?}", style);
1480        }
1481    }
1482
1483    #[test]
1484    fn test_appearance_state_pdf_names() {
1485        assert_eq!(AppearanceState::Normal.pdf_name(), "N");
1486        assert_eq!(AppearanceState::Rollover.pdf_name(), "R");
1487        assert_eq!(AppearanceState::Down.pdf_name(), "D");
1488    }
1489
1490    #[test]
1491    fn test_appearance_stream_creation_advanced() {
1492        let content = b"q 1 0 0 1 0 0 cm Q".to_vec();
1493        let bbox = [0.0, 0.0, 100.0, 50.0];
1494        let stream = AppearanceStream::new(content.clone(), bbox);
1495
1496        assert_eq!(stream.content, content);
1497        assert_eq!(stream.bbox, bbox);
1498        assert!(stream.resources.is_empty());
1499    }
1500
1501    #[test]
1502    fn test_appearance_stream_with_resources_advanced() {
1503        let mut resources = Dictionary::new();
1504        resources.set("Font", Object::Dictionary(Dictionary::new()));
1505
1506        let stream =
1507            AppearanceStream::new(vec![], [0.0, 0.0, 10.0, 10.0]).with_resources(resources.clone());
1508
1509        assert_eq!(stream.resources, resources);
1510    }
1511
1512    #[test]
1513    fn test_appearance_dictionary_new() {
1514        let dict = AppearanceDictionary::new();
1515        assert!(dict.appearances.is_empty());
1516        assert!(dict.down_appearances.is_empty());
1517    }
1518
1519    #[test]
1520    fn test_appearance_dictionary_set_get() {
1521        let mut dict = AppearanceDictionary::new();
1522        let stream = AppearanceStream::new(vec![1, 2, 3], [0.0, 0.0, 10.0, 10.0]);
1523
1524        dict.set_appearance(AppearanceState::Normal, stream);
1525        assert!(dict.get_appearance(AppearanceState::Normal).is_some());
1526        assert!(dict.get_appearance(AppearanceState::Down).is_none());
1527    }
1528
1529    #[test]
1530    fn test_text_field_multiline() {
1531        let mut generator = TextFieldAppearance::default();
1532        generator.multiline = true;
1533
1534        let widget = Widget::new(Rectangle {
1535            lower_left: Point { x: 0.0, y: 0.0 },
1536            upper_right: Point { x: 200.0, y: 100.0 },
1537        });
1538
1539        let text = "Line 1\nLine 2\nLine 3";
1540        let result = generator.generate_appearance(&widget, Some(text), AppearanceState::Normal);
1541        assert!(result.is_ok());
1542    }
1543
1544    #[test]
1545    fn test_appearance_with_custom_colors() {
1546        let mut generator = TextFieldAppearance::default();
1547        generator.text_color = Color::rgb(1.0, 0.0, 0.0); // Red text
1548        generator.font_size = 14.0;
1549        generator.justification = 1; // center
1550
1551        let widget = Widget::new(Rectangle {
1552            lower_left: Point { x: 0.0, y: 0.0 },
1553            upper_right: Point { x: 100.0, y: 30.0 },
1554        });
1555
1556        let result =
1557            generator.generate_appearance(&widget, Some("Colored"), AppearanceState::Normal);
1558        assert!(result.is_ok());
1559    }
1560}