Skip to main content

oxidize_pdf/text/
mod.rs

1pub mod cid_to_unicode;
2pub mod cmap;
3pub(crate) mod encoding;
4pub(crate) mod encoding_cmap;
5pub mod extraction;
6mod extraction_cmap;
7mod flow;
8mod font;
9pub mod font_manager;
10pub mod fonts;
11pub(crate) mod graphics_state_stack;
12mod header_footer;
13pub mod invoice;
14mod layout;
15mod list;
16pub mod metrics;
17pub mod ocr;
18pub mod plaintext;
19pub mod structured;
20pub mod table;
21pub mod table_detection;
22pub mod text_block;
23pub mod validation;
24
25#[cfg(test)]
26mod cmap_tests;
27
28#[cfg(feature = "ocr-tesseract")]
29pub mod tesseract_provider;
30
31pub use encoding::{escape_pdf_string_literal, TextEncoding};
32pub use extraction::{
33    sanitize_extracted_text, ExtractedText, ExtractionOptions, TextExtractor, TextFragment,
34};
35pub use flow::{TextAlign, TextFlowContext};
36pub use font::{Font, FontEncoding, FontFamily, FontWithEncoding};
37pub use font_manager::{CustomFont, FontDescriptor, FontFlags, FontManager, FontMetrics, FontType};
38pub use header_footer::{HeaderFooter, HeaderFooterOptions, HeaderFooterPosition};
39pub use layout::{ColumnContent, ColumnLayout, ColumnOptions, TextFormat};
40pub use list::{
41    BulletStyle, ListElement, ListItem, ListOptions, ListStyle as ListStyleEnum, OrderedList,
42    OrderedListStyle, UnorderedList,
43};
44pub use metrics::{
45    measure_char, measure_char_with, measure_text, measure_text_with, split_into_words,
46    FontMetricsStore,
47};
48pub use ocr::{
49    CharacterConfidence, CorrectionCandidate, CorrectionReason, CorrectionSuggestion,
50    CorrectionType, FragmentType, ImagePreprocessing, MockOcrProvider, OcrEngine, OcrError,
51    OcrOptions, OcrPostProcessor, OcrProcessingResult, OcrProvider, OcrRegion, OcrResult,
52    OcrTextFragment, WordConfidence,
53};
54pub use plaintext::{LineBreakMode, PlainTextConfig, PlainTextExtractor, PlainTextResult};
55pub use table::{HeaderStyle, Table, TableCell, TableOptions};
56pub use text_block::{
57    compute_line_widths, measure_text_block, measure_text_block_with, TextBlockMetrics,
58};
59pub use validation::{MatchType, TextMatch, TextValidationResult, TextValidator};
60
61#[cfg(feature = "ocr-tesseract")]
62pub use tesseract_provider::{RustyTesseractConfig, RustyTesseractProvider};
63
64use crate::error::Result;
65use crate::Color;
66use std::collections::{HashMap, HashSet};
67
68/// Text rendering mode for PDF text operations.
69///
70/// Re-exported via `oxidize_pdf::text::TextRenderingMode`.
71#[derive(Clone, Copy, Debug, PartialEq, Eq)]
72pub enum TextRenderingMode {
73    /// Fill text (default)
74    Fill = 0,
75    /// Stroke text
76    Stroke = 1,
77    /// Fill and stroke text
78    FillStroke = 2,
79    /// Invisible text (for searchable text over images)
80    Invisible = 3,
81    /// Fill text and add to path for clipping
82    FillClip = 4,
83    /// Stroke text and add to path for clipping
84    StrokeClip = 5,
85    /// Fill and stroke text and add to path for clipping
86    FillStrokeClip = 6,
87    /// Add text to path for clipping (invisible)
88    Clip = 7,
89}
90
91/// Build the show-text IR op for `text` rendered with `font`. Single
92/// emission path shared by `TextContext::write` and
93/// `TextFlowContext::write_wrapped` so the two cannot diverge on encoding
94/// or escaping (issue #240 — pre-fix, the flow path emitted raw UTF-8
95/// bytes inside the literal `( … ) Tj` and any character outside ASCII
96/// rendered as Windows-1252 mojibake).
97///
98/// - `Font::Custom(_)` → UTF-16BE hex string per ISO 32000-1 §9.10.3,
99///   wrapped in `Op::ShowTextHex` so the writer emits `< … > Tj`.
100/// - Any builtin font → bytes are first WinAnsi-encoded
101///   ([`TextEncoding::WinAnsiEncoding`]) and then escaped for inclusion
102///   in a PDF string literal via
103///   [`encoding::escape_show_text_literal_bytes`].
104pub(crate) fn build_show_text_op(text: &str, font: &Font) -> crate::graphics::ops::Op {
105    use crate::graphics::ops::Op;
106
107    match font {
108        Font::Custom(_) => {
109            let utf16_units: Vec<u16> = text.encode_utf16().collect();
110            let mut hex = String::with_capacity(utf16_units.len() * 4);
111            for unit in utf16_units {
112                use std::fmt::Write as _;
113                write!(
114                    &mut hex,
115                    "{:02X}{:02X}",
116                    (unit >> 8) as u8,
117                    (unit & 0xFF) as u8
118                )
119                .expect("write to String never fails");
120            }
121            Op::ShowTextHex(hex.into_bytes())
122        }
123        _ => {
124            let encoded = TextEncoding::WinAnsiEncoding.encode(text);
125            Op::ShowText(encoding::escape_show_text_literal_bytes(&encoded))
126        }
127    }
128}
129
130#[derive(Clone)]
131pub struct TextContext {
132    operations: Vec<crate::graphics::ops::Op>,
133    current_font: Font,
134    font_size: f64,
135    text_matrix: [f64; 6],
136    // Pending position for next write operation
137    pending_position: Option<(f64, f64)>,
138    // Text state parameters
139    character_spacing: Option<f64>,
140    word_spacing: Option<f64>,
141    horizontal_scaling: Option<f64>,
142    leading: Option<f64>,
143    text_rise: Option<f64>,
144    rendering_mode: Option<TextRenderingMode>,
145    // Color parameters
146    fill_color: Option<Color>,
147    stroke_color: Option<Color>,
148    // Track used characters per custom-font name (issue #204 — a single
149    // global set caused every registered font to be subsetted with the
150    // same characters, so two fonts of the same family ended up with
151    // duplicated subsets). Builtin fonts are not tracked because they
152    // don't need subsetting. Extended by `write` whenever the active
153    // font is `Font::Custom`.
154    used_characters_by_font: HashMap<String, HashSet<char>>,
155    /// Per-document font metrics store threaded from `Page` (issue #230).
156    /// `None` means the built-in heuristic width tables are used.
157    /// Non-test callers arrive in Task 9-11 (Document integration).
158    #[allow(dead_code)]
159    pub(crate) font_metrics_store: Option<FontMetricsStore>,
160}
161
162impl Default for TextContext {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168impl TextContext {
169    pub fn new() -> Self {
170        Self {
171            operations: Vec::new(),
172            current_font: Font::Helvetica,
173            font_size: 12.0,
174            text_matrix: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
175            pending_position: None,
176            character_spacing: None,
177            word_spacing: None,
178            horizontal_scaling: None,
179            leading: None,
180            text_rise: None,
181            rendering_mode: None,
182            fill_color: None,
183            stroke_color: None,
184            used_characters_by_font: HashMap::new(),
185            font_metrics_store: None,
186        }
187    }
188
189    /// Create a `TextContext` bound to a per-document `FontMetricsStore`
190    /// (issue #230). `None` is equivalent to `TextContext::new()`.
191    ///
192    /// `pub(crate)` — wired by `Page::*_with_metrics()` constructors and
193    /// by `Document::new_page_*()` factories.
194    pub(crate) fn with_metrics_store(store: Option<FontMetricsStore>) -> Self {
195        let mut ctx = Self::default();
196        ctx.font_metrics_store = store;
197        ctx
198    }
199
200    /// Inject or replace the per-Document `FontMetricsStore` on an
201    /// already-constructed context. Preserves accumulated ops and any
202    /// other state — only the `font_metrics_store` field is mutated.
203    ///
204    /// Called by `Document::add_page` for pages constructed via
205    /// `Page::a4()` / `Page::letter()` / `Page::new()` (those start with
206    /// `font_metrics_store: None` and may already carry ops the caller
207    /// pushed before transferring ownership to the Document).
208    pub(crate) fn set_metrics_store(&mut self, store: Option<FontMetricsStore>) {
209        self.font_metrics_store = store;
210    }
211
212    /// Record `text` as drawn with the currently-active font, bucketed
213    /// under the font's PDF name (issue #204). Builtin and custom fonts
214    /// are both tracked; the writer later filters to the set of
215    /// registered custom fonts when subsetting.
216    fn record_used_chars(&mut self, text: &str) {
217        let name = match &self.current_font {
218            Font::Custom(name) => name.clone(),
219            builtin => builtin.pdf_name(),
220        };
221        self.used_characters_by_font
222            .entry(name)
223            .or_default()
224            .extend(text.chars());
225    }
226
227    /// Introspection helper for Task 7 tests (issue #230).
228    #[cfg(test)]
229    pub(crate) fn font_metrics_store_for_test(&self) -> Option<&FontMetricsStore> {
230        self.font_metrics_store.as_ref()
231    }
232
233    /// Get the characters used in this text context (merged across all
234    /// fonts). Test-only compatibility accessor; callers that need
235    /// per-font accuracy for subsetting should use
236    /// [`TextContext::get_used_characters_by_font`] (issue #204).
237    #[cfg(test)]
238    pub(crate) fn get_used_characters(&self) -> Option<HashSet<char>> {
239        let merged: HashSet<char> = self
240            .used_characters_by_font
241            .values()
242            .flat_map(|s| s.iter().copied())
243            .collect();
244        if merged.is_empty() {
245            None
246        } else {
247            Some(merged)
248        }
249    }
250
251    /// Get the per-font character map for font subsetting (issue #204).
252    pub(crate) fn get_used_characters_by_font(&self) -> &HashMap<String, HashSet<char>> {
253        &self.used_characters_by_font
254    }
255
256    pub fn set_font(&mut self, font: Font, size: f64) -> &mut Self {
257        self.current_font = font;
258        self.font_size = size;
259        self
260    }
261
262    /// Get the current font
263    #[allow(dead_code)]
264    pub(crate) fn current_font(&self) -> &Font {
265        &self.current_font
266    }
267
268    /// Current non-stroking (fill) colour, if one has been explicitly set.
269    /// Used by `Page::text_flow` to propagate the page-level text colour
270    /// into derived `TextFlowContext`s (issue #216).
271    pub(crate) fn fill_color(&self) -> Option<Color> {
272        self.fill_color
273    }
274
275    /// Accessors for the remaining text-state parameters (issue #222 —
276    /// Phase 6 of the v2.7.0 IR refactor). Used by `Page::text_flow` to
277    /// propagate the configured page-level state into derived
278    /// `TextFlowContext`s. Mirror of `fill_color()` above.
279    pub(crate) fn character_spacing(&self) -> Option<f64> {
280        self.character_spacing
281    }
282    pub(crate) fn word_spacing(&self) -> Option<f64> {
283        self.word_spacing
284    }
285    pub(crate) fn horizontal_scaling(&self) -> Option<f64> {
286        self.horizontal_scaling
287    }
288    pub(crate) fn leading(&self) -> Option<f64> {
289        self.leading
290    }
291    pub(crate) fn text_rise(&self) -> Option<f64> {
292        self.text_rise
293    }
294    pub(crate) fn rendering_mode(&self) -> Option<TextRenderingMode> {
295        self.rendering_mode
296    }
297    pub(crate) fn stroke_color(&self) -> Option<Color> {
298        self.stroke_color
299    }
300
301    pub fn at(&mut self, x: f64, y: f64) -> &mut Self {
302        // Update text_matrix immediately and store for write() operation
303        self.text_matrix[4] = x;
304        self.text_matrix[5] = y;
305        self.pending_position = Some((x, y));
306        self
307    }
308
309    pub fn write(&mut self, text: &str) -> Result<&mut Self> {
310        use crate::graphics::ops::Op;
311
312        self.operations.push(Op::BeginText);
313
314        // Set font
315        self.operations.push(Op::SetFont {
316            name: self.current_font.pdf_name(),
317            size: self.font_size,
318        });
319
320        // Apply text state parameters (Tc/Tw/Tz/TL/Ts/Tr + colour)
321        self.apply_text_state_parameters();
322
323        // Set text position using pending_position if available, otherwise use text_matrix
324        let (x, y) = if let Some((px, py)) = self.pending_position.take() {
325            (px, py)
326        } else {
327            (self.text_matrix[4], self.text_matrix[5])
328        };
329        self.operations.push(Op::SetTextPosition { x, y });
330
331        // Shared encoding + escape pipeline (issue #240): builtin fonts
332        // route through WinAnsi + literal-string escape; Custom (CJK)
333        // fonts route through UTF-16BE hex. Mirror of the same call in
334        // `TextFlowContext::write_wrapped` — single source of truth.
335        self.operations
336            .push(build_show_text_op(text, &self.current_font));
337
338        // Track used characters for font subsetting bucketed by the
339        // active custom font (issue #204).
340        self.record_used_chars(text);
341
342        self.operations.push(Op::EndText);
343
344        Ok(self)
345    }
346
347    pub fn write_line(&mut self, text: &str) -> Result<&mut Self> {
348        self.write(text)?;
349        self.text_matrix[5] -= self.font_size * 1.2; // Move down for next line
350        Ok(self)
351    }
352
353    pub fn set_character_spacing(&mut self, spacing: f64) -> &mut Self {
354        self.character_spacing = Some(spacing);
355        self
356    }
357
358    pub fn set_word_spacing(&mut self, spacing: f64) -> &mut Self {
359        self.word_spacing = Some(spacing);
360        self
361    }
362
363    pub fn set_horizontal_scaling(&mut self, scale: f64) -> &mut Self {
364        self.horizontal_scaling = Some(scale);
365        self
366    }
367
368    pub fn set_leading(&mut self, leading: f64) -> &mut Self {
369        self.leading = Some(leading);
370        self
371    }
372
373    pub fn set_text_rise(&mut self, rise: f64) -> &mut Self {
374        self.text_rise = Some(rise);
375        self
376    }
377
378    /// Set the text rendering mode
379    pub fn set_rendering_mode(&mut self, mode: TextRenderingMode) -> &mut Self {
380        self.rendering_mode = Some(mode);
381        self
382    }
383
384    /// Set the text fill color
385    pub fn set_fill_color(&mut self, color: Color) -> &mut Self {
386        self.fill_color = Some(color);
387        self
388    }
389
390    /// Set the text stroke color
391    pub fn set_stroke_color(&mut self, color: Color) -> &mut Self {
392        self.stroke_color = Some(color);
393        self
394    }
395
396    /// Apply text state parameters as `Op` values pushed into `self.operations`.
397    ///
398    /// All non-finite floats are clamped to `0.0` at serialisation time by
399    /// `serialize_ops` (issues #220 + #221 extend to non-colour emitters in
400    /// the v2.7.0 IR refactor).
401    fn apply_text_state_parameters(&mut self) {
402        use crate::graphics::ops::Op;
403
404        if let Some(spacing) = self.character_spacing {
405            self.operations.push(Op::SetCharSpacing(spacing));
406        }
407        if let Some(spacing) = self.word_spacing {
408            self.operations.push(Op::SetWordSpacing(spacing));
409        }
410        if let Some(scale) = self.horizontal_scaling {
411            // Tz operator takes a percentage. The setter accepts a 0.0–1.0
412            // ratio and the original implementation multiplied by 100 at
413            // emission; preserve that contract.
414            self.operations
415                .push(Op::SetHorizontalScaling(scale * 100.0));
416        }
417        if let Some(leading) = self.leading {
418            self.operations.push(Op::SetLeading(leading));
419        }
420        if let Some(rise) = self.text_rise {
421            self.operations.push(Op::SetTextRise(rise));
422        }
423        if let Some(mode) = self.rendering_mode {
424            self.operations.push(Op::SetRenderingMode(mode as u8));
425        }
426
427        // Fill / stroke colour delegates to the IR variants which in turn
428        // delegate to `write_fill_color_bytes` / `write_stroke_color_bytes`
429        // (issues #220 + #221).
430        if let Some(color) = self.fill_color {
431            self.operations.push(Op::SetFillColor(color));
432        }
433        if let Some(color) = self.stroke_color {
434            self.operations.push(Op::SetStrokeColor(color));
435        }
436    }
437
438    pub(crate) fn generate_operations(&self) -> Result<Vec<u8>> {
439        let mut buf = Vec::new();
440        crate::graphics::ops::serialize_ops(&mut buf, &self.operations);
441        Ok(buf)
442    }
443
444    /// Take ownership of the accumulated `Op` buffer, leaving an empty
445    /// `Vec` in its place. Mirror of `GraphicsContext::drain_ops` —
446    /// used by `Page` to flush the text buffer into a unified content
447    /// stream on context switch (issue #227).
448    pub(crate) fn drain_ops(&mut self) -> Vec<crate::graphics::ops::Op> {
449        std::mem::take(&mut self.operations)
450    }
451
452    /// Read-only access to the operation list.
453    pub(crate) fn ops_slice(&self) -> &[crate::graphics::ops::Op] {
454        &self.operations
455    }
456
457    /// Appends a raw PDF operation to the text context
458    ///
459    /// This is used internally for marked content operators (BDC/EMC) and other
460    /// low-level PDF operations that need to be interleaved with text operations.
461    pub(crate) fn append_raw_operation(&mut self, operation: &str) {
462        self.operations
463            .push(crate::graphics::ops::Op::Raw(operation.as_bytes().to_vec()));
464    }
465
466    /// Get the current font size
467    pub fn font_size(&self) -> f64 {
468        self.font_size
469    }
470
471    /// Get the current text matrix
472    pub fn text_matrix(&self) -> [f64; 6] {
473        self.text_matrix
474    }
475
476    /// Get the current position
477    pub fn position(&self) -> (f64, f64) {
478        (self.text_matrix[4], self.text_matrix[5])
479    }
480
481    /// Clear all operations and reset text state parameters
482    pub fn clear(&mut self) {
483        self.operations.clear();
484        self.character_spacing = None;
485        self.word_spacing = None;
486        self.horizontal_scaling = None;
487        self.leading = None;
488        self.text_rise = None;
489        self.rendering_mode = None;
490        self.fill_color = None;
491        self.stroke_color = None;
492    }
493
494    /// Get the operations as a serialised PDF content-stream `String`.
495    ///
496    /// Pre-2.7.0 this returned `&str`. The IR migration replaced the
497    /// internal `String` buffer with a typed `Vec<Op>`, so the legacy
498    /// borrow is materialised on demand. Internal callers prefer
499    /// `generate_operations()` which returns the byte buffer directly.
500    pub fn operations(&self) -> String {
501        crate::graphics::ops::ops_to_string(&self.operations)
502    }
503
504    /// Generate text state operations for testing purposes.
505    /// Routes through the IR so the same sanitisation applies.
506    #[cfg(test)]
507    pub fn generate_text_state_operations(&self) -> String {
508        use crate::graphics::ops::{ops_to_string, Op};
509
510        let mut ops = Vec::new();
511        if let Some(spacing) = self.character_spacing {
512            ops.push(Op::SetCharSpacing(spacing));
513        }
514        if let Some(spacing) = self.word_spacing {
515            ops.push(Op::SetWordSpacing(spacing));
516        }
517        if let Some(scale) = self.horizontal_scaling {
518            ops.push(Op::SetHorizontalScaling(scale * 100.0));
519        }
520        if let Some(leading) = self.leading {
521            ops.push(Op::SetLeading(leading));
522        }
523        if let Some(rise) = self.text_rise {
524            ops.push(Op::SetTextRise(rise));
525        }
526        if let Some(mode) = self.rendering_mode {
527            ops.push(Op::SetRenderingMode(mode as u8));
528        }
529        ops_to_string(&ops)
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    #[test]
538    fn test_text_context_new() {
539        let context = TextContext::new();
540        assert_eq!(context.current_font, Font::Helvetica);
541        assert_eq!(context.font_size, 12.0);
542        assert_eq!(context.text_matrix, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
543        assert!(context.operations.is_empty());
544    }
545
546    #[test]
547    fn test_text_context_default() {
548        let context = TextContext::default();
549        assert_eq!(context.current_font, Font::Helvetica);
550        assert_eq!(context.font_size, 12.0);
551    }
552
553    #[test]
554    fn test_set_font() {
555        let mut context = TextContext::new();
556        context.set_font(Font::TimesBold, 14.0);
557        assert_eq!(context.current_font, Font::TimesBold);
558        assert_eq!(context.font_size, 14.0);
559    }
560
561    #[test]
562    fn test_position() {
563        let mut context = TextContext::new();
564        context.at(100.0, 200.0);
565        let (x, y) = context.position();
566        assert_eq!(x, 100.0);
567        assert_eq!(y, 200.0);
568        assert_eq!(context.text_matrix[4], 100.0);
569        assert_eq!(context.text_matrix[5], 200.0);
570    }
571
572    #[test]
573    fn test_write_simple_text() {
574        let mut context = TextContext::new();
575        context.write("Hello").unwrap();
576
577        let ops = context.operations();
578        assert!(ops.contains("BT\n"));
579        assert!(ops.contains("ET\n"));
580        assert!(ops.contains("/Helvetica 12 Tf"));
581        assert!(ops.contains("(Hello) Tj"));
582    }
583
584    #[test]
585    fn test_write_text_with_escaping() {
586        let mut context = TextContext::new();
587        context.write("(Hello)").unwrap();
588
589        let ops = context.operations();
590        assert!(ops.contains("(\\(Hello\\)) Tj"));
591    }
592
593    #[test]
594    fn test_write_line() {
595        let mut context = TextContext::new();
596        let initial_y = context.text_matrix[5];
597        context.write_line("Line 1").unwrap();
598
599        // Y position should have moved down
600        let new_y = context.text_matrix[5];
601        assert!(new_y < initial_y);
602        assert_eq!(new_y, initial_y - 12.0 * 1.2); // font_size * 1.2
603    }
604
605    #[test]
606    fn test_character_spacing() {
607        let mut context = TextContext::new();
608        context.set_character_spacing(2.5);
609
610        let ops = context.generate_text_state_operations();
611        assert!(ops.contains("2.50 Tc"));
612    }
613
614    #[test]
615    fn test_word_spacing() {
616        let mut context = TextContext::new();
617        context.set_word_spacing(1.5);
618
619        let ops = context.generate_text_state_operations();
620        assert!(ops.contains("1.50 Tw"));
621    }
622
623    #[test]
624    fn test_horizontal_scaling() {
625        let mut context = TextContext::new();
626        context.set_horizontal_scaling(1.25);
627
628        let ops = context.generate_text_state_operations();
629        assert!(ops.contains("125.00 Tz")); // 1.25 * 100
630    }
631
632    #[test]
633    fn test_leading() {
634        let mut context = TextContext::new();
635        context.set_leading(15.0);
636
637        let ops = context.generate_text_state_operations();
638        assert!(ops.contains("15.00 TL"));
639    }
640
641    #[test]
642    fn test_text_rise() {
643        let mut context = TextContext::new();
644        context.set_text_rise(3.0);
645
646        let ops = context.generate_text_state_operations();
647        assert!(ops.contains("3.00 Ts"));
648    }
649
650    #[test]
651    fn test_clear() {
652        let mut context = TextContext::new();
653        context.write("Hello").unwrap();
654        assert!(!context.operations().is_empty());
655
656        context.clear();
657        assert!(context.operations().is_empty());
658    }
659
660    #[test]
661    fn test_generate_operations() {
662        let mut context = TextContext::new();
663        context.write("Test").unwrap();
664
665        let ops_bytes = context.generate_operations().unwrap();
666        let ops_string = String::from_utf8(ops_bytes).unwrap();
667        assert_eq!(ops_string, context.operations());
668    }
669
670    #[test]
671    fn test_method_chaining() {
672        let mut context = TextContext::new();
673        context
674            .set_font(Font::Courier, 10.0)
675            .at(50.0, 100.0)
676            .set_character_spacing(1.0)
677            .set_word_spacing(2.0);
678
679        assert_eq!(context.current_font(), &Font::Courier);
680        assert_eq!(context.font_size(), 10.0);
681        let (x, y) = context.position();
682        assert_eq!(x, 50.0);
683        assert_eq!(y, 100.0);
684    }
685
686    #[test]
687    fn test_text_matrix_access() {
688        let mut context = TextContext::new();
689        context.at(25.0, 75.0);
690
691        let matrix = context.text_matrix();
692        assert_eq!(matrix, [1.0, 0.0, 0.0, 1.0, 25.0, 75.0]);
693    }
694
695    #[test]
696    fn test_special_characters_encoding() {
697        let mut context = TextContext::new();
698        context.write("Test\nLine\tTab").unwrap();
699
700        let ops = context.operations();
701        assert!(ops.contains("\\n"));
702        assert!(ops.contains("\\t"));
703    }
704
705    #[test]
706    fn test_rendering_mode_fill() {
707        let mut context = TextContext::new();
708        context.set_rendering_mode(TextRenderingMode::Fill);
709
710        let ops = context.generate_text_state_operations();
711        assert!(ops.contains("0 Tr"));
712    }
713
714    #[test]
715    fn test_rendering_mode_stroke() {
716        let mut context = TextContext::new();
717        context.set_rendering_mode(TextRenderingMode::Stroke);
718
719        let ops = context.generate_text_state_operations();
720        assert!(ops.contains("1 Tr"));
721    }
722
723    #[test]
724    fn test_rendering_mode_fill_stroke() {
725        let mut context = TextContext::new();
726        context.set_rendering_mode(TextRenderingMode::FillStroke);
727
728        let ops = context.generate_text_state_operations();
729        assert!(ops.contains("2 Tr"));
730    }
731
732    #[test]
733    fn test_rendering_mode_invisible() {
734        let mut context = TextContext::new();
735        context.set_rendering_mode(TextRenderingMode::Invisible);
736
737        let ops = context.generate_text_state_operations();
738        assert!(ops.contains("3 Tr"));
739    }
740
741    #[test]
742    fn test_rendering_mode_fill_clip() {
743        let mut context = TextContext::new();
744        context.set_rendering_mode(TextRenderingMode::FillClip);
745
746        let ops = context.generate_text_state_operations();
747        assert!(ops.contains("4 Tr"));
748    }
749
750    #[test]
751    fn test_rendering_mode_stroke_clip() {
752        let mut context = TextContext::new();
753        context.set_rendering_mode(TextRenderingMode::StrokeClip);
754
755        let ops = context.generate_text_state_operations();
756        assert!(ops.contains("5 Tr"));
757    }
758
759    #[test]
760    fn test_rendering_mode_fill_stroke_clip() {
761        let mut context = TextContext::new();
762        context.set_rendering_mode(TextRenderingMode::FillStrokeClip);
763
764        let ops = context.generate_text_state_operations();
765        assert!(ops.contains("6 Tr"));
766    }
767
768    #[test]
769    fn test_rendering_mode_clip() {
770        let mut context = TextContext::new();
771        context.set_rendering_mode(TextRenderingMode::Clip);
772
773        let ops = context.generate_text_state_operations();
774        assert!(ops.contains("7 Tr"));
775    }
776
777    #[test]
778    fn test_text_state_parameters_chaining() {
779        let mut context = TextContext::new();
780        context
781            .set_character_spacing(1.5)
782            .set_word_spacing(2.0)
783            .set_horizontal_scaling(1.1)
784            .set_leading(14.0)
785            .set_text_rise(0.5)
786            .set_rendering_mode(TextRenderingMode::FillStroke);
787
788        let ops = context.generate_text_state_operations();
789        assert!(ops.contains("1.50 Tc"));
790        assert!(ops.contains("2.00 Tw"));
791        assert!(ops.contains("110.00 Tz"));
792        assert!(ops.contains("14.00 TL"));
793        assert!(ops.contains("0.50 Ts"));
794        assert!(ops.contains("2 Tr"));
795    }
796
797    #[test]
798    fn test_all_text_state_operators_generated() {
799        let mut context = TextContext::new();
800
801        // Test all operators in sequence
802        context.set_character_spacing(1.0); // Tc
803        context.set_word_spacing(2.0); // Tw
804        context.set_horizontal_scaling(1.2); // Tz
805        context.set_leading(15.0); // TL
806        context.set_text_rise(1.0); // Ts
807        context.set_rendering_mode(TextRenderingMode::Stroke); // Tr
808
809        let ops = context.generate_text_state_operations();
810
811        // Verify all PDF text state operators are present
812        assert!(
813            ops.contains("Tc"),
814            "Character spacing operator (Tc) not found"
815        );
816        assert!(ops.contains("Tw"), "Word spacing operator (Tw) not found");
817        assert!(
818            ops.contains("Tz"),
819            "Horizontal scaling operator (Tz) not found"
820        );
821        assert!(ops.contains("TL"), "Leading operator (TL) not found");
822        assert!(ops.contains("Ts"), "Text rise operator (Ts) not found");
823        assert!(
824            ops.contains("Tr"),
825            "Text rendering mode operator (Tr) not found"
826        );
827    }
828
829    #[test]
830    fn test_text_color_operations() {
831        use crate::Color;
832
833        let mut context = TextContext::new();
834
835        // Test RGB fill color
836        context.set_fill_color(Color::rgb(1.0, 0.0, 0.0));
837        context.apply_text_state_parameters();
838
839        let ops = context.operations();
840        assert!(
841            ops.contains("1.000 0.000 0.000 rg"),
842            "RGB fill color operator (rg) not found in: {ops}"
843        );
844
845        // Clear and test RGB stroke color
846        context.clear();
847        context.set_stroke_color(Color::rgb(0.0, 1.0, 0.0));
848        context.apply_text_state_parameters();
849
850        let ops = context.operations();
851        assert!(
852            ops.contains("0.000 1.000 0.000 RG"),
853            "RGB stroke color operator (RG) not found in: {ops}"
854        );
855
856        // Clear and test grayscale fill color
857        context.clear();
858        context.set_fill_color(Color::gray(0.5));
859        context.apply_text_state_parameters();
860
861        let ops = context.operations();
862        assert!(
863            ops.contains("0.500 g"),
864            "Gray fill color operator (g) not found in: {ops}"
865        );
866
867        // Clear and test CMYK stroke color
868        context.clear();
869        context.set_stroke_color(Color::cmyk(0.2, 0.3, 0.4, 0.1));
870        context.apply_text_state_parameters();
871
872        let ops = context.operations();
873        assert!(
874            ops.contains("0.200 0.300 0.400 0.100 K"),
875            "CMYK stroke color operator (K) not found in: {ops}"
876        );
877
878        // Test both fill and stroke colors together
879        context.clear();
880        context.set_fill_color(Color::rgb(1.0, 0.0, 0.0));
881        context.set_stroke_color(Color::rgb(0.0, 0.0, 1.0));
882        context.apply_text_state_parameters();
883
884        let ops = context.operations();
885        assert!(
886            ops.contains("1.000 0.000 0.000 rg") && ops.contains("0.000 0.000 1.000 RG"),
887            "Both fill and stroke colors not found in: {ops}"
888        );
889    }
890
891    // Issue #97: Test used_characters tracking
892    #[test]
893    fn test_used_characters_tracking_ascii() {
894        let mut context = TextContext::new();
895        context.write("Hello").unwrap();
896
897        let chars = context.get_used_characters();
898        assert!(chars.is_some());
899        let chars = chars.unwrap();
900        assert!(chars.contains(&'H'));
901        assert!(chars.contains(&'e'));
902        assert!(chars.contains(&'l'));
903        assert!(chars.contains(&'o'));
904        assert_eq!(chars.len(), 4); // H, e, l, o (l appears twice but HashSet dedupes)
905    }
906
907    #[test]
908    fn test_used_characters_tracking_cjk() {
909        let mut context = TextContext::new();
910        context.set_font(Font::Custom("NotoSansCJK".to_string()), 12.0);
911        context.write("中文测试").unwrap();
912
913        let chars = context.get_used_characters();
914        assert!(chars.is_some());
915        let chars = chars.unwrap();
916        assert!(chars.contains(&'中'));
917        assert!(chars.contains(&'文'));
918        assert!(chars.contains(&'测'));
919        assert!(chars.contains(&'试'));
920        assert_eq!(chars.len(), 4);
921    }
922
923    #[test]
924    fn test_used_characters_empty_initially() {
925        let context = TextContext::new();
926        assert!(context.get_used_characters().is_none());
927    }
928
929    #[test]
930    fn test_used_characters_multiple_writes() {
931        let mut context = TextContext::new();
932        context.write("AB").unwrap();
933        context.write("CD").unwrap();
934
935        let chars = context.get_used_characters();
936        assert!(chars.is_some());
937        let chars = chars.unwrap();
938        assert!(chars.contains(&'A'));
939        assert!(chars.contains(&'B'));
940        assert!(chars.contains(&'C'));
941        assert!(chars.contains(&'D'));
942        assert_eq!(chars.len(), 4);
943    }
944
945    /// RED for Phase 2 of the v2.7.0 IR refactor: with the legacy `String`
946    /// emission, `set_character_spacing(f64::NAN)` propagates `NaN` into a
947    /// `Tc` operator, which is invalid per ISO 32000-1 §7.3.3. Once the
948    /// migration routes Tc through `serialize_ops`, `finite_or_zero`
949    /// clamps non-finite values to `0.0` and the assertion below passes.
950    #[test]
951    fn nan_char_spacing_sanitised_at_emission() {
952        let mut ctx = TextContext::new();
953        ctx.set_character_spacing(f64::NAN);
954        ctx.write("hi").unwrap();
955        let ops = ctx.operations();
956        assert!(
957            ops.contains("0.00 Tc\n"),
958            "NaN char spacing must emit `0.00 Tc`, got: {ops:?}"
959        );
960        assert!(
961            !ops.contains("NaN") && !ops.contains("inf"),
962            "non-finite tokens must not appear in any Tc/Tw/Tz/TL/Ts emission, got: {ops:?}"
963        );
964    }
965
966    #[test]
967    fn pos_inf_word_spacing_sanitised_at_emission() {
968        let mut ctx = TextContext::new();
969        ctx.set_word_spacing(f64::INFINITY);
970        ctx.write("hi").unwrap();
971        let ops = ctx.operations();
972        assert!(
973            ops.contains("0.00 Tw\n"),
974            "+inf word spacing must emit `0.00 Tw`, got: {ops:?}"
975        );
976        assert!(
977            !ops.contains("inf"),
978            "`inf` must not appear in Tw output, got: {ops:?}"
979        );
980    }
981
982    #[test]
983    fn nan_horizontal_scaling_sanitised_at_emission() {
984        let mut ctx = TextContext::new();
985        ctx.set_horizontal_scaling(f64::NAN);
986        ctx.write("hi").unwrap();
987        let ops = ctx.operations();
988        assert!(
989            ops.contains("0.00 Tz\n"),
990            "NaN horizontal scaling must emit `0.00 Tz`, got: {ops:?}"
991        );
992    }
993
994    #[test]
995    fn nan_leading_and_text_rise_sanitised_at_emission() {
996        let mut ctx = TextContext::new();
997        ctx.set_leading(f64::NEG_INFINITY);
998        ctx.set_text_rise(f64::NAN);
999        ctx.write("hi").unwrap();
1000        let ops = ctx.operations();
1001        assert!(
1002            ops.contains("0.00 TL\n"),
1003            "-inf leading must emit `0.00 TL`, got: {ops:?}"
1004        );
1005        assert!(
1006            ops.contains("0.00 Ts\n"),
1007            "NaN text rise must emit `0.00 Ts`, got: {ops:?}"
1008        );
1009    }
1010
1011    #[test]
1012    fn test_text_context_threads_metrics_store() {
1013        use crate::text::metrics::{FontMetrics, FontMetricsStore};
1014        let store = FontMetricsStore::new();
1015        let ctx = TextContext::with_metrics_store(Some(store.clone()));
1016        // The store handle round-trips.
1017        assert!(ctx.font_metrics_store_for_test().is_some());
1018        // Cloning shares state.
1019        store.register("X", FontMetrics::new(400));
1020        assert_eq!(
1021            ctx.font_metrics_store_for_test().unwrap().len(),
1022            1,
1023            "TextContext must hold a clone that shares the underlying registry"
1024        );
1025    }
1026}