Skip to main content

justpdf_core/text/
text_layout.rs

1//! Text layout engine for PDF text writing.
2//!
3//! Provides word wrapping, alignment, and vertical layout for rendering text
4//! into a bounded area.
5
6use crate::font::FontInfo;
7
8/// Text alignment.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum TextAlignment {
11    Left,
12    Center,
13    Right,
14    Justify,
15}
16
17/// A laid-out line of text.
18#[derive(Debug, Clone)]
19pub struct LayoutLine {
20    /// The text content of this line.
21    pub text: String,
22    /// X offset from the left edge of the bounding box.
23    pub x_offset: f64,
24    /// Y offset from the top of the bounding box (positive downward in layout coords).
25    pub y_offset: f64,
26    /// Width of the text in this line (in points).
27    pub width: f64,
28    /// Extra word spacing for justified text.
29    pub word_spacing: f64,
30}
31
32/// Result of text layout.
33#[derive(Debug, Clone)]
34pub struct LayoutResult {
35    /// Laid-out lines.
36    pub lines: Vec<LayoutLine>,
37    /// Total height of the laid-out text.
38    pub total_height: f64,
39    /// Whether all text fit in the bounding box.
40    pub overflow: bool,
41}
42
43/// Options for text layout.
44#[derive(Debug, Clone)]
45pub struct LayoutOptions {
46    /// Font size in points.
47    pub font_size: f64,
48    /// Line height as a multiple of font size (default 1.2).
49    pub line_height_factor: f64,
50    /// Text alignment.
51    pub alignment: TextAlignment,
52    /// Maximum width for text wrapping (in points). None = no wrapping.
53    pub max_width: Option<f64>,
54    /// Maximum height (in points). None = unlimited.
55    pub max_height: Option<f64>,
56    /// First line indent (in points).
57    pub first_line_indent: f64,
58}
59
60impl Default for LayoutOptions {
61    fn default() -> Self {
62        Self {
63            font_size: 12.0,
64            line_height_factor: 1.2,
65            alignment: TextAlignment::Left,
66            max_width: None,
67            max_height: None,
68            first_line_indent: 0.0,
69        }
70    }
71}
72
73/// Calculate the width of a single character in font units (1/1000 of text space).
74pub fn char_width(ch: char, font: &FontInfo) -> f64 {
75    let code = ch as u32;
76    font.widths.get_width(code)
77}
78
79/// Calculate the width of a string in points for a given font and size.
80pub fn measure_text_width(text: &str, font: &FontInfo, font_size: f64) -> f64 {
81    let total_units: f64 = text.chars().map(|ch| char_width(ch, font)).sum();
82    total_units * font_size / 1000.0
83}
84
85/// Lay out text within a bounding box.
86///
87/// Performs word wrapping, alignment, and vertical stacking of text lines
88/// according to the given options.
89pub fn layout_text(text: &str, font: &FontInfo, options: &LayoutOptions) -> LayoutResult {
90    let line_height = options.font_size * options.line_height_factor;
91    let max_width = options.max_width;
92
93    // Split input into paragraphs on explicit newlines.
94    let paragraphs: Vec<&str> = text.split('\n').collect();
95
96    let mut raw_lines: Vec<(String, bool)> = Vec::new(); // (text, is_last_line_of_paragraph)
97
98    for (para_idx, para) in paragraphs.iter().enumerate() {
99        let trimmed = *para;
100        if trimmed.is_empty() {
101            // Preserve empty lines from explicit newlines.
102            raw_lines.push((String::new(), true));
103            continue;
104        }
105
106        let indent = if para_idx == 0 {
107            options.first_line_indent
108        } else {
109            // Only the very first line of the entire text gets the indent.
110            0.0
111        };
112
113        let wrapped = wrap_paragraph(trimmed, font, options.font_size, max_width, indent);
114        let count = wrapped.len();
115        for (i, line_text) in wrapped.into_iter().enumerate() {
116            let is_last = i == count - 1;
117            raw_lines.push((line_text, is_last));
118        }
119    }
120
121    // Now apply alignment and vertical positioning.
122    let mut lines = Vec::new();
123    let mut y_offset = 0.0;
124    let mut overflow = false;
125
126    for (i, (line_text, is_last_of_para)) in raw_lines.into_iter().enumerate() {
127        // Check overflow before adding this line.
128        if let Some(max_h) = options.max_height {
129            if y_offset + line_height > max_h + 1e-9 {
130                overflow = true;
131                break;
132            }
133        }
134
135        let line_width = measure_text_width(&line_text, font, options.font_size);
136
137        let effective_max = max_width.unwrap_or(line_width);
138
139        let (x_offset, word_spacing) = match options.alignment {
140            TextAlignment::Left => {
141                let indent = if i == 0 { options.first_line_indent } else { 0.0 };
142                (indent, 0.0)
143            }
144            TextAlignment::Center => {
145                let offset = (effective_max - line_width) / 2.0;
146                (offset.max(0.0), 0.0)
147            }
148            TextAlignment::Right => {
149                let offset = effective_max - line_width;
150                (offset.max(0.0), 0.0)
151            }
152            TextAlignment::Justify => {
153                let indent = if i == 0 { options.first_line_indent } else { 0.0 };
154                if is_last_of_para || max_width.is_none() {
155                    // Last line of paragraph: left-align.
156                    (indent, 0.0)
157                } else {
158                    let word_count = line_text.split_whitespace().count();
159                    let gap_count = if word_count > 1 { word_count - 1 } else { 0 };
160                    if gap_count > 0 {
161                        let extra = effective_max - line_width - indent;
162                        let ws = if extra > 0.0 {
163                            extra / gap_count as f64
164                        } else {
165                            0.0
166                        };
167                        (indent, ws)
168                    } else {
169                        (indent, 0.0)
170                    }
171                }
172            }
173        };
174
175        lines.push(LayoutLine {
176            text: line_text,
177            x_offset,
178            y_offset,
179            width: line_width,
180            word_spacing,
181        });
182
183        y_offset += line_height;
184    }
185
186    let total_height = if lines.is_empty() {
187        0.0
188    } else {
189        y_offset
190    };
191
192    LayoutResult {
193        lines,
194        total_height,
195        overflow,
196    }
197}
198
199/// Wrap a single paragraph into lines that fit within `max_width`.
200fn wrap_paragraph(
201    text: &str,
202    font: &FontInfo,
203    font_size: f64,
204    max_width: Option<f64>,
205    first_line_indent: f64,
206) -> Vec<String> {
207    let max_w = match max_width {
208        Some(w) => w,
209        None => return vec![text.to_string()],
210    };
211
212    let words: Vec<&str> = text.split_whitespace().collect();
213    if words.is_empty() {
214        return vec![String::new()];
215    }
216
217    let space_width = measure_text_width(" ", font, font_size);
218
219    let mut lines: Vec<String> = Vec::new();
220    let mut current_line = String::new();
221    let mut current_width: f64 = 0.0;
222    let mut is_first_line = true;
223
224    for word in &words {
225        let word_width = measure_text_width(word, font, font_size);
226        let indent = if is_first_line { first_line_indent } else { 0.0 };
227        let available = max_w - indent;
228
229        if current_line.is_empty() {
230            // First word on this line.
231            if word_width > available {
232                // Word itself is wider than available space: force-break at character level.
233                let broken = force_break_word(word, font, font_size, available);
234                for (j, part) in broken.into_iter().enumerate() {
235                    if j > 0 || !current_line.is_empty() {
236                        if !current_line.is_empty() {
237                            lines.push(current_line);
238                        }
239                        is_first_line = false;
240                    }
241                    current_line = part.clone();
242                    current_width = measure_text_width(&part, font, font_size);
243                }
244            } else {
245                current_line = word.to_string();
246                current_width = word_width;
247            }
248        } else {
249            // Not the first word: check if adding a space + this word fits.
250            let new_width = current_width + space_width + word_width;
251            if new_width <= available + 1e-9 {
252                current_line.push(' ');
253                current_line.push_str(word);
254                current_width = new_width;
255            } else {
256                // Doesn't fit: push current line, start new one.
257                lines.push(current_line);
258                is_first_line = false;
259
260                let new_indent = 0.0; // indent only on first line
261                let new_available = max_w - new_indent;
262
263                if word_width > new_available {
264                    let broken = force_break_word(word, font, font_size, new_available);
265                    current_line = String::new();
266                    current_width = 0.0;
267                    for (j, part) in broken.into_iter().enumerate() {
268                        if j > 0 && !current_line.is_empty() {
269                            lines.push(current_line);
270                        }
271                        current_line = part.clone();
272                        current_width = measure_text_width(&part, font, font_size);
273                    }
274                } else {
275                    current_line = word.to_string();
276                    current_width = word_width;
277                }
278            }
279        }
280    }
281
282    if !current_line.is_empty() {
283        lines.push(current_line);
284    }
285
286    if lines.is_empty() {
287        lines.push(String::new());
288    }
289
290    lines
291}
292
293/// Force-break a single word into chunks that each fit within `max_width`.
294fn force_break_word(
295    word: &str,
296    font: &FontInfo,
297    font_size: f64,
298    max_width: f64,
299) -> Vec<String> {
300    let mut parts = Vec::new();
301    let mut current = String::new();
302    let mut current_width = 0.0;
303
304    for ch in word.chars() {
305        let ch_width = char_width(ch, font) * font_size / 1000.0;
306        if current_width + ch_width > max_width + 1e-9 && !current.is_empty() {
307            parts.push(current);
308            current = String::new();
309            current_width = 0.0;
310        }
311        current.push(ch);
312        current_width += ch_width;
313    }
314
315    if !current.is_empty() {
316        parts.push(current);
317    }
318
319    if parts.is_empty() {
320        parts.push(String::new());
321    }
322
323    parts
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use crate::font::{Encoding, FontWidths};
330
331    /// Create a simple test font with monospaced 600-unit widths.
332    fn test_font() -> FontInfo {
333        // Monospaced: every character is 600 units wide.
334        // At font_size 10, each char = 600 * 10 / 1000 = 6.0 points.
335        FontInfo {
336            base_font: b"TestFont".to_vec(),
337            subtype: b"Type1".to_vec(),
338            encoding: Encoding::StandardEncoding,
339            widths: FontWidths::None { default_width: 600.0 },
340            to_unicode: None,
341            is_standard14: false,
342            descriptor: None,
343        }
344    }
345
346    /// Create a font with varying widths for more realistic tests.
347    fn variable_width_font() -> FontInfo {
348        // Simple font: chars 32..127 with varying widths.
349        let mut widths = vec![0.0; 128];
350        widths[32] = 250.0; // space
351        for i in 33..127u32 {
352            widths[i as usize] = 500.0; // all visible chars = 500
353        }
354        // Make some chars wider/narrower for realism
355        widths[b'i' as usize] = 250.0;
356        widths[b'l' as usize] = 250.0;
357        widths[b'm' as usize] = 750.0;
358        widths[b'w' as usize] = 750.0;
359        widths[b'W' as usize] = 750.0;
360
361        FontInfo {
362            base_font: b"VarFont".to_vec(),
363            subtype: b"Type1".to_vec(),
364            encoding: Encoding::StandardEncoding,
365            widths: FontWidths::Simple {
366                first_char: 0,
367                widths,
368                default_width: 500.0,
369            },
370            to_unicode: None,
371            is_standard14: false,
372            descriptor: None,
373        }
374    }
375
376    #[test]
377    fn test_measure_text_width() {
378        let font = test_font();
379        // Each char is 600 units. At font_size 10: 600 * 10 / 1000 = 6.0 per char.
380        let w = measure_text_width("Hello", &font, 10.0);
381        assert!((w - 30.0).abs() < 0.01, "expected 30.0, got {w}");
382    }
383
384    #[test]
385    fn test_measure_text_width_empty() {
386        let font = test_font();
387        let w = measure_text_width("", &font, 12.0);
388        assert!((w - 0.0).abs() < 0.01);
389    }
390
391    #[test]
392    fn test_measure_text_width_variable() {
393        let font = variable_width_font();
394        // "i" = 250, at font_size 10: 250 * 10 / 1000 = 2.5
395        let w = measure_text_width("i", &font, 10.0);
396        assert!((w - 2.5).abs() < 0.01, "expected 2.5, got {w}");
397    }
398
399    #[test]
400    fn test_simple_single_line() {
401        let font = test_font();
402        let options = LayoutOptions {
403            font_size: 10.0,
404            ..Default::default()
405        };
406        let result = layout_text("Hello", &font, &options);
407        assert_eq!(result.lines.len(), 1);
408        assert_eq!(result.lines[0].text, "Hello");
409        assert!(!result.overflow);
410        assert!((result.lines[0].width - 30.0).abs() < 0.01);
411    }
412
413    #[test]
414    fn test_word_wrapping() {
415        let font = test_font();
416        // Each char = 6pt at size 10. "Hello World" = 11 chars * 6 = 66pt.
417        // With max_width = 40, "Hello" (30pt) fits, "World" needs a new line.
418        let options = LayoutOptions {
419            font_size: 10.0,
420            max_width: Some(40.0),
421            ..Default::default()
422        };
423        let result = layout_text("Hello World", &font, &options);
424        assert_eq!(result.lines.len(), 2);
425        assert_eq!(result.lines[0].text, "Hello");
426        assert_eq!(result.lines[1].text, "World");
427    }
428
429    #[test]
430    fn test_explicit_line_break() {
431        let font = test_font();
432        let options = LayoutOptions {
433            font_size: 10.0,
434            ..Default::default()
435        };
436        let result = layout_text("Line one\nLine two", &font, &options);
437        assert_eq!(result.lines.len(), 2);
438        assert_eq!(result.lines[0].text, "Line one");
439        assert_eq!(result.lines[1].text, "Line two");
440    }
441
442    #[test]
443    fn test_center_alignment() {
444        let font = test_font();
445        // "Hi" = 2 chars * 6pt = 12pt. max_width = 100. x_offset = (100 - 12) / 2 = 44.
446        let options = LayoutOptions {
447            font_size: 10.0,
448            alignment: TextAlignment::Center,
449            max_width: Some(100.0),
450            ..Default::default()
451        };
452        let result = layout_text("Hi", &font, &options);
453        assert_eq!(result.lines.len(), 1);
454        assert!((result.lines[0].x_offset - 44.0).abs() < 0.01,
455            "expected x_offset ~44.0, got {}", result.lines[0].x_offset);
456    }
457
458    #[test]
459    fn test_right_alignment() {
460        let font = test_font();
461        // "Hi" = 12pt. max_width = 100. x_offset = 100 - 12 = 88.
462        let options = LayoutOptions {
463            font_size: 10.0,
464            alignment: TextAlignment::Right,
465            max_width: Some(100.0),
466            ..Default::default()
467        };
468        let result = layout_text("Hi", &font, &options);
469        assert_eq!(result.lines.len(), 1);
470        assert!((result.lines[0].x_offset - 88.0).abs() < 0.01,
471            "expected x_offset ~88.0, got {}", result.lines[0].x_offset);
472    }
473
474    #[test]
475    fn test_justify_alignment() {
476        let font = test_font();
477        // "A B C" => 5 chars * 6 = 30pt. max_width = 60.
478        // 3 words, 2 gaps. Extra space = 60 - 30 = 30. word_spacing = 30 / 2 = 15.
479        // But this is a single-line paragraph (last line), so it should be left-aligned.
480        let options = LayoutOptions {
481            font_size: 10.0,
482            alignment: TextAlignment::Justify,
483            max_width: Some(60.0),
484            ..Default::default()
485        };
486        let result = layout_text("A B C", &font, &options);
487        assert_eq!(result.lines.len(), 1);
488        // Last line of paragraph: no justification.
489        assert!((result.lines[0].word_spacing - 0.0).abs() < 0.01);
490
491        // Two-line case: "AA BB CC DD" with tight max_width to force wrapping.
492        // Each word is 2 chars = 12pt. Space = 6pt.
493        // "AA BB" = 12 + 6 + 12 = 30pt. max_width = 35 should keep "AA BB" on line 1.
494        // "CC DD" on line 2 (last line, no justify).
495        let result2 = layout_text("AA BB CC DD", &font, &LayoutOptions {
496            font_size: 10.0,
497            alignment: TextAlignment::Justify,
498            max_width: Some(35.0),
499            ..Default::default()
500        });
501        assert!(result2.lines.len() >= 2);
502        // First line "AA BB" = 30pt. Extra = 35 - 30 = 5. 1 gap. word_spacing = 5.
503        if result2.lines[0].text == "AA BB" {
504            assert!((result2.lines[0].word_spacing - 5.0).abs() < 0.5,
505                "expected word_spacing ~5.0, got {}", result2.lines[0].word_spacing);
506        }
507    }
508
509    #[test]
510    fn test_first_line_indent() {
511        let font = test_font();
512        let options = LayoutOptions {
513            font_size: 10.0,
514            alignment: TextAlignment::Left,
515            first_line_indent: 20.0,
516            ..Default::default()
517        };
518        let result = layout_text("Hello World", &font, &options);
519        assert_eq!(result.lines.len(), 1);
520        assert!((result.lines[0].x_offset - 20.0).abs() < 0.01);
521    }
522
523    #[test]
524    fn test_first_line_indent_with_wrapping() {
525        let font = test_font();
526        // "Hello" = 30pt, indent = 20pt, so total = 50pt.
527        // max_width = 55: "Hello" fits on first line with indent.
528        // "World" = 30pt, no indent on second line.
529        let options = LayoutOptions {
530            font_size: 10.0,
531            alignment: TextAlignment::Left,
532            max_width: Some(55.0),
533            first_line_indent: 20.0,
534            ..Default::default()
535        };
536        let result = layout_text("Hello World", &font, &options);
537        assert_eq!(result.lines.len(), 2);
538        assert!((result.lines[0].x_offset - 20.0).abs() < 0.01);
539        assert!((result.lines[1].x_offset - 0.0).abs() < 0.01);
540    }
541
542    #[test]
543    fn test_overflow_detection() {
544        let font = test_font();
545        // line_height = 10 * 1.2 = 12pt. max_height = 20.
546        // Can fit 1 line (y=0..12), but not 2 (y=0..24).
547        let options = LayoutOptions {
548            font_size: 10.0,
549            max_height: Some(20.0),
550            ..Default::default()
551        };
552        let result = layout_text("Line1\nLine2\nLine3", &font, &options);
553        assert!(result.overflow);
554        // Should have only 1 line that fits (12 <= 20), second would go to 24 > 20.
555        assert!(result.lines.len() < 3);
556    }
557
558    #[test]
559    fn test_empty_text() {
560        let font = test_font();
561        let options = LayoutOptions::default();
562        let result = layout_text("", &font, &options);
563        // Empty text produces one empty line from the paragraph split.
564        assert_eq!(result.lines.len(), 1);
565        assert_eq!(result.lines[0].text, "");
566        assert!((result.lines[0].width - 0.0).abs() < 0.01);
567        assert!(!result.overflow);
568    }
569
570    #[test]
571    fn test_force_break_wide_word() {
572        let font = test_font();
573        // "ABCDEFGHIJ" = 10 chars * 6pt = 60pt. max_width = 20pt.
574        // Should break into: "ABC" (18pt), "DEF" (18pt), "GHI" (18pt), "J" (6pt).
575        let options = LayoutOptions {
576            font_size: 10.0,
577            max_width: Some(20.0),
578            ..Default::default()
579        };
580        let result = layout_text("ABCDEFGHIJ", &font, &options);
581        assert!(result.lines.len() >= 3, "expected >=3 lines, got {}", result.lines.len());
582        // Verify all text is present.
583        let all_text: String = result.lines.iter().map(|l| l.text.as_str()).collect();
584        assert_eq!(all_text, "ABCDEFGHIJ");
585    }
586
587    #[test]
588    fn test_vertical_layout() {
589        let font = test_font();
590        let options = LayoutOptions {
591            font_size: 10.0,
592            line_height_factor: 1.5,
593            ..Default::default()
594        };
595        let result = layout_text("A\nB\nC", &font, &options);
596        assert_eq!(result.lines.len(), 3);
597        // line_height = 10 * 1.5 = 15
598        assert!((result.lines[0].y_offset - 0.0).abs() < 0.01);
599        assert!((result.lines[1].y_offset - 15.0).abs() < 0.01);
600        assert!((result.lines[2].y_offset - 30.0).abs() < 0.01);
601        assert!((result.total_height - 45.0).abs() < 0.01);
602    }
603
604    #[test]
605    fn test_default_options() {
606        let opts = LayoutOptions::default();
607        assert!((opts.font_size - 12.0).abs() < 0.01);
608        assert!((opts.line_height_factor - 1.2).abs() < 0.01);
609        assert_eq!(opts.alignment, TextAlignment::Left);
610        assert!(opts.max_width.is_none());
611        assert!(opts.max_height.is_none());
612        assert!((opts.first_line_indent - 0.0).abs() < 0.01);
613    }
614
615    #[test]
616    fn test_multiple_spaces_collapsed() {
617        let font = test_font();
618        // Word wrapping uses split_whitespace which collapses multiple spaces.
619        let options = LayoutOptions {
620            font_size: 10.0,
621            max_width: Some(500.0),
622            ..Default::default()
623        };
624        let result = layout_text("Hello    World", &font, &options);
625        assert_eq!(result.lines.len(), 1);
626        assert_eq!(result.lines[0].text, "Hello World");
627    }
628
629    #[test]
630    fn test_no_overflow_when_fits() {
631        let font = test_font();
632        let options = LayoutOptions {
633            font_size: 10.0,
634            max_height: Some(100.0),
635            ..Default::default()
636        };
637        let result = layout_text("Short", &font, &options);
638        assert!(!result.overflow);
639    }
640
641    #[test]
642    fn test_char_width_function() {
643        let font = test_font();
644        let w = char_width('A', &font);
645        assert!((w - 600.0).abs() < 0.01);
646    }
647
648    #[test]
649    fn test_char_width_variable_font() {
650        let font = variable_width_font();
651        assert!((char_width('i', &font) - 250.0).abs() < 0.01);
652        assert!((char_width('m', &font) - 750.0).abs() < 0.01);
653        assert!((char_width(' ', &font) - 250.0).abs() < 0.01);
654    }
655}