c2pdf/
text_manipulation.rs

1//! Primitives for wrapping text
2
3use std::collections::HashMap;
4
5use fontdue::{Font, FontSettings};
6use printpdf::Pt;
7
8/// Uses the [`fontdue`] text rasterizer to split text into lines less than the `max_width`
9pub fn split_into_lines_fontdue<F: Fn(usize) -> Pt>(
10  txt: &str,
11  font: &Font,
12  font_size: f32,
13  max_width: F,
14  cache: &mut std::collections::HashMap<char, f32>,
15) -> Vec<(String, f32)> {
16  let mut lines: Vec<(String, f32)> = vec![];
17  let mut line_buf = String::new();
18  let mut current_line_width = 0.0;
19  // Stores the max line width for the current line (may be different depending on what line we're on)
20  let mut max_line_width = max_width(0).0;
21  for ch in txt.chars() {
22    let width = match cache.get(&ch) {
23      Some(w) => *w,
24      None => {
25        let width = font.rasterize(ch, font_size).0.advance_width;
26        cache.insert(ch, width);
27        width
28      }
29    };
30    // Move onto new line if width exceeds maximum, or if we're close to the maximum and find a space
31    if (current_line_width + width >= max_line_width)
32      || ((max_line_width - (current_line_width + width) < 30.0) && ch.is_whitespace())
33    {
34      // Push this character so we know that the new line was due to line splitting
35      line_buf.push('\n');
36      lines.push((line_buf.trim_start().to_string(), current_line_width));
37      // Retrieve new line width for the next line
38      max_line_width = max_width(lines.len()).0;
39      line_buf.clear();
40      current_line_width = 0.0;
41    }
42    line_buf.push(ch);
43    current_line_width += width;
44  }
45  lines.push((line_buf.trim().to_string(), current_line_width));
46  lines
47}
48
49/// Handles wrapping text into multiple lines
50pub struct TextWrapper {
51  rasterize_cache: HashMap<char, f32>,
52  font: Font,
53  font_size: f32,
54}
55
56impl TextWrapper {
57  /// Initialises new [`TextWrapper`] from `font_bytes`, and `font_size`
58  pub fn new(font_bytes: &[u8], font_size: f32) -> Self {
59    Self {
60      rasterize_cache: HashMap::new(),
61      font: Font::from_bytes(font_bytes, FontSettings::default()).unwrap(),
62      font_size,
63    }
64  }
65
66  /// Splits a given &[`str`] into a [`Vec<String>`] of lines not exceeding the `max_width` set
67  pub fn split_into_lines<T: Fn(usize) -> Pt>(
68    &mut self,
69    txt: &str,
70    max_width: T,
71  ) -> Vec<(String, f32)> {
72    split_into_lines_fontdue(
73      txt,
74      &self.font,
75      self.font_size,
76      max_width,
77      &mut self.rasterize_cache,
78    )
79  }
80
81  /// Returns the width of a given string in Point
82  pub fn get_width(&mut self, txt: &str) -> Pt {
83    let mut total_width = 0.0;
84    for ch in txt.chars() {
85      let char_width = match self.rasterize_cache.get(&ch) {
86        Some(w) => *w,
87        None => {
88          let width = self.font.rasterize(ch, self.font_size).0.advance_width;
89          self.rasterize_cache.insert(ch, width);
90          width
91        }
92      };
93      total_width += char_width;
94    }
95    Pt(total_width)
96  }
97  /// Returns the set `font_size`
98  pub fn font_size(&self) -> f32 {
99    self.font_size
100  }
101}
102
103#[cfg(test)]
104mod tests {
105  use super::*;
106
107  const FONT_BYTES: &[u8] = include_bytes!("../../fonts/Helvetica.ttf") as &[u8];
108  const TEXT: &str = "Hello World!! This is a vaguely long string to test string splitting!";
109  #[test]
110  fn splitting_lines() {
111    let result = split_into_lines_fontdue(
112      TEXT,
113      &Font::from_bytes(FONT_BYTES, FontSettings::default()).unwrap(),
114      20.0,
115      |_| Pt(100.0),
116      &mut HashMap::new(),
117    );
118    assert_eq!(result.len(), 7);
119    // Check that joining back together creates the original string (spaces are trimmed so doesn't matter if these aren't retained)
120    assert_eq!(
121      result
122        .iter()
123        .map(|x| x.0.clone())
124        .collect::<Vec<String>>()
125        .join("")
126        .replace(' ', "").replace('\n', ""),
127      TEXT.replace(' ', "")
128    );
129  }
130}