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            // Encode + escape through the shared show-text factory
337            // (issue #240). Pre-fix this branch wrote `ch.encode_utf8(...)`
338            // bytes directly, bypassing `TextEncoding::WinAnsiEncoding` so
339            // characters outside ASCII (`€`, `—`, smart quotes …) reached
340            // the content stream as raw UTF-8 multi-byte runs that any
341            // PDF viewer re-interpreted as Windows-1252 mojibake.
342            self.operations.push(crate::text::build_show_text_op(
343                &line_text,
344                &self.current_font,
345            ));
346
347            // Record per-font char usage so the consuming page can
348            // report it to the writer (issue #204).
349            self.used_characters_by_font
350                .entry(self.current_font.pdf_name())
351                .or_default()
352                .extend(line_text.chars());
353
354            // Reset word spacing if it was set. The IR emits this as
355            // `0.00 Tw` (was `0 Tw` in pre-2.7.0 — documented in CHANGELOG).
356            if self.alignment == TextAlign::Justified && i < lines.len() - 1 {
357                self.operations.push(Op::SetWordSpacing(0.0));
358            }
359
360            self.operations.push(Op::EndText);
361
362            // Move cursor down for next line
363            self.cursor_y -= self.font_size * self.line_height;
364        }
365
366        Ok(self)
367    }
368
369    pub fn write_paragraph(&mut self, text: &str) -> Result<&mut Self> {
370        self.write_wrapped(text)?;
371        // Add extra space after paragraph
372        self.cursor_y -= self.font_size * self.line_height * 0.5;
373        Ok(self)
374    }
375
376    pub fn newline(&mut self) -> &mut Self {
377        self.cursor_y -= self.font_size * self.line_height;
378        self.cursor_x = self.margins.left;
379        self
380    }
381
382    pub fn cursor_position(&self) -> (f64, f64) {
383        (self.cursor_x, self.cursor_y)
384    }
385
386    pub fn generate_operations(&self) -> Vec<u8> {
387        let mut buf = Vec::new();
388        crate::graphics::ops::serialize_ops(&mut buf, &self.operations);
389        buf
390    }
391
392    /// Get the current alignment
393    pub fn alignment(&self) -> TextAlign {
394        self.alignment
395    }
396
397    /// Get the page dimensions
398    pub fn page_dimensions(&self) -> (f64, f64) {
399        (self.page_width, self.page_height)
400    }
401
402    /// Get the margins
403    pub fn margins(&self) -> &Margins {
404        &self.margins
405    }
406
407    /// Get current line height multiplier
408    pub fn line_height(&self) -> f64 {
409        self.line_height
410    }
411
412    /// Get the operations as a serialised PDF content-stream `String`.
413    ///
414    /// Pre-2.7.0 this returned `&str`. The IR migration replaced the
415    /// internal `String` buffer with a typed `Vec<Op>`, so the legacy
416    /// borrow is materialised on demand. Internal callers prefer
417    /// `generate_operations()` which returns the byte buffer directly.
418    pub fn operations(&self) -> String {
419        crate::graphics::ops::ops_to_string(&self.operations)
420    }
421
422    /// Clear all operations
423    pub fn clear(&mut self) {
424        self.operations.clear();
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use crate::page::Margins;
432
433    fn create_test_margins() -> Margins {
434        Margins {
435            left: 50.0,
436            right: 50.0,
437            top: 50.0,
438            bottom: 50.0,
439        }
440    }
441
442    #[test]
443    fn test_text_flow_context_new() {
444        let margins = create_test_margins();
445        let context = TextFlowContext::new(400.0, 600.0, margins);
446
447        assert_eq!(context.current_font, Font::Helvetica);
448        assert_eq!(context.font_size, 12.0);
449        assert_eq!(context.line_height, 1.2);
450        assert_eq!(context.alignment, TextAlign::Left);
451        assert_eq!(context.page_width, 400.0);
452        assert_eq!(context.page_height, 600.0);
453        assert_eq!(context.cursor_x, 50.0); // margins.left
454        assert_eq!(context.cursor_y, 550.0); // page_height - margins.top
455    }
456
457    #[test]
458    fn test_set_font() {
459        let margins = create_test_margins();
460        let mut context = TextFlowContext::new(400.0, 600.0, margins);
461
462        context.set_font(Font::TimesBold, 16.0);
463        assert_eq!(context.current_font, Font::TimesBold);
464        assert_eq!(context.font_size, 16.0);
465    }
466
467    #[test]
468    fn test_set_line_height() {
469        let margins = create_test_margins();
470        let mut context = TextFlowContext::new(400.0, 600.0, margins);
471
472        context.set_line_height(1.5);
473        assert_eq!(context.line_height(), 1.5);
474    }
475
476    #[test]
477    fn test_set_alignment() {
478        let margins = create_test_margins();
479        let mut context = TextFlowContext::new(400.0, 600.0, margins);
480
481        context.set_alignment(TextAlign::Center);
482        assert_eq!(context.alignment(), TextAlign::Center);
483    }
484
485    #[test]
486    fn test_at_position() {
487        let margins = create_test_margins();
488        let mut context = TextFlowContext::new(400.0, 600.0, margins);
489
490        context.at(100.0, 200.0);
491        let (x, y) = context.cursor_position();
492        assert_eq!(x, 100.0);
493        assert_eq!(y, 200.0);
494    }
495
496    #[test]
497    fn test_content_width() {
498        let margins = create_test_margins();
499        let context = TextFlowContext::new(400.0, 600.0, margins);
500
501        let content_width = context.content_width();
502        assert_eq!(content_width, 300.0); // 400 - 50 - 50
503    }
504
505    #[test]
506    fn test_text_align_variants() {
507        assert_eq!(TextAlign::Left, TextAlign::Left);
508        assert_eq!(TextAlign::Right, TextAlign::Right);
509        assert_eq!(TextAlign::Center, TextAlign::Center);
510        assert_eq!(TextAlign::Justified, TextAlign::Justified);
511
512        assert_ne!(TextAlign::Left, TextAlign::Right);
513    }
514
515    #[test]
516    fn test_write_wrapped_simple() {
517        let margins = create_test_margins();
518        let mut context = TextFlowContext::new(400.0, 600.0, margins);
519
520        context.write_wrapped("Hello World").unwrap();
521
522        let ops = context.operations();
523        assert!(ops.contains("BT\n"));
524        assert!(ops.contains("ET\n"));
525        assert!(ops.contains("/Helvetica 12 Tf"));
526        assert!(ops.contains("(Hello World) Tj"));
527    }
528
529    #[test]
530    fn test_write_paragraph() {
531        let margins = create_test_margins();
532        let mut context = TextFlowContext::new(400.0, 600.0, margins);
533
534        let initial_y = context.cursor_y;
535        context.write_paragraph("Test paragraph").unwrap();
536
537        // Y position should have moved down more than just line height
538        assert!(context.cursor_y < initial_y);
539    }
540
541    #[test]
542    fn test_newline() {
543        let margins = create_test_margins();
544        let mut context = TextFlowContext::new(400.0, 600.0, margins.clone());
545
546        let initial_y = context.cursor_y;
547        context.newline();
548
549        assert_eq!(context.cursor_x, margins.left);
550        assert!(context.cursor_y < initial_y);
551        assert_eq!(
552            context.cursor_y,
553            initial_y - context.font_size * context.line_height
554        );
555    }
556
557    #[test]
558    fn test_cursor_position() {
559        let margins = create_test_margins();
560        let mut context = TextFlowContext::new(400.0, 600.0, margins);
561
562        context.at(75.0, 125.0);
563        let (x, y) = context.cursor_position();
564        assert_eq!(x, 75.0);
565        assert_eq!(y, 125.0);
566    }
567
568    #[test]
569    fn test_generate_operations() {
570        let margins = create_test_margins();
571        let mut context = TextFlowContext::new(400.0, 600.0, margins);
572
573        context.write_wrapped("Test").unwrap();
574        let ops_bytes = context.generate_operations();
575        let ops_string = String::from_utf8(ops_bytes).unwrap();
576
577        assert_eq!(ops_string, context.operations());
578    }
579
580    #[test]
581    fn test_clear_operations() {
582        let margins = create_test_margins();
583        let mut context = TextFlowContext::new(400.0, 600.0, margins);
584
585        context.write_wrapped("Test").unwrap();
586        assert!(!context.operations().is_empty());
587
588        context.clear();
589        assert!(context.operations().is_empty());
590    }
591
592    #[test]
593    fn test_page_dimensions() {
594        let margins = create_test_margins();
595        let context = TextFlowContext::new(400.0, 600.0, margins);
596
597        let (width, height) = context.page_dimensions();
598        assert_eq!(width, 400.0);
599        assert_eq!(height, 600.0);
600    }
601
602    #[test]
603    fn test_margins_access() {
604        let margins = create_test_margins();
605        let context = TextFlowContext::new(400.0, 600.0, margins);
606
607        let ctx_margins = context.margins();
608        assert_eq!(ctx_margins.left, 50.0);
609        assert_eq!(ctx_margins.right, 50.0);
610        assert_eq!(ctx_margins.top, 50.0);
611        assert_eq!(ctx_margins.bottom, 50.0);
612    }
613
614    #[test]
615    fn test_method_chaining() {
616        let margins = create_test_margins();
617        let mut context = TextFlowContext::new(400.0, 600.0, margins);
618
619        context
620            .set_font(Font::Courier, 10.0)
621            .set_line_height(1.5)
622            .set_alignment(TextAlign::Center)
623            .at(100.0, 200.0);
624
625        assert_eq!(context.current_font, Font::Courier);
626        assert_eq!(context.font_size, 10.0);
627        assert_eq!(context.line_height(), 1.5);
628        assert_eq!(context.alignment(), TextAlign::Center);
629        let (x, y) = context.cursor_position();
630        assert_eq!(x, 100.0);
631        assert_eq!(y, 200.0);
632    }
633
634    #[test]
635    fn test_text_align_debug() {
636        let align = TextAlign::Center;
637        let debug_str = format!("{align:?}");
638        assert_eq!(debug_str, "Center");
639    }
640
641    #[test]
642    fn test_text_align_clone() {
643        let align1 = TextAlign::Justified;
644        let align2 = align1;
645        assert_eq!(align1, align2);
646    }
647
648    #[test]
649    fn test_text_align_copy() {
650        let align1 = TextAlign::Right;
651        let align2 = align1; // Copy semantics
652        assert_eq!(align1, align2);
653
654        // Both variables should still be usable
655        assert_eq!(align1, TextAlign::Right);
656        assert_eq!(align2, TextAlign::Right);
657    }
658
659    #[test]
660    fn test_write_wrapped_with_alignment_right() {
661        let margins = create_test_margins();
662        let mut context = TextFlowContext::new(400.0, 600.0, margins);
663
664        context.set_alignment(TextAlign::Right);
665        context.write_wrapped("Right aligned text").unwrap();
666
667        let ops = context.operations();
668        assert!(ops.contains("BT\n"));
669        assert!(ops.contains("ET\n"));
670        // Right alignment should position text differently
671        assert!(ops.contains("Td"));
672    }
673
674    #[test]
675    fn test_write_wrapped_with_alignment_center() {
676        let margins = create_test_margins();
677        let mut context = TextFlowContext::new(400.0, 600.0, margins);
678
679        context.set_alignment(TextAlign::Center);
680        context.write_wrapped("Centered text").unwrap();
681
682        let ops = context.operations();
683        assert!(ops.contains("BT\n"));
684        assert!(ops.contains("(Centered text) Tj"));
685    }
686
687    #[test]
688    fn test_write_wrapped_with_alignment_justified() {
689        let margins = create_test_margins();
690        let mut context = TextFlowContext::new(400.0, 600.0, margins);
691
692        context.set_alignment(TextAlign::Justified);
693        // Long text that will wrap and justify
694        context.write_wrapped("This is a longer text that should wrap across multiple lines to test justification").unwrap();
695
696        let ops = context.operations();
697        assert!(ops.contains("BT\n"));
698        // Justified text may have word spacing adjustments
699        assert!(ops.contains("Tw") || ops.contains("0 Tw"));
700    }
701
702    #[test]
703    fn test_write_wrapped_empty_text() {
704        let margins = create_test_margins();
705        let mut context = TextFlowContext::new(400.0, 600.0, margins);
706
707        context.write_wrapped("").unwrap();
708
709        // Empty text should not generate operations
710        assert!(context.operations().is_empty());
711    }
712
713    #[test]
714    fn test_write_wrapped_whitespace_only() {
715        let margins = create_test_margins();
716        let mut context = TextFlowContext::new(400.0, 600.0, margins);
717
718        context.write_wrapped("   ").unwrap();
719
720        let ops = context.operations();
721        // Should handle whitespace-only text
722        assert!(ops.contains("BT\n") || ops.is_empty());
723    }
724
725    #[test]
726    fn test_write_wrapped_special_characters() {
727        let margins = create_test_margins();
728        let mut context = TextFlowContext::new(400.0, 600.0, margins);
729
730        context
731            .write_wrapped("Text with (parentheses) and \\backslash\\")
732            .unwrap();
733
734        let ops = context.operations();
735        // Special characters should be escaped
736        assert!(ops.contains("\\(parentheses\\)"));
737        assert!(ops.contains("\\\\backslash\\\\"));
738    }
739
740    #[test]
741    fn test_write_wrapped_newlines_tabs() {
742        let margins = create_test_margins();
743        let mut context = TextFlowContext::new(400.0, 600.0, margins);
744
745        context.write_wrapped("Line1\nLine2\tTabbed").unwrap();
746
747        let ops = context.operations();
748        // Newlines and tabs should be escaped
749        assert!(ops.contains("\\n") || ops.contains("\\t"));
750    }
751
752    #[test]
753    fn test_write_wrapped_very_long_word() {
754        let margins = create_test_margins();
755        let mut context = TextFlowContext::new(200.0, 600.0, margins); // Narrow page
756
757        let long_word = "a".repeat(100);
758        context.write_wrapped(&long_word).unwrap();
759
760        let ops = context.operations();
761        assert!(ops.contains("BT\n"));
762        assert!(ops.contains(&long_word));
763    }
764
765    #[test]
766    fn test_write_wrapped_cursor_movement() {
767        let margins = create_test_margins();
768        let mut context = TextFlowContext::new(400.0, 600.0, margins);
769
770        let initial_y = context.cursor_y;
771
772        context.write_wrapped("Line 1").unwrap();
773        let y_after_line1 = context.cursor_y;
774
775        context.write_wrapped("Line 2").unwrap();
776        let y_after_line2 = context.cursor_y;
777
778        // Cursor should move down after each line
779        assert!(y_after_line1 < initial_y);
780        assert!(y_after_line2 < y_after_line1);
781    }
782
783    #[test]
784    fn test_write_paragraph_spacing() {
785        let margins = create_test_margins();
786        let mut context = TextFlowContext::new(400.0, 600.0, margins);
787
788        let initial_y = context.cursor_y;
789        context.write_paragraph("Paragraph 1").unwrap();
790        let y_after_p1 = context.cursor_y;
791
792        context.write_paragraph("Paragraph 2").unwrap();
793        let y_after_p2 = context.cursor_y;
794
795        // Paragraphs should have extra spacing
796        let spacing1 = initial_y - y_after_p1;
797        let spacing2 = y_after_p1 - y_after_p2;
798
799        assert!(spacing1 > 0.0);
800        assert!(spacing2 > 0.0);
801    }
802
803    #[test]
804    fn test_multiple_newlines() {
805        let margins = create_test_margins();
806        let mut context = TextFlowContext::new(400.0, 600.0, margins);
807
808        let initial_y = context.cursor_y;
809
810        context.newline();
811        let y1 = context.cursor_y;
812
813        context.newline();
814        let y2 = context.cursor_y;
815
816        context.newline();
817        let y3 = context.cursor_y;
818
819        // Each newline should move cursor down by same amount
820        let spacing1 = initial_y - y1;
821        let spacing2 = y1 - y2;
822        let spacing3 = y2 - y3;
823
824        // Use approximate equality for floating point comparisons
825        assert!((spacing1 - spacing2).abs() < 1e-10);
826        assert!((spacing2 - spacing3).abs() < 1e-10);
827        assert!((spacing1 - context.font_size * context.line_height).abs() < 1e-10);
828    }
829
830    #[test]
831    fn test_content_width_different_margins() {
832        let margins = Margins {
833            left: 30.0,
834            right: 70.0,
835            top: 40.0,
836            bottom: 60.0,
837        };
838        let context = TextFlowContext::new(500.0, 700.0, margins);
839
840        let content_width = context.content_width();
841        assert_eq!(content_width, 400.0); // 500 - 30 - 70
842    }
843
844    #[test]
845    fn test_custom_line_height() {
846        let margins = create_test_margins();
847        let mut context = TextFlowContext::new(400.0, 600.0, margins);
848
849        context.set_line_height(2.0);
850
851        let initial_y = context.cursor_y;
852        context.newline();
853        let y_after = context.cursor_y;
854
855        let spacing = initial_y - y_after;
856        assert_eq!(spacing, context.font_size * 2.0); // line_height = 2.0
857    }
858
859    #[test]
860    fn test_different_fonts() {
861        let margins = create_test_margins();
862        let mut context = TextFlowContext::new(400.0, 600.0, margins);
863
864        let fonts = vec![
865            Font::Helvetica,
866            Font::HelveticaBold,
867            Font::TimesRoman,
868            Font::TimesBold,
869            Font::Courier,
870            Font::CourierBold,
871        ];
872
873        for font in fonts {
874            context.clear();
875            let font_name = font.pdf_name();
876            context.set_font(font, 14.0);
877            context.write_wrapped("Test text").unwrap();
878
879            let ops = context.operations();
880            assert!(ops.contains(&format!("/{font_name} 14 Tf")));
881        }
882    }
883
884    #[test]
885    fn test_font_size_variations() {
886        let margins = create_test_margins();
887        let mut context = TextFlowContext::new(400.0, 600.0, margins);
888
889        let sizes = vec![8.0, 10.0, 12.0, 14.0, 16.0, 24.0, 36.0];
890
891        for size in sizes {
892            context.clear();
893            context.set_font(Font::Helvetica, size);
894            context.write_wrapped("Test").unwrap();
895
896            let ops = context.operations();
897            assert!(ops.contains(&format!("/Helvetica {size} Tf")));
898        }
899    }
900
901    #[test]
902    fn test_at_position_edge_cases() {
903        let margins = create_test_margins();
904        let mut context = TextFlowContext::new(400.0, 600.0, margins);
905
906        // Test zero position
907        context.at(0.0, 0.0);
908        assert_eq!(context.cursor_position(), (0.0, 0.0));
909
910        // Test negative position
911        context.at(-10.0, -20.0);
912        assert_eq!(context.cursor_position(), (-10.0, -20.0));
913
914        // Test large position
915        context.at(10000.0, 20000.0);
916        assert_eq!(context.cursor_position(), (10000.0, 20000.0));
917    }
918
919    #[test]
920    fn test_write_wrapped_with_narrow_content() {
921        let margins = Margins {
922            left: 190.0,
923            right: 190.0,
924            top: 50.0,
925            bottom: 50.0,
926        };
927        let mut context = TextFlowContext::new(400.0, 600.0, margins);
928
929        // Content width is only 20.0 units
930        context
931            .write_wrapped("This text should wrap a lot")
932            .unwrap();
933
934        let ops = context.operations();
935        // Should have multiple text objects for wrapped lines
936        let bt_count = ops.matches("BT\n").count();
937        assert!(bt_count > 1);
938    }
939
940    #[test]
941    fn test_justified_text_single_word_line() {
942        let margins = create_test_margins();
943        let mut context = TextFlowContext::new(400.0, 600.0, margins);
944
945        context.set_alignment(TextAlign::Justified);
946        context.write_wrapped("SingleWord").unwrap();
947
948        let ops = context.operations();
949        // Single word lines should not have word spacing
950        assert!(!ops.contains(" Tw") || ops.contains("0 Tw"));
951    }
952
953    #[test]
954    fn test_justified_text_last_line() {
955        let margins = create_test_margins();
956        let mut context = TextFlowContext::new(400.0, 600.0, margins);
957
958        context.set_alignment(TextAlign::Justified);
959        // Text that will create multiple lines
960        context.write_wrapped("This is a test of justified text alignment where the last line should not be justified").unwrap();
961
962        let ops = context.operations();
963        // Should reset word spacing (0 Tw) for last line
964        assert!(ops.contains("0 Tw"));
965    }
966
967    #[test]
968    fn test_generate_operations_encoding() {
969        let margins = create_test_margins();
970        let mut context = TextFlowContext::new(400.0, 600.0, margins);
971
972        context.write_wrapped("UTF-8 Text: Ñ").unwrap();
973
974        let ops_bytes = context.generate_operations();
975        let ops_string = String::from_utf8(ops_bytes.clone()).unwrap();
976
977        assert_eq!(ops_bytes, context.operations().as_bytes());
978        assert_eq!(ops_string, context.operations());
979    }
980
981    #[test]
982    fn test_clear_resets_operations_only() {
983        let margins = create_test_margins();
984        let mut context = TextFlowContext::new(400.0, 600.0, margins);
985
986        context.set_font(Font::TimesBold, 18.0);
987        context.set_alignment(TextAlign::Right);
988        context.at(100.0, 200.0);
989        context.write_wrapped("Text").unwrap();
990
991        context.clear();
992
993        // Operations should be cleared
994        assert!(context.operations().is_empty());
995
996        // But other settings should remain
997        assert_eq!(context.current_font, Font::TimesBold);
998        assert_eq!(context.font_size, 18.0);
999        assert_eq!(context.alignment(), TextAlign::Right);
1000        // Cursor position should reflect where we are after writing text (moved down by line height)
1001        let (x, y) = context.cursor_position();
1002        assert_eq!(x, 100.0); // X position should be unchanged
1003        assert!(y < 200.0); // Y position should have moved down after writing text
1004    }
1005
1006    #[test]
1007    fn test_long_text_wrapping() {
1008        let margins = create_test_margins();
1009        let mut context = TextFlowContext::new(400.0, 600.0, margins);
1010
1011        let long_text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. \
1012                        Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. \
1013                        Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.";
1014
1015        context.write_wrapped(long_text).unwrap();
1016
1017        let ops = context.operations();
1018        // Should have multiple lines
1019        let tj_count = ops.matches(") Tj").count();
1020        assert!(tj_count > 1);
1021    }
1022
1023    #[test]
1024    fn test_empty_operations_initially() {
1025        let margins = create_test_margins();
1026        let context = TextFlowContext::new(400.0, 600.0, margins);
1027
1028        assert!(context.operations().is_empty());
1029        assert_eq!(context.generate_operations().len(), 0);
1030    }
1031
1032    #[test]
1033    fn test_write_paragraph_empty() {
1034        let margins = create_test_margins();
1035        let mut context = TextFlowContext::new(400.0, 600.0, margins);
1036
1037        let initial_y = context.cursor_y;
1038        context.write_paragraph("").unwrap();
1039
1040        // Empty paragraph should still add spacing
1041        assert!(context.cursor_y < initial_y);
1042    }
1043
1044    #[test]
1045    fn test_extreme_line_height() {
1046        let margins = create_test_margins();
1047        let mut context = TextFlowContext::new(400.0, 600.0, margins);
1048
1049        // Very small line height
1050        context.set_line_height(0.1);
1051        let initial_y = context.cursor_y;
1052        context.newline();
1053        assert_eq!(context.cursor_y, initial_y - context.font_size * 0.1);
1054
1055        // Very large line height
1056        context.set_line_height(10.0);
1057        let initial_y2 = context.cursor_y;
1058        context.newline();
1059        assert_eq!(context.cursor_y, initial_y2 - context.font_size * 10.0);
1060    }
1061
1062    #[test]
1063    fn test_zero_content_width() {
1064        let margins = Margins {
1065            left: 200.0,
1066            right: 200.0,
1067            top: 50.0,
1068            bottom: 50.0,
1069        };
1070        let context = TextFlowContext::new(400.0, 600.0, margins);
1071
1072        assert_eq!(context.content_width(), 0.0);
1073    }
1074
1075    #[test]
1076    fn test_cursor_x_reset_on_newline() {
1077        let margins = create_test_margins();
1078        let mut context = TextFlowContext::new(400.0, 600.0, margins.clone());
1079
1080        context.at(250.0, 300.0); // Move cursor to custom position
1081        context.newline();
1082
1083        // X should reset to left margin
1084        assert_eq!(context.cursor_x, margins.left);
1085        // Y should decrease by line height
1086        assert_eq!(
1087            context.cursor_y,
1088            300.0 - context.font_size * context.line_height
1089        );
1090    }
1091
1092    // --- Issue #167: available_width respects cursor_x ---
1093
1094    #[test]
1095    fn test_available_width_respects_cursor_x() {
1096        // Page: 400pt wide, 50pt margins each side → content_width = 300pt
1097        let margins = create_test_margins(); // left=50, right=50
1098        let mut context = TextFlowContext::new(400.0, 600.0, margins);
1099
1100        // Default: cursor_x == margins.left == 50, available_width == 300
1101        assert_eq!(context.available_width(), 300.0);
1102
1103        // After .at(200, 500): cursor_x = 200, available_width = 400 - 50 - 200 = 150
1104        context.at(200.0, 500.0);
1105        assert_eq!(context.available_width(), 150.0);
1106    }
1107
1108    #[test]
1109    fn test_available_width_clamps_to_zero() {
1110        // cursor_x past the right margin → available_width = 0 (not negative)
1111        let margins = create_test_margins(); // right = 50
1112        let mut context = TextFlowContext::new(400.0, 600.0, margins);
1113
1114        // cursor_x = 380, right margin = 50 → would be 400-50-380 = -30 → clamp to 0
1115        context.at(380.0, 500.0);
1116        assert_eq!(context.available_width(), 0.0);
1117    }
1118
1119    #[test]
1120    fn test_write_wrapped_at_x_limits_available_width() {
1121        // Page 400pt, margins 50pt each → content_width = 300pt
1122        // Place cursor at x=250: available_width = 400-50-250 = 100pt
1123        // Use text wider than 100pt but narrower than 300pt → must wrap at x=250
1124        let margins = create_test_margins();
1125        let mut context = TextFlowContext::new(400.0, 600.0, margins);
1126
1127        context.set_font(Font::Helvetica, 12.0);
1128        // "Hello World Hello World" at 12pt Helvetica exceeds 100pt easily
1129        context.at(250.0, 500.0);
1130        context.write_wrapped("Hello World Hello World").unwrap();
1131
1132        let ops = context.operations();
1133        // Multiple BT blocks → wrapping occurred
1134        let bt_count = ops.matches("BT\n").count();
1135        assert!(
1136            bt_count > 1,
1137            "Expected wrapping (multiple lines), got {bt_count} BT blocks. ops:\n{ops}"
1138        );
1139    }
1140
1141    #[test]
1142    fn test_write_wrapped_respects_cursor_x_offset() {
1143        // Cursor at x=300, page 600pt wide, margins 50pt each → available_width = 250pt
1144        let margins = Margins {
1145            left: 50.0,
1146            right: 50.0,
1147            top: 50.0,
1148            bottom: 50.0,
1149        };
1150        let mut context = TextFlowContext::new(600.0, 800.0, margins);
1151
1152        context.set_font(Font::Helvetica, 12.0);
1153        context.at(300.0, 700.0);
1154        context
1155            .write_wrapped("Hello World Foo Bar Baz Qux")
1156            .unwrap();
1157
1158        let ops = context.operations();
1159        // Every Td x-coordinate should be >= 300.0
1160        for line in ops.lines() {
1161            if line.ends_with(" Td") {
1162                let parts: Vec<&str> = line.split_whitespace().collect();
1163                if parts.len() >= 3 {
1164                    let x: f64 = parts[0].parse().expect("Td x should be a number");
1165                    assert!(
1166                        x >= 300.0 - 1e-6,
1167                        "Expected Td x >= 300.0 but got {x}. ops:\n{ops}"
1168                    );
1169                }
1170            }
1171        }
1172    }
1173
1174    #[test]
1175    fn test_text_flow_context_threads_metrics_store() {
1176        use crate::text::metrics::{FontMetrics, FontMetricsStore};
1177        let unique = format!("FlowThreadTask6_{}", std::process::id());
1178        let store = FontMetricsStore::new();
1179        // 'A' = 1000 units → (1000/1000) * 12.0 = 12.0 pts per char.
1180        // "AA" = 24.0 pts total line width with the per-store widths.
1181        // Without the store, the default fallback maps 'A' = 667 →
1182        // (667/1000) * 12.0 ≈ 8.004 pts per char → "AA" ≈ 16.008 pts.
1183        store.register(
1184            unique.clone(),
1185            FontMetrics::new(500).with_widths(&[('A', 1000)]),
1186        );
1187
1188        let mut ctx = TextFlowContext::with_metrics_store(
1189            595.0, // A4 width pt
1190            842.0, // A4 height pt
1191            Margins::default(),
1192            Some(store),
1193        );
1194        ctx.set_font(Font::Custom(unique), 12.0);
1195        ctx.set_alignment(TextAlign::Center);
1196        ctx.write_wrapped("AA").unwrap();
1197
1198        // With center alignment the emitted `Td x` is:
1199        //   x = margins.left + (available_width - line_width) / 2
1200        // available_width = 595 - 72 - 72 = 451 pts (A4, default margins)
1201        //
1202        // With store    : line_width = 24.0  → x = 72 + (451 - 24.0)  / 2 = 285.5
1203        // Without store : line_width ≈ 16.008 → x ≈ 72 + (451 - 16.008) / 2 ≈ 289.496
1204        //
1205        // The two values are ~4 pts apart, far above the 0.01 tolerance.
1206        // A regression where the store is silently dropped produces x ≈ 289.5
1207        // and the assertion fails.
1208        let margins = Margins::default();
1209        let available_width = 595.0_f64 - margins.left - margins.right; // 451.0
1210        let expected_line_width = 24.0_f64; // 'A'=1000 units × 2 chars × 12 pt / 1000
1211        let expected_td_x = margins.left + (available_width - expected_line_width) / 2.0;
1212
1213        let ops_bytes = ctx.generate_operations();
1214        let ops_str =
1215            String::from_utf8(ops_bytes).expect("generated operations must be valid UTF-8");
1216
1217        // Extract the Td x-coordinate from the first `<x> <y> Td` line.
1218        let td_x: f64 = ops_str
1219            .lines()
1220            .find(|l| l.ends_with(" Td"))
1221            .and_then(|l| l.split_whitespace().next())
1222            .and_then(|tok| tok.parse().ok())
1223            .expect("operations must contain a Td operator");
1224
1225        assert!(
1226            (td_x - expected_td_x).abs() < 0.01,
1227            "Td x must reflect per-store line width 24.0 pts \
1228             (expected {:.2}, got {:.2}); if the store was dropped the \
1229             fallback width produces x ≈ 289.50",
1230            expected_td_x,
1231            td_x
1232        );
1233    }
1234
1235    /// RED for Phase 3 of the v2.7.0 IR refactor: with the legacy `String`
1236    /// emission, a non-finite cursor position (e.g. `at(NaN, NaN)`) reaches
1237    /// `write_wrapped` and emits `NaN NaN Td`, which is invalid per
1238    /// ISO 32000-1 §7.3.3. Once the migration routes Td through
1239    /// `serialize_ops`, `finite_or_zero` clamps non-finite values to `0.0`
1240    /// and the assertion below passes.
1241    #[test]
1242    fn nan_cursor_position_in_flow_is_sanitised_at_emission() {
1243        let mut ctx = TextFlowContext::new(595.0, 842.0, Margins::default());
1244        ctx.at(f64::NAN, f64::NAN);
1245        ctx.write_wrapped("hello").unwrap();
1246        let ops = String::from_utf8(ctx.generate_operations())
1247            .expect("operations bytes must be valid UTF-8");
1248        assert!(
1249            !ops.contains("NaN") && !ops.contains("inf"),
1250            "non-finite tokens must not appear in flow content stream, got: {ops:?}"
1251        );
1252        assert!(
1253            ops.contains(" Td\n"),
1254            "Td operator must still be emitted, got: {ops:?}"
1255        );
1256    }
1257}