Skip to main content

oxidize_pdf/text/
mod.rs

1pub mod cid_to_unicode;
2pub mod cmap;
3mod encoding;
4pub mod extraction;
5mod extraction_cmap;
6mod flow;
7mod font;
8pub mod font_manager;
9pub mod fonts;
10mod header_footer;
11pub mod invoice;
12mod layout;
13mod list;
14pub mod metrics;
15pub mod ocr;
16pub mod plaintext;
17pub mod structured;
18pub mod table;
19pub mod table_detection;
20pub mod validation;
21
22#[cfg(test)]
23mod cmap_tests;
24
25#[cfg(feature = "ocr-tesseract")]
26pub mod tesseract_provider;
27
28pub use encoding::TextEncoding;
29pub use extraction::{
30    sanitize_extracted_text, ExtractedText, ExtractionOptions, TextExtractor, TextFragment,
31};
32pub use flow::{TextAlign, TextFlowContext};
33pub use font::{Font, FontEncoding, FontFamily, FontWithEncoding};
34pub use font_manager::{CustomFont, FontDescriptor, FontFlags, FontManager, FontMetrics, FontType};
35pub use header_footer::{HeaderFooter, HeaderFooterOptions, HeaderFooterPosition};
36pub use layout::{ColumnContent, ColumnLayout, ColumnOptions, TextFormat};
37pub use list::{
38    BulletStyle, ListElement, ListItem, ListOptions, ListStyle as ListStyleEnum, OrderedList,
39    OrderedListStyle, UnorderedList,
40};
41pub use metrics::{measure_char, measure_text, split_into_words};
42pub use ocr::{
43    CharacterConfidence, CorrectionCandidate, CorrectionReason, CorrectionSuggestion,
44    CorrectionType, FragmentType, ImagePreprocessing, MockOcrProvider, OcrEngine, OcrError,
45    OcrOptions, OcrPostProcessor, OcrProcessingResult, OcrProvider, OcrRegion, OcrResult,
46    OcrTextFragment, WordConfidence,
47};
48pub use plaintext::{LineBreakMode, PlainTextConfig, PlainTextExtractor, PlainTextResult};
49pub use table::{HeaderStyle, Table, TableCell, TableOptions};
50pub use validation::{MatchType, TextMatch, TextValidationResult, TextValidator};
51
52#[cfg(feature = "ocr-tesseract")]
53pub use tesseract_provider::{RustyTesseractConfig, RustyTesseractProvider};
54
55use crate::error::Result;
56use crate::Color;
57use std::collections::HashSet;
58use std::fmt::Write;
59
60/// Text rendering mode for PDF text operations
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub enum TextRenderingMode {
63    /// Fill text (default)
64    Fill = 0,
65    /// Stroke text
66    Stroke = 1,
67    /// Fill and stroke text
68    FillStroke = 2,
69    /// Invisible text (for searchable text over images)
70    Invisible = 3,
71    /// Fill text and add to path for clipping
72    FillClip = 4,
73    /// Stroke text and add to path for clipping
74    StrokeClip = 5,
75    /// Fill and stroke text and add to path for clipping
76    FillStrokeClip = 6,
77    /// Add text to path for clipping (invisible)
78    Clip = 7,
79}
80
81#[derive(Clone)]
82pub struct TextContext {
83    operations: String,
84    current_font: Font,
85    font_size: f64,
86    text_matrix: [f64; 6],
87    // Pending position for next write operation
88    pending_position: Option<(f64, f64)>,
89    // Text state parameters
90    character_spacing: Option<f64>,
91    word_spacing: Option<f64>,
92    horizontal_scaling: Option<f64>,
93    leading: Option<f64>,
94    text_rise: Option<f64>,
95    rendering_mode: Option<TextRenderingMode>,
96    // Color parameters
97    fill_color: Option<Color>,
98    stroke_color: Option<Color>,
99    // Track used characters for font subsetting (fixes issue #97)
100    used_characters: HashSet<char>,
101}
102
103impl Default for TextContext {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109impl TextContext {
110    pub fn new() -> Self {
111        Self {
112            operations: String::new(),
113            current_font: Font::Helvetica,
114            font_size: 12.0,
115            text_matrix: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
116            pending_position: None,
117            character_spacing: None,
118            word_spacing: None,
119            horizontal_scaling: None,
120            leading: None,
121            text_rise: None,
122            rendering_mode: None,
123            fill_color: None,
124            stroke_color: None,
125            used_characters: HashSet::new(),
126        }
127    }
128
129    /// Get the characters used in this text context for font subsetting.
130    ///
131    /// This is used to determine which glyphs need to be embedded when using
132    /// custom fonts (especially CJK fonts).
133    pub(crate) fn get_used_characters(&self) -> Option<HashSet<char>> {
134        if self.used_characters.is_empty() {
135            None
136        } else {
137            Some(self.used_characters.clone())
138        }
139    }
140
141    pub fn set_font(&mut self, font: Font, size: f64) -> &mut Self {
142        self.current_font = font;
143        self.font_size = size;
144        self
145    }
146
147    /// Get the current font
148    #[allow(dead_code)]
149    pub(crate) fn current_font(&self) -> &Font {
150        &self.current_font
151    }
152
153    pub fn at(&mut self, x: f64, y: f64) -> &mut Self {
154        // Update text_matrix immediately and store for write() operation
155        self.text_matrix[4] = x;
156        self.text_matrix[5] = y;
157        self.pending_position = Some((x, y));
158        self
159    }
160
161    pub fn write(&mut self, text: &str) -> Result<&mut Self> {
162        // Begin text object
163        self.operations.push_str("BT\n");
164
165        // Set font
166        writeln!(
167            &mut self.operations,
168            "/{} {} Tf",
169            self.current_font.pdf_name(),
170            self.font_size
171        )
172        .expect("Writing to String should never fail");
173
174        // Apply text state parameters
175        self.apply_text_state_parameters();
176
177        // Set text position using pending_position if available, otherwise use text_matrix
178        let (x, y) = if let Some((px, py)) = self.pending_position.take() {
179            // Use and consume the pending position
180            (px, py)
181        } else {
182            // Fallback to text_matrix values
183            (self.text_matrix[4], self.text_matrix[5])
184        };
185
186        writeln!(&mut self.operations, "{:.2} {:.2} Td", x, y)
187            .expect("Writing to String should never fail");
188
189        // Choose encoding based on font type
190        match &self.current_font {
191            Font::Custom(_) => {
192                // For custom fonts (CJK), use UTF-16BE encoding with hex strings
193                let utf16_units: Vec<u16> = text.encode_utf16().collect();
194                let mut utf16be_bytes = Vec::new();
195
196                for unit in utf16_units {
197                    utf16be_bytes.push((unit >> 8) as u8); // High byte
198                    utf16be_bytes.push((unit & 0xFF) as u8); // Low byte
199                }
200
201                // Write as hex string for Type0 fonts
202                self.operations.push('<');
203                for &byte in &utf16be_bytes {
204                    write!(&mut self.operations, "{:02X}", byte)
205                        .expect("Writing to String should never fail");
206                }
207                self.operations.push_str("> Tj\n");
208            }
209            _ => {
210                // For standard fonts, use WinAnsiEncoding with literal strings
211                let encoding = TextEncoding::WinAnsiEncoding;
212                let encoded_bytes = encoding.encode(text);
213
214                // Show text as a literal string
215                self.operations.push('(');
216                for &byte in &encoded_bytes {
217                    match byte {
218                        b'(' => self.operations.push_str("\\("),
219                        b')' => self.operations.push_str("\\)"),
220                        b'\\' => self.operations.push_str("\\\\"),
221                        b'\n' => self.operations.push_str("\\n"),
222                        b'\r' => self.operations.push_str("\\r"),
223                        b'\t' => self.operations.push_str("\\t"),
224                        // For bytes in the printable ASCII range, write as is
225                        0x20..=0x7E => self.operations.push(byte as char),
226                        // For other bytes, write as octal escape sequences
227                        _ => write!(&mut self.operations, "\\{byte:03o}")
228                            .expect("Writing to String should never fail"),
229                    }
230                }
231                self.operations.push_str(") Tj\n");
232            }
233        }
234
235        // Track used characters for font subsetting (fixes issue #97)
236        self.used_characters.extend(text.chars());
237
238        // End text object
239        self.operations.push_str("ET\n");
240
241        Ok(self)
242    }
243
244    pub fn write_line(&mut self, text: &str) -> Result<&mut Self> {
245        self.write(text)?;
246        self.text_matrix[5] -= self.font_size * 1.2; // Move down for next line
247        Ok(self)
248    }
249
250    pub fn set_character_spacing(&mut self, spacing: f64) -> &mut Self {
251        self.character_spacing = Some(spacing);
252        self
253    }
254
255    pub fn set_word_spacing(&mut self, spacing: f64) -> &mut Self {
256        self.word_spacing = Some(spacing);
257        self
258    }
259
260    pub fn set_horizontal_scaling(&mut self, scale: f64) -> &mut Self {
261        self.horizontal_scaling = Some(scale);
262        self
263    }
264
265    pub fn set_leading(&mut self, leading: f64) -> &mut Self {
266        self.leading = Some(leading);
267        self
268    }
269
270    pub fn set_text_rise(&mut self, rise: f64) -> &mut Self {
271        self.text_rise = Some(rise);
272        self
273    }
274
275    /// Set the text rendering mode
276    pub fn set_rendering_mode(&mut self, mode: TextRenderingMode) -> &mut Self {
277        self.rendering_mode = Some(mode);
278        self
279    }
280
281    /// Set the text fill color
282    pub fn set_fill_color(&mut self, color: Color) -> &mut Self {
283        self.fill_color = Some(color);
284        self
285    }
286
287    /// Set the text stroke color
288    pub fn set_stroke_color(&mut self, color: Color) -> &mut Self {
289        self.stroke_color = Some(color);
290        self
291    }
292
293    /// Apply text state parameters to the operations string
294    fn apply_text_state_parameters(&mut self) {
295        // Character spacing (Tc)
296        if let Some(spacing) = self.character_spacing {
297            writeln!(&mut self.operations, "{spacing:.2} Tc")
298                .expect("Writing to String should never fail");
299        }
300
301        // Word spacing (Tw)
302        if let Some(spacing) = self.word_spacing {
303            writeln!(&mut self.operations, "{spacing:.2} Tw")
304                .expect("Writing to String should never fail");
305        }
306
307        // Horizontal scaling (Tz)
308        if let Some(scale) = self.horizontal_scaling {
309            writeln!(&mut self.operations, "{:.2} Tz", scale * 100.0)
310                .expect("Writing to String should never fail");
311        }
312
313        // Leading (TL)
314        if let Some(leading) = self.leading {
315            writeln!(&mut self.operations, "{leading:.2} TL")
316                .expect("Writing to String should never fail");
317        }
318
319        // Text rise (Ts)
320        if let Some(rise) = self.text_rise {
321            writeln!(&mut self.operations, "{rise:.2} Ts")
322                .expect("Writing to String should never fail");
323        }
324
325        // Text rendering mode (Tr)
326        if let Some(mode) = self.rendering_mode {
327            writeln!(&mut self.operations, "{} Tr", mode as u8)
328                .expect("Writing to String should never fail");
329        }
330
331        // Fill color
332        if let Some(color) = self.fill_color {
333            match color {
334                Color::Rgb(r, g, b) => {
335                    writeln!(&mut self.operations, "{r:.3} {g:.3} {b:.3} rg")
336                        .expect("Writing to String should never fail");
337                }
338                Color::Gray(gray) => {
339                    writeln!(&mut self.operations, "{gray:.3} g")
340                        .expect("Writing to String should never fail");
341                }
342                Color::Cmyk(c, m, y, k) => {
343                    writeln!(&mut self.operations, "{c:.3} {m:.3} {y:.3} {k:.3} k")
344                        .expect("Writing to String should never fail");
345                }
346            }
347        }
348
349        // Stroke color
350        if let Some(color) = self.stroke_color {
351            match color {
352                Color::Rgb(r, g, b) => {
353                    writeln!(&mut self.operations, "{r:.3} {g:.3} {b:.3} RG")
354                        .expect("Writing to String should never fail");
355                }
356                Color::Gray(gray) => {
357                    writeln!(&mut self.operations, "{gray:.3} G")
358                        .expect("Writing to String should never fail");
359                }
360                Color::Cmyk(c, m, y, k) => {
361                    writeln!(&mut self.operations, "{c:.3} {m:.3} {y:.3} {k:.3} K")
362                        .expect("Writing to String should never fail");
363                }
364            }
365        }
366    }
367
368    pub(crate) fn generate_operations(&self) -> Result<Vec<u8>> {
369        Ok(self.operations.as_bytes().to_vec())
370    }
371
372    /// Appends a raw PDF operation to the text context
373    ///
374    /// This is used internally for marked content operators (BDC/EMC) and other
375    /// low-level PDF operations that need to be interleaved with text operations.
376    pub(crate) fn append_raw_operation(&mut self, operation: &str) {
377        self.operations.push_str(operation);
378    }
379
380    /// Get the current font size
381    pub fn font_size(&self) -> f64 {
382        self.font_size
383    }
384
385    /// Get the current text matrix
386    pub fn text_matrix(&self) -> [f64; 6] {
387        self.text_matrix
388    }
389
390    /// Get the current position
391    pub fn position(&self) -> (f64, f64) {
392        (self.text_matrix[4], self.text_matrix[5])
393    }
394
395    /// Clear all operations and reset text state parameters
396    pub fn clear(&mut self) {
397        self.operations.clear();
398        self.character_spacing = None;
399        self.word_spacing = None;
400        self.horizontal_scaling = None;
401        self.leading = None;
402        self.text_rise = None;
403        self.rendering_mode = None;
404        self.fill_color = None;
405        self.stroke_color = None;
406    }
407
408    /// Get the raw operations string
409    pub fn operations(&self) -> &str {
410        &self.operations
411    }
412
413    /// Generate text state operations for testing purposes
414    #[cfg(test)]
415    pub fn generate_text_state_operations(&self) -> String {
416        let mut ops = String::new();
417
418        // Character spacing (Tc)
419        if let Some(spacing) = self.character_spacing {
420            writeln!(&mut ops, "{spacing:.2} Tc").unwrap();
421        }
422
423        // Word spacing (Tw)
424        if let Some(spacing) = self.word_spacing {
425            writeln!(&mut ops, "{spacing:.2} Tw").unwrap();
426        }
427
428        // Horizontal scaling (Tz)
429        if let Some(scale) = self.horizontal_scaling {
430            writeln!(&mut ops, "{:.2} Tz", scale * 100.0).unwrap();
431        }
432
433        // Leading (TL)
434        if let Some(leading) = self.leading {
435            writeln!(&mut ops, "{leading:.2} TL").unwrap();
436        }
437
438        // Text rise (Ts)
439        if let Some(rise) = self.text_rise {
440            writeln!(&mut ops, "{rise:.2} Ts").unwrap();
441        }
442
443        // Text rendering mode (Tr)
444        if let Some(mode) = self.rendering_mode {
445            writeln!(&mut ops, "{} Tr", mode as u8).unwrap();
446        }
447
448        ops
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    #[test]
457    fn test_text_context_new() {
458        let context = TextContext::new();
459        assert_eq!(context.current_font, Font::Helvetica);
460        assert_eq!(context.font_size, 12.0);
461        assert_eq!(context.text_matrix, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
462        assert!(context.operations.is_empty());
463    }
464
465    #[test]
466    fn test_text_context_default() {
467        let context = TextContext::default();
468        assert_eq!(context.current_font, Font::Helvetica);
469        assert_eq!(context.font_size, 12.0);
470    }
471
472    #[test]
473    fn test_set_font() {
474        let mut context = TextContext::new();
475        context.set_font(Font::TimesBold, 14.0);
476        assert_eq!(context.current_font, Font::TimesBold);
477        assert_eq!(context.font_size, 14.0);
478    }
479
480    #[test]
481    fn test_position() {
482        let mut context = TextContext::new();
483        context.at(100.0, 200.0);
484        let (x, y) = context.position();
485        assert_eq!(x, 100.0);
486        assert_eq!(y, 200.0);
487        assert_eq!(context.text_matrix[4], 100.0);
488        assert_eq!(context.text_matrix[5], 200.0);
489    }
490
491    #[test]
492    fn test_write_simple_text() {
493        let mut context = TextContext::new();
494        context.write("Hello").unwrap();
495
496        let ops = context.operations();
497        assert!(ops.contains("BT\n"));
498        assert!(ops.contains("ET\n"));
499        assert!(ops.contains("/Helvetica 12 Tf"));
500        assert!(ops.contains("(Hello) Tj"));
501    }
502
503    #[test]
504    fn test_write_text_with_escaping() {
505        let mut context = TextContext::new();
506        context.write("(Hello)").unwrap();
507
508        let ops = context.operations();
509        assert!(ops.contains("(\\(Hello\\)) Tj"));
510    }
511
512    #[test]
513    fn test_write_line() {
514        let mut context = TextContext::new();
515        let initial_y = context.text_matrix[5];
516        context.write_line("Line 1").unwrap();
517
518        // Y position should have moved down
519        let new_y = context.text_matrix[5];
520        assert!(new_y < initial_y);
521        assert_eq!(new_y, initial_y - 12.0 * 1.2); // font_size * 1.2
522    }
523
524    #[test]
525    fn test_character_spacing() {
526        let mut context = TextContext::new();
527        context.set_character_spacing(2.5);
528
529        let ops = context.generate_text_state_operations();
530        assert!(ops.contains("2.50 Tc"));
531    }
532
533    #[test]
534    fn test_word_spacing() {
535        let mut context = TextContext::new();
536        context.set_word_spacing(1.5);
537
538        let ops = context.generate_text_state_operations();
539        assert!(ops.contains("1.50 Tw"));
540    }
541
542    #[test]
543    fn test_horizontal_scaling() {
544        let mut context = TextContext::new();
545        context.set_horizontal_scaling(1.25);
546
547        let ops = context.generate_text_state_operations();
548        assert!(ops.contains("125.00 Tz")); // 1.25 * 100
549    }
550
551    #[test]
552    fn test_leading() {
553        let mut context = TextContext::new();
554        context.set_leading(15.0);
555
556        let ops = context.generate_text_state_operations();
557        assert!(ops.contains("15.00 TL"));
558    }
559
560    #[test]
561    fn test_text_rise() {
562        let mut context = TextContext::new();
563        context.set_text_rise(3.0);
564
565        let ops = context.generate_text_state_operations();
566        assert!(ops.contains("3.00 Ts"));
567    }
568
569    #[test]
570    fn test_clear() {
571        let mut context = TextContext::new();
572        context.write("Hello").unwrap();
573        assert!(!context.operations().is_empty());
574
575        context.clear();
576        assert!(context.operations().is_empty());
577    }
578
579    #[test]
580    fn test_generate_operations() {
581        let mut context = TextContext::new();
582        context.write("Test").unwrap();
583
584        let ops_bytes = context.generate_operations().unwrap();
585        let ops_string = String::from_utf8(ops_bytes).unwrap();
586        assert_eq!(ops_string, context.operations());
587    }
588
589    #[test]
590    fn test_method_chaining() {
591        let mut context = TextContext::new();
592        context
593            .set_font(Font::Courier, 10.0)
594            .at(50.0, 100.0)
595            .set_character_spacing(1.0)
596            .set_word_spacing(2.0);
597
598        assert_eq!(context.current_font(), &Font::Courier);
599        assert_eq!(context.font_size(), 10.0);
600        let (x, y) = context.position();
601        assert_eq!(x, 50.0);
602        assert_eq!(y, 100.0);
603    }
604
605    #[test]
606    fn test_text_matrix_access() {
607        let mut context = TextContext::new();
608        context.at(25.0, 75.0);
609
610        let matrix = context.text_matrix();
611        assert_eq!(matrix, [1.0, 0.0, 0.0, 1.0, 25.0, 75.0]);
612    }
613
614    #[test]
615    fn test_special_characters_encoding() {
616        let mut context = TextContext::new();
617        context.write("Test\nLine\tTab").unwrap();
618
619        let ops = context.operations();
620        assert!(ops.contains("\\n"));
621        assert!(ops.contains("\\t"));
622    }
623
624    #[test]
625    fn test_rendering_mode_fill() {
626        let mut context = TextContext::new();
627        context.set_rendering_mode(TextRenderingMode::Fill);
628
629        let ops = context.generate_text_state_operations();
630        assert!(ops.contains("0 Tr"));
631    }
632
633    #[test]
634    fn test_rendering_mode_stroke() {
635        let mut context = TextContext::new();
636        context.set_rendering_mode(TextRenderingMode::Stroke);
637
638        let ops = context.generate_text_state_operations();
639        assert!(ops.contains("1 Tr"));
640    }
641
642    #[test]
643    fn test_rendering_mode_fill_stroke() {
644        let mut context = TextContext::new();
645        context.set_rendering_mode(TextRenderingMode::FillStroke);
646
647        let ops = context.generate_text_state_operations();
648        assert!(ops.contains("2 Tr"));
649    }
650
651    #[test]
652    fn test_rendering_mode_invisible() {
653        let mut context = TextContext::new();
654        context.set_rendering_mode(TextRenderingMode::Invisible);
655
656        let ops = context.generate_text_state_operations();
657        assert!(ops.contains("3 Tr"));
658    }
659
660    #[test]
661    fn test_rendering_mode_fill_clip() {
662        let mut context = TextContext::new();
663        context.set_rendering_mode(TextRenderingMode::FillClip);
664
665        let ops = context.generate_text_state_operations();
666        assert!(ops.contains("4 Tr"));
667    }
668
669    #[test]
670    fn test_rendering_mode_stroke_clip() {
671        let mut context = TextContext::new();
672        context.set_rendering_mode(TextRenderingMode::StrokeClip);
673
674        let ops = context.generate_text_state_operations();
675        assert!(ops.contains("5 Tr"));
676    }
677
678    #[test]
679    fn test_rendering_mode_fill_stroke_clip() {
680        let mut context = TextContext::new();
681        context.set_rendering_mode(TextRenderingMode::FillStrokeClip);
682
683        let ops = context.generate_text_state_operations();
684        assert!(ops.contains("6 Tr"));
685    }
686
687    #[test]
688    fn test_rendering_mode_clip() {
689        let mut context = TextContext::new();
690        context.set_rendering_mode(TextRenderingMode::Clip);
691
692        let ops = context.generate_text_state_operations();
693        assert!(ops.contains("7 Tr"));
694    }
695
696    #[test]
697    fn test_text_state_parameters_chaining() {
698        let mut context = TextContext::new();
699        context
700            .set_character_spacing(1.5)
701            .set_word_spacing(2.0)
702            .set_horizontal_scaling(1.1)
703            .set_leading(14.0)
704            .set_text_rise(0.5)
705            .set_rendering_mode(TextRenderingMode::FillStroke);
706
707        let ops = context.generate_text_state_operations();
708        assert!(ops.contains("1.50 Tc"));
709        assert!(ops.contains("2.00 Tw"));
710        assert!(ops.contains("110.00 Tz"));
711        assert!(ops.contains("14.00 TL"));
712        assert!(ops.contains("0.50 Ts"));
713        assert!(ops.contains("2 Tr"));
714    }
715
716    #[test]
717    fn test_all_text_state_operators_generated() {
718        let mut context = TextContext::new();
719
720        // Test all operators in sequence
721        context.set_character_spacing(1.0); // Tc
722        context.set_word_spacing(2.0); // Tw
723        context.set_horizontal_scaling(1.2); // Tz
724        context.set_leading(15.0); // TL
725        context.set_text_rise(1.0); // Ts
726        context.set_rendering_mode(TextRenderingMode::Stroke); // Tr
727
728        let ops = context.generate_text_state_operations();
729
730        // Verify all PDF text state operators are present
731        assert!(
732            ops.contains("Tc"),
733            "Character spacing operator (Tc) not found"
734        );
735        assert!(ops.contains("Tw"), "Word spacing operator (Tw) not found");
736        assert!(
737            ops.contains("Tz"),
738            "Horizontal scaling operator (Tz) not found"
739        );
740        assert!(ops.contains("TL"), "Leading operator (TL) not found");
741        assert!(ops.contains("Ts"), "Text rise operator (Ts) not found");
742        assert!(
743            ops.contains("Tr"),
744            "Text rendering mode operator (Tr) not found"
745        );
746    }
747
748    #[test]
749    fn test_text_color_operations() {
750        use crate::Color;
751
752        let mut context = TextContext::new();
753
754        // Test RGB fill color
755        context.set_fill_color(Color::rgb(1.0, 0.0, 0.0));
756        context.apply_text_state_parameters();
757
758        let ops = context.operations();
759        assert!(
760            ops.contains("1.000 0.000 0.000 rg"),
761            "RGB fill color operator (rg) not found in: {ops}"
762        );
763
764        // Clear and test RGB stroke color
765        context.clear();
766        context.set_stroke_color(Color::rgb(0.0, 1.0, 0.0));
767        context.apply_text_state_parameters();
768
769        let ops = context.operations();
770        assert!(
771            ops.contains("0.000 1.000 0.000 RG"),
772            "RGB stroke color operator (RG) not found in: {ops}"
773        );
774
775        // Clear and test grayscale fill color
776        context.clear();
777        context.set_fill_color(Color::gray(0.5));
778        context.apply_text_state_parameters();
779
780        let ops = context.operations();
781        assert!(
782            ops.contains("0.500 g"),
783            "Gray fill color operator (g) not found in: {ops}"
784        );
785
786        // Clear and test CMYK stroke color
787        context.clear();
788        context.set_stroke_color(Color::cmyk(0.2, 0.3, 0.4, 0.1));
789        context.apply_text_state_parameters();
790
791        let ops = context.operations();
792        assert!(
793            ops.contains("0.200 0.300 0.400 0.100 K"),
794            "CMYK stroke color operator (K) not found in: {ops}"
795        );
796
797        // Test both fill and stroke colors together
798        context.clear();
799        context.set_fill_color(Color::rgb(1.0, 0.0, 0.0));
800        context.set_stroke_color(Color::rgb(0.0, 0.0, 1.0));
801        context.apply_text_state_parameters();
802
803        let ops = context.operations();
804        assert!(
805            ops.contains("1.000 0.000 0.000 rg") && ops.contains("0.000 0.000 1.000 RG"),
806            "Both fill and stroke colors not found in: {ops}"
807        );
808    }
809
810    // Issue #97: Test used_characters tracking
811    #[test]
812    fn test_used_characters_tracking_ascii() {
813        let mut context = TextContext::new();
814        context.write("Hello").unwrap();
815
816        let chars = context.get_used_characters();
817        assert!(chars.is_some());
818        let chars = chars.unwrap();
819        assert!(chars.contains(&'H'));
820        assert!(chars.contains(&'e'));
821        assert!(chars.contains(&'l'));
822        assert!(chars.contains(&'o'));
823        assert_eq!(chars.len(), 4); // H, e, l, o (l appears twice but HashSet dedupes)
824    }
825
826    #[test]
827    fn test_used_characters_tracking_cjk() {
828        let mut context = TextContext::new();
829        context.set_font(Font::Custom("NotoSansCJK".to_string()), 12.0);
830        context.write("中文测试").unwrap();
831
832        let chars = context.get_used_characters();
833        assert!(chars.is_some());
834        let chars = chars.unwrap();
835        assert!(chars.contains(&'中'));
836        assert!(chars.contains(&'文'));
837        assert!(chars.contains(&'测'));
838        assert!(chars.contains(&'试'));
839        assert_eq!(chars.len(), 4);
840    }
841
842    #[test]
843    fn test_used_characters_empty_initially() {
844        let context = TextContext::new();
845        assert!(context.get_used_characters().is_none());
846    }
847
848    #[test]
849    fn test_used_characters_multiple_writes() {
850        let mut context = TextContext::new();
851        context.write("AB").unwrap();
852        context.write("CD").unwrap();
853
854        let chars = context.get_used_characters();
855        assert!(chars.is_some());
856        let chars = chars.unwrap();
857        assert!(chars.contains(&'A'));
858        assert!(chars.contains(&'B'));
859        assert!(chars.contains(&'C'));
860        assert!(chars.contains(&'D'));
861        assert_eq!(chars.len(), 4);
862    }
863}