1
//! Clipboard content types for copy/paste operations
2
//!
3
//! Contains `ClipboardContent` and `StyledTextRun`, used by clipboard and
4
//! changeset modules.
5
//!
6
//! **Rich-text status:** `StyledTextRun`, `StyledTextRunVec` and the
7
//! `ClipboardContent.styled_runs` field are FFI-exported (api.json), but the
8
//! rich path is only half-wired: the live clipboard producers build
9
//! `styled_runs` empty (`window.rs::get_selected_content_for_clipboard`,
10
//! paste in `common/event.rs`) and the platform clipboard backends write only
11
//! `plain_text`. Fully wiring it means (a) extracting per-run style from the
12
//! styled DOM when copying and (b) adding an HTML/RTF format to each platform's
13
//! clipboard write (and reading it back on paste). `to_html()` below is the
14
//! retained consumer for that future format. Until then the FFI surface is
15
//! kept (it is public API) but `styled_runs` stays empty.
16

            
17
use azul_css::{impl_option, impl_option_inner, AzString, OptionString};
18

            
19
// Clipboard Content Extraction
20

            
21
/// Styled text run for rich clipboard content
22
#[derive(Debug, Clone, PartialEq)]
23
#[repr(C)]
24
pub struct StyledTextRun {
25
    /// The actual text content
26
    pub text: AzString,
27
    /// Font family name
28
    pub font_family: OptionString,
29
    /// Font size in pixels
30
    pub font_size_px: f32,
31
    /// Text color
32
    pub color: azul_css::props::basic::ColorU,
33
    /// Whether text is bold
34
    pub is_bold: bool,
35
    /// Whether text is italic
36
    pub is_italic: bool,
37
}
38

            
39
azul_css::impl_option!(StyledTextRun, OptionStyledTextRun, copy = false, [Debug, Clone, PartialEq]);
40
azul_css::impl_vec!(StyledTextRun, StyledTextRunVec, StyledTextRunVecDestructor, StyledTextRunVecDestructorType, StyledTextRunVecSlice, OptionStyledTextRun);
41
azul_css::impl_vec_debug!(StyledTextRun, StyledTextRunVec);
42
azul_css::impl_vec_clone!(StyledTextRun, StyledTextRunVec, StyledTextRunVecDestructor);
43
azul_css::impl_vec_partialeq!(StyledTextRun, StyledTextRunVec);
44

            
45
/// Clipboard content with both plain text and styled (HTML) representation
46
#[derive(Debug, Clone, PartialEq)]
47
#[repr(C)]
48
pub struct ClipboardContent {
49
    /// Plain text representation (UTF-8)
50
    pub plain_text: AzString,
51
    /// Rich text runs with styling information
52
    pub styled_runs: StyledTextRunVec,
53
}
54

            
55
impl_option!(
56
    ClipboardContent,
57
    OptionClipboardContent,
58
    copy = false,
59
    [Debug, Clone, PartialEq]
60
);
61

            
62
impl ClipboardContent {
63
    /// Convert styled runs to HTML for rich clipboard formats.
64
    ///
65
    /// Retained consumer of the FFI-exported `styled_runs`: returns an empty
66
    /// `<div></div>` until `styled_runs` is populated and the platform clipboard
67
    /// backends gain an HTML format (see module docs). Kept as public API.
68
53
    #[must_use] pub fn to_html(&self) -> String {
69
        use core::fmt::Write as _;
70
53
        let mut html = String::from("<div>");
71

            
72
2053
        for run in self.styled_runs.as_slice() {
73
2053
            html.push_str("<span style=\"");
74

            
75
2053
            if let Some(font_family) = run.font_family.as_ref() {
76
2008
                let _ = write!(html, "font-family: {}; ", font_family.as_str());
77
2008
            }
78
2053
            let _ = write!(html, "font-size: {}px; ", run.font_size_px);
79
2053
            let _ = write!(
80
2053
                html,
81
2053
                "color: rgba({}, {}, {}, {}); ",
82
                run.color.r,
83
                run.color.g,
84
                run.color.b,
85
2053
                f32::from(run.color.a) / 255.0
86
            );
87
2053
            if run.is_bold {
88
1
                html.push_str("font-weight: bold; ");
89
2052
            }
90
2053
            if run.is_italic {
91
1
                html.push_str("font-style: italic; ");
92
2052
            }
93

            
94
2053
            html.push_str("\">");
95
            // Escape HTML entities
96
2053
            let escaped = run
97
2053
                .text
98
2053
                .as_str()
99
2053
                .replace('&', "&amp;")
100
2053
                .replace('<', "&lt;")
101
2053
                .replace('>', "&gt;");
102
2053
            html.push_str(&escaped);
103
2053
            html.push_str("</span>");
104
        }
105

            
106
53
        html.push_str("</div>");
107
53
        html
108
53
    }
