1use dioxus::prelude::*;
6
7#[derive(Clone, Debug, PartialEq)]
9pub struct WatermarkFont {
10 pub color: String,
12 pub font_size: f32,
14 pub font_weight: String,
16 pub font_style: String,
18 pub font_family: String,
20 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#[derive(Props, Clone, PartialEq)]
39pub struct WatermarkProps {
40 #[props(default = 9)]
42 pub z_index: i32,
43
44 #[props(default = -22.0)]
46 pub rotate: f32,
47
48 #[props(optional)]
50 pub width: Option<f32>,
51
52 #[props(optional)]
54 pub height: Option<f32>,
55
56 #[props(optional)]
58 pub image: Option<String>,
59
60 #[props(optional)]
62 pub content: Option<Vec<String>>,
63
64 #[props(optional)]
66 pub font: Option<WatermarkFont>,
67
68 #[props(optional)]
70 pub gap: Option<[f32; 2]>,
71
72 #[props(optional)]
74 pub offset: Option<[f32; 2]>,
75
76 #[props(optional)]
78 pub class: Option<String>,
79
80 #[props(optional)]
82 pub root_class: Option<String>,
83
84 #[props(optional)]
86 pub style: Option<String>,
87
88 #[props(default = true)]
90 pub inherit: bool,
91
92 pub children: Element,
94}
95
96#[derive(Clone, Copy)]
98#[allow(dead_code)]
99struct WatermarkContext {
100 has_watermark: bool,
102}
103
104#[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 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 let (mark_width, mark_height) =
150 calculate_mark_size(width, height, &content, &font, image.is_some());
151
152 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 if inherit {
171 use_context_provider(|| WatermarkContext {
172 has_watermark: true,
173 });
174 }
175
176 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
207fn 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 (width.unwrap_or(120.0), height.unwrap_or(64.0))
218 } else if let Some(lines) = content {
219 let font_gap = 3.0;
221 let line_count = lines.len().max(1) as f32;
222
223 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 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
237fn 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 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 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 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 let svg_base64 = base64_encode(&svg_content);
283 let background_image = format!("url('data:image/svg+xml;base64,{}')", svg_base64);
284
285 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 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 style.push_str(" visibility: visible !important;");
324
325 style
326}
327
328fn 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 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 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 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 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 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 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 content_svg,
424 cell_width,
426 half_cell_height,
427 content_svg,
428 cell_height,
430 content_svg,
431 cell_width,
433 cell_height + half_cell_height,
434 content_svg
435 )
436}
437
438fn escape_xml(s: &str) -> String {
440 s.replace('&', "&")
441 .replace('<', "<")
442 .replace('>', ">")
443 .replace('"', """)
444 .replace('\'', "'")
445}
446
447fn 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>"), "<test>");
516 assert_eq!(escape_xml("a & b"), "a & b");
517 assert_eq!(escape_xml("\"quote\""), ""quote"");
518 }
519
520 #[test]
521 fn base64_encode_produces_valid_output() {
522 assert_eq!(base64_encode("Hello"), "SGVsbG8=");
524 assert_eq!(base64_encode(""), "");
526 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}