escpos_rust/formatter.rs
1/// Options to print tables
2#[derive(Clone, Debug)]
3pub struct TableOptions {
4 /// Indicates the header/row division character
5 pub header_division_pattern: Option<String>,
6 /// Inicates if a pattern should be used to bridge between columns
7 pub join_columns_pattern: Option<String>
8}
9
10/// Helper structure to format text
11///
12/// The Formatter structure helps create some simple shapes through text, like tables and just formatted text. By default, for tables a header division pattern of `-` and no pattern for bridging columns will be used. This can be modified by using either [set_table_options](Formatter::set_table_options) or by [modify_table_options](Formatter::modify_table_options).
13pub struct Formatter {
14 /// Inner table options
15 table_options: TableOptions,
16 /// Width to use for formatting
17 width: u8
18}
19
20impl Formatter {
21 /// Creates a new formatter with a default width
22 pub fn new(width: u8) -> Formatter {
23 Formatter{
24 table_options: TableOptions {
25 header_division_pattern: Some("-".into()),
26 join_columns_pattern: None
27 },
28 width
29 }
30 }
31
32 /// Sets a new set of table options
33 ///
34 /// To modify just one parameter in a simpler way, check the [modify_table_options](self::Formatter::modify_table_options) method.
35 ///
36 /// ```rust
37 /// # use escpos_rs::{Formatter, TableOptions};
38 /// let mut formatter = Formatter::new(20);
39 /// formatter.set_table_options(TableOptions {
40 /// header_division_pattern: Some(".-".into()),
41 /// join_columns_pattern: Some(".".into())
42 /// });
43 /// ```
44 pub fn set_table_options(&mut self, table_options: TableOptions) {
45 self.table_options = table_options
46 }
47
48 /// Gives back a reference to the active table options
49 ///
50 /// ```rust
51 /// # use escpos_rs::Formatter;
52 /// let mut formatter = Formatter::new(20);
53 /// assert_eq!(Some("-".to_string()), formatter.get_table_options().header_division_pattern);
54 /// ```
55 pub fn get_table_options(&self) -> &TableOptions {
56 &self.table_options
57 }
58
59 /// Modify the table options through a callback
60 ///
61 /// Allows table options modification with a function or closure. Sometimes it may come in hand.
62 ///
63 /// ```rust
64 /// # use escpos_rs::Formatter;
65 /// let mut formatter = Formatter::new(20);
66 /// formatter.modify_table_options(|table_options| {
67 /// table_options.header_division_pattern = Some("=".to_string());
68 /// });
69 /// assert_eq!(Some("=".to_string()), formatter.get_table_options().header_division_pattern);
70 /// ```
71 pub fn modify_table_options<F: Fn(&mut TableOptions)>(&mut self, modifier: F) {
72 modifier(&mut self.table_options);
73 }
74
75 /// Splits a string by whitespaces, according to the given width
76 ///
77 /// Notice that the final line will not contain a new line at the end.
78 ///
79 /// ```rust
80 /// use escpos_rs::Formatter;
81 ///
82 /// let formatter = Formatter::new(16);
83 /// let res = formatter.space_split("Sentence with two lines.");
84 /// assert_eq!("Sentence with\ntwo lines.", res.as_str());
85 /// ```
86 pub fn space_split<A: AsRef<str>>(&self, source: A) -> String {
87 let mut result = source.as_ref().split("\n").map(|line| {
88 // Now, for each line, we split it into words.
89 let mut current_line = String::new();
90 let mut broken_lines = Vec::new();
91 for word in line.split_whitespace() {
92 let num_chars = word.chars().count();
93 // The one being added marks the space
94 if current_line.len() + num_chars + 1 < self.width.into() {
95 // Easy to add to the current line, the conditional if is for the first word of them all.
96 current_line += &format!("{}{}", if current_line.len() == 0 {""} else {" "}, word);
97 } else {
98 // We have to terminate the current line, in case it contains something
99 if !current_line.is_empty() {
100 broken_lines.push(current_line.clone());
101 }
102 if num_chars < self.width.into() {
103 // We start the next line with the current word
104 current_line = word.to_string();
105 } else {
106 // We use a char iterator to split this into lines
107 let mut chars = word.chars();
108 let mut word_fragment: String = chars.by_ref().take(self.width.into()).collect();
109 broken_lines.push(format!("{}",word_fragment));
110 while !word_fragment.is_empty() {
111 word_fragment = chars.by_ref().take(self.width.into()).collect();
112 broken_lines.push(format!("{}",word_fragment));
113 }
114 }
115 }
116 }
117 if !current_line.is_empty() {
118 broken_lines.push(current_line);
119 }
120 broken_lines.join("\n")
121 }).collect::<Vec<_>>().join("");
122 // If the last character is a new line, we need to add it back in
123 if let Some(last_char) = source.as_ref().chars().last() {
124 if last_char == '\n' {
125 result += "\n";
126 }
127 }
128 result
129 }
130
131 /// Creates a table with two columns
132 ///
133 /// In case the headers do not fit with at least one space between, priority will be given to the second header, and the last remaining character from the first header will be replaced by a dot. If the second header would need to be shortened to less than 3 characters, then the first header will now also be truncated, with the same dot replacing the last charcater from the remaining part of the first header.
134 ///
135 /// ```rust
136 /// # use escpos_rs::Formatter;
137 /// let formatter = Formatter::new(20);
138 /// let header = ("Product", "Price");
139 /// let rows = vec![
140 /// ("Milk", "5.00"),
141 /// ("Cereal", "10.00")
142 /// ];
143 ///
144 /// // We use trim_start just to show the table nicer in this example.
145 /// let target = r#"
146 /// Product Price
147 /// --------------------
148 /// Milk 5.00
149 /// Cereal 10.00
150 /// "#.trim_start();
151 ///
152 /// assert_eq!(target, formatter.duo_table(header, rows));
153 /// ```
154 pub fn duo_table<A: Into<String>, B: Into<String>, C: IntoIterator<Item = (D, E)>, D: Into<String>, E: Into<String>>(&self, header: (A, B), rows: C) -> String {
155 // Aux closure to create each row.
156 let aux_duo_table = |mut first: String, mut second: String, width: u8, replace_last: Option<char>| -> String {
157 let row_width = first.len() + second.len();
158 let (column_1, column_2) = if row_width < width as usize {
159 (first, second)
160 } else {
161 // If the second column requires all the space, we give it
162 if second.len() + 4 > (width as usize) {
163 if let Some(replacement) = replace_last {
164 second.truncate((width as usize) - 5);
165 second += &replacement.to_string();
166 } else {
167 second.truncate((width as usize) - 4);
168 }
169 }
170
171 // We calculate the remaining space for the second word now.
172 let remaining = (width as usize) - second.len();
173 // We just need to shorten the second word. We need to include the separating whitespace
174 if first.len() > remaining {
175 if let Some(replacement) = replace_last {
176 first.truncate(remaining - 2);
177 first += &replacement.to_string();
178 } else {
179 first.truncate(remaining - 1);
180 }
181 }
182
183 (first, second)
184 };
185
186 format!("{} {:>2$}\n",
187 column_1,
188 column_2,
189 (width as usize) - (column_1.len() + 1)
190 )
191 };
192
193 let mut content = aux_duo_table(header.0.into(), header.1.into(), self.width, Some('.'));
194
195 if let Some(hdp) = self.print_header_division_pattern() {
196 content += &hdp;
197 }
198
199 for row in rows {
200 let (first, second) = (row.0.into(), row.1.into());
201 content += &aux_duo_table(first, second, self.width, None);
202 }
203 content
204 }
205
206 /// Creates a table with three columns
207 ///
208 /// In case the headers do not fit with at least one space between, priority will be given to the first header, and the last remaining character from the second header will be replaced by a dot. If the second header would need to be shortened to less than 3 characters, then the first header will now also be truncated, with the same dot replacing the last charcater from the remaining part of the first header.
209 ///
210 /// ```rust
211 /// # use escpos_rs::Formatter;
212 /// let formatter = Formatter::new(20);
213 /// let header = ("Product", "Price", "Qty.");
214 /// let rows = vec![
215 /// ("Milk", "5.00", "3"),
216 /// ("Cereal", "10.00", "1")
217 /// ];
218 ///
219 /// // We use trim_start just to show the table nicer in this example.
220 /// let target = r#"
221 /// Product Price Qty.
222 /// --------------------
223 /// Milk 5.00 3
224 /// Cereal 10.00 1
225 /// "#.trim_start();
226 ///
227 /// assert_eq!(target, formatter.trio_table(header, rows));
228 /// ```
229 pub fn trio_table<A: Into<String>, B: Into<String>, C: Into<String>, D: IntoIterator<Item = (E, F, G)>, E: Into<String>, F: Into<String>, G: Into<String>>(&self, header: (A, B, C), rows: D) -> String {
230 // Auxiliary closure for printing
231 let aux_trio_table = |mut first: String, mut second: String, mut third: String, width: u8, limits: (u8, u8), replace_last: Option<char>| -> String {
232 if first.len() > limits.0 as usize {
233 let max_width = (limits.0 as usize) - 1;
234 if let Some(replacement) = replace_last {
235 first.truncate(max_width);
236 first += &replacement.to_string();
237 } else {
238 first.truncate(max_width);
239 }
240 }
241 if second.len() > (limits.1 - limits.0) as usize {
242 let max_width = (limits.1 - limits.0) as usize;
243 if let Some(replacement) = replace_last {
244 second.truncate(max_width);
245 second += &replacement.to_string();
246 } else {
247 second.truncate(max_width);
248 }
249 }
250 if third.len() - 1 > (width - limits.1) as usize {
251 let max_width = (width - limits.1) as usize;
252 if let Some(replacement) = replace_last {
253 third.truncate(max_width);
254 third += &replacement.to_string();
255 } else {
256 third.truncate(max_width);
257 }
258 }
259 format!("{:<3$} {:^4$} {:>5$}\n",
260 first,
261 second,
262 third,
263 (limits.0 - 1) as usize,
264 (limits.1 - limits.0) as usize,
265 (width - limits.1 - 1) as usize
266 )
267 };
268
269 // First step, is to find the maximum desirable width of a column.
270 let header: (String, String, String) = (header.0.into(), header.1.into(), header.2.into());
271 let mut max_left = header.0.len();
272 let mut max_middle = header.1.len();
273 let mut max_right = header.2.len();
274
275 // I was not able to do 2 for loops with the IntoIterator trait with borrowed items :(
276 let rows: Vec<(String, String, String)> = rows.into_iter().map(|(a, b, c)| (a.into(), b.into(), c.into())).collect();
277
278 // Now we compare to all rows
279 for row in &rows {
280 if row.0.len() > max_left {
281 max_left = row.0.len();
282 }
283 if row.1.len() > max_middle {
284 max_middle = row.1.len();
285 }
286 if row.2.len() > max_right {
287 max_right = row.2.len();
288 }
289 }
290
291 let limits = if max_left + max_middle + max_right + 2 < self.width as usize {
292 // Nothing to do, easy peasy
293 ((max_left + 1) as u8, (self.width as usize - max_right - 1) as u8)
294 } else {
295 let mut limits = (0u8, self.width as u8);
296 // The left-most column must be at least 4 characters wide, with the lowest priority
297 if max_middle + max_right + 4 > (self.width as usize) {
298 limits.0 = 4;
299 } else {
300 limits.0 = ((self.width as usize) - max_middle - max_right) as u8;
301 }
302
303 // Ahora para el segundo lĂmite
304 let remaining = self.width - limits.0;
305
306 if (max_right as u8) + 4 > remaining {
307 limits.1 = limits.0 + 4;
308 } else {
309 limits.1 = limits.0 + remaining - (max_right as u8);
310 }
311 limits
312 };
313
314 let mut content = aux_trio_table(header.0, header.1, header.2, self.width, limits, None);
315
316 if let Some(hdp) = self.print_header_division_pattern() {
317 content += &hdp;
318 }
319
320 for row in rows {
321 content += &aux_trio_table(row.0, row.1, row.2, self.width, limits, None);
322 }
323 content
324 }
325
326 fn print_header_division_pattern(&self) -> Option<String> {
327 if let Some(header_division_pattern) = &self.table_options.header_division_pattern {
328 let mut line = header_division_pattern.repeat((self.width as usize) / header_division_pattern.len() + 1);
329 line.truncate(self.width as usize);
330 Some(line + "\n")
331 } else {
332 None
333 }
334 }
335}