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