adui_dioxus/components/
watermark.rs

1//! Watermark component for adding watermarks to content areas.
2//!
3//! Ported from Ant Design 6.x watermark component.
4
5use dioxus::prelude::*;
6
7/// Font configuration for text watermarks.
8#[derive(Clone, Debug, PartialEq)]
9pub struct WatermarkFont {
10    /// Font color. Defaults to `rgba(0, 0, 0, 0.15)`.
11    pub color: String,
12    /// Font size in pixels. Defaults to 16.
13    pub font_size: f32,
14    /// Font weight (e.g., "normal", "bold", or numeric like 400, 700).
15    pub font_weight: String,
16    /// Font style (e.g., "normal", "italic").
17    pub font_style: String,
18    /// Font family. Defaults to "sans-serif".
19    pub font_family: String,
20    /// Text alignment. Defaults to "center".
21    pub text_align: String,
22}
23
24impl Default for WatermarkFont {
25    fn default() -> Self {
26        Self {
27            color: "rgba(0, 0, 0, 0.15)".into(),
28            font_size: 16.0,
29            font_weight: "normal".into(),
30            font_style: "normal".into(),
31            font_family: "sans-serif".into(),
32            text_align: "center".into(),
33        }
34    }
35}
36
37/// Props for the Watermark component.
38#[derive(Props, Clone, PartialEq)]
39pub struct WatermarkProps {
40    /// Z-index of the watermark layer. Defaults to 9.
41    #[props(default = 9)]
42    pub z_index: i32,
43
44    /// Rotation angle in degrees. Defaults to -22.
45    #[props(default = -22.0)]
46    pub rotate: f32,
47
48    /// Width of the watermark. Auto-calculated if not provided.
49    #[props(optional)]
50    pub width: Option<f32>,
51
52    /// Height of the watermark. Auto-calculated if not provided.
53    #[props(optional)]
54    pub height: Option<f32>,
55
56    /// Image URL for image watermarks. Takes precedence over content.
57    #[props(optional)]
58    pub image: Option<String>,
59
60    /// Text content for the watermark. Can be a single string or multiple lines.
61    #[props(optional)]
62    pub content: Option<Vec<String>>,
63
64    /// Font configuration for text watermarks.
65    #[props(optional)]
66    pub font: Option<WatermarkFont>,
67
68    /// Gap between watermarks as `[horizontal, vertical]`. Defaults to `[100, 100]`.
69    #[props(optional)]
70    pub gap: Option<[f32; 2]>,
71
72    /// Offset of the watermark from top-left as `[left, top]`.
73    #[props(optional)]
74    pub offset: Option<[f32; 2]>,
75
76    /// Extra class name for the wrapper.
77    #[props(optional)]
78    pub class: Option<String>,
79
80    /// Extra class name for the root element.
81    #[props(optional)]
82    pub root_class: Option<String>,
83
84    /// Inline style for the wrapper.
85    #[props(optional)]
86    pub style: Option<String>,
87
88    /// Whether nested watermark contexts should inherit this watermark.
89    #[props(default = true)]
90    pub inherit: bool,
91
92    /// Content to be watermarked.
93    pub children: Element,
94}
95
96/// Watermark context for nested support (e.g., Modal, Drawer).
97#[derive(Clone, Copy)]
98#[allow(dead_code)]
99struct WatermarkContext {
100    /// Whether parent has watermark enabled.
101    has_watermark: bool,
102}
103
104/// Watermark component that adds a watermark layer over its children.
105///
106/// Supports both text and image watermarks with customizable appearance.
107///
108/// # Example
109///
110/// ```rust,ignore
111/// rsx! {
112///     Watermark {
113///         content: vec!["Confidential".to_string()],
114///         div { class: "content",
115///             "Protected content here"
116///         }
117///     }
118/// }
119/// ```
120#[component]
121pub fn Watermark(props: WatermarkProps) -> Element {
122    let WatermarkProps {
123        z_index,
124        rotate,
125        width,
126        height,
127        image,
128        content,
129        font,
130        gap,
131        offset,
132        class,
133        root_class,
134        style,
135        inherit,
136        children,
137    } = props;
138
139    // Merge font with defaults
140    let font = font.unwrap_or_default();
141    let gap = gap.unwrap_or([100.0, 100.0]);
142    let [gap_x, gap_y] = gap;
143    let gap_x_center = gap_x / 2.0;
144    let gap_y_center = gap_y / 2.0;
145    let offset_left = offset.map(|o| o[0]).unwrap_or(gap_x_center);
146    let offset_top = offset.map(|o| o[1]).unwrap_or(gap_y_center);
147
148    // Calculate watermark dimensions
149    let (mark_width, mark_height) =
150        calculate_mark_size(width, height, &content, &font, image.is_some());
151
152    // Generate watermark pattern
153    let watermark_style = generate_watermark_style(
154        z_index,
155        rotate,
156        mark_width,
157        mark_height,
158        &image,
159        &content,
160        &font,
161        gap_x,
162        gap_y,
163        offset_left,
164        offset_top,
165        gap_x_center,
166        gap_y_center,
167    );
168
169    // Provide context for nested watermarks
170    if inherit {
171        use_context_provider(|| WatermarkContext {
172            has_watermark: true,
173        });
174    }
175
176    // Build class list
177    let mut class_list = vec!["adui-watermark".to_string()];
178    if let Some(extra) = class {
179        class_list.push(extra);
180    }
181    let class_attr = class_list.join(" ");
182
183    let mut root_class_list = vec!["adui-watermark-wrapper".to_string()];
184    if let Some(extra) = root_class {
185        root_class_list.push(extra);
186    }
187    let root_class_attr = root_class_list.join(" ");
188
189    let wrapper_style = format!(
190        "position: relative; overflow: hidden; {}",
191        style.unwrap_or_default()
192    );
193
194    rsx! {
195        div {
196            class: "{root_class_attr}",
197            style: "{wrapper_style}",
198            {children}
199            div {
200                class: "{class_attr}",
201                style: "{watermark_style}",
202            }
203        }
204    }
205}
206
207/// Calculate the size of a single watermark cell.
208fn calculate_mark_size(
209    width: Option<f32>,
210    height: Option<f32>,
211    content: &Option<Vec<String>>,
212    font: &WatermarkFont,
213    is_image: bool,
214) -> (f32, f32) {
215    if is_image {
216        // Default image dimensions
217        (width.unwrap_or(120.0), height.unwrap_or(64.0))
218    } else if let Some(lines) = content {
219        // Estimate text dimensions based on content
220        let font_gap = 3.0;
221        let line_count = lines.len().max(1) as f32;
222
223        // Estimate width based on longest line
224        let max_chars = lines.iter().map(|s| s.chars().count()).max().unwrap_or(0);
225        let estimated_width = width.unwrap_or((max_chars as f32 * font.font_size * 0.6).max(60.0));
226
227        // Height based on line count
228        let estimated_height =
229            height.unwrap_or(line_count * font.font_size + (line_count - 1.0).max(0.0) * font_gap);
230
231        (estimated_width, estimated_height)
232    } else {
233        (width.unwrap_or(120.0), height.unwrap_or(64.0))
234    }
235}
236
237/// Generate the CSS style for the watermark overlay.
238fn generate_watermark_style(
239    z_index: i32,
240    rotate: f32,
241    mark_width: f32,
242    mark_height: f32,
243    image: &Option<String>,
244    content: &Option<Vec<String>>,
245    font: &WatermarkFont,
246    gap_x: f32,
247    gap_y: f32,
248    offset_left: f32,
249    offset_top: f32,
250    gap_x_center: f32,
251    gap_y_center: f32,
252) -> String {
253    // Calculate position offset
254    let position_left = (offset_left - gap_x_center).max(0.0);
255    let position_top = (offset_top - gap_y_center).max(0.0);
256
257    // Calculate background position
258    let bg_position_left = if offset_left > gap_x_center {
259        0.0
260    } else {
261        offset_left - gap_x_center
262    };
263    let bg_position_top = if offset_top > gap_y_center {
264        0.0
265    } else {
266        offset_top - gap_y_center
267    };
268
269    // Generate SVG-based watermark for better cross-browser support
270    let svg_content = generate_svg_watermark(
271        rotate,
272        mark_width,
273        mark_height,
274        image,
275        content,
276        font,
277        gap_x,
278        gap_y,
279    );
280
281    // Base64 encode the SVG for use as background
282    let svg_base64 = base64_encode(&svg_content);
283    let background_image = format!("url('data:image/svg+xml;base64,{}')", svg_base64);
284
285    // Calculate rotation for pattern size
286    let angle_rad = rotate * std::f32::consts::PI / 180.0;
287    let cos_a = angle_rad.cos();
288    let sin_a = angle_rad.sin();
289    let rotated_width = (mark_width * cos_a.abs() + mark_height * sin_a.abs()).ceil();
290    let rotated_height = (mark_width * sin_a.abs() + mark_height * cos_a.abs()).ceil();
291
292    // Calculate the full pattern size (matches SVG dimensions)
293    let cell_width = rotated_width + gap_x;
294    let cell_height = rotated_height + gap_y;
295    let pattern_width = cell_width * 2.0;
296    let pattern_height = cell_height * 2.0;
297
298    let mut style = format!(
299        "position: absolute; \
300         z-index: {}; \
301         left: {}px; \
302         top: {}px; \
303         width: calc(100% - {}px); \
304         height: calc(100% - {}px); \
305         pointer-events: none; \
306         background-repeat: repeat; \
307         background-image: {}; \
308         background-size: {}px {}px; \
309         background-position: {}px {}px;",
310        z_index,
311        position_left,
312        position_top,
313        position_left,
314        position_top,
315        background_image,
316        pattern_width,
317        pattern_height,
318        bg_position_left,
319        bg_position_top,
320    );
321
322    // Add visibility important to prevent hiding via CSS
323    style.push_str(" visibility: visible !important;");
324
325    style
326}
327
328/// Generate an SVG watermark pattern.
329fn generate_svg_watermark(
330    rotate: f32,
331    mark_width: f32,
332    mark_height: f32,
333    image: &Option<String>,
334    content: &Option<Vec<String>>,
335    font: &WatermarkFont,
336    gap_x: f32,
337    gap_y: f32,
338) -> String {
339    let font_gap = 3.0;
340
341    // Calculate rotation
342    let angle_rad = rotate * std::f32::consts::PI / 180.0;
343    let cos_a = angle_rad.cos();
344    let sin_a = angle_rad.sin();
345
346    // Calculate rotated bounding box
347    let rotated_width = (mark_width * cos_a.abs() + mark_height * sin_a.abs()).ceil();
348    let rotated_height = (mark_width * sin_a.abs() + mark_height * cos_a.abs()).ceil();
349
350    // Pattern dimensions - account for alternating offset
351    let cell_width = rotated_width + gap_x;
352    let cell_height = rotated_height + gap_y;
353    let pattern_width = cell_width * 2.0;
354    let pattern_height = cell_height * 2.0;
355
356    let content_svg = if let Some(url) = image {
357        // Image watermark
358        let cx = rotated_width / 2.0;
359        let cy = rotated_height / 2.0;
360        format!(
361            r#"<image href="{}" width="{}" height="{}" x="{}" y="{}" transform="rotate({} {} {})" preserveAspectRatio="xMidYMid meet"/>"#,
362            escape_xml(url),
363            mark_width,
364            mark_height,
365            cx - mark_width / 2.0,
366            cy - mark_height / 2.0,
367            rotate,
368            cx,
369            cy
370        )
371    } else if let Some(lines) = content {
372        // Text watermark
373        let cx = rotated_width / 2.0;
374        let cy = rotated_height / 2.0;
375        let line_height = font.font_size + font_gap;
376        let total_height = lines.len() as f32 * line_height - font_gap;
377        let start_y = cy - total_height / 2.0 + font.font_size;
378
379        let text_elements: String = lines
380            .iter()
381            .enumerate()
382            .map(|(i, line)| {
383                let y = start_y + i as f32 * line_height;
384                format!(
385                    r#"<text x="{}" y="{}" text-anchor="middle" fill="{}" font-size="{}px" font-weight="{}" font-style="{}" font-family="{}">{}</text>"#,
386                    cx,
387                    y,
388                    escape_xml(&font.color),
389                    font.font_size,
390                    escape_xml(&font.font_weight),
391                    escape_xml(&font.font_style),
392                    escape_xml(&font.font_family),
393                    escape_xml(line)
394                )
395            })
396            .collect();
397
398        format!(
399            r#"<g transform="rotate({} {} {})">{}</g>"#,
400            rotate, cx, cy, text_elements
401        )
402    } else {
403        String::new()
404    };
405
406    // Create alternating pattern with 4 cells (2x2 grid with offset)
407    // Row 0: positions (0,0) and (cell_width, cell_height/2)
408    // Row 1: positions (0, cell_height) and (cell_width, cell_height + cell_height/2)
409    let half_cell_height = cell_height / 2.0;
410
411    format!(
412        r#"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" viewBox="0 0 {} {}">
413            <g transform="translate(0, 0)">{}</g>
414            <g transform="translate({}, {})">{}</g>
415            <g transform="translate(0, {})">{}</g>
416            <g transform="translate({}, {})">{}</g>
417        </svg>"#,
418        pattern_width,
419        pattern_height,
420        pattern_width,
421        pattern_height,
422        // First row, first column
423        content_svg,
424        // First row, second column (offset down by half)
425        cell_width,
426        half_cell_height,
427        content_svg,
428        // Second row, first column
429        cell_height,
430        content_svg,
431        // Second row, second column (offset down by half)
432        cell_width,
433        cell_height + half_cell_height,
434        content_svg
435    )
436}
437
438/// Escape XML special characters.
439fn escape_xml(s: &str) -> String {
440    s.replace('&', "&amp;")
441        .replace('<', "&lt;")
442        .replace('>', "&gt;")
443        .replace('"', "&quot;")
444        .replace('\'', "&apos;")
445}
446
447/// Simple base64 encoding for ASCII strings.
448fn base64_encode(input: &str) -> String {
449    const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
450
451    let bytes = input.as_bytes();
452    let mut result = String::with_capacity((bytes.len() + 2) / 3 * 4);
453
454    for chunk in bytes.chunks(3) {
455        let b0 = chunk[0] as u32;
456        let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
457        let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
458
459        let n = (b0 << 16) | (b1 << 8) | b2;
460
461        result.push(ALPHABET[(n >> 18 & 0x3F) as usize] as char);
462        result.push(ALPHABET[(n >> 12 & 0x3F) as usize] as char);
463
464        if chunk.len() > 1 {
465            result.push(ALPHABET[(n >> 6 & 0x3F) as usize] as char);
466        } else {
467            result.push('=');
468        }
469
470        if chunk.len() > 2 {
471            result.push(ALPHABET[(n & 0x3F) as usize] as char);
472        } else {
473            result.push('=');
474        }
475    }
476
477    result
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    #[test]
485    fn default_font_has_expected_values() {
486        let font = WatermarkFont::default();
487        assert_eq!(font.font_size, 16.0);
488        assert_eq!(font.font_weight, "normal");
489        assert_eq!(font.font_family, "sans-serif");
490        assert!(font.color.contains("rgba"));
491    }
492
493    #[test]
494    fn calculate_mark_size_returns_defaults_for_image() {
495        let (w, h) = calculate_mark_size(None, None, &None, &WatermarkFont::default(), true);
496        assert_eq!(w, 120.0);
497        assert_eq!(h, 64.0);
498    }
499
500    #[test]
501    fn calculate_mark_size_respects_explicit_dimensions() {
502        let (w, h) = calculate_mark_size(
503            Some(200.0),
504            Some(100.0),
505            &None,
506            &WatermarkFont::default(),
507            true,
508        );
509        assert_eq!(w, 200.0);
510        assert_eq!(h, 100.0);
511    }
512
513    #[test]
514    fn escape_xml_handles_special_characters() {
515        assert_eq!(escape_xml("<test>"), "&lt;test&gt;");
516        assert_eq!(escape_xml("a & b"), "a &amp; b");
517        assert_eq!(escape_xml("\"quote\""), "&quot;quote&quot;");
518    }
519
520    #[test]
521    fn base64_encode_produces_valid_output() {
522        // "Hello" should encode to "SGVsbG8="
523        assert_eq!(base64_encode("Hello"), "SGVsbG8=");
524        // Empty string
525        assert_eq!(base64_encode(""), "");
526        // Single character
527        assert_eq!(base64_encode("a"), "YQ==");
528    }
529
530    #[test]
531    fn generate_svg_watermark_creates_valid_svg() {
532        let svg = generate_svg_watermark(
533            -22.0,
534            120.0,
535            64.0,
536            &None,
537            &Some(vec!["Test".to_string()]),
538            &WatermarkFont::default(),
539            100.0,
540            100.0,
541        );
542        assert!(svg.starts_with("<svg"));
543        assert!(svg.contains("Test"));
544        assert!(svg.contains("rotate(-22"));
545    }
546}