1use azul_css::{impl_option, impl_option_inner, AzString, OptionString};
18
19#[derive(Debug, Clone, PartialEq)]
23#[repr(C)]
24pub struct StyledTextRun {
25 pub text: AzString,
27 pub font_family: OptionString,
29 pub font_size_px: f32,
31 pub color: azul_css::props::basic::ColorU,
33 pub is_bold: bool,
35 pub is_italic: bool,
37}
38
39azul_css::impl_option!(StyledTextRun, OptionStyledTextRun, copy = false, [Debug, Clone, PartialEq]);
40azul_css::impl_vec!(StyledTextRun, StyledTextRunVec, StyledTextRunVecDestructor, StyledTextRunVecDestructorType, StyledTextRunVecSlice, OptionStyledTextRun);
41azul_css::impl_vec_debug!(StyledTextRun, StyledTextRunVec);
42azul_css::impl_vec_clone!(StyledTextRun, StyledTextRunVec, StyledTextRunVecDestructor);
43azul_css::impl_vec_partialeq!(StyledTextRun, StyledTextRunVec);
44
45#[derive(Debug, Clone, PartialEq)]
47#[repr(C)]
48pub struct ClipboardContent {
49 pub plain_text: AzString,
51 pub styled_runs: StyledTextRunVec,
53}
54
55impl_option!(
56 ClipboardContent,
57 OptionClipboardContent,
58 copy = false,
59 [Debug, Clone, PartialEq]
60);
61
62impl ClipboardContent {
63 #[must_use] pub fn to_html(&self) -> String {
69 use core::fmt::Write as _;
70 let mut html = String::from("<div>");
71
72 for run in self.styled_runs.as_slice() {
73 html.push_str("<span style=\"");
74
75 if let Some(font_family) = run.font_family.as_ref() {
76 let _ = write!(html, "font-family: {}; ", font_family.as_str());
77 }
78 let _ = write!(html, "font-size: {}px; ", run.font_size_px);
79 let _ = write!(
80 html,
81 "color: rgba({}, {}, {}, {}); ",
82 run.color.r,
83 run.color.g,
84 run.color.b,
85 f32::from(run.color.a) / 255.0
86 );
87 if run.is_bold {
88 html.push_str("font-weight: bold; ");
89 }
90 if run.is_italic {
91 html.push_str("font-style: italic; ");
92 }
93
94 html.push_str("\">");
95 let escaped = run
97 .text
98 .as_str()
99 .replace('&', "&")
100 .replace('<', "<")
101 .replace('>', ">");
102 html.push_str(&escaped);
103 html.push_str("</span>");
104 }
105
106 html.push_str("</div>");
107 html
108 }
109}
110
111#[cfg(test)]
112mod autotest_generated {
113 use azul_css::{props::basic::ColorU, AzString, OptionString};
114
115 use super::*;
116
117 const OPAQUE_BLACK: ColorU = ColorU {
129 r: 0,
130 g: 0,
131 b: 0,
132 a: 255,
133 };
134
135 fn run(text: &str, font_size_px: f32, family: Option<&str>) -> StyledTextRun {
137 StyledTextRun {
138 text: AzString::from(text),
139 font_family: family.map_or(OptionString::None, |f| {
140 OptionString::Some(AzString::from(f))
141 }),
142 font_size_px,
143 color: OPAQUE_BLACK,
144 is_bold: false,
145 is_italic: false,
146 }
147 }
148
149 fn content(runs: Vec<StyledTextRun>) -> ClipboardContent {
150 ClipboardContent {
151 plain_text: AzString::from(""),
152 styled_runs: runs.into(),
153 }
154 }
155
156 fn unescape(s: &str) -> String {
159 s.replace("<", "<")
160 .replace(">", ">")
161 .replace("&", "&")
162 }
163
164 #[test]
169 fn to_html_single_run_produces_exact_markup() {
170 let c = content(vec![run("hi", 12.0, Some("Arial"))]);
171 assert_eq!(
172 c.to_html(),
173 "<div><span style=\"font-family: Arial; font-size: 12px; color: rgba(0, 0, 0, 1); \
174 \">hi</span></div>"
175 );
176 }
177
178 #[test]
179 fn to_html_omits_font_family_when_none() {
180 let html = content(vec![run("x", 1.0, None)]).to_html();
181 assert!(!html.contains("font-family"), "{html}");
182 assert!(html.contains("font-size: 1px; "), "{html}");
183 }
184
185 #[test]
186 fn to_html_emits_bold_and_italic_only_when_set() {
187 let mut r = run("x", 10.0, None);
188 assert!(!content(vec![r.clone()]).to_html().contains("font-weight"));
189 assert!(!content(vec![r.clone()]).to_html().contains("font-style"));
190
191 r.is_bold = true;
192 r.is_italic = true;
193 let html = content(vec![r]).to_html();
194 assert!(html.contains("font-weight: bold; "), "{html}");
195 assert!(html.contains("font-style: italic; "), "{html}");
196 }
197
198 #[test]
199 fn to_html_concatenates_runs_in_order() {
200 let html = content(vec![
201 run("a", 1.0, None),
202 run("b", 2.0, None),
203 run("c", 3.0, None),
204 ])
205 .to_html();
206 let a = html.find(">a<").expect("run a missing");
207 let b = html.find(">b<").expect("run b missing");
208 let c = html.find(">c<").expect("run c missing");
209 assert!(a < b && b < c, "runs reordered: {html}");
210 }
211
212 #[test]
217 fn to_html_empty_runs_is_empty_div() {
218 assert_eq!(content(Vec::new()).to_html(), "<div></div>");
219 }
220
221 #[test]
222 fn to_html_empty_run_text_yields_empty_span_body() {
223 let html = content(vec![run("", 0.0, Some(""))]).to_html();
224 assert!(html.ends_with("\"></span></div>"), "{html}");
225 assert!(html.contains("font-family: ; "), "{html}");
226 }
227
228 #[test]
229 fn to_html_ignores_plain_text_field() {
230 let c = ClipboardContent {
233 plain_text: AzString::from("SHOULD-NOT-APPEAR"),
234 styled_runs: Vec::<StyledTextRun>::new().into(),
235 };
236 assert_eq!(c.to_html(), "<div></div>");
237 }
238
239 #[test]
244 fn to_html_escapes_angle_brackets_and_ampersand() {
245 let html = content(vec![run("<script>a && b</script>", 10.0, None)]).to_html();
246 assert!(
247 html.contains("<script>a && b</script>"),
248 "{html}"
249 );
250 assert!(!html.contains("<script>"), "raw tag survived: {html}");
251 }
252
253 #[test]
254 fn to_html_does_not_double_escape_existing_entities() {
255 let html = content(vec![run("<&", 10.0, None)]).to_html();
257 assert!(html.contains(">&lt;&amp;<"), "{html}");
258 }
259
260 #[test]
261 fn to_html_text_round_trips_through_unescape() {
262 for text in [
263 "",
264 "plain",
265 "&",
266 "<",
267 ">",
268 "&",
269 "<<>>",
270 "a<b>c&d",
271 "&&&&<<<<>>>>",
272 ] {
273 let html = content(vec![run(text, 10.0, None)]).to_html();
274 let body = html
275 .rsplit_once("</span>")
276 .and_then(|(head, _)| head.rsplit_once("\">").map(|(_, b)| b.to_string()))
277 .expect("span body not found");
278 assert_eq!(unescape(&body), text, "round-trip failed for {text:?}");
279 }
280 }
281
282 #[test]
283 fn to_html_escaped_text_introduces_no_raw_markup_chars() {
284 let html = content(vec![run("<<<>>>&&&", 10.0, None)]).to_html();
287 assert_eq!(html.matches('<').count(), 4, "{html}");
288 assert_eq!(html.matches('>').count(), 4, "{html}");
289 }
290
291 #[test]
292 fn to_html_font_family_is_interpolated_raw_into_the_style_attribute() {
293 let html = content(vec![run("t", 10.0, Some("\"><img onerror=x>"))]).to_html();
299 assert!(
300 html.contains("font-family: \"><img onerror=x>; "),
301 "escaping behaviour changed, re-check the injection note: {html}"
302 );
303 }
304
305 #[test]
310 fn to_html_non_finite_font_size_does_not_panic() {
311 for (size, rendered) in [
312 (f32::NAN, "font-size: NaNpx; "),
313 (f32::INFINITY, "font-size: infpx; "),
314 (f32::NEG_INFINITY, "font-size: -infpx; "),
315 ] {
316 let html = content(vec![run("x", size, None)]).to_html();
317 assert!(html.contains(rendered), "{size} -> {html}");
318 assert!(html.ends_with("</div>"), "{html}");
319 }
320 }
321
322 #[test]
323 fn to_html_extreme_finite_font_sizes_do_not_panic() {
324 for size in [
325 0.0,
326 -0.0,
327 -1.0,
328 f32::MIN,
329 f32::MAX,
330 f32::MIN_POSITIVE,
331 f32::EPSILON,
332 ] {
333 let html = content(vec![run("x", size, None)]).to_html();
334 assert!(html.starts_with("<div><span"), "{size} -> {html}");
335 assert!(html.ends_with("x</span></div>"), "{size} -> {html}");
336 assert!(html.contains("font-size: "), "{size} -> {html}");
337 }
338 }
339
340 #[test]
341 fn to_html_alpha_is_normalized_to_0_1_at_channel_boundaries() {
342 let alpha_of = |a: u8| {
343 let mut r = run("x", 10.0, None);
344 r.color = ColorU { r: 0, g: 0, b: 0, a };
345 content(vec![r]).to_html()
346 };
347 assert!(alpha_of(255).contains("rgba(0, 0, 0, 1); "));
348 assert!(alpha_of(0).contains("rgba(0, 0, 0, 0); "));
349 let expected = format!("rgba(0, 0, 0, {}); ", 1.0_f32 / 255.0);
351 assert!(alpha_of(1).contains(&expected), "{}", alpha_of(1));
352 }
353
354 #[test]
355 fn to_html_color_channels_are_verbatim_u8() {
356 let mut r = run("x", 10.0, None);
357 r.color = ColorU {
358 r: 255,
359 g: 0,
360 b: 128,
361 a: 255,
362 };
363 assert!(content(vec![r])
364 .to_html()
365 .contains("color: rgba(255, 0, 128, 1); "));
366 }
367
368 #[test]
373 fn to_html_preserves_unicode_and_control_characters() {
374 for text in [
375 "๐๐จโ๐ฉโ๐งโ๐ฆ", "ู
ุฑุญุจุง ุจุงูุนุงูู
", "e\u{0301}\u{0327}", "a\u{0}b", "line\nbreak\ttab",
380 "\u{200B}\u{FEFF}", "\u{202E}reversed", ] {
383 let html = content(vec![run(text, 10.0, None)]).to_html();
384 assert!(html.contains(text), "lost {text:?} in {html:?}");
385 assert!(html.ends_with("</span></div>"), "{html:?}");
386 }
387 }
388
389 #[test]
390 fn to_html_large_text_does_not_panic() {
391 let text = "&".repeat(100_000);
392 let html = content(vec![run(&text, 10.0, None)]).to_html();
393 assert_eq!(html.matches("&").count(), 100_000);
395 assert!(html.ends_with("</span></div>"));
396 }
397
398 #[test]
399 fn to_html_many_runs_emits_one_span_each() {
400 let runs: Vec<_> = (0..2_000u16)
401 .map(|i| run("t", f32::from(i), Some("Arial")))
402 .collect();
403 let html = content(runs).to_html();
404 assert_eq!(html.matches("<span style=\"").count(), 2_000);
405 assert_eq!(html.matches("</span>").count(), 2_000);
406 }
407
408 #[test]
413 fn to_html_always_wraps_output_in_a_single_div() {
414 let cases = vec![
415 content(Vec::new()),
416 content(vec![run("", f32::NAN, None)]),
417 content(vec![run("<>&", -0.0, Some("a\"b"))]),
418 content(vec![run("๐", f32::MAX, Some(""))]),
419 ];
420 for c in cases {
421 let html = c.to_html();
422 assert!(html.starts_with("<div>"), "{html}");
423 assert!(html.ends_with("</div>"), "{html}");
424 assert_eq!(html.matches("<div>").count(), 1, "{html}");
425 assert_eq!(html.matches("</div>").count(), 1, "{html}");
426 assert_eq!(
427 html.matches("<span style=\"").count(),
428 html.matches("</span>").count(),
429 "unbalanced spans: {html}"
430 );
431 }
432 }
433
434 #[test]
435 fn to_html_is_pure_and_deterministic() {
436 let c = content(vec![run("<a>", 12.5, Some("Arial")), run("&", 0.0, None)]);
437 let before = c.clone();
438 let first = c.to_html();
439 let second = c.to_html();
440 assert_eq!(first, second, "to_html is not deterministic");
441 assert_eq!(c, before, "to_html mutated the receiver");
442 assert_eq!(c.clone().to_html(), first, "clone renders differently");
443 }
444}
445