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