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
50#[derive(Clone)]
51pub struct TextWrapper {
52  rasterize_cache: HashMap<char, f32>,
53  font: Font,
54  font_size: f32,
55}
56
57impl TextWrapper {
58  /// Initialises new [`TextWrapper`] from `font_bytes`, and `font_size`
59  pub fn new(font_bytes: &[u8], font_size: f32) -> Self {
60    Self {
61      rasterize_cache: HashMap::new(),
62      font: Font::from_bytes(font_bytes, FontSettings::default()).unwrap(),
63      font_size,
64    }
65  }
66
67  /// Splits a given &[`str`] into a [`Vec<String>`] of lines not exceeding the `max_width` set
68  pub fn split_into_lines<T: Fn(usize) -> Pt>(
69    &mut self,
70    txt: &str,
71    max_width: T,
72  ) -> Vec<(String, f32)> {
73    split_into_lines_fontdue(
74      txt,
75      &self.font,
76      self.font_size,
77      max_width,
78      &mut self.rasterize_cache,
79    )
80  }
81
82  /// Returns the width of a given string in Point
83  pub fn get_width(&mut self, txt: &str) -> Pt {
84    let mut total_width = 0.0;
85    for ch in txt.chars() {
86      let char_width = match self.rasterize_cache.get(&ch) {
87        Some(w) => *w,
88        None => {
89          let width = self.font.rasterize(ch, self.font_size).0.advance_width;
90          self.rasterize_cache.insert(ch, width);
91          width
92        }
93      };
94      total_width += char_width;
95    }
96    Pt(total_width)
97  }
98  /// Returns the set `font_size`
99  pub fn font_size(&self) -> f32 {
100    self.font_size
101  }
102}
103
104#[cfg(test)]
105mod tests {
106  use super::*;
107
108  const FONT_BYTES: &[u8] = include_bytes!("../../fonts/Helvetica.ttf") as &[u8];
109  const TEXT: &str = "Hello World!! This is a vaguely long string to test string splitting!";
110  #[test]
111  fn splitting_lines() {
112    let result = split_into_lines_fontdue(
113      TEXT,
114      &Font::from_bytes(FONT_BYTES, FontSettings::default()).unwrap(),
115      20.0,
116      |_| Pt(100.0),
117      &mut HashMap::new(),
118    );
119    assert_eq!(result.len(), 7);
120    // Check that joining back together creates the original string (spaces are trimmed so doesn't matter if these aren't retained)
121    assert_eq!(
122      result
123        .iter()
124        .map(|x| x.0.clone())
125        .collect::<Vec<String>>()
126        .join("")
127        .replace([' ', '\n'], ""),
128      TEXT.replace(' ', "")
129    );
130  }
131}