Skip to main content

oxidize_pdf/text/
flow.rs

1use crate::error::Result;
2use crate::graphics::Color;
3use crate::page::Margins;
4use crate::text::{measure_text, split_into_words, Font};
5use std::collections::{HashMap, HashSet};
6use std::fmt::Write;
7
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub enum TextAlign {
10    Left,
11    Right,
12    Center,
13    Justified,
14}
15
16pub struct TextFlowContext {
17    operations: String,
18    current_font: Font,
19    font_size: f64,
20    line_height: f64,
21    cursor_x: f64,
22    cursor_y: f64,
23    alignment: TextAlign,
24    page_width: f64,
25    #[allow(dead_code)]
26    page_height: f64,
27    margins: Margins,
28    /// Optional fill color for text glyphs (issue #216). When `Some`,
29    /// `write_wrapped` emits the corresponding non-stroking color
30    /// operator (`rg`/`g`/`k`) inside each `BT … ET` block before the
31    /// `Tj`. `None` keeps the previous behaviour (whatever fill colour
32    /// the surrounding graphics state is already carrying).
33    fill_color: Option<Color>,
34    /// Characters drawn so far, bucketed by active font name (issue
35    /// #204). Consumed by `Page::add_text_flow` to merge into the
36    /// page's graphics-context tracking so the writer can subset each
37    /// custom font with only its own characters.
38    used_characters_by_font: HashMap<String, HashSet<char>>,
39}
40
41impl TextFlowContext {
42    pub fn new(page_width: f64, page_height: f64, margins: Margins) -> Self {
43        Self {
44            operations: String::new(),
45            current_font: Font::Helvetica,
46            font_size: 12.0,
47            line_height: 1.2,
48            cursor_x: margins.left,
49            cursor_y: page_height - margins.top,
50            alignment: TextAlign::Left,
51            page_width,
52            page_height,
53            margins,
54            fill_color: None,
55            used_characters_by_font: HashMap::new(),
56        }
57    }
58
59    /// Get the per-font character usage accumulated by `write_wrapped`
60    /// (issue #204). `Page::add_text_flow` merges this into the page's
61    /// graphics context so the writer knows which custom fonts were
62    /// referenced and what characters each drew.
63    pub(crate) fn get_used_characters_by_font(&self) -> &HashMap<String, HashSet<char>> {
64        &self.used_characters_by_font
65    }
66
67    pub fn set_font(&mut self, font: Font, size: f64) -> &mut Self {
68        self.current_font = font;
69        self.font_size = size;
70        self
71    }
72
73    pub fn set_line_height(&mut self, multiplier: f64) -> &mut Self {
74        self.line_height = multiplier;
75        self
76    }
77
78    pub fn set_alignment(&mut self, alignment: TextAlign) -> &mut Self {
79        self.alignment = alignment;
80        self
81    }
82
83    /// Sets the non-stroking (fill) color used for subsequent text emitted
84    /// by `write_wrapped` (issue #216). Mirrors `TextContext::set_fill_color`.
85    /// `None` keeps the surrounding graphics state untouched (previous
86    /// behaviour); `Some(color)` emits the matching PDF operator inside each
87    /// `BT … ET` block.
88    pub fn set_fill_color(&mut self, color: Color) -> &mut Self {
89        self.fill_color = Some(color);
90        self
91    }
92
93    /// Current font this context will use when emitting text.
94    pub fn current_font(&self) -> &Font {
95        &self.current_font
96    }
97
98    /// Current font size in points.
99    pub fn font_size(&self) -> f64 {
100        self.font_size
101    }
102
103    /// Current fill color, if one has been explicitly set (issue #216).
104    pub fn fill_color(&self) -> Option<Color> {
105        self.fill_color
106    }
107
108    pub fn at(&mut self, x: f64, y: f64) -> &mut Self {
109        self.cursor_x = x;
110        self.cursor_y = y;
111        self
112    }
113
114    pub fn content_width(&self) -> f64 {
115        self.page_width - self.margins.left - self.margins.right
116    }
117
118    /// Returns the width available for text starting at the current cursor_x position.
119    ///
120    /// Unlike `content_width()` which always uses `margins.left` as the origin,
121    /// `available_width()` accounts for the actual cursor position so that text
122    /// placed via `.at(x, y)` does not overflow the right margin.
123    pub fn available_width(&self) -> f64 {
124        (self.page_width - self.margins.right - self.cursor_x).max(0.0)
125    }
126
127    pub fn write_wrapped(&mut self, text: &str) -> Result<&mut Self> {
128        let start_x = self.cursor_x;
129        let available_width = self.available_width();
130
131        // Split text into words
132        let words = split_into_words(text);
133        let mut lines: Vec<Vec<&str>> = Vec::new();
134        let mut current_line: Vec<&str> = Vec::new();
135        let mut current_width = 0.0;
136
137        // Build lines based on available width (respects cursor_x offset)
138        for word in words {
139            let word_width = measure_text(word, &self.current_font, self.font_size);
140
141            // Check if we need to start a new line
142            if !current_line.is_empty() && current_width + word_width > available_width {
143                lines.push(current_line);
144                current_line = vec![word];
145                current_width = word_width;
146            } else {
147                current_line.push(word);
148                current_width += word_width;
149            }
150        }
151
152        if !current_line.is_empty() {
153            lines.push(current_line);
154        }
155
156        // Render each line
157        for (i, line) in lines.iter().enumerate() {
158            let line_text = line.join("");
159            let line_width = measure_text(&line_text, &self.current_font, self.font_size);
160
161            // Calculate x position based on alignment.
162            // start_x is the column where this block of text begins (set via .at()).
163            // Left/Justified start at start_x; Center is relative to start_x;
164            // Right stays anchored to the right margin.
165            let x = match self.alignment {
166                TextAlign::Left => start_x,
167                TextAlign::Right => self.page_width - self.margins.right - line_width,
168                TextAlign::Center => start_x + (available_width - line_width) / 2.0,
169                TextAlign::Justified => start_x,
170            };
171
172            // Begin text object
173            self.operations.push_str("BT\n");
174
175            // Set font
176            writeln!(
177                &mut self.operations,
178                "/{} {} Tf",
179                self.current_font.pdf_name(),
180                self.font_size
181            )
182            .expect("Writing to String should never fail");
183
184            // Apply non-stroking fill colour if one was inherited from the
185            // page-level text state (issue #216) or explicitly configured
186            // via `set_fill_color`. PDF spec ISO 32000-1 §8.6.8 allows
187            // colour-setting operators inside a text object; they take
188            // effect for the show-text operators that follow.
189            //
190            // Routed through the shared NaN-sanitising helper (issues
191            // #220 + #221) so this site cannot diverge from `TextContext`
192            // / `GraphicsContext`.
193            if let Some(color) = self.fill_color {
194                crate::graphics::color::write_fill_color(&mut self.operations, color);
195            }
196
197            // Set text position
198            writeln!(&mut self.operations, "{:.2} {:.2} Td", x, self.cursor_y)
199                .expect("Writing to String should never fail");
200
201            // Handle justification
202            if self.alignment == TextAlign::Justified && i < lines.len() - 1 && line.len() > 1 {
203                // Calculate extra space to distribute
204                let spaces_count = line.iter().filter(|w| w.trim().is_empty()).count();
205                if spaces_count > 0 {
206                    let extra_space = available_width - line_width;
207                    let space_adjustment = extra_space / spaces_count as f64;
208
209                    // Set word spacing
210                    writeln!(&mut self.operations, "{space_adjustment:.2} Tw")
211                        .expect("Writing to String should never fail");
212                }
213            }
214
215            // Show text
216            self.operations.push('(');
217            for ch in line_text.chars() {
218                match ch {
219                    '(' => self.operations.push_str("\\("),
220                    ')' => self.operations.push_str("\\)"),
221                    '\\' => self.operations.push_str("\\\\"),
222                    '\n' => self.operations.push_str("\\n"),
223                    '\r' => self.operations.push_str("\\r"),
224                    '\t' => self.operations.push_str("\\t"),
225                    _ => self.operations.push(ch),
226                }
227            }
228            self.operations.push_str(") Tj\n");
229
230            // Record per-font char usage so the consuming page can
231            // report it to the writer (issue #204). Bucketed under the
232            // current font's PDF name so both custom and builtin fonts
233            // are visible — the writer filters to registered custom
234            // fonts when subsetting.
235            self.used_characters_by_font
236                .entry(self.current_font.pdf_name())
237                .or_default()
238                .extend(line_text.chars());
239
240            // Reset word spacing if it was set
241            if self.alignment == TextAlign::Justified && i < lines.len() - 1 {
242                self.operations.push_str("0 Tw\n");
243            }
244
245            // End text object
246            self.operations.push_str("ET\n");
247
248            // Move cursor down for next line
249            self.cursor_y -= self.font_size * self.line_height;
250        }
251
252        Ok(self)
253    }
254
255    pub fn write_paragraph(&mut self, text: &str) -> Result<&mut Self> {
256        self.write_wrapped(text)?;
257        // Add extra space after paragraph
258        self.cursor_y -= self.font_size * self.line_height * 0.5;
259        Ok(self)
260    }
261
262    pub fn newline(&mut self) -> &mut Self {
263        self.cursor_y -= self.font_size * self.line_height;
264        self.cursor_x = self.margins.left;
265        self
266    }
267
268    pub fn cursor_position(&self) -> (f64, f64) {
269        (self.cursor_x, self.cursor_y)
270    }
271
272    pub fn generate_operations(&self) -> Vec<u8> {
273        self.operations.as_bytes().to_vec()
274    }
275
276    /// Get the current alignment
277    pub fn alignment(&self) -> TextAlign {
278        self.alignment
279    }
280
281    /// Get the page dimensions
282    pub fn page_dimensions(&self) -> (f64, f64) {
283        (self.page_width, self.page_height)
284    }
285
286    /// Get the margins
287    pub fn margins(&self) -> &Margins {
288        &self.margins
289    }
290
291    /// Get current line height multiplier
292    pub fn line_height(&self) -> f64 {
293        self.line_height
294    }
295
296    /// Get the operations string
297    pub fn operations(&self) -> &str {
298        &self.operations
299    }
300
301    /// Clear all operations
302    pub fn clear(&mut self) {
303        self.operations.clear();
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use crate::page::Margins;
311
312    fn create_test_margins() -> Margins {
313        Margins {
314            left: 50.0,
315            right: 50.0,
316            top: 50.0,
317            bottom: 50.0,
318        }
319    }
320
321    #[test]
322    fn test_text_flow_context_new() {
323        let margins = create_test_margins();
324        let context = TextFlowContext::new(400.0, 600.0, margins);
325
326        assert_eq!(context.current_font, Font::Helvetica);
327        assert_eq!(context.font_size, 12.0);
328        assert_eq!(context.line_height, 1.2);
329        assert_eq!(context.alignment, TextAlign::Left);
330        assert_eq!(context.page_width, 400.0);
331        assert_eq!(context.page_height, 600.0);
332        assert_eq!(context.cursor_x, 50.0); // margins.left
333        assert_eq!(context.cursor_y, 550.0); // page_height - margins.top
334    }
335
336    #[test]
337    fn test_set_font() {
338        let margins = create_test_margins();
339        let mut context = TextFlowContext::new(400.0, 600.0, margins);
340
341        context.set_font(Font::TimesBold, 16.0);
342        assert_eq!(context.current_font, Font::TimesBold);
343        assert_eq!(context.font_size, 16.0);
344    }
345
346    #[test]
347    fn test_set_line_height() {
348        let margins = create_test_margins();
349        let mut context = TextFlowContext::new(400.0, 600.0, margins);
350
351        context.set_line_height(1.5);
352        assert_eq!(context.line_height(), 1.5);
353    }
354
355    #[test]
356    fn test_set_alignment() {
357        let margins = create_test_margins();
358        let mut context = TextFlowContext::new(400.0, 600.0, margins);
359
360        context.set_alignment(TextAlign::Center);
361        assert_eq!(context.alignment(), TextAlign::Center);
362    }
363
364    #[test]
365    fn test_at_position() {
366        let margins = create_test_margins();
367        let mut context = TextFlowContext::new(400.0, 600.0, margins);
368
369        context.at(100.0, 200.0);
370        let (x, y) = context.cursor_position();
371        assert_eq!(x, 100.0);
372        assert_eq!(y, 200.0);
373    }
374
375    #[test]
376    fn test_content_width() {
377        let margins = create_test_margins();
378        let context = TextFlowContext::new(400.0, 600.0, margins);
379
380        let content_width = context.content_width();
381        assert_eq!(content_width, 300.0); // 400 - 50 - 50
382    }
383
384    #[test]
385    fn test_text_align_variants() {
386        assert_eq!(TextAlign::Left, TextAlign::Left);
387        assert_eq!(TextAlign::Right, TextAlign::Right);
388        assert_eq!(TextAlign::Center, TextAlign::Center);
389        assert_eq!(TextAlign::Justified, TextAlign::Justified);
390
391        assert_ne!(TextAlign::Left, TextAlign::Right);
392    }
393
394    #[test]
395    fn test_write_wrapped_simple() {
396        let margins = create_test_margins();
397        let mut context = TextFlowContext::new(400.0, 600.0, margins);
398
399        context.write_wrapped("Hello World").unwrap();
400
401        let ops = context.operations();
402        assert!(ops.contains("BT\n"));
403        assert!(ops.contains("ET\n"));
404        assert!(ops.contains("/Helvetica 12 Tf"));
405        assert!(ops.contains("(Hello World) Tj"));
406    }
407
408    #[test]
409    fn test_write_paragraph() {
410        let margins = create_test_margins();
411        let mut context = TextFlowContext::new(400.0, 600.0, margins);
412
413        let initial_y = context.cursor_y;
414        context.write_paragraph("Test paragraph").unwrap();
415
416        // Y position should have moved down more than just line height
417        assert!(context.cursor_y < initial_y);
418    }
419
420    #[test]
421    fn test_newline() {
422        let margins = create_test_margins();
423        let mut context = TextFlowContext::new(400.0, 600.0, margins.clone());
424
425        let initial_y = context.cursor_y;
426        context.newline();
427
428        assert_eq!(context.cursor_x, margins.left);
429        assert!(context.cursor_y < initial_y);
430        assert_eq!(
431            context.cursor_y,
432            initial_y - context.font_size * context.line_height
433        );
434    }
435
436    #[test]
437    fn test_cursor_position() {
438        let margins = create_test_margins();
439        let mut context = TextFlowContext::new(400.0, 600.0, margins);
440
441        context.at(75.0, 125.0);
442        let (x, y) = context.cursor_position();
443        assert_eq!(x, 75.0);
444        assert_eq!(y, 125.0);
445    }
446
447    #[test]
448    fn test_generate_operations() {
449        let margins = create_test_margins();
450        let mut context = TextFlowContext::new(400.0, 600.0, margins);
451
452        context.write_wrapped("Test").unwrap();
453        let ops_bytes = context.generate_operations();
454        let ops_string = String::from_utf8(ops_bytes).unwrap();
455
456        assert_eq!(ops_string, context.operations());
457    }
458
459    #[test]
460    fn test_clear_operations() {
461        let margins = create_test_margins();
462        let mut context = TextFlowContext::new(400.0, 600.0, margins);
463
464        context.write_wrapped("Test").unwrap();
465        assert!(!context.operations().is_empty());
466
467        context.clear();
468        assert!(context.operations().is_empty());
469    }
470
471    #[test]
472    fn test_page_dimensions() {
473        let margins = create_test_margins();
474        let context = TextFlowContext::new(400.0, 600.0, margins);
475
476        let (width, height) = context.page_dimensions();
477        assert_eq!(width, 400.0);
478        assert_eq!(height, 600.0);
479    }
480
481    #[test]
482    fn test_margins_access() {
483        let margins = create_test_margins();
484        let context = TextFlowContext::new(400.0, 600.0, margins);
485
486        let ctx_margins = context.margins();
487        assert_eq!(ctx_margins.left, 50.0);
488        assert_eq!(ctx_margins.right, 50.0);
489        assert_eq!(ctx_margins.top, 50.0);
490        assert_eq!(ctx_margins.bottom, 50.0);
491    }
492
493    #[test]
494    fn test_method_chaining() {
495        let margins = create_test_margins();
496        let mut context = TextFlowContext::new(400.0, 600.0, margins);
497
498        context
499            .set_font(Font::Courier, 10.0)
500            .set_line_height(1.5)
501            .set_alignment(TextAlign::Center)
502            .at(100.0, 200.0);
503
504        assert_eq!(context.current_font, Font::Courier);
505        assert_eq!(context.font_size, 10.0);
506        assert_eq!(context.line_height(), 1.5);
507        assert_eq!(context.alignment(), TextAlign::Center);
508        let (x, y) = context.cursor_position();
509        assert_eq!(x, 100.0);
510        assert_eq!(y, 200.0);
511    }
512
513    #[test]
514    fn test_text_align_debug() {
515        let align = TextAlign::Center;
516        let debug_str = format!("{align:?}");
517        assert_eq!(debug_str, "Center");
518    }
519
520    #[test]
521    fn test_text_align_clone() {
522        let align1 = TextAlign::Justified;
523        let align2 = align1;
524        assert_eq!(align1, align2);
525    }
526
527    #[test]
528    fn test_text_align_copy() {
529        let align1 = TextAlign::Right;
530        let align2 = align1; // Copy semantics
531        assert_eq!(align1, align2);
532
533        // Both variables should still be usable
534        assert_eq!(align1, TextAlign::Right);
535        assert_eq!(align2, TextAlign::Right);
536    }
537
538    #[test]
539    fn test_write_wrapped_with_alignment_right() {
540        let margins = create_test_margins();
541        let mut context = TextFlowContext::new(400.0, 600.0, margins);
542
543        context.set_alignment(TextAlign::Right);
544        context.write_wrapped("Right aligned text").unwrap();
545
546        let ops = context.operations();
547        assert!(ops.contains("BT\n"));
548        assert!(ops.contains("ET\n"));
549        // Right alignment should position text differently
550        assert!(ops.contains("Td"));
551    }
552
553    #[test]
554    fn test_write_wrapped_with_alignment_center() {
555        let margins = create_test_margins();
556        let mut context = TextFlowContext::new(400.0, 600.0, margins);
557
558        context.set_alignment(TextAlign::Center);
559        context.write_wrapped("Centered text").unwrap();
560
561        let ops = context.operations();
562        assert!(ops.contains("BT\n"));
563        assert!(ops.contains("(Centered text) Tj"));
564    }
565
566    #[test]
567    fn test_write_wrapped_with_alignment_justified() {
568        let margins = create_test_margins();
569        let mut context = TextFlowContext::new(400.0, 600.0, margins);
570
571        context.set_alignment(TextAlign::Justified);
572        // Long text that will wrap and justify
573        context.write_wrapped("This is a longer text that should wrap across multiple lines to test justification").unwrap();
574
575        let ops = context.operations();
576        assert!(ops.contains("BT\n"));
577        // Justified text may have word spacing adjustments
578        assert!(ops.contains("Tw") || ops.contains("0 Tw"));
579    }
580
581    #[test]
582    fn test_write_wrapped_empty_text() {
583        let margins = create_test_margins();
584        let mut context = TextFlowContext::new(400.0, 600.0, margins);
585
586        context.write_wrapped("").unwrap();
587
588        // Empty text should not generate operations
589        assert!(context.operations().is_empty());
590    }
591
592    #[test]
593    fn test_write_wrapped_whitespace_only() {
594        let margins = create_test_margins();
595        let mut context = TextFlowContext::new(400.0, 600.0, margins);
596
597        context.write_wrapped("   ").unwrap();
598
599        let ops = context.operations();
600        // Should handle whitespace-only text
601        assert!(ops.contains("BT\n") || ops.is_empty());
602    }
603
604    #[test]
605    fn test_write_wrapped_special_characters() {
606        let margins = create_test_margins();
607        let mut context = TextFlowContext::new(400.0, 600.0, margins);
608
609        context
610            .write_wrapped("Text with (parentheses) and \\backslash\\")
611            .unwrap();
612
613        let ops = context.operations();
614        // Special characters should be escaped
615        assert!(ops.contains("\\(parentheses\\)"));
616        assert!(ops.contains("\\\\backslash\\\\"));
617    }
618
619    #[test]
620    fn test_write_wrapped_newlines_tabs() {
621        let margins = create_test_margins();
622        let mut context = TextFlowContext::new(400.0, 600.0, margins);
623
624        context.write_wrapped("Line1\nLine2\tTabbed").unwrap();
625
626        let ops = context.operations();
627        // Newlines and tabs should be escaped
628        assert!(ops.contains("\\n") || ops.contains("\\t"));
629    }
630
631    #[test]
632    fn test_write_wrapped_very_long_word() {
633        let margins = create_test_margins();
634        let mut context = TextFlowContext::new(200.0, 600.0, margins); // Narrow page
635
636        let long_word = "a".repeat(100);
637        context.write_wrapped(&long_word).unwrap();
638
639        let ops = context.operations();
640        assert!(ops.contains("BT\n"));
641        assert!(ops.contains(&long_word));
642    }
643
644    #[test]
645    fn test_write_wrapped_cursor_movement() {
646        let margins = create_test_margins();
647        let mut context = TextFlowContext::new(400.0, 600.0, margins);
648
649        let initial_y = context.cursor_y;
650
651        context.write_wrapped("Line 1").unwrap();
652        let y_after_line1 = context.cursor_y;
653
654        context.write_wrapped("Line 2").unwrap();
655        let y_after_line2 = context.cursor_y;
656
657        // Cursor should move down after each line
658        assert!(y_after_line1 < initial_y);
659        assert!(y_after_line2 < y_after_line1);
660    }
661
662    #[test]
663    fn test_write_paragraph_spacing() {
664        let margins = create_test_margins();
665        let mut context = TextFlowContext::new(400.0, 600.0, margins);
666
667        let initial_y = context.cursor_y;
668        context.write_paragraph("Paragraph 1").unwrap();
669        let y_after_p1 = context.cursor_y;
670
671        context.write_paragraph("Paragraph 2").unwrap();
672        let y_after_p2 = context.cursor_y;
673
674        // Paragraphs should have extra spacing
675        let spacing1 = initial_y - y_after_p1;
676        let spacing2 = y_after_p1 - y_after_p2;
677
678        assert!(spacing1 > 0.0);
679        assert!(spacing2 > 0.0);
680    }
681
682    #[test]
683    fn test_multiple_newlines() {
684        let margins = create_test_margins();
685        let mut context = TextFlowContext::new(400.0, 600.0, margins);
686
687        let initial_y = context.cursor_y;
688
689        context.newline();
690        let y1 = context.cursor_y;
691
692        context.newline();
693        let y2 = context.cursor_y;
694
695        context.newline();
696        let y3 = context.cursor_y;
697
698        // Each newline should move cursor down by same amount
699        let spacing1 = initial_y - y1;
700        let spacing2 = y1 - y2;
701        let spacing3 = y2 - y3;
702
703        // Use approximate equality for floating point comparisons
704        assert!((spacing1 - spacing2).abs() < 1e-10);
705        assert!((spacing2 - spacing3).abs() < 1e-10);
706        assert!((spacing1 - context.font_size * context.line_height).abs() < 1e-10);
707    }
708
709    #[test]
710    fn test_content_width_different_margins() {
711        let margins = Margins {
712            left: 30.0,
713            right: 70.0,
714            top: 40.0,
715            bottom: 60.0,
716        };
717        let context = TextFlowContext::new(500.0, 700.0, margins);
718
719        let content_width = context.content_width();
720        assert_eq!(content_width, 400.0); // 500 - 30 - 70
721    }
722
723    #[test]
724    fn test_custom_line_height() {
725        let margins = create_test_margins();
726        let mut context = TextFlowContext::new(400.0, 600.0, margins);
727
728        context.set_line_height(2.0);
729
730        let initial_y = context.cursor_y;
731        context.newline();
732        let y_after = context.cursor_y;
733
734        let spacing = initial_y - y_after;
735        assert_eq!(spacing, context.font_size * 2.0); // line_height = 2.0
736    }
737
738    #[test]
739    fn test_different_fonts() {
740        let margins = create_test_margins();
741        let mut context = TextFlowContext::new(400.0, 600.0, margins);
742
743        let fonts = vec![
744            Font::Helvetica,
745            Font::HelveticaBold,
746            Font::TimesRoman,
747            Font::TimesBold,
748            Font::Courier,
749            Font::CourierBold,
750        ];
751
752        for font in fonts {
753            context.clear();
754            let font_name = font.pdf_name();
755            context.set_font(font, 14.0);
756            context.write_wrapped("Test text").unwrap();
757
758            let ops = context.operations();
759            assert!(ops.contains(&format!("/{font_name} 14 Tf")));
760        }
761    }
762
763    #[test]
764    fn test_font_size_variations() {
765        let margins = create_test_margins();
766        let mut context = TextFlowContext::new(400.0, 600.0, margins);
767
768        let sizes = vec![8.0, 10.0, 12.0, 14.0, 16.0, 24.0, 36.0];
769
770        for size in sizes {
771            context.clear();
772            context.set_font(Font::Helvetica, size);
773            context.write_wrapped("Test").unwrap();
774
775            let ops = context.operations();
776            assert!(ops.contains(&format!("/Helvetica {size} Tf")));
777        }
778    }
779
780    #[test]
781    fn test_at_position_edge_cases() {
782        let margins = create_test_margins();
783        let mut context = TextFlowContext::new(400.0, 600.0, margins);
784
785        // Test zero position
786        context.at(0.0, 0.0);
787        assert_eq!(context.cursor_position(), (0.0, 0.0));
788
789        // Test negative position
790        context.at(-10.0, -20.0);
791        assert_eq!(context.cursor_position(), (-10.0, -20.0));
792
793        // Test large position
794        context.at(10000.0, 20000.0);
795        assert_eq!(context.cursor_position(), (10000.0, 20000.0));
796    }
797
798    #[test]
799    fn test_write_wrapped_with_narrow_content() {
800        let margins = Margins {
801            left: 190.0,
802            right: 190.0,
803            top: 50.0,
804            bottom: 50.0,
805        };
806        let mut context = TextFlowContext::new(400.0, 600.0, margins);
807
808        // Content width is only 20.0 units
809        context
810            .write_wrapped("This text should wrap a lot")
811            .unwrap();
812
813        let ops = context.operations();
814        // Should have multiple text objects for wrapped lines
815        let bt_count = ops.matches("BT\n").count();
816        assert!(bt_count > 1);
817    }
818
819    #[test]
820    fn test_justified_text_single_word_line() {
821        let margins = create_test_margins();
822        let mut context = TextFlowContext::new(400.0, 600.0, margins);
823
824        context.set_alignment(TextAlign::Justified);
825        context.write_wrapped("SingleWord").unwrap();
826
827        let ops = context.operations();
828        // Single word lines should not have word spacing
829        assert!(!ops.contains(" Tw") || ops.contains("0 Tw"));
830    }
831
832    #[test]
833    fn test_justified_text_last_line() {
834        let margins = create_test_margins();
835        let mut context = TextFlowContext::new(400.0, 600.0, margins);
836
837        context.set_alignment(TextAlign::Justified);
838        // Text that will create multiple lines
839        context.write_wrapped("This is a test of justified text alignment where the last line should not be justified").unwrap();
840
841        let ops = context.operations();
842        // Should reset word spacing (0 Tw) for last line
843        assert!(ops.contains("0 Tw"));
844    }
845
846    #[test]
847    fn test_generate_operations_encoding() {
848        let margins = create_test_margins();
849        let mut context = TextFlowContext::new(400.0, 600.0, margins);
850
851        context.write_wrapped("UTF-8 Text: Ñ").unwrap();
852
853        let ops_bytes = context.generate_operations();
854        let ops_string = String::from_utf8(ops_bytes.clone()).unwrap();
855
856        assert_eq!(ops_bytes, context.operations().as_bytes());
857        assert_eq!(ops_string, context.operations());
858    }
859
860    #[test]
861    fn test_clear_resets_operations_only() {
862        let margins = create_test_margins();
863        let mut context = TextFlowContext::new(400.0, 600.0, margins);
864
865        context.set_font(Font::TimesBold, 18.0);
866        context.set_alignment(TextAlign::Right);
867        context.at(100.0, 200.0);
868        context.write_wrapped("Text").unwrap();
869
870        context.clear();
871
872        // Operations should be cleared
873        assert!(context.operations().is_empty());
874
875        // But other settings should remain
876        assert_eq!(context.current_font, Font::TimesBold);
877        assert_eq!(context.font_size, 18.0);
878        assert_eq!(context.alignment(), TextAlign::Right);
879        // Cursor position should reflect where we are after writing text (moved down by line height)
880        let (x, y) = context.cursor_position();
881        assert_eq!(x, 100.0); // X position should be unchanged
882        assert!(y < 200.0); // Y position should have moved down after writing text
883    }
884
885    #[test]
886    fn test_long_text_wrapping() {
887        let margins = create_test_margins();
888        let mut context = TextFlowContext::new(400.0, 600.0, margins);
889
890        let long_text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. \
891                        Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. \
892                        Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.";
893
894        context.write_wrapped(long_text).unwrap();
895
896        let ops = context.operations();
897        // Should have multiple lines
898        let tj_count = ops.matches(") Tj").count();
899        assert!(tj_count > 1);
900    }
901
902    #[test]
903    fn test_empty_operations_initially() {
904        let margins = create_test_margins();
905        let context = TextFlowContext::new(400.0, 600.0, margins);
906
907        assert!(context.operations().is_empty());
908        assert_eq!(context.generate_operations().len(), 0);
909    }
910
911    #[test]
912    fn test_write_paragraph_empty() {
913        let margins = create_test_margins();
914        let mut context = TextFlowContext::new(400.0, 600.0, margins);
915
916        let initial_y = context.cursor_y;
917        context.write_paragraph("").unwrap();
918
919        // Empty paragraph should still add spacing
920        assert!(context.cursor_y < initial_y);
921    }
922
923    #[test]
924    fn test_extreme_line_height() {
925        let margins = create_test_margins();
926        let mut context = TextFlowContext::new(400.0, 600.0, margins);
927
928        // Very small line height
929        context.set_line_height(0.1);
930        let initial_y = context.cursor_y;
931        context.newline();
932        assert_eq!(context.cursor_y, initial_y - context.font_size * 0.1);
933
934        // Very large line height
935        context.set_line_height(10.0);
936        let initial_y2 = context.cursor_y;
937        context.newline();
938        assert_eq!(context.cursor_y, initial_y2 - context.font_size * 10.0);
939    }
940
941    #[test]
942    fn test_zero_content_width() {
943        let margins = Margins {
944            left: 200.0,
945            right: 200.0,
946            top: 50.0,
947            bottom: 50.0,
948        };
949        let context = TextFlowContext::new(400.0, 600.0, margins);
950
951        assert_eq!(context.content_width(), 0.0);
952    }
953
954    #[test]
955    fn test_cursor_x_reset_on_newline() {
956        let margins = create_test_margins();
957        let mut context = TextFlowContext::new(400.0, 600.0, margins.clone());
958
959        context.at(250.0, 300.0); // Move cursor to custom position
960        context.newline();
961
962        // X should reset to left margin
963        assert_eq!(context.cursor_x, margins.left);
964        // Y should decrease by line height
965        assert_eq!(
966            context.cursor_y,
967            300.0 - context.font_size * context.line_height
968        );
969    }
970
971    // --- Issue #167: available_width respects cursor_x ---
972
973    #[test]
974    fn test_available_width_respects_cursor_x() {
975        // Page: 400pt wide, 50pt margins each side → content_width = 300pt
976        let margins = create_test_margins(); // left=50, right=50
977        let mut context = TextFlowContext::new(400.0, 600.0, margins);
978
979        // Default: cursor_x == margins.left == 50, available_width == 300
980        assert_eq!(context.available_width(), 300.0);
981
982        // After .at(200, 500): cursor_x = 200, available_width = 400 - 50 - 200 = 150
983        context.at(200.0, 500.0);
984        assert_eq!(context.available_width(), 150.0);
985    }
986
987    #[test]
988    fn test_available_width_clamps_to_zero() {
989        // cursor_x past the right margin → available_width = 0 (not negative)
990        let margins = create_test_margins(); // right = 50
991        let mut context = TextFlowContext::new(400.0, 600.0, margins);
992
993        // cursor_x = 380, right margin = 50 → would be 400-50-380 = -30 → clamp to 0
994        context.at(380.0, 500.0);
995        assert_eq!(context.available_width(), 0.0);
996    }
997
998    #[test]
999    fn test_write_wrapped_at_x_limits_available_width() {
1000        // Page 400pt, margins 50pt each → content_width = 300pt
1001        // Place cursor at x=250: available_width = 400-50-250 = 100pt
1002        // Use text wider than 100pt but narrower than 300pt → must wrap at x=250
1003        let margins = create_test_margins();
1004        let mut context = TextFlowContext::new(400.0, 600.0, margins);
1005
1006        context.set_font(Font::Helvetica, 12.0);
1007        // "Hello World Hello World" at 12pt Helvetica exceeds 100pt easily
1008        context.at(250.0, 500.0);
1009        context.write_wrapped("Hello World Hello World").unwrap();
1010
1011        let ops = context.operations();
1012        // Multiple BT blocks → wrapping occurred
1013        let bt_count = ops.matches("BT\n").count();
1014        assert!(
1015            bt_count > 1,
1016            "Expected wrapping (multiple lines), got {bt_count} BT blocks. ops:\n{ops}"
1017        );
1018    }
1019
1020    #[test]
1021    fn test_write_wrapped_respects_cursor_x_offset() {
1022        // Cursor at x=300, page 600pt wide, margins 50pt each → available_width = 250pt
1023        let margins = Margins {
1024            left: 50.0,
1025            right: 50.0,
1026            top: 50.0,
1027            bottom: 50.0,
1028        };
1029        let mut context = TextFlowContext::new(600.0, 800.0, margins);
1030
1031        context.set_font(Font::Helvetica, 12.0);
1032        context.at(300.0, 700.0);
1033        context
1034            .write_wrapped("Hello World Foo Bar Baz Qux")
1035            .unwrap();
1036
1037        let ops = context.operations();
1038        // Every Td x-coordinate should be >= 300.0
1039        for line in ops.lines() {
1040            if line.ends_with(" Td") {
1041                let parts: Vec<&str> = line.split_whitespace().collect();
1042                if parts.len() >= 3 {
1043                    let x: f64 = parts[0].parse().expect("Td x should be a number");
1044                    assert!(
1045                        x >= 300.0 - 1e-6,
1046                        "Expected Td x >= 300.0 but got {x}. ops:\n{ops}"
1047                    );
1048                }
1049            }
1050        }
1051    }
1052}