oxidize_pdf/text/text_block.rs
1use crate::text::metrics::{measure_text_with, FontMetricsStore};
2use crate::text::{split_into_words, Font};
3
4/// Result of measuring a text block before rendering.
5///
6/// Used to calculate how much vertical space a block of wrapped text
7/// will occupy, enabling layout decisions (page breaks, element positioning)
8/// before committing to rendering.
9#[derive(Debug, Clone, PartialEq)]
10pub struct TextBlockMetrics {
11 /// Width of the longest line in points
12 pub width: f64,
13 /// Total height of the block in points (font_size × line_height × line_count)
14 pub height: f64,
15 /// Number of lines after word-wrapping
16 pub line_count: usize,
17}
18
19/// Computes wrapped line widths for a given text, font, size, and max width.
20///
21/// Returns a vector of line widths (one per wrapped line). This is the shared
22/// wrapping algorithm used by both `measure_text_block` and `TextFlowContext`.
23///
24/// Each "word" from `split_into_words` is placed on the current line if it fits;
25/// otherwise a new line is started. A single word wider than `max_width` is placed
26/// on its own line (it will exceed `max_width` but avoids infinite loops).
27///
28/// Back-compat shim; delegates to `compute_line_widths_with(..., None)`.
29#[inline]
30pub fn compute_line_widths(text: &str, font: &Font, font_size: f64, max_width: f64) -> Vec<f64> {
31 compute_line_widths_with(text, font, font_size, max_width, None)
32}
33
34/// Scope-aware variant of `compute_line_widths`. Consults `store` (if Some)
35/// before the legacy global registry for `Font::Custom` lookups via the
36/// underlying `measure_text_with`.
37pub(crate) fn compute_line_widths_with(
38 text: &str,
39 font: &Font,
40 font_size: f64,
41 max_width: f64,
42 store: Option<&FontMetricsStore>,
43) -> Vec<f64> {
44 if text.is_empty() {
45 return Vec::new();
46 }
47
48 let words = split_into_words(text);
49 if words.is_empty() {
50 return Vec::new();
51 }
52
53 let mut line_widths: Vec<f64> = Vec::new();
54 let mut current_width = 0.0;
55
56 for word in &words {
57 let word_width = measure_text_with(word, font, font_size, store);
58
59 if current_width > 0.0 && current_width + word_width > max_width {
60 line_widths.push(current_width);
61 current_width = word_width;
62 } else {
63 current_width += word_width;
64 }
65 }
66
67 if current_width > 0.0 {
68 line_widths.push(current_width);
69 }
70
71 line_widths
72}
73
74/// Measures a block of word-wrapped text without rendering it.
75///
76/// Given a text string, font, font size, line height multiplier, and maximum
77/// width, computes how the text would be laid out with word wrapping and returns
78/// the resulting dimensions.
79///
80/// # Arguments
81///
82/// * `text` - The text to measure
83/// * `font` - The font to use for width calculations
84/// * `font_size` - Font size in points
85/// * `line_height` - Line height multiplier (e.g., 1.2 for 120% spacing)
86/// * `max_width` - Maximum width available for text in points
87///
88/// # Returns
89///
90/// A `TextBlockMetrics` with the measured `width`, `height`, and `line_count`.
91///
92/// # Example
93///
94/// ```rust
95/// use oxidize_pdf::text::text_block::measure_text_block;
96/// use oxidize_pdf::Font;
97///
98/// let metrics = measure_text_block("Hello World", &Font::Helvetica, 12.0, 1.2, 200.0);
99/// assert_eq!(metrics.line_count, 1);
100/// assert!(metrics.width > 0.0);
101/// assert!(metrics.height > 0.0);
102/// ```
103///
104/// Back-compat shim; delegates to `measure_text_block_with(..., None)`.
105#[inline]
106pub fn measure_text_block(
107 text: &str,
108 font: &Font,
109 font_size: f64,
110 line_height: f64,
111 max_width: f64,
112) -> TextBlockMetrics {
113 measure_text_block_with(text, font, font_size, line_height, max_width, None)
114}
115
116/// Scope-aware variant of `measure_text_block`. Consults `store` (if Some)
117/// before the legacy global registry for `Font::Custom` lookups via the
118/// underlying `measure_text_with`.
119pub fn measure_text_block_with(
120 text: &str,
121 font: &Font,
122 font_size: f64,
123 line_height: f64,
124 max_width: f64,
125 store: Option<&FontMetricsStore>,
126) -> TextBlockMetrics {
127 let line_widths = compute_line_widths_with(text, font, font_size, max_width, store);
128
129 let line_count = line_widths.len();
130 let width = line_widths.iter().copied().fold(0.0_f64, f64::max);
131 let height = line_count as f64 * font_size * line_height;
132
133 TextBlockMetrics {
134 width,
135 height,
136 line_count,
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn test_compute_line_widths_empty() {
146 let widths = compute_line_widths("", &Font::Helvetica, 12.0, 200.0);
147 assert!(widths.is_empty());
148 }
149
150 #[test]
151 fn test_compute_line_widths_single_word() {
152 let widths = compute_line_widths("Hello", &Font::Helvetica, 12.0, 500.0);
153 assert_eq!(widths.len(), 1);
154 assert!(widths[0] > 0.0);
155 }
156
157 #[test]
158 fn test_measure_text_block_empty() {
159 let m = measure_text_block("", &Font::Helvetica, 12.0, 1.2, 300.0);
160 assert_eq!(m.line_count, 0);
161 assert_eq!(m.width, 0.0);
162 assert_eq!(m.height, 0.0);
163 }
164
165 #[test]
166 fn test_measure_text_block_with_uses_document_scope() {
167 use crate::text::metrics::{FontMetrics, FontMetricsStore};
168 let unique = format!("MeasureBlockTask5_{}", std::process::id());
169 let store = FontMetricsStore::new();
170 // Make every char width = 1000 (i.e., 1.0em per char). Word "AB" = 24 at 12pt.
171 store.register(
172 unique.clone(),
173 FontMetrics::new(500).with_widths(&[('A', 1000), ('B', 1000)]),
174 );
175
176 let m = measure_text_block_with(
177 "AB",
178 &Font::Custom(unique.clone()),
179 12.0,
180 1.2,
181 500.0,
182 Some(&store),
183 );
184 // One line with width = 2 * 1000 / 1000 * 12 = 24
185 assert!(
186 (m.width - 24.0).abs() < 0.01,
187 "expected scope-aware width 24, got {}",
188 m.width
189 );
190 }
191
192 #[test]
193 fn test_measure_text_block_with_uses_store_across_wrap() {
194 use crate::text::metrics::{FontMetrics, FontMetricsStore};
195 let unique = format!("MeasureBlockWrapTask5_{}", std::process::id());
196 let store = FontMetricsStore::new();
197 // 'A' = 'B' = 1000 units → "AB" word width = 2000 units → 24.0 at 12pt.
198 // Space ' ' = 1000 → 12.0 at 12pt.
199 //
200 // `split_into_words("AB AB")` yields three tokens: ["AB", " ", "AB"].
201 // With max_width = 30.0 and per-store widths:
202 // token 1 "AB" → current = 24.0 (fits)
203 // token 2 " " → 24.0 + 12.0 = 36.0 > 30.0 → push 24.0, current = 12.0
204 // token 3 "AB" → 12.0 + 24.0 = 36.0 > 30.0 → push 12.0, current = 24.0
205 // end → push 24.0
206 // → 3 lines: [24.0, 12.0, 24.0], width = 24.0.
207 // This exercises all three iterations of the wrap loop, proving the store
208 // is correctly threaded into compute_line_widths_with on every pass.
209 store.register(
210 unique.clone(),
211 FontMetrics::new(500).with_widths(&[('A', 1000), ('B', 1000), (' ', 1000)]),
212 );
213
214 // max_width = 30.0 fits the first "AB" (24.0) but neither the space+AB
215 // continuation nor the trailing "AB" fits, forcing two wraps (three lines).
216 let m = measure_text_block_with(
217 "AB AB",
218 &Font::Custom(unique.clone()),
219 12.0,
220 1.2,
221 30.0,
222 Some(&store),
223 );
224
225 assert_eq!(
226 m.line_count, 3,
227 "split_into_words yields 3 tokens (\"AB\", \" \", \"AB\") and max_width=30 \
228 forces a wrap after each; expected 3 lines"
229 );
230 // Block width is the max of [24.0, 12.0, 24.0] = 24.0, derived from
231 // per-store widths. If the store were not threaded the widths would differ.
232 assert!(
233 (m.width - 24.0).abs() < 0.01,
234 "wrapped block width must come from per-store widths (24.0); got {}",
235 m.width
236 );
237 }
238}