Skip to main content

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}