109
}
110

            
111
#[cfg(test)]
112
mod autotest_generated {
113
    use azul_css::{props::basic::ColorU, AzString, OptionString};
114

            
115
    use super::*;
116

            
117
    // =========================================================================
118
    // Fixtures
119
    //
120
    // `ClipboardContent::to_html` is a pure string builder, so the adversarial
121
    // surface is (a) the escaping pass over `text` (ordering, double-escaping,
122
    // characters it does *not* cover), (b) `f32` Display of `font_size_px`
123
    // (NaN / inf / -0.0 / MAX), (c) the u8 -> f32 alpha division at the
124
    // channel boundaries, and (d) structural invariants that must hold for
125
    // every input (balanced tags, one span per run, determinism).
126
    // =========================================================================
127

            
128
    const OPAQUE_BLACK: ColorU = ColorU {
129
        r: 0,
130
        g: 0,
131
        b: 0,
132
        a: 255,
133
    };
134

            
135
    /// A styled run, parameterized on every field the formatter reads.
136
    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
    /// Inverse of the escaping pass in `to_html` (entities undone in reverse
157
    /// order, so `&amp;` is restored last).
158
    fn unescape(s: &str) -> String {
159
        s.replace("&lt;", "<")
160
            .replace("&gt;", ">")
161
            .replace("&amp;", "&")
162
    }
163

            
164
    // ---------------------------------------------------------------------
165
    // basic_access: expected value after a known construction
166
    // ---------------------------------------------------------------------
167

            
168
    #[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
    // ---------------------------------------------------------------------
213
    // edge_access: default / empty / extreme instances must not panic
214
    // ---------------------------------------------------------------------
215

            
216
    #[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
        // `to_html` only reads `styled_runs`; a populated `plain_text` (the
231
        // only field the live producers fill) must not leak into the markup.
232
        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
    // ---------------------------------------------------------------------
240
    // escaping / round-trip: escape(text) must be losslessly reversible
241
    // ---------------------------------------------------------------------
242

            
243
    #[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("&lt;script&gt;a &amp;&amp; b&lt;/script&gt;"),
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
        // `&` is replaced first, so an input entity is escaped exactly once.
256
        let html = content(vec![run("&lt;&amp;", 10.0, None)]).to_html();
257
        assert!(html.contains(">&amp;lt;&amp;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
            "&amp;",
269
            "&lt;<>&gt;",
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
        // With no font-family, every `<`/`>` in the output must come from the
285
        // four structural tags: <div>, <span ...>, </span>, </div>.
286
        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
        // Characterization test (NOT an endorsement): unlike `text`, the font
294
        // family is written into the `style="..."` attribute with no escaping
295
        // or quoting, so a quote in the family name terminates the attribute.
296
        // Live producers never populate `styled_runs`, so this is currently
297
        // unreachable โ€” but any future producer must sanitize the family name.
298
        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
    // ---------------------------------------------------------------------
306
    // numeric: font_size_px is a raw f32 Display
307
    // ---------------------------------------------------------------------
308

            
309
    #[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
        // 1/255 keeps full f32 precision โ€” it is not truncated to 0.
350
        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
    // ---------------------------------------------------------------------
369
    // unicode / hostile payloads
370
    // ---------------------------------------------------------------------
371

            
372
    #[test]
373
    fn to_html_preserves_unicode_and_control_characters() {
374
        for text in [
375
            "๐Ÿ˜€๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ",         // emoji + ZWJ sequence
376
            "ู…ุฑุญุจุง ุจุงู„ุนุงู„ู…",  // RTL
377
            "e\u{0301}\u{0327}", // combining marks
378
            "a\u{0}b",        // interior NUL
379
            "line\nbreak\ttab",
380
            "\u{200B}\u{FEFF}", // zero-width space + BOM
381
            "\u{202E}reversed", // RTL override
382
        ] {
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
        // every `&` expands to the 5-byte `&amp;`
394
        assert_eq!(html.matches("&amp;").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
    // ---------------------------------------------------------------------
409
    // invariants: structure, purity, clone-equivalence
410
    // ---------------------------------------------------------------------
411

            
412
    #[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