araea_wordcloud/
lib.rs

1/*!
2 * Araea WordCloud Library
3 *
4 * A pure Rust implementation of the Word Cloud algorithm, aligned with the logic
5 * found in wordcloud2.js / B8yHTEJ1.js.
6 */
7
8use 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// =============================================================================
17// Error Types
18// =============================================================================
19
20#[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// =============================================================================
35// Public Data Types
36// =============================================================================
37
38#[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// =============================================================================
104// Preset Masks
105// =============================================================================
106
107#[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
131// =============================================================================
132// Font Info
133// =============================================================================
134
135struct 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
151// =============================================================================
152// Builder
153// =============================================================================
154
155pub 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        // 1. Init Grid
285        let mut collision_map = CollisionMap::new(self.width, self.height);
286
287        // 2. Apply Mask
288        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        // Sort by weight desc
298        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        // 3. Layout Loop
309        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            // Linear sizing logic (matches JS simple scaling)
317            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            // 4. Try Place
323            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                            // Logic matches JS: white (sum >= 750) or transparent (a < 128) is blocked
376                            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        // Rasterize text to tight bounding box bitmask
444        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        // 5. Spiral Search (Archimedean)
456        let spiral = ArchimedeanSpiral::new(map.width as i32, map.height as i32, dt);
457        let max_iter = (map.width * map.height) as usize / 2; // Reasonable limit
458
459        for (dx, dy) in spiral.take(max_iter) {
460            // Attempt placement at (current_x, current_y) which represents Top-Left of Sprite
461            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            // 6. Collision Check
465            if !map.check_collision(&sprite, current_x, current_y) {
466                // 7. Update Grid
467                map.write_sprite(&sprite, current_x, current_y);
468
469                // Return CENTER coordinates for SVG transformation
470                // The sprite was placed at top-left `current_x`, `current_y`.
471                // The center is simply half dimensions away.
472                // NOTE: We don't use anchor_x/y here anymore because rasterize_text
473                // returns a tight box, and we position that tight box centered on the spiral point.
474                // For SVG `text-anchor="middle"`, we need the coordinates of the text origin/center.
475                // Since `rasterize_text` now returns the offset from the TightBox-TopLeft
476                // to the Text-Center (text_center_x, text_center_y), we add that.
477
478                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
489// =============================================================================
490// Collision Detection (Optimized)
491// =============================================================================
492
493struct 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        // Bounding box pre-check
527        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; // Out of bounds usually means collision in this context
539            }
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, // Offset from TopLeft to Text Center
619    text_center_y: f32,
620}
621
622fn rasterize_text(text: &str, size: f32, angle_deg: f32, font: &Font, padding: u32) -> TextSprite {
623    // 1. Basic Rasterization
624    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    // 2. Transformations
643    let padding_f = padding as f32;
644    // Initial geometric box (untight)
645    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    // Center of text
649    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    // Calculate geometric bounds for buffer allocation
662    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    // 3. Pixel Collection (finding tight bounds)
684    // We map pixels to a set of (x,y) points
685    let mut pixels = Vec::new();
686    let base_x = padding_f;
687    let base_y = padding_f + metrics.ascent;
688
689    // To align with JS behavior, we collect actual pixels to find the "Tight" bounding box.
690    // The "geometric" box is often too large for diagonal text.
691    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                // Alpha threshold
703                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                    // Map to buffer coordinates
709                    let fx = (rx - min_x).round() as i32;
710                    let fy = (ry - min_y).round() as i32;
711
712                    // Apply padding
713                    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    // 4. Create Tight Sprite
745    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    // 5. Calculate Center Offset
761    // We need the position of the text's rotation center (cx, cy)
762    // relative to the Top-Left of the Tight Bounding Box.
763    // (cx, cy) after transform is simply (cx, cy) relative to origin if purely rotated?
764    // transform() rotates around (cx, cy).
765    // The rotated point corresponding to (cx, cy) is ... (cx, cy).
766    // In buffer coords (relative to min_x, min_y):
767    // center_in_buffer = transform(cx, cy) - (min_x, min_y)
768    //                  = (cx, cy) - (min_x, min_y) ? NO.
769    // transform(cx, cy) = (cx, cy) by definition of rotation center.
770    // So buffer_cx = cx - min_x; buffer_cy = cy - min_y;
771    //
772    // The Tight Box Top Left in buffer coords is (tight_min_x, tight_min_y).
773    //
774    // So offset = buffer_center - tight_top_left
775    //           = (cx - min_x - tight_min_x, cy - min_y - tight_min_y)
776
777    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
793// =============================================================================
794// Helper: Archimedean Spiral
795// =============================================================================
796
797struct 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
838// =============================================================================
839// Output Generation
840// =============================================================================
841
842pub 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            // JS Output uses: transform="translate(x,y) rotate(deg)" with text-anchor="middle"
872            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('&', "&amp;")
937        .replace('<', "&lt;")
938        .replace('>', "&gt;")
939        .replace('"', "&quot;")
940        .replace('\'', "&apos;")
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}