Skip to main content

oxidize_pdf/text/
flow.rs

1use crate::error::Result;
2use crate::graphics::Color;
3use crate::page::Margins;
4use crate::text::metrics::{measure_text_with, FontMetricsStore};
5use crate::text::{split_into_words, Font};
6use std::collections::{HashMap, HashSet};
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: Vec<crate::graphics::ops::Op>,
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    /// Remaining text-state parameters propagated from `TextContext`
35    /// (issue #222 — Phase 6 of the v2.7.0 IR refactor). When set,
36    /// `write_wrapped` emits the corresponding operator inside each
37    /// `BT … ET` block. `None` keeps the surrounding graphics state.
38    character_spacing: Option<f64>,
39    word_spacing: Option<f64>,
40    horizontal_scaling: Option<f64>,
41    leading: Option<f64>,
42    text_rise: Option<f64>,
43    rendering_mode: Option<u8>,
44    stroke_color: Option<Color>,
45    /// Characters drawn so far, bucketed by active font name (issue
46    /// #204). Consumed by `Page::add_text_flow` to merge into the
47    /// page's graphics-context tracking so the writer can subset each
48    /// custom font with only its own characters.
49    used_characters_by_font: HashMap<String, HashSet<char>>,
50    /// Per-Document font metrics store threaded from the owning `Document`
51    /// (issue #230, v2.8.0). When `Some`, `write_wrapped` resolves custom
52    /// font widths via this store instead of the process-wide legacy registry.
53    pub(crate) font_metrics_store: Option<FontMetricsStore>,
54}
55
56impl TextFlowContext {
57    pub fn new(page_width: f64, page_height: f64, margins: Margins) -> Self {
58        Self {
59            operations: Vec::new(),
60            current_font: Font::Helvetica,
61            font_size: 12.0,
62            line_height: 1.2,
63            cursor_x: margins.left,
64            cursor_y: page_height - margins.top,
65            alignment: TextAlign::Left,
66            page_width,
67            page_height,
68            margins,
69            fill_color: None,
70            character_spacing: None,
71            word_spacing: None,
72            horizontal_scaling: None,
73            leading: None,
74            text_rise: None,
75            rendering_mode: None,
76            stroke_color: None,
77            used_characters_by_font: HashMap::new(),
78            font_metrics_store: None,
79        }
80    }
81
82    /// Create a `TextFlowContext` bound to a per-Document `FontMetricsStore`
83    /// (issue #230, v2.8.0). Internal use only — external callers should use
84    /// `Document::new_page_a4()` and `page.text_flow()`.
85    ///
86    /// When `store` is `None`, behaviour is identical to `TextFlowContext::new`.
87    pub(crate) fn with_metrics_store(
88        page_width: f64,
89        page_height: f64,
90        margins: Margins,
91        store: Option<FontMetricsStore>,
92    ) -> Self {
93        let mut ctx = Self::new(page_width, page_height, margins);
94        ctx.font_metrics_store = store;
95        ctx
96    }
97
98    /// Get the per-font character usage accumulated by `write_wrapped`
99    /// (issue #204). `Page::add_text_flow` merges this into the page's
100    /// graphics context so the writer knows which custom fonts were
101    /// referenced and what characters each drew.
102    pub(crate) fn get_used_characters_by_font(&self) -> &HashMap<String, HashSet<char>> {
103        &self.used_characters_by_font
104    }
105
106    pub fn set_font(&mut self, font: Font, size: f64) -> &mut Self {
107        self.current_font = font;
108        self.font_size = size;
109        self
110    }
111
112    pub fn set_line_height(&mut self, multiplier: f64) -> &mut Self {
113        self.line_height = multiplier;
114        self
115    }
116
117    pub fn set_alignment(&mut self, alignment: TextAlign) -> &mut Self {
118        self.alignment = alignment;
119        self
120    }
121
122    /// Sets the non-stroking (fill) color used for subsequent text emitted
123    /// by `write_wrapped` (issue #216). Mirrors `TextContext::set_fill_color`.
124    /// `None` keeps the surrounding graphics state untouched (previous
125    /// behaviour); `Some(color)` emits the matching PDF operator inside each
126    /// `BT … ET` block.
127    pub fn set_fill_color(&mut self, color: Color) -> &mut Self {
128        self.fill_color = Some(color);
129        self
130    }
131
132    /// Setters for the remaining text-state parameters, mirroring
133    /// `TextContext`. Closes the propagation gap reported in issue #222.
134    /// All apply on the next `BT … ET` block emitted by `write_wrapped`.
135    pub fn set_character_spacing(&mut self, spacing: f64) -> &mut Self {
136        self.character_spacing = Some(spacing);
137        self
138    }
139
140    pub fn set_word_spacing(&mut self, spacing: f64) -> &mut Self {
141        self.word_spacing = Some(spacing);
142        self
143    }
144
145    /// Set horizontal scaling. The argument is the ratio (e.g. `0.85`
146    /// for 85 %); it is converted to the PDF `Tz` percentage at
147    /// emission time. Matches the contract documented on
148    /// `TextContext::set_horizontal_scaling`.
149    pub fn set_horizontal_scaling(&mut self, scale: f64) -> &mut Self {
150        self.horizontal_scaling = Some(scale);
151        self
152    }
153
154    pub fn set_leading(&mut self, leading: f64) -> &mut Self {
155        self.leading = Some(leading);
156        self
157    }
158
159    pub fn set_text_rise(&mut self, rise: f64) -> &mut Self {
160        self.text_rise = Some(rise);
161        self
162    }
163
164    /// Set the text rendering mode (`0`..=`7` per ISO 32000-1 §9.3.6).
165    /// The argument is taken as a `u8` rather than the typed
166    /// `TextRenderingMode` enum to avoid an extra public dependency
167    /// from `TextFlowContext` on the parent module.
168    pub fn set_rendering_mode(&mut self, mode: u8) -> &mut Self {
169        self.rendering_mode = Some(mode);
170        self
171    }
172
173    pub fn set_stroke_color(&mut self, color: Color) -> &mut Self {
174        self.stroke_color = Some(color);
175        self
176    }
177
178    /// Current font this context will use when emitting text.
179    pub fn current_font(&self) -> &Font {
180        &self.current_font
181    }
182
183    /// Current font size in points.
184    pub fn font_size(&self) -> f64 {
185        self.font_size
186    }
187
188    /// Current fill color, if one has been explicitly set (issue #216).
189    pub fn fill_color(&self) -> Option<Color> {
190        self.fill_color
191    }
192
193    pub fn at(&mut self, x: f64, y: f64) -> &mut Self {
194        self.cursor_x = x;
195        self.cursor_y = y;
196        self
197    }
198
199    pub fn content_width(&self) -> f64 {
200        self.page_width - self.margins.left - self.margins.right
201    }
202
203    /// Returns the width available for text starting at the current cursor_x position.
204    ///
205    /// Unlike `content_width()` which always uses `margins.left` as the origin,
206    /// `available_width()` accounts for the actual cursor position so that text
207    /// placed via `.at(x, y)` does not overflow the right margin.
208    pub fn available_width(&self) -> f64 {
209        (self.page_width - self.margins.right - self.cursor_x).max(0.0)
210    }
211
212    pub fn write_wrapped(&mut self, text: &str) -> Result<&mut Self> {
213        let start_x = self.cursor_x;
214        let available_width = self.available_width();
215
216        // Split text into words
217        let words = split_into_words(text);
218        let mut lines: Vec<Vec<&str>> = Vec::new();
219        let mut current_line: Vec<&str> = Vec::new();
220        let mut current_width = 0.0;
221
222        // Build lines based on available width (respects cursor_x offset)
223        for word in words {
224            let word_width = measure_text_with(
225                word,
226                &self.current_font,
227                self.font_size,
228                self.font_metrics_store.as_ref(),
229            );
230
231            // Check if we need to start a new line
232            if !current_line.is_empty() && current_width + word_width > available_width {
233                lines.push(current_line);
234                current_line = vec![word];
235                current_width = word_width;
236            } else {
237                current_line.push(word);
238                current_width += word_width;
239            }
240        }
241
242        if !current_line.is_empty() {
243            lines.push(current_line);
244        }
245
246        // Render each line
247        for (i, line) in lines.iter().enumerate() {
248            let line_text = line.join("");
249            let line_width = measure_text_with(
250                &line_text,
251                &self.current_font,
252                self.font_size,
253                self.font_metrics_store.as_ref(),
254            );
255
256            // Calculate x position based on alignment.
257            // start_x is the column where this block of text begins (set via .at()).
258            // Left/Justified start at start_x; Center is relative to start_x;
259            // Right stays anchored to the right margin.
260            let x = match self.alignment {
261                TextAlign::Left => start_x,
262                TextAlign::Right => self.page_width - self.margins.right - line_width,
263                TextAlign::Center => start_x + (available_width - line_width) / 2.0,
264                TextAlign::Justified => start_x,
265            };
266
267            use crate::graphics::ops::Op;
268
269            self.operations.push(Op::BeginText);
270
271            // Set font
272            self.operations.push(Op::SetFont {
273                name: self.current_font.pdf_name(),
274                size: self.font_size,
275            });
276
277            // Apply text-state parameters propagated from `TextContext`
278            // (issue #222 — Phase 6 of the v2.7.0 IR refactor).
279            // These mirror `TextContext::apply_text_state_parameters`
280            // but live inside the per-line `BT … ET` block of the flow
281            // emitter. PDF spec ISO 32000-1 §8.6.8 / §9.3 allow these
282            // operators inside a text object; they take effect for the
283            // `Tj` that follows.
284            if let Some(spacing) = self.character_spacing {
285                self.operations.push(Op::SetCharSpacing(spacing));
286            }
287            if let Some(spacing) = self.word_spacing {
288                self.operations.push(Op::SetWordSpacing(spacing));
289            }
290            if let Some(scale) = self.horizontal_scaling {
291                // The Tz operator takes a percentage; the setter accepts
292                // a 0.0–1.0 ratio (matching `TextContext`), so multiply
293                // by 100 at emission.
294                self.operations
295                    .push(Op::SetHorizontalScaling(scale * 100.0));
296            }
297            if let Some(leading) = self.leading {
298                self.operations.push(Op::SetLeading(leading));
299            }
300            if let Some(rise) = self.text_rise {
301                self.operations.push(Op::SetTextRise(rise));
302            }
303            if let Some(mode) = self.rendering_mode {
304                self.operations.push(Op::SetRenderingMode(mode));
305            }
306
307            // Apply non-stroking fill colour (issue #216) and stroking
308            // colour (issue #222) if one was inherited from the
309            // page-level text state or explicitly configured via the
310            // setters. The IR variants route through
311            // `write_fill_color_bytes` / `write_stroke_color_bytes` so
312            // the same NaN-sanitising helpers (issues #220 + #221) apply.
313            if let Some(color) = self.fill_color {
314                self.operations.push(Op::SetFillColor(color));
315            }
316            if let Some(color) = self.stroke_color {
317                self.operations.push(Op::SetStrokeColor(color));
318            }
319
320            self.operations.push(Op::SetTextPosition {
321                x,
322                y: self.cursor_y,
323            });
324
325            // Handle justification: emit Tw with the per-line word-spacing
326            // adjustment so the rendered line spans `available_width`.
327            if self.alignment == TextAlign::Justified && i < lines.len() - 1 && line.len() > 1 {
328                let spaces_count = line.iter().filter(|w| w.trim().is_empty()).count();
329                if spaces_count > 0 {
330                    let extra_space = available_width - line_width;
331                    let space_adjustment = extra_space / spaces_count as f64;
332                    self.operations.push(Op::SetWordSpacing(space_adjustment));
333                }
334            }
335
336            // Show text — escape PDF literal-string special characters.
337            let mut buf = Vec::with_capacity(line_text.len());
338            for ch in line_text.chars() {
339                match ch {
340                    '(' => buf.extend_from_slice(b"\\("),
341                    ')' => buf.extend_from_slice(b"\\)"),
342                    '\\' => buf.extend_from_slice(b"\\\\"),
343                    '\n' => buf.extend_from_slice(b"\\n"),
344                    '\r' => buf.extend_from_slice(b"\\r"),
345                    '\t' => buf.extend_from_slice(b"\\t"),
346                    _ => {
347                        let mut tmp = [0u8; 4];
348                        buf.extend_from_slice(ch.encode_utf8(&mut tmp).as_bytes());
349                    }
350                }
351            }
352            self.operations.push(Op::ShowText(buf));
353
354            // Record per-font char usage so the consuming page can
355            // report it to the writer (issue #204).
356            self.used_characters_by_font
357                .entry(self.current_font.pdf_name())
358                .or_default()
359                .extend(line_text.chars());
360
361            // Reset word spacing if it was set. The IR emits this as
362            // `0.00 Tw` (was `0 Tw` in pre-2.7.0 — documented in CHANGELOG).
363            if self.alignment == TextAlign::Justified && i < lines.len() - 1 {
364                self.operations.push(Op::SetWordSpacing(0.0));
365            }
366
367            self.operations.push(Op::EndText);
368
369            // Move cursor down for next line
370            self.cursor_y -= self.font_size * self.line_height;
371        }
372
373        Ok(self)
374    }
375
376    pub fn write_paragraph(&mut self, text: &str) -> Result<&mut Self> {
377        self.write_wrapped(text)?;
378        // Add extra space after paragraph
379        self.cursor_y -= self.font_size * self.line_height * 0.5;
380        Ok(self)
381    }
382
383    pub fn newline(&mut self) -> &mut Self {
384        self.cursor_y -= self.font_size * self.line_height;
385        self.cursor_x = self.margins.left;
386        self
387    }
388
389    pub fn cursor_position(&self) -> (f64, f64) {
390        (self.cursor_x, self.cursor_y)
391    }
392
393    pub fn generate_operations(&self) -> Vec<u8> {
394        let mut buf = Vec::new();
395        crate::graphics::ops::serialize_ops(&mut buf, &self.operations);
396        buf
397    }
398
399    /// Get the current alignment
400    pub fn alignment(&self) -> TextAlign {
401        self.alignment
402    }
403
404    /// Get the page dimensions
405    pub fn page_dimensions(&self) -> (f64, f64) {
406        (self.page_width, self.page_height)
407    }
408
409    /// Get the margins
410    pub fn margins(&self) -> &Margins {
411        &self.margins
412    }
413
414    /// Get current line height multiplier
415    pub fn line_height(&self) -> f64 {
416        self.line_height
417    }
418
419    /// Get the operations as a serialised PDF content-stream `String`.
420    ///
421    /// Pre-2.7.0 this returned `&str`. The IR migration replaced the
422    /// internal `String` buffer with a typed `Vec<Op>`, so the legacy
423    /// borrow is materialised on demand. Internal callers prefer
424    /// `generate_operations()` which returns the byte buffer directly.
425    pub fn operations(&self) -> String {
426        crate::graphics::ops::ops_to_string(&self.operations)
427    }
428
429    /// Clear all operations
430    pub fn clear(&mut self) {
431        self.operations.clear();
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use crate::page::Margins;
439
440    fn create_test_margins() -> Margins {
441        Margins {
442            left: 50.0,
443            right: 50.0,
444            top: 50.0,
445            bottom: 50.0,
446        }
447    }
448
449    #[test]
450    fn test_text_flow_context_new() {
451        let margins = create_test_margins();
452        let context = TextFlowContext::new(400.0, 600.0, margins);
453
454        assert_eq!(context.current_font, Font::Helvetica);
455        assert_eq!(context.font_size, 12.0);
456        assert_eq!(context.line_height, 1.2);
457        assert_eq!(context.alignment, TextAlign::Left);
458        assert_eq!(context.page_width, 400.0);
459        assert_eq!(context.page_height, 600.0);
460        assert_eq!(context.cursor_x, 50.0); // margins.left
461        assert_eq!(context.cursor_y, 550.0); // page_height - margins.top
462    }
463
464    #[test]
465    fn test_set_font() {
466        let margins = create_test_margins();
467        let mut context = TextFlowContext::new(400.0, 600.0, margins);
468
469        context.set_font(Font::TimesBold, 16.0);
470        assert_eq!(context.current_font, Font::TimesBold);
471        assert_eq!(context.font_size, 16.0);
472    }
473
474    #[test]
475    fn test_set_line_height() {
476        let margins = create_test_margins();
477        let mut context = TextFlowContext::new(400.0, 600.0, margins);
478
479        context.set_line_height(1.5);
480        assert_eq!(context.line_height(), 1.5);
481    }
482
483    #[test]
484    fn test_set_alignment() {
485        let margins = create_test_margins();
486        let mut context = TextFlowContext::new(400.0, 600.0, margins);
487
488        context.set_alignment(TextAlign::Center);
489        assert_eq!(context.alignment(), TextAlign::Center);
490    }
491
492    #[test]
493    fn test_at_position() {
494        let margins = create_test_margins();
495        let mut context = TextFlowContext::new(400.0, 600.0, margins);
496
497        context.at(100.0, 200.0);
498        let (x, y) = context.cursor_position();
499        assert_eq!(x, 100.0);
500        assert_eq!(y, 200.0);
501    }
502
503    #[test]
504    fn test_content_width() {
505        let margins = create_test_margins();
506        let context = TextFlowContext::new(400.0, 600.0, margins);
507
508        let content_width = context.content_width();
509        assert_eq!(content_width, 300.0); // 400 - 50 - 50
510    }
511
512    #[test]
513    fn test_text_align_variants() {
514        assert_eq!(TextAlign::Left, TextAlign::Left);
515        assert_eq!(TextAlign::Right, TextAlign::Right);
516        assert_eq!(TextAlign::Center, TextAlign::Center);
517        assert_eq!(TextAlign::Justified, TextAlign::Justified);
518
519        assert_ne!(TextAlign::Left, TextAlign::Right);
520    }
521
522    #[test]
523    fn test_write_wrapped_simple() {
524        let margins = create_test_margins();
525        let mut context = TextFlowContext::new(400.0, 600.0, margins);
526
527        context.write_wrapped("Hello World").unwrap();
528
529        let ops = context.operations();
530        assert!(ops.contains("BT\n"));
531        assert!(ops.contains("ET\n"));
532        assert!(ops.contains("/Helvetica 12 Tf"));
533        assert!(ops.contains("(Hello World) Tj"));
534    }
535
536    #[test]
537    fn test_write_paragraph() {
538        let margins = create_test_margins();
539        let mut context = TextFlowContext::new(400.0, 600.0, margins);
540
541        let initial_y = context.cursor_y;
542        context.write_paragraph("Test paragraph").unwrap();
543
544        // Y position should have moved down more than just line height
545        assert!(context.cursor_y < initial_y);
546    }
547
548    #[test]
549    fn test_newline() {
550        let margins = create_test_margins();
551        let mut context = TextFlowContext::new(400.0, 600.0, margins.clone());
552
553        let initial_y = context.cursor_y;
554        context.newline();
555
556        assert_eq!(context.cursor_x, margins.left);
557        assert!(context.cursor_y < initial_y);
558        assert_eq!(
559            context.cursor_y,
560            initial_y - context.font_size * context.line_height
561        );
562    }
563
564    #[test]
565    fn test_cursor_position() {
566        let margins = create_test_margins();
567        let mut context = TextFlowContext::new(400.0, 600.0, margins);
568
569        context.at(75.0, 125.0);
570        let (x, y) = context.cursor_position();
571        assert_eq!(x, 75.0);
572        assert_eq!(y, 125.0);
573    }
574
575    #[test]
576    fn test_generate_operations() {
577        let margins = create_test_margins();
578        let mut context = TextFlowContext::new(400.0, 600.0, margins);
579
580        context.write_wrapped("Test").unwrap();
581        let ops_bytes = context.generate_operations();
582        let ops_string = String::from_utf8(ops_bytes).unwrap();
583
584        assert_eq!(ops_string, context.operations());
585    }
586
587    #[test]
588    fn test_clear_operations() {
589        let margins = create_test_margins();
590        let mut context = TextFlowContext::new(400.0, 600.0, margins);
591
592        context.write_wrapped("Test").unwrap();
593        assert!(!context.operations().is_empty());
594
595        context.clear();
596        assert!(context.operations().is_empty());
597    }
598
599    #[test]
600    fn test_page_dimensions() {
601        let margins = create_test_margins();
602        let context = TextFlowContext::new(400.0, 600.0, margins);
603
604        let (width, height) = context.page_dimensions();
605        assert_eq!(width, 400.0);
606        assert_eq!(height, 600.0);
607    }
608
609    #[test]
610    fn test_margins_access() {
611        let margins = create_test_margins();
612        let context = TextFlowContext::new(400.0, 600.0, margins);
613
614        let ctx_margins = context.margins();
615        assert_eq!(ctx_margins.left, 50.0);
616        assert_eq!(ctx_margins.right, 50.0);
617        assert_eq!(ctx_margins.top, 50.0);
618        assert_eq!(ctx_margins.bottom, 50.0);
619    }
620
621    #[test]
622    fn test_method_chaining() {
623        let margins = create_test_margins();
624        let mut context = TextFlowContext::new(400.0, 600.0, margins);
625
626        context
627            .set_font(Font::Courier, 10.0)
628            .set_line_height(1.5)
629            .set_alignment(TextAlign::Center)
630            .at(100.0, 200.0);
631
632        assert_eq!(context.current_font, Font::Courier);
633        assert_eq!(context.font_size, 10.0);
634        assert_eq!(context.line_height(), 1.5);
635        assert_eq!(context.alignment(), TextAlign::Center);
636        let (x, y) = context.cursor_position();
637        assert_eq!(x, 100.0);
638        assert_eq!(y, 200.0);
639    }
640
641    #[test]
642    fn test_text_align_debug() {
643        let align = TextAlign::Center;
644        let debug_str = format!("{align:?}");
645        assert_eq!(debug_str, "Center");
646    }
647
648    #[test]
649    fn test_text_align_clone() {
650        let align1 = TextAlign::Justified;
651        let align2 = align1;
652        assert_eq!(align1, align2);
653    }
654
655    #[test]
656    fn test_text_align_copy() {
657        let align1 = TextAlign::Right;
658        let align2 = align1; // Copy semantics
659        assert_eq!(align1, align2);
660
661        // Both variables should still be usable
662        assert_eq!(align1, TextAlign::Right);
663        assert_eq!(align2, TextAlign::Right);
664    }
665
666    #[test]
667    fn test_write_wrapped_with_alignment_right() {
668        let margins = create_test_margins();
669        let mut context = TextFlowContext::new(400.0, 600.0, margins);
670
671        context.set_alignment(TextAlign::Right);
672        context.write_wrapped("Right aligned text").unwrap();
673
674        let ops = context.operations();
675        assert!(ops.contains("BT\n"));
676        assert!(ops.contains("ET\n"));
677        // Right alignment should position text differently
678        assert!(ops.contains("Td"));
679    }
680
681    #[test]
682    fn test_write_wrapped_with_alignment_center() {
683        let margins = create_test_margins();
684        let mut context = TextFlowContext::new(400.0, 600.0, margins);
685
686        context.set_alignment(TextAlign::Center);
687        context.write_wrapped("Centered text").unwrap();
688
689        let ops = context.operations();
690        assert!(ops.contains("BT\n"));
691        assert!(ops.contains("(Centered text) Tj"));
692    }
693
694    #[test]
695    fn test_write_wrapped_with_alignment_justified() {
696        let margins = create_test_margins();
697        let mut context = TextFlowContext::new(400.0, 600.0, margins);
698
699        context.set_alignment(TextAlign::Justified);
700        // Long text that will wrap and justify
701        context.write_wrapped("This is a longer text that should wrap across multiple lines to test justification").unwrap();
702
703        let ops = context.operations();
704        assert!(ops.contains("BT\n"));
705        // Justified text may have word spacing adjustments
706        assert!(ops.contains("Tw") || ops.contains("0 Tw"));
707    }
708
709    #[test]
710    fn test_write_wrapped_empty_text() {
711        let margins = create_test_margins();
712        let mut context = TextFlowContext::new(400.0, 600.0, margins);
713
714        context.write_wrapped("").unwrap();
715
716        // Empty text should not generate operations
717        assert!(context.operations().is_empty());
718    }
719
720    #[test]
721    fn test_write_wrapped_whitespace_only() {
722        let margins = create_test_margins();
723        let mut context = TextFlowContext::new(400.0, 600.0, margins);
724
725        context.write_wrapped("   ").unwrap();
726
727        let ops = context.operations();
728        // Should handle whitespace-only text
729        assert!(ops.contains("BT\n") || ops.is_empty());
730    }
731
732    #[test]
733    fn test_write_wrapped_special_characters() {
734        let margins = create_test_margins();
735        let mut context = TextFlowContext::new(400.0, 600.0, margins);
736
737        context
738            .write_wrapped("Text with (parentheses) and \\backslash\\")
739            .unwrap();
740
741        let ops = context.operations();
742        // Special characters should be escaped
743        assert!(ops.contains("\\(parentheses\\)"));
744        assert!(ops.contains("\\\\backslash\\\\"));
745    }
746
747    #[test]
748    fn test_write_wrapped_newlines_tabs() {
749        let margins = create_test_margins();
750        let mut context = TextFlowContext::new(400.0, 600.0, margins);
751
752        context.write_wrapped("Line1\nLine2\tTabbed").unwrap();
753
754        let ops = context.operations();
755        // Newlines and tabs should be escaped
756        assert!(ops.contains("\\n") || ops.contains("\\t"));
757    }
758
759    #[test]
760    fn test_write_wrapped_very_long_word() {
761        let margins = create_test_margins();
762        let mut context = TextFlowContext::new(200.0, 600.0, margins); // Narrow page
763
764        let long_word = "a".repeat(100);
765        context.write_wrapped(&long_word).unwrap();
766
767        let ops = context.operations();
768        assert!(ops.contains("BT\n"));
769        assert!(ops.contains(&long_word));
770    }
771
772    #[test]
773    fn test_write_wrapped_cursor_movement() {
774        let margins = create_test_margins();
775        let mut context = TextFlowContext::new(400.0, 600.0, margins);
776
777        let initial_y = context.cursor_y;
778
779        context.write_wrapped("Line 1").unwrap();
780        let y_after_line1 = context.cursor_y;
781
782        context.write_wrapped("Line 2").unwrap();
783        let y_after_line2 = context.cursor_y;
784
785        // Cursor should move down after each line
786        assert!(y_after_line1 < initial_y);
787        assert!(y_after_line2 < y_after_line1);
788    }
789
790    #[test]
791    fn test_write_paragraph_spacing() {
792        let margins = create_test_margins();
793        let mut context = TextFlowContext::new(400.0, 600.0, margins);
794
795        let initial_y = context.cursor_y;
796        context.write_paragraph("Paragraph 1").unwrap();
797        let y_after_p1 = context.cursor_y;
798
799        context.write_paragraph("Paragraph 2").unwrap();
800        let y_after_p2 = context.cursor_y;
801
802        // Paragraphs should have extra spacing
803        let spacing1 = initial_y - y_after_p1;
804        let spacing2 = y_after_p1 - y_after_p2;
805
806        assert!(spacing1 > 0.0);
807        assert!(spacing2 > 0.0);
808    }
809
810    #[test]
811    fn test_multiple_newlines() {
812        let margins = create_test_margins();
813        let mut context = TextFlowContext::new(400.0, 600.0, margins);
814
815        let initial_y = context.cursor_y;
816
817        context.newline();
818        let y1 = context.cursor_y;
819
820        context.newline();
821        let y2 = context.cursor_y;
822
823        context.newline();
824        let y3 = context.cursor_y;
825
826        // Each newline should move cursor down by same amount
827        let spacing1 = initial_y - y1;
828        let spacing2 = y1 - y2;
829        let spacing3 = y2 - y3;
830
831        // Use approximate equality for floating point comparisons
832        assert!((spacing1 - spacing2).abs() < 1e-10);
833        assert!((spacing2 - spacing3).abs() < 1e-10);
834        assert!((spacing1 - context.font_size * context.line_height).abs() < 1e-10);
835    }
836
837    #[test]
838    fn test_content_width_different_margins() {
839        let margins = Margins {
840            left: 30.0,
841            right: 70.0,
842            top: 40.0,
843            bottom: 60.0,
844        };
845        let context = TextFlowContext::new(500.0, 700.0, margins);
846
847        let content_width = context.content_width();
848        assert_eq!(content_width, 400.0); // 500 - 30 - 70
849    }
850
851    #[test]
852    fn test_custom_line_height() {
853        let margins = create_test_margins();
854        let mut context = TextFlowContext::new(400.0, 600.0, margins);
855
856        context.set_line_height(2.0);
857
858        let initial_y = context.cursor_y;
859        context.newline();
860        let y_after = context.cursor_y;
861
862        let spacing = initial_y - y_after;
863        assert_eq!(spacing, context.font_size * 2.0); // line_height = 2.0
864    }
865
866    #[test]
867    fn test_different_fonts() {
868        let margins = create_test_margins();
869        let mut context = TextFlowContext::new(400.0, 600.0, margins);
870
871        let fonts = vec![
872            Font::Helvetica,
873            Font::HelveticaBold,
874            Font::TimesRoman,
875            Font::TimesBold,
876            Font::Courier,
877            Font::CourierBold,
878        ];
879
880        for font in fonts {
881            context.clear();
882            let font_name = font.pdf_name();
883            context.set_font(font, 14.0);
884            context.write_wrapped("Test text").unwrap();
885
886            let ops = context.operations();
887            assert!(ops.contains(&format!("/{font_name} 14 Tf")));
888        }
889    }
890
891    #[test]
892    fn test_font_size_variations() {
893        let margins = create_test_margins();
894        let mut context = TextFlowContext::new(400.0, 600.0, margins);
895
896        let sizes = vec![8.0, 10.0, 12.0, 14.0, 16.0, 24.0, 36.0];
897
898        for size in sizes {
899            context.clear();
900            context.set_font(Font::Helvetica, size);
901            context.write_wrapped("Test").unwrap();
902
903            let ops = context.operations();
904            assert!(ops.contains(&format!("/Helvetica {size} Tf")));
905        }
906    }
907
908    #[test]
909    fn test_at_position_edge_cases() {
910        let margins = create_test_margins();
911        let mut context = TextFlowContext::new(400.0, 600.0, margins);
912
913        // Test zero position
914        context.at(0.0, 0.0);
915        assert_eq!(context.cursor_position(), (0.0, 0.0));
916
917        // Test negative position
918        context.at(-10.0, -20.0);
919        assert_eq!(context.cursor_position(), (-10.0, -20.0));
920
921        // Test large position
922        context.at(10000.0, 20000.0);
923        assert_eq!(context.cursor_position(), (10000.0, 20000.0));
924    }
925
926    #[test]
927    fn test_write_wrapped_with_narrow_content() {
928        let margins = Margins {
929            left: 190.0,
930            right: 190.0,
931            top: 50.0,
932            bottom: 50.0,
933        };
934        let mut context = TextFlowContext::new(400.0, 600.0, margins);
935
936        // Content width is only 20.0 units
937        context
938            .write_wrapped("This text should wrap a lot")
939            .unwrap();
940
941        let ops = context.operations();
942        // Should have multiple text objects for wrapped lines
943        let bt_count = ops.matches("BT\n").count();
944        assert!(bt_count > 1);
945    }
946
947    #[test]
948    fn test_justified_text_single_word_line() {
949        let margins = create_test_margins();
950        let mut context = TextFlowContext::new(400.0, 600.0, margins);
951
952        context.set_alignment(TextAlign::Justified);
953        context.write_wrapped("SingleWord").unwrap();
954
955        let ops = context.operations();
956        // Single word lines should not have word spacing
957        assert!(!ops.contains(" Tw") || ops.contains("0 Tw"));
958    }
959
960    #[test]
961    fn test_justified_text_last_line() {
962        let margins = create_test_margins();
963        let mut context = TextFlowContext::new(400.0, 600.0, margins);
964
965        context.set_alignment(TextAlign::Justified);
966        // Text that will create multiple lines
967        context.write_wrapped("This is a test of justified text alignment where the last line should not be justified").unwrap();
968
969        let ops = context.operations();
970        // Should reset word spacing (0 Tw) for last line
971        assert!(ops.contains("0 Tw"));
972    }
973
974    #[test]
975    fn test_generate_operations_encoding() {
976        let margins = create_test_margins();
977        let mut context = TextFlowContext::new(400.0, 600.0, margins);
978
979        context.write_wrapped("UTF-8 Text: Ñ").unwrap();
980
981        let ops_bytes = context.generate_operations();
982        let ops_string = String::from_utf8(ops_bytes.clone()).unwrap();
983
984        assert_eq!(ops_bytes, context.operations().as_bytes());
985        assert_eq!(ops_string, context.operations());
986    }
987
988    #[test]
989    fn test_clear_resets_operations_only() {
990        let margins = create_test_margins();
991        let mut context = TextFlowContext::new(400.0, 600.0, margins);
992
993        context.set_font(Font::TimesBold, 18.0);
994        context.set_alignment(TextAlign::Right);
995        context.at(100.0, 200.0);
996        context.write_wrapped("Text").unwrap();
997
998        context.clear();
999
1000        // Operations should be cleared
1001        assert!(context.operations().is_empty());
1002
1003        // But other settings should remain
1004        assert_eq!(context.current_font, Font::TimesBold);
1005        assert_eq!(context.font_size, 18.0);
1006        assert_eq!(context.alignment(), TextAlign::Right);
1007        // Cursor position should reflect where we are after writing text (moved down by line height)
1008        let (x, y) = context.cursor_position();
1009        assert_eq!(x, 100.0); // X position should be unchanged
1010        assert!(y < 200.0); // Y position should have moved down after writing text
1011    }
1012
1013    #[test]
1014    fn test_long_text_wrapping() {
1015        let margins = create_test_margins();
1016        let mut context = TextFlowContext::new(400.0, 600.0, margins);
1017
1018        let long_text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. \
1019                        Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. \
1020                        Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.";
1021
1022        context.write_wrapped(long_text).unwrap();
1023
1024        let ops = context.operations();
1025        // Should have multiple lines
1026        let tj_count = ops.matches(") Tj").count();
1027        assert!(tj_count > 1);
1028    }
1029
1030    #[test]
1031    fn test_empty_operations_initially() {
1032        let margins = create_test_margins();
1033        let context = TextFlowContext::new(400.0, 600.0, margins);
1034
1035        assert!(context.operations().is_empty());
1036        assert_eq!(context.generate_operations().len(), 0);
1037    }
1038
1039    #[test]
1040    fn test_write_paragraph_empty() {
1041        let margins = create_test_margins();
1042        let mut context = TextFlowContext::new(400.0, 600.0, margins);
1043
1044        let initial_y = context.cursor_y;
1045        context.write_paragraph("").unwrap();
1046
1047        // Empty paragraph should still add spacing
1048        assert!(context.cursor_y < initial_y);
1049    }
1050
1051    #[test]
1052    fn test_extreme_line_height() {
1053        let margins = create_test_margins();
1054        let mut context = TextFlowContext::new(400.0, 600.0, margins);
1055
1056        // Very small line height
1057        context.set_line_height(0.1);
1058        let initial_y = context.cursor_y;
1059        context.newline();
1060        assert_eq!(context.cursor_y, initial_y - context.font_size * 0.1);
1061
1062        // Very large line height
1063        context.set_line_height(10.0);
1064        let initial_y2 = context.cursor_y;
1065        context.newline();
1066        assert_eq!(context.cursor_y, initial_y2 - context.font_size * 10.0);
1067    }
1068
1069    #[test]
1070    fn test_zero_content_width() {
1071        let margins = Margins {
1072            left: 200.0,
1073            right: 200.0,
1074            top: 50.0,
1075            bottom: 50.0,
1076        };
1077        let context = TextFlowContext::new(400.0, 600.0, margins);
1078
1079        assert_eq!(context.content_width(), 0.0);
1080    }
1081
1082    #[test]
1083    fn test_cursor_x_reset_on_newline() {
1084        let margins = create_test_margins();
1085        let mut context = TextFlowContext::new(400.0, 600.0, margins.clone());
1086
1087        context.at(250.0, 300.0); // Move cursor to custom position
1088        context.newline();
1089
1090        // X should reset to left margin
1091        assert_eq!(context.cursor_x, margins.left);
1092        // Y should decrease by line height
1093        assert_eq!(
1094            context.cursor_y,
1095            300.0 - context.font_size * context.line_height
1096        );
1097    }
1098
1099    // --- Issue #167: available_width respects cursor_x ---
1100
1101    #[test]
1102    fn test_available_width_respects_cursor_x() {
1103        // Page: 400pt wide, 50pt margins each side → content_width = 300pt
1104        let margins = create_test_margins(); // left=50, right=50
1105        let mut context = TextFlowContext::new(400.0, 600.0, margins);
1106
1107        // Default: cursor_x == margins.left == 50, available_width == 300
1108        assert_eq!(context.available_width(), 300.0);
1109
1110        // After .at(200, 500): cursor_x = 200, available_width = 400 - 50 - 200 = 150
1111        context.at(200.0, 500.0);
1112        assert_eq!(context.available_width(), 150.0);
1113    }
1114
1115    #[test]
1116    fn test_available_width_clamps_to_zero() {
1117        // cursor_x past the right margin → available_width = 0 (not negative)
1118        let margins = create_test_margins(); // right = 50
1119        let mut context = TextFlowContext::new(400.0, 600.0, margins);
1120
1121        // cursor_x = 380, right margin = 50 → would be 400-50-380 = -30 → clamp to 0
1122        context.at(380.0, 500.0);
1123        assert_eq!(context.available_width(), 0.0);
1124    }
1125
1126    #[test]
1127    fn test_write_wrapped_at_x_limits_available_width() {
1128        // Page 400pt, margins 50pt each → content_width = 300pt
1129        // Place cursor at x=250: available_width = 400-50-250 = 100pt
1130        // Use text wider than 100pt but narrower than 300pt → must wrap at x=250
1131        let margins = create_test_margins();
1132        let mut context = TextFlowContext::new(400.0, 600.0, margins);
1133
1134        context.set_font(Font::Helvetica, 12.0);
1135        // "Hello World Hello World" at 12pt Helvetica exceeds 100pt easily
1136        context.at(250.0, 500.0);
1137        context.write_wrapped("Hello World Hello World").unwrap();
1138
1139        let ops = context.operations();
1140        // Multiple BT blocks → wrapping occurred
1141        let bt_count = ops.matches("BT\n").count();
1142        assert!(
1143            bt_count > 1,
1144            "Expected wrapping (multiple lines), got {bt_count} BT blocks. ops:\n{ops}"
1145        );
1146    }
1147
1148    #[test]
1149    fn test_write_wrapped_respects_cursor_x_offset() {
1150        // Cursor at x=300, page 600pt wide, margins 50pt each → available_width = 250pt
1151        let margins = Margins {
1152            left: 50.0,
1153            right: 50.0,
1154            top: 50.0,
1155            bottom: 50.0,
1156        };
1157        let mut context = TextFlowContext::new(600.0, 800.0, margins);
1158
1159        context.set_font(Font::Helvetica, 12.0);
1160        context.at(300.0, 700.0);
1161        context
1162            .write_wrapped("Hello World Foo Bar Baz Qux")
1163            .unwrap();
1164
1165        let ops = context.operations();
1166        // Every Td x-coordinate should be >= 300.0
1167        for line in ops.lines() {
1168            if line.ends_with(" Td") {
1169                let parts: Vec<&str> = line.split_whitespace().collect();
1170                if parts.len() >= 3 {
1171                    let x: f64 = parts[0].parse().expect("Td x should be a number");
1172                    assert!(
1173                        x >= 300.0 - 1e-6,
1174                        "Expected Td x >= 300.0 but got {x}. ops:\n{ops}"
1175                    );
1176                }
1177            }
1178        }
1179    }
1180
1181    #[test]
1182    fn test_text_flow_context_threads_metrics_store() {
1183        use crate::text::metrics::{FontMetrics, FontMetricsStore};
1184        let unique = format!("FlowThreadTask6_{}", std::process::id());
1185        let store = FontMetricsStore::new();
1186        // 'A' = 1000 units → (1000/1000) * 12.0 = 12.0 pts per char.
1187        // "AA" = 24.0 pts total line width with the per-store widths.
1188        // Without the store, the default fallback maps 'A' = 667 →
1189        // (667/1000) * 12.0 ≈ 8.004 pts per char → "AA" ≈ 16.008 pts.
1190        store.register(
1191            unique.clone(),
1192            FontMetrics::new(500).with_widths(&[('A', 1000)]),
1193        );
1194
1195        let mut ctx = TextFlowContext::with_metrics_store(
1196            595.0, // A4 width pt
1197            842.0, // A4 height pt
1198            Margins::default(),
1199            Some(store),
1200        );
1201        ctx.set_font(Font::Custom(unique), 12.0);
1202        ctx.set_alignment(TextAlign::Center);
1203        ctx.write_wrapped("AA").unwrap();
1204
1205        // With center alignment the emitted `Td x` is:
1206        //   x = margins.left + (available_width - line_width) / 2
1207        // available_width = 595 - 72 - 72 = 451 pts (A4, default margins)
1208        //
1209        // With store    : line_width = 24.0  → x = 72 + (451 - 24.0)  / 2 = 285.5
1210        // Without store : line_width ≈ 16.008 → x ≈ 72 + (451 - 16.008) / 2 ≈ 289.496
1211        //
1212        // The two values are ~4 pts apart, far above the 0.01 tolerance.
1213        // A regression where the store is silently dropped produces x ≈ 289.5
1214        // and the assertion fails.
1215        let margins = Margins::default();
1216        let available_width = 595.0_f64 - margins.left - margins.right; // 451.0
1217        let expected_line_width = 24.0_f64; // 'A'=1000 units × 2 chars × 12 pt / 1000
1218        let expected_td_x = margins.left + (available_width - expected_line_width) / 2.0;
1219
1220        let ops_bytes = ctx.generate_operations();
1221        let ops_str =
1222            String::from_utf8(ops_bytes).expect("generated operations must be valid UTF-8");
1223
1224        // Extract the Td x-coordinate from the first `<x> <y> Td` line.
1225        let td_x: f64 = ops_str
1226            .lines()
1227            .find(|l| l.ends_with(" Td"))
1228            .and_then(|l| l.split_whitespace().next())
1229            .and_then(|tok| tok.parse().ok())
1230            .expect("operations must contain a Td operator");
1231
1232        assert!(
1233            (td_x - expected_td_x).abs() < 0.01,
1234            "Td x must reflect per-store line width 24.0 pts \
1235             (expected {:.2}, got {:.2}); if the store was dropped the \
1236             fallback width produces x ≈ 289.50",
1237            expected_td_x,
1238            td_x
1239        );
1240    }
1241
1242    /// RED for Phase 3 of the v2.7.0 IR refactor: with the legacy `String`
1243    /// emission, a non-finite cursor position (e.g. `at(NaN, NaN)`) reaches
1244    /// `write_wrapped` and emits `NaN NaN Td`, which is invalid per
1245    /// ISO 32000-1 §7.3.3. Once the migration routes Td through
1246    /// `serialize_ops`, `finite_or_zero` clamps non-finite values to `0.0`
1247    /// and the assertion below passes.
1248    #[test]
1249    fn nan_cursor_position_in_flow_is_sanitised_at_emission() {
1250        let mut ctx = TextFlowContext::new(595.0, 842.0, Margins::default());
1251        ctx.at(f64::NAN, f64::NAN);
1252        ctx.write_wrapped("hello").unwrap();
1253        let ops = String::from_utf8(ctx.generate_operations())
1254            .expect("operations bytes must be valid UTF-8");
1255        assert!(
1256            !ops.contains("NaN") && !ops.contains("inf"),
1257            "non-finite tokens must not appear in flow content stream, got: {ops:?}"
1258        );
1259        assert!(
1260            ops.contains(" Td\n"),
1261            "Td operator must still be emitted, got: {ops:?}"
1262        );
1263    }
1264}