Skip to main content

pdfplumber_parse/
text_state.rs

1//! Text state machine for the content stream interpreter.
2//!
3//! Implements the PDF text state model: text object tracking (BT/ET),
4//! font selection (Tf), text matrix (Tm) and line matrix management,
5//! and text positioning operators (Td, TD, T*).
6
7use pdfplumber_core::geometry::Ctm;
8
9/// Text rendering mode values (Tr operator).
10///
11/// Determines how character glyphs are painted (filled, stroked, clipped, etc.).
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum TextRenderMode {
14    /// Fill character glyphs (default).
15    #[default]
16    Fill = 0,
17    /// Stroke (outline) character glyphs.
18    Stroke = 1,
19    /// Fill and stroke character glyphs.
20    FillStroke = 2,
21    /// Neither fill nor stroke (invisible text).
22    Invisible = 3,
23    /// Fill and add to clipping path.
24    FillClip = 4,
25    /// Stroke and add to clipping path.
26    StrokeClip = 5,
27    /// Fill, stroke, and add to clipping path.
28    FillStrokeClip = 6,
29    /// Add to clipping path only.
30    Clip = 7,
31}
32
33impl TextRenderMode {
34    /// Create a TextRenderMode from an integer value (0-7).
35    /// Returns None for invalid values.
36    pub fn from_i64(value: i64) -> Option<Self> {
37        match value {
38            0 => Some(Self::Fill),
39            1 => Some(Self::Stroke),
40            2 => Some(Self::FillStroke),
41            3 => Some(Self::Invisible),
42            4 => Some(Self::FillClip),
43            5 => Some(Self::StrokeClip),
44            6 => Some(Self::FillStrokeClip),
45            7 => Some(Self::Clip),
46            _ => None,
47        }
48    }
49}
50
51/// Text state parameters tracked during content stream interpretation.
52///
53/// These parameters are set by text state operators (Tc, Tw, Tz, TL, Tf, Tr, Ts)
54/// and persist across text objects (BT/ET blocks). They are part of the graphics
55/// state and are saved/restored by q/Q.
56#[derive(Debug, Clone, PartialEq)]
57pub struct TextState {
58    /// Character spacing (Tc operator). Extra space added after each character glyph.
59    pub char_spacing: f64,
60    /// Word spacing (Tw operator). Extra space added after each space character (code 32).
61    pub word_spacing: f64,
62    /// Horizontal scaling (Tz operator). Percentage value where 100 = normal.
63    /// Stored as percentage (e.g., 100.0 for 100%).
64    pub h_scaling: f64,
65    /// Text leading (TL operator). Distance between baselines of consecutive text lines.
66    pub leading: f64,
67    /// Current font name set by Tf operator.
68    pub font_name: String,
69    /// Current font size set by Tf operator.
70    pub font_size: f64,
71    /// Text rendering mode (Tr operator).
72    pub render_mode: TextRenderMode,
73    /// Text rise (Ts operator). Vertical offset for superscript/subscript.
74    pub rise: f64,
75    /// Whether we are inside a BT/ET text object.
76    in_text_object: bool,
77    /// The text matrix (set by Tm, updated by Td/TD/T*/Tj/TJ).
78    text_matrix: Ctm,
79    /// The text line matrix (set by BT, Td, TD, T*, Tm — records the start of each line).
80    line_matrix: Ctm,
81}
82
83impl Default for TextState {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89impl TextState {
90    /// Create a new TextState with default values per PDF spec.
91    pub fn new() -> Self {
92        Self {
93            char_spacing: 0.0,
94            word_spacing: 0.0,
95            h_scaling: 100.0,
96            leading: 0.0,
97            font_name: String::new(),
98            font_size: 0.0,
99            render_mode: TextRenderMode::default(),
100            rise: 0.0,
101            in_text_object: false,
102            text_matrix: Ctm::identity(),
103            line_matrix: Ctm::identity(),
104        }
105    }
106
107    /// Whether we are currently inside a BT/ET text object.
108    pub fn in_text_object(&self) -> bool {
109        self.in_text_object
110    }
111
112    /// Get the current text matrix.
113    pub fn text_matrix(&self) -> &Ctm {
114        &self.text_matrix
115    }
116
117    /// Get the current text matrix as a 6-element array.
118    pub fn text_matrix_array(&self) -> [f64; 6] {
119        [
120            self.text_matrix.a,
121            self.text_matrix.b,
122            self.text_matrix.c,
123            self.text_matrix.d,
124            self.text_matrix.e,
125            self.text_matrix.f,
126        ]
127    }
128
129    /// Get the current line matrix.
130    pub fn line_matrix(&self) -> &Ctm {
131        &self.line_matrix
132    }
133
134    /// Get the horizontal scaling as a fraction (1.0 = 100%).
135    pub fn h_scaling_normalized(&self) -> f64 {
136        self.h_scaling / 100.0
137    }
138
139    // --- BT operator ---
140
141    /// `BT` operator: begin text object.
142    ///
143    /// Resets the text matrix and line matrix to identity.
144    /// Sets in_text_object to true.
145    pub fn begin_text(&mut self) {
146        self.text_matrix = Ctm::identity();
147        self.line_matrix = Ctm::identity();
148        self.in_text_object = true;
149    }
150
151    // --- ET operator ---
152
153    /// `ET` operator: end text object.
154    ///
155    /// Sets in_text_object to false. Text matrix and line matrix
156    /// become undefined (but we keep them for potential inspection).
157    pub fn end_text(&mut self) {
158        self.in_text_object = false;
159    }
160
161    // --- Tf operator ---
162
163    /// `Tf` operator: set text font and size.
164    pub fn set_font(&mut self, font_name: String, font_size: f64) {
165        self.font_name = font_name;
166        self.font_size = font_size;
167    }
168
169    // --- Tc operator ---
170
171    /// `Tc` operator: set character spacing.
172    pub fn set_char_spacing(&mut self, spacing: f64) {
173        self.char_spacing = spacing;
174    }
175
176    // --- Tw operator ---
177
178    /// `Tw` operator: set word spacing.
179    pub fn set_word_spacing(&mut self, spacing: f64) {
180        self.word_spacing = spacing;
181    }
182
183    // --- Tz operator ---
184
185    /// `Tz` operator: set horizontal scaling (percentage).
186    pub fn set_h_scaling(&mut self, scale: f64) {
187        self.h_scaling = scale;
188    }
189
190    // --- TL operator ---
191
192    /// `TL` operator: set text leading.
193    pub fn set_leading(&mut self, leading: f64) {
194        self.leading = leading;
195    }
196
197    // --- Tr operator ---
198
199    /// `Tr` operator: set text rendering mode.
200    pub fn set_render_mode(&mut self, mode: TextRenderMode) {
201        self.render_mode = mode;
202    }
203
204    // --- Ts operator ---
205
206    /// `Ts` operator: set text rise.
207    pub fn set_rise(&mut self, rise: f64) {
208        self.rise = rise;
209    }
210
211    // --- Tm operator ---
212
213    /// `Tm` operator: set the text matrix and line matrix directly.
214    ///
215    /// Both text matrix and line matrix are set to the given matrix.
216    /// This replaces (not concatenates) the current text matrix.
217    pub fn set_text_matrix(&mut self, a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) {
218        let m = Ctm::new(a, b, c, d, e, f);
219        self.text_matrix = m;
220        self.line_matrix = m;
221    }
222
223    // --- Td operator ---
224
225    /// `Td` operator: move to start of next line, offset from start of current line.
226    ///
227    /// Translates the line matrix by (tx, ty) and sets the text matrix
228    /// to the new line matrix value.
229    pub fn move_text_position(&mut self, tx: f64, ty: f64) {
230        let translation = Ctm::new(1.0, 0.0, 0.0, 1.0, tx, ty);
231        self.line_matrix = translation.concat(&self.line_matrix);
232        self.text_matrix = self.line_matrix;
233    }
234
235    // --- TD operator ---
236
237    /// `TD` operator: move to start of next line and set leading.
238    ///
239    /// Equivalent to: `-ty TL` then `tx ty Td`.
240    /// Sets leading to `-ty` then moves text position by (tx, ty).
241    pub fn move_text_position_and_set_leading(&mut self, tx: f64, ty: f64) {
242        self.leading = -ty;
243        self.move_text_position(tx, ty);
244    }
245
246    // --- T* operator ---
247
248    /// `T*` operator: move to start of next line.
249    ///
250    /// Equivalent to `0 -TL Td` (using current leading value).
251    pub fn move_to_next_line(&mut self) {
252        let leading = self.leading;
253        self.move_text_position(0.0, -leading);
254    }
255
256    // --- Text position advancement (for Tj/TJ) ---
257
258    /// Advance the text matrix by a horizontal displacement.
259    ///
260    /// Used after rendering a character glyph to move to the next glyph position.
261    /// The displacement is in text space units, already accounting for font size
262    /// and horizontal scaling.
263    pub fn advance_text_position(&mut self, tx: f64) {
264        // Translate text matrix horizontally in text space
265        let translation = Ctm::new(1.0, 0.0, 0.0, 1.0, tx, 0.0);
266        self.text_matrix = translation.concat(&self.text_matrix);
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    fn assert_approx(actual: f64, expected: f64) {
275        assert!(
276            (actual - expected).abs() < 1e-10,
277            "expected {expected}, got {actual}"
278        );
279    }
280
281    fn assert_matrix_approx(ctm: &Ctm, expected: [f64; 6]) {
282        assert_approx(ctm.a, expected[0]);
283        assert_approx(ctm.b, expected[1]);
284        assert_approx(ctm.c, expected[2]);
285        assert_approx(ctm.d, expected[3]);
286        assert_approx(ctm.e, expected[4]);
287        assert_approx(ctm.f, expected[5]);
288    }
289
290    // --- TextRenderMode ---
291
292    #[test]
293    fn test_render_mode_from_i64_valid() {
294        assert_eq!(TextRenderMode::from_i64(0), Some(TextRenderMode::Fill));
295        assert_eq!(TextRenderMode::from_i64(1), Some(TextRenderMode::Stroke));
296        assert_eq!(
297            TextRenderMode::from_i64(2),
298            Some(TextRenderMode::FillStroke)
299        );
300        assert_eq!(TextRenderMode::from_i64(3), Some(TextRenderMode::Invisible));
301        assert_eq!(TextRenderMode::from_i64(4), Some(TextRenderMode::FillClip));
302        assert_eq!(
303            TextRenderMode::from_i64(5),
304            Some(TextRenderMode::StrokeClip)
305        );
306        assert_eq!(
307            TextRenderMode::from_i64(6),
308            Some(TextRenderMode::FillStrokeClip)
309        );
310        assert_eq!(TextRenderMode::from_i64(7), Some(TextRenderMode::Clip));
311    }
312
313    #[test]
314    fn test_render_mode_from_i64_invalid() {
315        assert_eq!(TextRenderMode::from_i64(-1), None);
316        assert_eq!(TextRenderMode::from_i64(8), None);
317        assert_eq!(TextRenderMode::from_i64(100), None);
318    }
319
320    #[test]
321    fn test_render_mode_default_is_fill() {
322        assert_eq!(TextRenderMode::default(), TextRenderMode::Fill);
323    }
324
325    // --- TextState construction and defaults ---
326
327    #[test]
328    fn test_new_defaults() {
329        let ts = TextState::new();
330        assert_eq!(ts.char_spacing, 0.0);
331        assert_eq!(ts.word_spacing, 0.0);
332        assert_eq!(ts.h_scaling, 100.0);
333        assert_eq!(ts.leading, 0.0);
334        assert_eq!(ts.font_name, "");
335        assert_eq!(ts.font_size, 0.0);
336        assert_eq!(ts.render_mode, TextRenderMode::Fill);
337        assert_eq!(ts.rise, 0.0);
338        assert!(!ts.in_text_object());
339        assert_matrix_approx(ts.text_matrix(), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
340        assert_matrix_approx(ts.line_matrix(), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
341    }
342
343    #[test]
344    fn test_default_equals_new() {
345        assert_eq!(TextState::default(), TextState::new());
346    }
347
348    #[test]
349    fn test_h_scaling_normalized() {
350        let mut ts = TextState::new();
351        assert_approx(ts.h_scaling_normalized(), 1.0);
352
353        ts.set_h_scaling(50.0);
354        assert_approx(ts.h_scaling_normalized(), 0.5);
355
356        ts.set_h_scaling(200.0);
357        assert_approx(ts.h_scaling_normalized(), 2.0);
358    }
359
360    // --- BT/ET operators ---
361
362    #[test]
363    fn test_begin_text_sets_in_text_object() {
364        let mut ts = TextState::new();
365        assert!(!ts.in_text_object());
366
367        ts.begin_text();
368        assert!(ts.in_text_object());
369    }
370
371    #[test]
372    fn test_end_text_clears_in_text_object() {
373        let mut ts = TextState::new();
374        ts.begin_text();
375        assert!(ts.in_text_object());
376
377        ts.end_text();
378        assert!(!ts.in_text_object());
379    }
380
381    #[test]
382    fn test_begin_text_resets_matrices_to_identity() {
383        let mut ts = TextState::new();
384        ts.begin_text();
385
386        // Modify text matrix via Td
387        ts.move_text_position(100.0, 200.0);
388        assert_ne!(*ts.text_matrix(), Ctm::identity());
389
390        // BT should reset both matrices
391        ts.begin_text();
392        assert_matrix_approx(ts.text_matrix(), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
393        assert_matrix_approx(ts.line_matrix(), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
394    }
395
396    // --- Tf operator ---
397
398    #[test]
399    fn test_set_font() {
400        let mut ts = TextState::new();
401        ts.set_font("Helvetica".to_string(), 12.0);
402
403        assert_eq!(ts.font_name, "Helvetica");
404        assert_eq!(ts.font_size, 12.0);
405    }
406
407    #[test]
408    fn test_set_font_changes_both_name_and_size() {
409        let mut ts = TextState::new();
410        ts.set_font("Helvetica".to_string(), 12.0);
411        ts.set_font("Times-Roman".to_string(), 14.0);
412
413        assert_eq!(ts.font_name, "Times-Roman");
414        assert_eq!(ts.font_size, 14.0);
415    }
416
417    // --- Tc operator ---
418
419    #[test]
420    fn test_set_char_spacing() {
421        let mut ts = TextState::new();
422        ts.set_char_spacing(0.5);
423        assert_eq!(ts.char_spacing, 0.5);
424    }
425
426    // --- Tw operator ---
427
428    #[test]
429    fn test_set_word_spacing() {
430        let mut ts = TextState::new();
431        ts.set_word_spacing(2.0);
432        assert_eq!(ts.word_spacing, 2.0);
433    }
434
435    // --- Tz operator ---
436
437    #[test]
438    fn test_set_h_scaling() {
439        let mut ts = TextState::new();
440        ts.set_h_scaling(150.0);
441        assert_eq!(ts.h_scaling, 150.0);
442    }
443
444    // --- TL operator ---
445
446    #[test]
447    fn test_set_leading() {
448        let mut ts = TextState::new();
449        ts.set_leading(14.0);
450        assert_eq!(ts.leading, 14.0);
451    }
452
453    // --- Tr operator ---
454
455    #[test]
456    fn test_set_render_mode() {
457        let mut ts = TextState::new();
458        ts.set_render_mode(TextRenderMode::Stroke);
459        assert_eq!(ts.render_mode, TextRenderMode::Stroke);
460    }
461
462    // --- Ts operator ---
463
464    #[test]
465    fn test_set_rise() {
466        let mut ts = TextState::new();
467        ts.set_rise(5.0);
468        assert_eq!(ts.rise, 5.0);
469    }
470
471    #[test]
472    fn test_set_rise_negative() {
473        let mut ts = TextState::new();
474        ts.set_rise(-3.0);
475        assert_eq!(ts.rise, -3.0);
476    }
477
478    // --- Tm operator ---
479
480    #[test]
481    fn test_set_text_matrix() {
482        let mut ts = TextState::new();
483        ts.begin_text();
484        ts.set_text_matrix(12.0, 0.0, 0.0, 12.0, 72.0, 720.0);
485
486        assert_matrix_approx(ts.text_matrix(), [12.0, 0.0, 0.0, 12.0, 72.0, 720.0]);
487        // Line matrix is also set to the same value
488        assert_matrix_approx(ts.line_matrix(), [12.0, 0.0, 0.0, 12.0, 72.0, 720.0]);
489    }
490
491    #[test]
492    fn test_set_text_matrix_replaces_not_concatenates() {
493        let mut ts = TextState::new();
494        ts.begin_text();
495
496        // Set first matrix
497        ts.set_text_matrix(2.0, 0.0, 0.0, 2.0, 100.0, 200.0);
498
499        // Set second matrix — should replace, not multiply
500        ts.set_text_matrix(1.0, 0.0, 0.0, 1.0, 50.0, 60.0);
501
502        assert_matrix_approx(ts.text_matrix(), [1.0, 0.0, 0.0, 1.0, 50.0, 60.0]);
503        assert_matrix_approx(ts.line_matrix(), [1.0, 0.0, 0.0, 1.0, 50.0, 60.0]);
504    }
505
506    #[test]
507    fn test_text_matrix_array() {
508        let mut ts = TextState::new();
509        ts.begin_text();
510        ts.set_text_matrix(12.0, 0.0, 0.0, 12.0, 72.0, 720.0);
511
512        assert_eq!(ts.text_matrix_array(), [12.0, 0.0, 0.0, 12.0, 72.0, 720.0]);
513    }
514
515    // --- Td operator ---
516
517    #[test]
518    fn test_move_text_position_simple() {
519        let mut ts = TextState::new();
520        ts.begin_text();
521
522        ts.move_text_position(100.0, 700.0);
523
524        // After Td, text matrix should be translated
525        assert_matrix_approx(ts.text_matrix(), [1.0, 0.0, 0.0, 1.0, 100.0, 700.0]);
526        // Line matrix should match text matrix
527        assert_matrix_approx(ts.line_matrix(), [1.0, 0.0, 0.0, 1.0, 100.0, 700.0]);
528    }
529
530    #[test]
531    fn test_move_text_position_cumulative() {
532        let mut ts = TextState::new();
533        ts.begin_text();
534
535        ts.move_text_position(100.0, 700.0);
536        ts.move_text_position(0.0, -14.0);
537
538        // Second Td adds to the line matrix (not from identity)
539        assert_matrix_approx(ts.text_matrix(), [1.0, 0.0, 0.0, 1.0, 100.0, 686.0]);
540        assert_matrix_approx(ts.line_matrix(), [1.0, 0.0, 0.0, 1.0, 100.0, 686.0]);
541    }
542
543    #[test]
544    fn test_move_text_position_after_tm() {
545        let mut ts = TextState::new();
546        ts.begin_text();
547
548        // Set text matrix with scaling
549        ts.set_text_matrix(2.0, 0.0, 0.0, 2.0, 0.0, 0.0);
550
551        // Td should translate relative to the current line matrix
552        ts.move_text_position(50.0, 100.0);
553
554        // Translation is pre-multiplied: [1 0 0 1 50 100] × [2 0 0 2 0 0]
555        // Result: [2 0 0 2 100 200]
556        assert_matrix_approx(ts.text_matrix(), [2.0, 0.0, 0.0, 2.0, 100.0, 200.0]);
557        assert_matrix_approx(ts.line_matrix(), [2.0, 0.0, 0.0, 2.0, 100.0, 200.0]);
558    }
559
560    // --- TD operator ---
561
562    #[test]
563    fn test_move_text_position_and_set_leading() {
564        let mut ts = TextState::new();
565        ts.begin_text();
566
567        // TD with ty = -14 should set leading to 14
568        ts.move_text_position_and_set_leading(0.0, -14.0);
569
570        assert_eq!(ts.leading, 14.0); // leading = -ty = 14
571        assert_matrix_approx(ts.text_matrix(), [1.0, 0.0, 0.0, 1.0, 0.0, -14.0]);
572        assert_matrix_approx(ts.line_matrix(), [1.0, 0.0, 0.0, 1.0, 0.0, -14.0]);
573    }
574
575    #[test]
576    fn test_td_sets_leading_positive_ty() {
577        let mut ts = TextState::new();
578        ts.begin_text();
579
580        // TD with ty = 10 sets leading to -10
581        ts.move_text_position_and_set_leading(5.0, 10.0);
582
583        assert_eq!(ts.leading, -10.0);
584    }
585
586    // --- T* operator ---
587
588    #[test]
589    fn test_move_to_next_line() {
590        let mut ts = TextState::new();
591        ts.begin_text();
592        ts.set_leading(14.0);
593
594        // Position at some starting point
595        ts.move_text_position(72.0, 700.0);
596
597        // T* should move by (0, -leading) = (0, -14)
598        ts.move_to_next_line();
599
600        assert_matrix_approx(ts.text_matrix(), [1.0, 0.0, 0.0, 1.0, 72.0, 686.0]);
601        assert_matrix_approx(ts.line_matrix(), [1.0, 0.0, 0.0, 1.0, 72.0, 686.0]);
602    }
603
604    #[test]
605    fn test_move_to_next_line_multiple_times() {
606        let mut ts = TextState::new();
607        ts.begin_text();
608        ts.set_leading(12.0);
609
610        ts.move_text_position(72.0, 700.0);
611        ts.move_to_next_line();
612        ts.move_to_next_line();
613        ts.move_to_next_line();
614
615        // 700 - 12*3 = 664
616        assert_matrix_approx(ts.text_matrix(), [1.0, 0.0, 0.0, 1.0, 72.0, 664.0]);
617    }
618
619    #[test]
620    fn test_move_to_next_line_zero_leading() {
621        let mut ts = TextState::new();
622        ts.begin_text();
623        // Default leading is 0
624        ts.move_text_position(72.0, 700.0);
625        ts.move_to_next_line();
626
627        // With leading=0, T* moves by (0, 0) — no change
628        assert_matrix_approx(ts.text_matrix(), [1.0, 0.0, 0.0, 1.0, 72.0, 700.0]);
629    }
630
631    // --- Text state persistence across BT/ET ---
632
633    #[test]
634    fn test_text_state_params_persist_across_bt_et() {
635        let mut ts = TextState::new();
636
637        // Set parameters before text object
638        ts.set_font("Helvetica".to_string(), 12.0);
639        ts.set_char_spacing(0.5);
640        ts.set_word_spacing(1.0);
641        ts.set_h_scaling(110.0);
642        ts.set_leading(14.0);
643        ts.set_render_mode(TextRenderMode::Stroke);
644        ts.set_rise(3.0);
645
646        // Enter and leave a text object
647        ts.begin_text();
648        ts.end_text();
649
650        // All text state parameters should persist
651        assert_eq!(ts.font_name, "Helvetica");
652        assert_eq!(ts.font_size, 12.0);
653        assert_eq!(ts.char_spacing, 0.5);
654        assert_eq!(ts.word_spacing, 1.0);
655        assert_eq!(ts.h_scaling, 110.0);
656        assert_eq!(ts.leading, 14.0);
657        assert_eq!(ts.render_mode, TextRenderMode::Stroke);
658        assert_eq!(ts.rise, 3.0);
659    }
660
661    #[test]
662    fn test_bt_resets_matrices_not_params() {
663        let mut ts = TextState::new();
664        ts.set_font("Helvetica".to_string(), 12.0);
665        ts.set_leading(14.0);
666
667        ts.begin_text();
668        ts.set_text_matrix(12.0, 0.0, 0.0, 12.0, 72.0, 720.0);
669        ts.end_text();
670
671        // Start new text object - matrices should reset, but params stay
672        ts.begin_text();
673        assert_matrix_approx(ts.text_matrix(), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
674        assert_matrix_approx(ts.line_matrix(), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
675        assert_eq!(ts.font_name, "Helvetica");
676        assert_eq!(ts.font_size, 12.0);
677        assert_eq!(ts.leading, 14.0);
678    }
679
680    // --- advance_text_position ---
681
682    #[test]
683    fn test_advance_text_position() {
684        let mut ts = TextState::new();
685        ts.begin_text();
686        ts.move_text_position(72.0, 700.0);
687
688        // Advance by 10 units horizontally
689        ts.advance_text_position(10.0);
690
691        // Text matrix should advance horizontally but line matrix stays
692        assert_matrix_approx(ts.text_matrix(), [1.0, 0.0, 0.0, 1.0, 82.0, 700.0]);
693        assert_matrix_approx(ts.line_matrix(), [1.0, 0.0, 0.0, 1.0, 72.0, 700.0]);
694    }
695
696    #[test]
697    fn test_advance_text_position_does_not_change_line_matrix() {
698        let mut ts = TextState::new();
699        ts.begin_text();
700        ts.move_text_position(72.0, 700.0);
701
702        let line_matrix_before = *ts.line_matrix();
703        ts.advance_text_position(50.0);
704
705        assert_eq!(*ts.line_matrix(), line_matrix_before);
706    }
707
708    #[test]
709    fn test_advance_text_position_cumulative() {
710        let mut ts = TextState::new();
711        ts.begin_text();
712        ts.move_text_position(72.0, 700.0);
713
714        ts.advance_text_position(10.0);
715        ts.advance_text_position(5.0);
716        ts.advance_text_position(8.0);
717
718        assert_matrix_approx(ts.text_matrix(), [1.0, 0.0, 0.0, 1.0, 95.0, 700.0]);
719    }
720
721    #[test]
722    fn test_advance_text_position_with_scaled_matrix() {
723        let mut ts = TextState::new();
724        ts.begin_text();
725        // Text matrix with font size scaling
726        ts.set_text_matrix(12.0, 0.0, 0.0, 12.0, 72.0, 700.0);
727
728        // Advance by 10 units in text space
729        // Translation [1 0 0 1 10 0] × [12 0 0 12 72 700]
730        // e' = 1*72 + 0*700 + 10*1 ... wait, let's compute:
731        // Actually: pre-multiply [1 0 0 1 10 0] × [12 0 0 12 72 700]
732        // new_e = e_trans * a_tm + f_trans * c_tm + e_tm = 10 * 12 + 0 * 0 + 72 = 192
733        // new_f = e_trans * b_tm + f_trans * d_tm + f_tm = 10 * 0 + 0 * 12 + 700 = 700
734        ts.advance_text_position(10.0);
735
736        assert_matrix_approx(ts.text_matrix(), [12.0, 0.0, 0.0, 12.0, 192.0, 700.0]);
737    }
738
739    // --- Realistic sequence ---
740
741    #[test]
742    fn test_realistic_text_rendering_sequence() {
743        let mut ts = TextState::new();
744
745        // Set up font and leading (before BT)
746        ts.set_font("Helvetica".to_string(), 12.0);
747        ts.set_leading(14.0);
748
749        // BT
750        ts.begin_text();
751        assert!(ts.in_text_object());
752
753        // 72 700 Td — position at top of page
754        ts.move_text_position(72.0, 700.0);
755        assert_matrix_approx(ts.text_matrix(), [1.0, 0.0, 0.0, 1.0, 72.0, 700.0]);
756
757        // Simulate rendering "Hello" — advance text matrix
758        ts.advance_text_position(30.0); // approximate width
759        assert_matrix_approx(ts.text_matrix(), [1.0, 0.0, 0.0, 1.0, 102.0, 700.0]);
760
761        // T* — next line
762        ts.move_to_next_line();
763        assert_matrix_approx(ts.text_matrix(), [1.0, 0.0, 0.0, 1.0, 72.0, 686.0]);
764        // Line matrix reset to start of new line
765        assert_matrix_approx(ts.line_matrix(), [1.0, 0.0, 0.0, 1.0, 72.0, 686.0]);
766
767        // Simulate rendering "World"
768        ts.advance_text_position(32.0);
769        assert_matrix_approx(ts.text_matrix(), [1.0, 0.0, 0.0, 1.0, 104.0, 686.0]);
770
771        // ET
772        ts.end_text();
773        assert!(!ts.in_text_object());
774    }
775
776    #[test]
777    fn test_td_td_sequence_with_tm() {
778        let mut ts = TextState::new();
779        ts.begin_text();
780
781        // Tm sets absolute position with scaling
782        ts.set_text_matrix(10.0, 0.0, 0.0, 10.0, 100.0, 500.0);
783
784        // Td moves relative to current line matrix
785        ts.move_text_position(5.0, -12.0);
786        // [1 0 0 1 5 -12] × [10 0 0 10 100 500]
787        // e' = 5*10 + (-12)*0 + 100 = 150
788        // f' = 5*0 + (-12)*10 + 500 = 380
789        assert_matrix_approx(ts.text_matrix(), [10.0, 0.0, 0.0, 10.0, 150.0, 380.0]);
790    }
791}