Skip to main content

oxidize_pdf/text/
mod.rs

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