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