oxidize_pdf/text/text_block.rs
1use crate::text::{measure_text, split_into_words, Font};
2
3/// Result of measuring a text block before rendering.
4///
5/// Used to calculate how much vertical space a block of wrapped text
6/// will occupy, enabling layout decisions (page breaks, element positioning)
7/// before committing to rendering.
8#[derive(Debug, Clone, PartialEq)]
9pub struct TextBlockMetrics {
10 /// Width of the longest line in points
11 pub width: f64,
12 /// Total height of the block in points (font_size × line_height × line_count)
13 pub height: f64,
14 /// Number of lines after word-wrapping
15 pub line_count: usize,
16}
17
18/// Computes wrapped line widths for a given text, font, size, and max width.
19///
20/// Returns a vector of line widths (one per wrapped line). This is the shared
21/// wrapping algorithm used by both `measure_text_block` and `TextFlowContext`.
22///
23/// Each "word" from `split_into_words` is placed on the current line if it fits;
24/// otherwise a new line is started. A single word wider than `max_width` is placed
25/// on its own line (it will exceed `max_width` but avoids infinite loops).
26pub fn compute_line_widths(text: &str, font: &Font, font_size: f64, max_width: f64) -> Vec<f64> {
27 if text.is_empty() {
28 return Vec::new();
29 }
30
31 let words = split_into_words(text);
32 if words.is_empty() {
33 return Vec::new();
34 }
35
36 let mut line_widths: Vec<f64> = Vec::new();
37 let mut current_width = 0.0;
38
39 for word in &words {
40 let word_width = measure_text(word, font, font_size);
41
42 if current_width > 0.0 && current_width + word_width > max_width {
43 line_widths.push(current_width);
44 current_width = word_width;
45 } else {
46 current_width += word_width;
47 }
48 }
49
50 if current_width > 0.0 {
51 line_widths.push(current_width);
52 }
53
54 line_widths
55}
56
57/// Measures a block of word-wrapped text without rendering it.
58///
59/// Given a text string, font, font size, line height multiplier, and maximum
60/// width, computes how the text would be laid out with word wrapping and returns
61/// the resulting dimensions.
62///
63/// # Arguments
64///
65/// * `text` - The text to measure
66/// * `font` - The font to use for width calculations
67/// * `font_size` - Font size in points
68/// * `line_height` - Line height multiplier (e.g., 1.2 for 120% spacing)
69/// * `max_width` - Maximum width available for text in points
70///
71/// # Returns
72///
73/// A `TextBlockMetrics` with the measured `width`, `height`, and `line_count`.
74///
75/// # Example
76///
77/// ```rust
78/// use oxidize_pdf::text::text_block::measure_text_block;
79/// use oxidize_pdf::Font;
80///
81/// let metrics = measure_text_block("Hello World", &Font::Helvetica, 12.0, 1.2, 200.0);
82/// assert_eq!(metrics.line_count, 1);
83/// assert!(metrics.width > 0.0);
84/// assert!(metrics.height > 0.0);
85/// ```
86pub fn measure_text_block(
87 text: &str,
88 font: &Font,
89 font_size: f64,
90 line_height: f64,
91 max_width: f64,
92) -> TextBlockMetrics {
93 let line_widths = compute_line_widths(text, font, font_size, max_width);
94
95 let line_count = line_widths.len();
96 let width = line_widths.iter().copied().fold(0.0_f64, f64::max);
97 let height = line_count as f64 * font_size * line_height;
98
99 TextBlockMetrics {
100 width,
101 height,
102 line_count,
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109
110 #[test]
111 fn test_compute_line_widths_empty() {
112 let widths = compute_line_widths("", &Font::Helvetica, 12.0, 200.0);
113 assert!(widths.is_empty());
114 }
115
116 #[test]
117 fn test_compute_line_widths_single_word() {
118 let widths = compute_line_widths("Hello", &Font::Helvetica, 12.0, 500.0);
119 assert_eq!(widths.len(), 1);
120 assert!(widths[0] > 0.0);
121 }
122
123 #[test]
124 fn test_measure_text_block_empty() {
125 let m = measure_text_block("", &Font::Helvetica, 12.0, 1.2, 300.0);
126 assert_eq!(m.line_count, 0);
127 assert_eq!(m.width, 0.0);
128 assert_eq!(m.height, 0.0);
129 }
130}