merman_render/text/
wrap.rs1use super::{
6 DeterministicTextMeasurer, TextMeasurer, TextStyle, trim_end_html_collapsible_ascii_whitespace,
7 trim_html_collapsible_ascii_whitespace,
8};
9
10pub fn ceil_to_1_64_px(v: f64) -> f64 {
11 if !(v.is_finite() && v >= 0.0) {
12 return 0.0;
13 }
14 let x = v * 64.0;
17 let r = x.round();
18 if (x - r).abs() < 1e-4 {
19 return r / 64.0;
20 }
21 ((x) - 1e-5).ceil() / 64.0
22}
23
24pub fn round_to_1_64_px(v: f64) -> f64 {
25 if !(v.is_finite() && v >= 0.0) {
26 return 0.0;
27 }
28 let x = v * 64.0;
29 let r = (x + 0.5).floor();
30 r / 64.0
31}
32
33pub fn round_to_1_64_px_ties_to_even(v: f64) -> f64 {
34 if !(v.is_finite() && v >= 0.0) {
35 return 0.0;
36 }
37 let x = v * 64.0;
38 let f = x.floor();
39 let frac = x - f;
40 let i = if frac < 0.5 {
41 f
42 } else if frac > 0.5 {
43 f + 1.0
44 } else {
45 let fi = f as i64;
46 if fi % 2 == 0 { f } else { f + 1.0 }
47 };
48 let out = i / 64.0;
49 if out == -0.0 { 0.0 } else { out }
50}
51
52pub fn wrap_text_lines_measurer(
58 text: &str,
59 measurer: &dyn TextMeasurer,
60 style: &TextStyle,
61 max_width_px: Option<f64>,
62) -> Vec<String> {
63 fn wrap_line(
64 line: &str,
65 measurer: &dyn TextMeasurer,
66 style: &TextStyle,
67 max_width_px: f64,
68 ) -> Vec<String> {
69 use std::collections::VecDeque;
70
71 if !max_width_px.is_finite() || max_width_px <= 0.0 {
72 return vec![line.to_string()];
73 }
74
75 let mut tokens = VecDeque::from(DeterministicTextMeasurer::split_line_to_words(line));
76 let mut out: Vec<String> = Vec::new();
77 let mut cur = String::new();
78
79 while let Some(tok) = tokens.pop_front() {
80 if cur.is_empty() && tok == " " {
81 continue;
82 }
83
84 let candidate = format!("{cur}{tok}");
85 if measurer
86 .measure(
87 trim_end_html_collapsible_ascii_whitespace(&candidate),
88 style,
89 )
90 .width
91 <= max_width_px
92 {
93 cur = candidate;
94 continue;
95 }
96
97 if !trim_html_collapsible_ascii_whitespace(&cur).is_empty() {
98 out.push(trim_end_html_collapsible_ascii_whitespace(&cur).to_string());
99 cur.clear();
100 tokens.push_front(tok);
101 continue;
102 }
103
104 if tok == " " {
105 continue;
106 }
107
108 let chars = tok.chars().collect::<Vec<_>>();
110 let mut cut = 1usize;
111 while cut < chars.len() {
112 let head: String = chars[..cut].iter().collect();
113 if measurer.measure(&head, style).width > max_width_px {
114 break;
115 }
116 cut += 1;
117 }
118 cut = cut.saturating_sub(1).max(1);
119 let head: String = chars[..cut].iter().collect();
120 let tail: String = chars[cut..].iter().collect();
121 out.push(head);
122 if !tail.is_empty() {
123 tokens.push_front(tail);
124 }
125 }
126
127 if !trim_html_collapsible_ascii_whitespace(&cur).is_empty() {
128 out.push(trim_end_html_collapsible_ascii_whitespace(&cur).to_string());
129 }
130
131 if out.is_empty() {
132 vec!["".to_string()]
133 } else {
134 out
135 }
136 }
137
138 let mut out: Vec<String> = Vec::new();
139 for line in split_html_br_lines(text) {
140 if let Some(w) = max_width_px {
141 out.extend(wrap_line(line, measurer, style, w));
142 } else {
143 out.push(line.to_string());
144 }
145 }
146 if out.is_empty() {
147 vec!["".to_string()]
148 } else {
149 out
150 }
151}
152
153pub fn split_html_br_lines(text: &str) -> Vec<&str> {
159 let b = text.as_bytes();
160 let mut parts: Vec<&str> = Vec::new();
161 let mut start = 0usize;
162 let mut i = 0usize;
163 while i + 3 < b.len() {
164 if b[i] != b'<' {
165 i += 1;
166 continue;
167 }
168 let b1 = b[i + 1];
169 let b2 = b[i + 2];
170 if !matches!(b1, b'b' | b'B') || !matches!(b2, b'r' | b'R') {
171 i += 1;
172 continue;
173 }
174 let mut j = i + 3;
175 while j < b.len() && matches!(b[j], b' ' | b'\t' | b'\r' | b'\n') {
176 j += 1;
177 }
178 if j < b.len() && b[j] == b'/' {
179 j += 1;
180 }
181 if j < b.len() && b[j] == b'>' {
182 parts.push(&text[start..i]);
183 start = j + 1;
184 i = start;
185 continue;
186 }
187 i += 1;
188 }
189 parts.push(&text[start..]);
190 parts
191}
192
193fn try_wrap_label_like_mermaid_lines<E>(
194 label: &str,
195 measurer: &dyn TextMeasurer,
196 style: &TextStyle,
197 max_width_px: f64,
198 checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
199) -> std::result::Result<Vec<String>, E> {
200 checkpoint()?;
201 if label.is_empty() {
202 return Ok(Vec::new());
203 }
204 if !max_width_px.is_finite() || max_width_px <= 0.0 {
205 return Ok(vec![label.to_string()]);
206 }
207
208 let explicit_lines = split_html_br_lines(label);
210 if explicit_lines.len() > 1 {
211 let mut lines = Vec::with_capacity(explicit_lines.len());
212 for line in explicit_lines {
213 checkpoint()?;
214 lines.push(line.to_string());
215 }
216 checkpoint()?;
217 return Ok(lines);
218 }
219 fn w_px<E>(
220 measurer: &dyn TextMeasurer,
221 style: &TextStyle,
222 s: &str,
223 checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
224 ) -> std::result::Result<f64, E> {
225 checkpoint()?;
227 let width = measurer
228 .measure_svg_simple_text_bbox_width_for_wrap_px(s, style)
229 .round();
230 checkpoint()?;
231 Ok(width)
232 }
233
234 fn break_string_like_mermaid<E>(
235 word: &str,
236 max_width_px: f64,
237 measurer: &dyn TextMeasurer,
238 style: &TextStyle,
239 checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
240 ) -> std::result::Result<(Vec<String>, String), E> {
241 let chars: Vec<char> = word.chars().collect();
242 let mut lines: Vec<String> = Vec::new();
243 let mut current = String::new();
244 for (idx, ch) in chars.iter().enumerate() {
245 checkpoint()?;
246 let next_line = format!("{current}{ch}");
247 let line_w = w_px(measurer, style, &next_line, checkpoint)?;
248 if line_w >= max_width_px {
249 let is_last = idx + 1 == chars.len();
250 if is_last {
251 lines.push(next_line);
252 } else {
253 lines.push(format!("{next_line}-"));
254 }
255 current.clear();
256 } else {
257 current = next_line;
258 }
259 }
260 Ok((lines, current))
261 }
262
263 let words: Vec<&str> = label.split(' ').filter(|w| !w.is_empty()).collect();
265 if words.is_empty() {
266 return Ok(vec![label.to_string()]);
267 }
268
269 let mut completed: Vec<String> = Vec::new();
270 let mut next_line = String::new();
271 for (idx, word) in words.iter().enumerate() {
272 checkpoint()?;
273 let word_len = w_px(measurer, style, &format!("{word} "), checkpoint)?;
274 let next_len = w_px(measurer, style, &next_line, checkpoint)?;
275 if word_len > max_width_px {
276 let (hyphenated, remaining) =
277 break_string_like_mermaid(word, max_width_px, measurer, style, checkpoint)?;
278 completed.push(next_line.clone());
279 completed.extend(hyphenated);
280 next_line = remaining;
281 } else if next_len + word_len >= max_width_px {
282 completed.push(next_line.clone());
283 next_line = (*word).to_string();
284 } else if next_line.is_empty() {
285 next_line = (*word).to_string();
286 } else {
287 next_line.push(' ');
288 next_line.push_str(word);
289 }
290
291 let is_last = idx + 1 == words.len();
292 if is_last {
293 completed.push(next_line.clone());
294 }
295 }
296
297 checkpoint()?;
298 Ok(completed.into_iter().filter(|l| !l.is_empty()).collect())
299}
300
301pub fn wrap_label_like_mermaid_lines(
306 label: &str,
307 measurer: &dyn TextMeasurer,
308 style: &TextStyle,
309 max_width_px: f64,
310) -> Vec<String> {
311 let mut checkpoint = || Ok::<(), std::convert::Infallible>(());
312 match try_wrap_label_like_mermaid_lines(label, measurer, style, max_width_px, &mut checkpoint) {
313 Ok(lines) => lines,
314 Err(error) => match error {},
315 }
316}
317
318pub(crate) fn wrap_label_like_mermaid_lines_with_checkpoint(
319 label: &str,
320 measurer: &dyn TextMeasurer,
321 style: &TextStyle,
322 max_width_px: f64,
323 mut checkpoint: impl FnMut() -> crate::Result<()>,
324) -> crate::Result<Vec<String>> {
325 try_wrap_label_like_mermaid_lines(label, measurer, style, max_width_px, &mut checkpoint)
326}