1use fontdue::{Font, FontSettings};
9use image::GenericImageView;
10use rand::{Rng, SeedableRng};
11use rand_chacha::ChaCha8Rng;
12use std::sync::Arc;
13use thiserror::Error;
14use tiny_skia::{Pixmap, Transform};
15
16#[derive(Debug, Error)]
21pub enum Error {
22 #[error("Font error: {0}")]
23 Font(String),
24 #[error("Image error: {0}")]
25 Image(String),
26 #[error("SVG error: {0}")]
27 Svg(String),
28 #[error("Render error: {0}")]
29 Render(String),
30 #[error("Invalid input: {0}")]
31 Input(String),
32}
33
34#[derive(Debug, Clone)]
39pub struct WordInput {
40 pub text: String,
41 pub weight: f32,
42}
43
44impl WordInput {
45 pub fn new(text: impl Into<String>, weight: f32) -> Self {
46 Self {
47 text: text.into(),
48 weight: weight.max(0.0),
49 }
50 }
51}
52
53#[derive(Debug, Clone)]
54pub struct PlacedWord {
55 pub text: String,
56 pub font_size: f32,
57 pub x: f32,
58 pub y: f32,
59 pub rotation: f32,
60 pub color: String,
61}
62
63#[derive(Debug, Clone, Copy, Default)]
64pub enum ColorScheme {
65 #[default]
66 Default,
67 Contrasting1,
68 Blue,
69 Green,
70 Cold1,
71 Black,
72 White,
73}
74
75impl ColorScheme {
76 pub fn colors(&self) -> Vec<&'static str> {
77 match self {
78 ColorScheme::Default => vec!["#0b100c", "#bb0119", "#c7804b", "#bca692", "#1c4e17"],
79 ColorScheme::Contrasting1 => {
80 vec!["#e76f3d", "#feab6b", "#f3e9e7", "#9bcfe0", "#00a7c7"]
81 }
82 ColorScheme::Blue => vec!["#264653", "#2a9d8f", "#e9c46a", "#f4a261", "#e76f51"],
83 ColorScheme::Green => vec!["#386641", "#6a994e", "#a7c957", "#f2e8cf", "#bc4749"],
84 ColorScheme::Cold1 => vec!["#252b31", "#5e6668", "#c1c8c7", "#f6fafb", "#d49c6b"],
85 ColorScheme::Black => vec!["#000000"],
86 ColorScheme::White => vec!["#ffffff"],
87 }
88 }
89
90 pub fn background_color(&self) -> &'static str {
91 match self {
92 ColorScheme::Default => "#ffffff",
93 ColorScheme::Contrasting1 => "#000000",
94 ColorScheme::Blue => "#ffffff",
95 ColorScheme::Green => "#ffffff",
96 ColorScheme::Cold1 => "#000000",
97 ColorScheme::Black => "#ffffff",
98 ColorScheme::White => "#000000",
99 }
100 }
101}
102
103#[derive(Debug, Clone, Copy, Default)]
108pub enum MaskShape {
109 #[default]
110 Circle,
111 Cloud,
112 Heart,
113 Skull,
114 Star,
115 Triangle,
116}
117
118impl MaskShape {
119 pub fn bytes(&self) -> &'static [u8] {
120 match self {
121 MaskShape::Circle => include_bytes!("../assets/circle.svg"),
122 MaskShape::Cloud => include_bytes!("../assets/cloud.svg"),
123 MaskShape::Heart => include_bytes!("../assets/heart.svg"),
124 MaskShape::Skull => include_bytes!("../assets/skull.svg"),
125 MaskShape::Star => include_bytes!("../assets/star.svg"),
126 MaskShape::Triangle => include_bytes!("../assets/triangle.svg"),
127 }
128 }
129}
130
131struct FontInfo {
136 data: Vec<u8>,
137 family_name: String,
138}
139
140fn extract_font_family_name(font_data: &[u8]) -> Option<String> {
141 let mut db = usvg::fontdb::Database::new();
142 db.load_font_source(usvg::fontdb::Source::Binary(Arc::new(font_data.to_vec())));
143 for face in db.faces() {
144 if let Some((name, _)) = face.families.first() {
145 return Some(name.clone());
146 }
147 }
148 None
149}
150
151pub struct WordCloudBuilder {
156 width: u32,
157 height: u32,
158 background: String,
159 colors: Vec<String>,
160 font_data: Option<Vec<u8>>,
161 mask_data: Option<Vec<u8>>,
162 padding: u32,
163 min_font_size: f32,
164 max_font_size: f32,
165 angles: Vec<f32>,
166 seed: Option<u64>,
167 word_spacing: f32,
168}
169
170impl Default for WordCloudBuilder {
171 fn default() -> Self {
172 let scheme = ColorScheme::Default;
173 Self {
174 width: 800,
175 height: 600,
176 background: scheme.background_color().into(),
177 colors: scheme.colors().into_iter().map(String::from).collect(),
178 font_data: None,
179 mask_data: None,
180 padding: 2,
181 min_font_size: 14.0,
182 max_font_size: 120.0,
183 angles: vec![0.0],
184 seed: None,
185 word_spacing: 4.0,
186 }
187 }
188}
189
190impl WordCloudBuilder {
191 pub fn new() -> Self {
192 Self::default()
193 }
194
195 pub fn size(mut self, width: u32, height: u32) -> Self {
196 self.width = width.max(100);
197 self.height = height.max(100);
198 self
199 }
200
201 pub fn background(mut self, color: impl Into<String>) -> Self {
202 self.background = color.into();
203 self
204 }
205
206 pub fn color_scheme(mut self, scheme: ColorScheme) -> Self {
207 self.colors = scheme.colors().into_iter().map(String::from).collect();
208 self.background = scheme.background_color().into();
209 self
210 }
211
212 pub fn colors(mut self, colors: impl IntoIterator<Item = impl Into<String>>) -> Self {
213 self.colors = colors.into_iter().map(|c| c.into()).collect();
214 if self.colors.is_empty() {
215 self.colors = ColorScheme::Default
216 .colors()
217 .into_iter()
218 .map(String::from)
219 .collect();
220 }
221 self
222 }
223
224 pub fn font(mut self, font_data: Vec<u8>) -> Self {
225 self.font_data = Some(font_data);
226 self
227 }
228
229 pub fn mask(mut self, image_data: Vec<u8>) -> Self {
230 self.mask_data = Some(image_data);
231 self
232 }
233
234 pub fn mask_preset(mut self, shape: MaskShape) -> Self {
235 self.mask_data = Some(shape.bytes().to_vec());
236 self
237 }
238
239 pub fn padding(mut self, padding: u32) -> Self {
240 self.padding = padding;
241 self
242 }
243
244 pub fn font_size_range(mut self, min: f32, max: f32) -> Self {
245 self.min_font_size = min.max(8.0);
246 self.max_font_size = max.max(self.min_font_size);
247 self
248 }
249
250 pub fn angles(mut self, angles: Vec<f32>) -> Self {
251 self.angles = if angles.is_empty() { vec![0.0] } else { angles };
252 self
253 }
254
255 pub fn word_spacing(mut self, spacing: f32) -> Self {
256 self.word_spacing = spacing.max(0.0);
257 self
258 }
259
260 pub fn seed(mut self, seed: u64) -> Self {
261 self.seed = Some(seed);
262 self
263 }
264
265 pub fn build(self, words: &[WordInput]) -> Result<WordCloud, Error> {
266 if words.is_empty() {
267 return Err(Error::Input("Word list cannot be empty".into()));
268 }
269
270 let valid_words: Vec<_> = words
271 .iter()
272 .filter(|w| !w.text.trim().is_empty() && w.weight > 0.0)
273 .cloned()
274 .collect();
275
276 if valid_words.is_empty() {
277 return Err(Error::Input("No valid words provided".into()));
278 }
279
280 let font_info = self.load_font()?;
281 let font = Font::from_bytes(font_info.data.as_slice(), FontSettings::default())
282 .map_err(|e| Error::Font(e.to_string()))?;
283
284 let mut collision_map = CollisionMap::new(self.width, self.height);
286
287 if let Some(mask_bytes) = &self.mask_data {
289 self.apply_mask(&mut collision_map, mask_bytes)?;
290 }
291
292 let mut rng = match self.seed {
293 Some(s) => ChaCha8Rng::seed_from_u64(s),
294 None => ChaCha8Rng::from_os_rng(),
295 };
296
297 let mut sorted_words = valid_words;
299 sorted_words.sort_by(|a, b| b.weight.partial_cmp(&a.weight).unwrap());
300
301 let max_weight = sorted_words.first().map(|w| w.weight).unwrap_or(1.0);
302 let min_weight = sorted_words.last().map(|w| w.weight).unwrap_or(1.0);
303 let weight_range = max_weight - min_weight;
304
305 let mut placed_words = Vec::with_capacity(sorted_words.len());
306 let effective_padding = self.padding + (self.word_spacing / 2.0) as u32;
307
308 for word in &sorted_words {
310 let normalized = if weight_range > 0.0 {
311 (word.weight - min_weight) / weight_range
312 } else {
313 1.0
314 };
315
316 let font_size =
318 self.min_font_size + normalized * (self.max_font_size - self.min_font_size);
319
320 let angle = self.angles[rng.random_range(0..self.angles.len())];
321
322 if let Some(pos) = self.try_place_word(
324 &word.text,
325 font_size,
326 angle,
327 &font,
328 &mut collision_map,
329 effective_padding,
330 &mut rng,
331 ) {
332 let color = self.colors[rng.random_range(0..self.colors.len())].clone();
333 placed_words.push(PlacedWord {
334 text: word.text.clone(),
335 font_size,
336 x: pos.0,
337 y: pos.1,
338 rotation: angle,
339 color,
340 });
341 }
342 }
343
344 Ok(WordCloud {
345 width: self.width,
346 height: self.height,
347 background: self.background,
348 words: placed_words,
349 font_data: font_info.data,
350 font_family: font_info.family_name,
351 })
352 }
353
354 fn load_font(&self) -> Result<FontInfo, Error> {
355 let data = match &self.font_data {
356 Some(d) => d.clone(),
357 None => include_bytes!("../assets/HarmonyOS_Sans_SC_Bold.ttf").to_vec(),
358 };
359
360 let family_name =
361 extract_font_family_name(&data).unwrap_or_else(|| "HarmonyOS Sans SC".to_string());
362
363 Font::from_bytes(data.as_slice(), FontSettings::default())
364 .map_err(|e| Error::Font(e.to_string()))?;
365
366 Ok(FontInfo { data, family_name })
367 }
368
369 fn apply_mask(&self, collision_map: &mut CollisionMap, mask_bytes: &[u8]) -> Result<(), Error> {
370 let mut apply_pixels =
371 |width: u32, height: u32, get_pixel: &dyn Fn(u32, u32) -> Option<(u8, u8, u8, u8)>| {
372 for y in 0..height {
373 for x in 0..width {
374 if let Some((r, g, b, a)) = get_pixel(x, y) {
375 let sum = r as u16 + g as u16 + b as u16;
377 let is_blocked = a < 128 || sum >= 750;
378
379 if is_blocked {
380 collision_map.set(x as i32, y as i32);
381 }
382 }
383 }
384 }
385 };
386
387 let opt = usvg::Options::default();
388 if let Ok(tree) = usvg::Tree::from_data(mask_bytes, &opt) {
389 let size = tree.size().to_int_size();
390 let scale_x = self.width as f32 / size.width() as f32;
391 let scale_y = self.height as f32 / size.height() as f32;
392
393 let mut pixmap = Pixmap::new(self.width, self.height)
394 .ok_or(Error::Render("Failed to create mask buffer".into()))?;
395
396 pixmap.fill(tiny_skia::Color::WHITE);
397
398 let transform = Transform::from_scale(scale_x, scale_y);
399 resvg::render(&tree, transform, &mut pixmap.as_mut());
400
401 apply_pixels(self.width, self.height, &|x, y| {
402 pixmap
403 .pixel(x, y)
404 .map(|p| (p.red(), p.green(), p.blue(), p.alpha()))
405 });
406 return Ok(());
407 }
408
409 if let Ok(img) = image::load_from_memory(mask_bytes) {
410 let resized = img.resize_exact(
411 self.width,
412 self.height,
413 image::imageops::FilterType::Nearest,
414 );
415
416 apply_pixels(self.width, self.height, &|x, y| {
417 if x < resized.width() && y < resized.height() {
418 let p = resized.get_pixel(x, y);
419 Some((p[0], p[1], p[2], p[3]))
420 } else {
421 None
422 }
423 });
424 return Ok(());
425 }
426
427 Err(Error::Image(
428 "The mask format could not be determined".into(),
429 ))
430 }
431
432 #[allow(clippy::too_many_arguments)]
433 fn try_place_word(
434 &self,
435 text: &str,
436 font_size: f32,
437 angle: f32,
438 font: &Font,
439 map: &mut CollisionMap,
440 padding: u32,
441 rng: &mut ChaCha8Rng,
442 ) -> Option<(f32, f32)> {
443 let sprite = rasterize_text(text, font_size, angle, font, padding);
445
446 if sprite.bbox_width == 0 || sprite.bbox_height == 0 {
447 return None;
448 }
449
450 let start_x = map.width as i32 / 2;
451 let start_y = map.height as i32 / 2;
452
453 let dt = if rng.random_bool(0.5) { 1 } else { -1 };
454
455 let spiral = ArchimedeanSpiral::new(map.width as i32, map.height as i32, dt);
457 let max_iter = (map.width * map.height) as usize / 2; for (dx, dy) in spiral.take(max_iter) {
460 let current_x = start_x + dx - (sprite.bbox_width as i32 / 2);
462 let current_y = start_y + dy - (sprite.bbox_height as i32 / 2);
463
464 if !map.check_collision(&sprite, current_x, current_y) {
466 map.write_sprite(&sprite, current_x, current_y);
468
469 return Some((
479 current_x as f32 + sprite.text_center_x,
480 current_y as f32 + sprite.text_center_y,
481 ));
482 }
483 }
484
485 None
486 }
487}
488
489struct CollisionMap {
494 width: u32,
495 height: u32,
496 stride: usize,
497 data: Vec<u32>,
498}
499
500impl CollisionMap {
501 fn new(width: u32, height: u32) -> Self {
502 let stride = ((width + 31) >> 5) as usize;
503 Self {
504 width,
505 height,
506 stride,
507 data: vec![0; stride * height as usize],
508 }
509 }
510
511 fn set(&mut self, x: i32, y: i32) {
512 if x >= 0 && y >= 0 && x < self.width as i32 && y < self.height as i32 {
513 let row_idx = y as usize * self.stride;
514 let col_idx = (x as usize) >> 5;
515 let bit_idx = 31 - (x & 31);
516 self.data[row_idx + col_idx] |= 1 << bit_idx;
517 }
518 }
519
520 fn check_collision(&self, sprite: &TextSprite, start_x: i32, start_y: i32) -> bool {
521 let sprite_w32 = sprite.width_u32;
522 let sprite_h = sprite.bbox_height;
523 let shift = (start_x & 31).unsigned_abs();
524 let r_shift = 32 - shift;
525
526 if start_x + (sprite.bbox_width as i32) < 0
528 || start_x >= self.width as i32
529 || start_y + (sprite.bbox_height as i32) < 0
530 || start_y >= self.height as i32
531 {
532 return true;
533 }
534
535 for sy in 0..sprite_h {
536 let gy = start_y + sy as i32;
537 if gy < 0 || gy >= self.height as i32 {
538 return true; }
540
541 let grid_row_idx = gy as usize * self.stride;
542 let grid_col_start = (start_x >> 5) as isize;
543 let mut carry = 0u32;
544
545 for sx in 0..=sprite_w32 {
546 let s_val = if sx < sprite_w32 {
547 sprite.data[sy as usize * sprite_w32 + sx]
548 } else {
549 0
550 };
551
552 let mask = if shift == 0 {
553 s_val
554 } else {
555 (carry << r_shift) | (s_val >> shift)
556 };
557
558 let gx = grid_col_start + sx as isize;
559
560 if mask != 0 {
561 if gx < 0 || gx >= self.stride as isize {
562 return true;
563 }
564 if (self.data[grid_row_idx + gx as usize] & mask) != 0 {
565 return true;
566 }
567 }
568 carry = s_val;
569 }
570 }
571 false
572 }
573
574 fn write_sprite(&mut self, sprite: &TextSprite, start_x: i32, start_y: i32) {
575 let sprite_w32 = sprite.width_u32;
576 let sprite_h = sprite.bbox_height;
577 let shift = (start_x & 31).unsigned_abs();
578 let r_shift = 32 - shift;
579
580 for sy in 0..sprite_h {
581 let gy = start_y + sy as i32;
582 if gy < 0 || gy >= self.height as i32 {
583 continue;
584 }
585
586 let grid_row_idx = gy as usize * self.stride;
587 let grid_col_start = (start_x >> 5) as isize;
588 let mut carry = 0u32;
589
590 for sx in 0..=sprite_w32 {
591 let s_val = if sx < sprite_w32 {
592 sprite.data[sy as usize * sprite_w32 + sx]
593 } else {
594 0
595 };
596
597 let mask = if shift == 0 {
598 s_val
599 } else {
600 (carry << r_shift) | (s_val >> shift)
601 };
602
603 let gx = grid_col_start + sx as isize;
604 if mask != 0 && gx >= 0 && gx < self.stride as isize {
605 self.data[grid_row_idx + gx as usize] |= mask;
606 }
607 carry = s_val;
608 }
609 }
610 }
611}
612
613struct TextSprite {
614 data: Vec<u32>,
615 width_u32: usize,
616 bbox_width: u32,
617 bbox_height: u32,
618 text_center_x: f32, text_center_y: f32,
620}
621
622fn rasterize_text(text: &str, size: f32, angle_deg: f32, font: &Font, padding: u32) -> TextSprite {
623 let metrics = font
625 .horizontal_line_metrics(size)
626 .unwrap_or(fontdue::LineMetrics {
627 ascent: size * 0.8,
628 descent: size * -0.2,
629 line_gap: 0.0,
630 new_line_size: size,
631 });
632
633 let mut glyphs = Vec::new();
634 let mut total_width = 0.0f32;
635
636 for ch in text.chars() {
637 let (glyph_metrics, bitmap) = font.rasterize(ch, size);
638 glyphs.push((total_width, glyph_metrics, bitmap));
639 total_width += glyph_metrics.advance_width;
640 }
641
642 let padding_f = padding as f32;
644 let unrotated_w = total_width.ceil() + padding_f * 2.0;
646 let unrotated_h = metrics.new_line_size.ceil() + padding_f * 2.0;
647
648 let cx = unrotated_w / 2.0;
650 let cy = unrotated_h / 2.0;
651
652 let rad = angle_deg.to_radians();
653 let (sin, cos) = rad.sin_cos();
654
655 let transform = |x: f32, y: f32| -> (f32, f32) {
656 let dx = x - cx;
657 let dy = y - cy;
658 (dx * cos - dy * sin + cx, dx * sin + dy * cos + cy)
659 };
660
661 let corners = [
663 transform(0.0, 0.0),
664 transform(unrotated_w, 0.0),
665 transform(0.0, unrotated_h),
666 transform(unrotated_w, unrotated_h),
667 ];
668
669 let min_x = corners.iter().map(|p| p.0).fold(f32::INFINITY, f32::min);
670 let max_x = corners
671 .iter()
672 .map(|p| p.0)
673 .fold(f32::NEG_INFINITY, f32::max);
674 let min_y = corners.iter().map(|p| p.1).fold(f32::INFINITY, f32::min);
675 let max_y = corners
676 .iter()
677 .map(|p| p.1)
678 .fold(f32::NEG_INFINITY, f32::max);
679
680 let buf_width = (max_x - min_x).ceil() as i32;
681 let buf_height = (max_y - min_y).ceil() as i32;
682
683 let mut pixels = Vec::new();
686 let base_x = padding_f;
687 let base_y = padding_f + metrics.ascent;
688
689 let mut tight_min_x = i32::MAX;
692 let mut tight_max_x = i32::MIN;
693 let mut tight_min_y = i32::MAX;
694 let mut tight_max_y = i32::MIN;
695
696 for (offset_x, glyph_metrics, bitmap) in &glyphs {
697 let char_left = base_x + offset_x + glyph_metrics.xmin as f32;
698 let char_top = base_y - glyph_metrics.height as f32 - glyph_metrics.ymin as f32;
699
700 for y in 0..glyph_metrics.height {
701 for x in 0..glyph_metrics.width {
702 if bitmap[y * glyph_metrics.width + x] > 10 {
704 let ox = char_left + x as f32;
705 let oy = char_top + y as f32;
706 let (rx, ry) = transform(ox, oy);
707
708 let fx = (rx - min_x).round() as i32;
710 let fy = (ry - min_y).round() as i32;
711
712 let pad = padding as i32;
714 for py in -pad..=pad {
715 for px in -pad..=pad {
716 let px_x = fx + px;
717 let px_y = fy + py;
718
719 if px_x >= 0 && px_y >= 0 && px_x < buf_width && px_y < buf_height {
720 pixels.push((px_x, px_y));
721 tight_min_x = tight_min_x.min(px_x);
722 tight_max_x = tight_max_x.max(px_x);
723 tight_min_y = tight_min_y.min(px_y);
724 tight_max_y = tight_max_y.max(px_y);
725 }
726 }
727 }
728 }
729 }
730 }
731 }
732
733 if pixels.is_empty() {
734 return TextSprite {
735 data: vec![],
736 width_u32: 0,
737 bbox_width: 0,
738 bbox_height: 0,
739 text_center_x: 0.0,
740 text_center_y: 0.0,
741 };
742 }
743
744 let tight_w = (tight_max_x - tight_min_x + 1) as u32;
746 let tight_h = (tight_max_y - tight_min_y + 1) as u32;
747 let width_u32 = ((tight_w + 31) >> 5) as usize;
748 let mut data = vec![0u32; width_u32 * tight_h as usize];
749
750 for (px, py) in pixels {
751 let rel_x = (px - tight_min_x) as usize;
752 let rel_y = (py - tight_min_y) as usize;
753
754 let row_idx = rel_y * width_u32;
755 let col_idx = rel_x >> 5;
756 let bit_idx = 31 - (rel_x & 31);
757 data[row_idx + col_idx] |= 1 << bit_idx;
758 }
759
760 let center_x_in_buffer = cx - min_x;
778 let center_y_in_buffer = cy - min_y;
779
780 let text_center_x = center_x_in_buffer - tight_min_x as f32;
781 let text_center_y = center_y_in_buffer - tight_min_y as f32;
782
783 TextSprite {
784 data,
785 width_u32,
786 bbox_width: tight_w,
787 bbox_height: tight_h,
788 text_center_x,
789 text_center_y,
790 }
791}
792
793struct ArchimedeanSpiral {
798 t: i32,
799 dt: i32,
800 dx: f64,
801 dy: f64,
802 ratio: f64,
803 e: f64,
804}
805
806impl ArchimedeanSpiral {
807 fn new(width: i32, height: i32, dt: i32) -> Self {
808 let e = 4.0;
809 let ratio = e * width as f64 / height as f64;
810 Self {
811 t: 0,
812 dt,
813 dx: 0.0,
814 dy: 0.0,
815 ratio,
816 e,
817 }
818 }
819}
820
821impl Iterator for ArchimedeanSpiral {
822 type Item = (i32, i32);
823
824 fn next(&mut self) -> Option<Self::Item> {
825 self.t += self.dt;
826 let sign = if self.t < 0 { -1.0 } else { 1.0 };
827 let idx = ((1.0 + 4.0 * sign * self.t as f64).sqrt() - sign) as i32 & 3;
828 match idx {
829 0 => self.dx += self.ratio,
830 1 => self.dy += self.e,
831 2 => self.dx -= self.ratio,
832 _ => self.dy -= self.e,
833 }
834 Some((self.dx as i32, self.dy as i32))
835 }
836}
837
838pub struct WordCloud {
843 pub width: u32,
844 pub height: u32,
845 pub background: String,
846 pub words: Vec<PlacedWord>,
847 font_data: Vec<u8>,
848 font_family: String,
849}
850
851impl WordCloud {
852 pub fn to_svg(&self) -> String {
853 let mut svg = String::with_capacity(8192);
854
855 svg.push_str(&format!(
856 r#"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" viewBox="0 0 {} {}">"#,
857 self.width, self.height, self.width, self.height
858 ));
859
860 svg.push_str(&format!(
861 r#"<rect x="0" y="0" width="100%" height="100%" fill="{}"/>"#,
862 self.background
863 ));
864
865 svg.push_str(&format!(
866 r#"<style>text{{font-family:'{}',Arial,sans-serif;text-anchor:middle;dominant-baseline:middle}}</style>"#,
867 escape_xml(&self.font_family)
868 ));
869
870 for word in &self.words {
871 svg.push_str(&format!(
873 r#"<text transform="translate({:.1},{:.1}) rotate({:.1})" fill="{}" font-size="{:.1}">{}</text>"#,
874 word.x,
875 word.y,
876 word.rotation,
877 word.color,
878 word.font_size,
879 escape_xml(&word.text)
880 ));
881 }
882
883 svg.push_str("</svg>");
884 svg
885 }
886
887 pub fn to_png(&self, scale: f32) -> Result<Vec<u8>, Error> {
888 let svg_content = self.to_svg();
889 let mut fontdb = usvg::fontdb::Database::new();
890
891 fontdb.load_font_source(usvg::fontdb::Source::Binary(Arc::new(
892 self.font_data.clone(),
893 )));
894
895 let options = usvg::Options {
896 font_family: self.font_family.clone(),
897 fontdb: Arc::new(fontdb),
898 ..Default::default()
899 };
900
901 let tree =
902 usvg::Tree::from_str(&svg_content, &options).map_err(|e| Error::Svg(e.to_string()))?;
903 let size = tree.size().to_int_size();
904 let out_width = (size.width() as f32 * scale).max(1.0) as u32;
905 let out_height = (size.height() as f32 * scale).max(1.0) as u32;
906
907 let mut pixmap = Pixmap::new(out_width, out_height)
908 .ok_or_else(|| Error::Render("Failed to create pixel buffer".into()))?;
909
910 if let Some(color) = parse_hex_color(&self.background) {
911 pixmap.fill(color);
912 }
913
914 let transform = Transform::from_scale(scale, scale);
915 resvg::render(&tree, transform, &mut pixmap.as_mut());
916
917 pixmap
918 .encode_png()
919 .map_err(|e| Error::Render(e.to_string()))
920 }
921}
922
923fn parse_hex_color(hex: &str) -> Option<tiny_skia::Color> {
924 let hex = hex.trim_start_matches('#');
925 if hex.len() == 6 {
926 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
927 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
928 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
929 Some(tiny_skia::Color::from_rgba8(r, g, b, 255))
930 } else {
931 None
932 }
933}
934
935fn escape_xml(s: &str) -> String {
936 s.replace('&', "&")
937 .replace('<', "<")
938 .replace('>', ">")
939 .replace('"', """)
940 .replace('\'', "'")
941}
942
943pub fn generate(words: &[(&str, f32)]) -> Result<WordCloud, Error> {
944 let inputs: Vec<WordInput> = words
945 .iter()
946 .map(|(text, weight)| WordInput::new(*text, *weight))
947 .collect();
948
949 WordCloudBuilder::new().build(&inputs)
950}