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