Skip to main content

par_term_render/cell_renderer/
atlas.rs

1use super::{CellRenderer, GlyphInfo};
2
3pub(crate) struct RasterizedGlyph {
4    pub width: u32,
5    pub height: u32,
6    pub bearing_x: f32,
7    pub bearing_y: f32,
8    pub pixels: Vec<u8>,
9    pub is_colored: bool,
10}
11
12/// Unicode ranges for symbols that should render monochromatically.
13/// These characters have emoji default presentation but are commonly used
14/// as symbols in terminal contexts (spinners, decorations, etc.) and should
15/// use the terminal foreground color rather than colorful emoji rendering.
16pub mod symbol_ranges {
17    /// Dingbats block (U+2700–U+27BF)
18    /// Contains asterisks, stars, sparkles, arrows, etc.
19    pub const DINGBATS_START: u32 = 0x2700;
20    pub const DINGBATS_END: u32 = 0x27BF;
21
22    /// Miscellaneous Symbols (U+2600–U+26FF)
23    /// Contains weather, zodiac, chess, etc.
24    pub const MISC_SYMBOLS_START: u32 = 0x2600;
25    pub const MISC_SYMBOLS_END: u32 = 0x26FF;
26
27    /// Miscellaneous Technical (U+2300–U+23FF)
28    /// Contains media controls (⏩⏪⏸⏹⏺), hourglass (⌛), power symbols, etc.
29    pub const MISC_TECHNICAL_START: u32 = 0x2300;
30    pub const MISC_TECHNICAL_END: u32 = 0x23FF;
31
32    /// Miscellaneous Symbols and Arrows (U+2B00–U+2BFF)
33    /// Contains various arrows and stars like ⭐
34    pub const MISC_SYMBOLS_ARROWS_START: u32 = 0x2B00;
35    pub const MISC_SYMBOLS_ARROWS_END: u32 = 0x2BFF;
36}
37
38/// Check if a character should be rendered as a monochrome symbol.
39///
40/// Returns true for characters that:
41/// 1. Are in symbol/dingbat Unicode ranges
42/// 2. Have emoji default presentation but are commonly used as symbols
43///
44/// These characters should use the terminal foreground color rather than
45/// colorful emoji bitmaps, even if the emoji font provides a colored glyph.
46pub fn should_render_as_symbol(ch: char) -> bool {
47    let code = ch as u32;
48
49    // Miscellaneous Technical (U+2300–U+23FF)
50    if (symbol_ranges::MISC_TECHNICAL_START..=symbol_ranges::MISC_TECHNICAL_END).contains(&code) {
51        return true;
52    }
53
54    // Miscellaneous Symbols (U+2600–U+26FF)
55    if (symbol_ranges::MISC_SYMBOLS_START..=symbol_ranges::MISC_SYMBOLS_END).contains(&code) {
56        return true;
57    }
58
59    // Dingbats (U+2700–U+27BF)
60    if (symbol_ranges::DINGBATS_START..=symbol_ranges::DINGBATS_END).contains(&code) {
61        return true;
62    }
63
64    // Miscellaneous Symbols and Arrows (U+2B00–U+2BFF)
65    if (symbol_ranges::MISC_SYMBOLS_ARROWS_START..=symbol_ranges::MISC_SYMBOLS_ARROWS_END)
66        .contains(&code)
67    {
68        return true;
69    }
70
71    false
72}
73
74impl CellRenderer {
75    pub fn clear_glyph_cache(&mut self) {
76        self.atlas.glyph_cache.clear();
77        self.atlas.lru_head = None;
78        self.atlas.lru_tail = None;
79        self.atlas.atlas_next_x = 0;
80        self.atlas.atlas_next_y = 0;
81        self.atlas.atlas_row_height = 0;
82        self.dirty_rows.fill(true);
83        // Re-upload the solid white pixel for geometric block rendering
84        self.upload_solid_pixel();
85    }
86
87    pub(crate) fn lru_remove(&mut self, key: u64) {
88        let info = self
89            .atlas
90            .glyph_cache
91            .get(&key)
92            .expect("Glyph cache entry must exist before calling lru_remove");
93        let prev = info.prev;
94        let next = info.next;
95
96        if let Some(p) = prev {
97            self.atlas
98                .glyph_cache
99                .get_mut(&p)
100                .expect("Glyph cache LRU prev entry must exist")
101                .next = next;
102        } else {
103            self.atlas.lru_head = next;
104        }
105
106        if let Some(n) = next {
107            self.atlas
108                .glyph_cache
109                .get_mut(&n)
110                .expect("Glyph cache LRU next entry must exist")
111                .prev = prev;
112        } else {
113            self.atlas.lru_tail = prev;
114        }
115    }
116
117    pub(crate) fn lru_push_front(&mut self, key: u64) {
118        let next = self.atlas.lru_head;
119        if let Some(n) = next {
120            self.atlas
121                .glyph_cache
122                .get_mut(&n)
123                .expect("Glyph cache LRU head entry must exist")
124                .prev = Some(key);
125        } else {
126            self.atlas.lru_tail = Some(key);
127        }
128
129        let info = self
130            .atlas
131            .glyph_cache
132            .get_mut(&key)
133            .expect("Glyph cache entry must exist before calling lru_push_front");
134        info.prev = None;
135        info.next = next;
136        self.atlas.lru_head = Some(key);
137    }
138
139    pub(crate) fn rasterize_glyph(
140        &mut self,
141        font_idx: usize,
142        glyph_id: u16,
143        force_monochrome: bool,
144    ) -> Option<RasterizedGlyph> {
145        let font = self.font_manager.get_font(font_idx)?;
146        // Use swash to rasterize
147        use swash::scale::Render;
148        use swash::scale::image::Content;
149        use swash::zeno::Format;
150
151        // Determine render format before creating the scaler so there is no live
152        // mutable borrow of `self.scale_context` when we call `should_use_thin_strokes`.
153        let use_thin_strokes = self.should_use_thin_strokes();
154        let render_format = if !self.font.font_antialias {
155            // No anti-aliasing: render as alpha mask (will be thresholded)
156            Format::Alpha
157        } else if use_thin_strokes {
158            // Thin strokes: use subpixel rendering for lighter appearance
159            Format::Subpixel
160        } else {
161            // Standard anti-aliased rendering
162            Format::Alpha
163        };
164
165        // For symbol characters (dingbats, etc.), prefer outline rendering to get
166        // monochrome glyphs that use the terminal foreground color. For emoji,
167        // prefer color bitmaps for proper colorful rendering.
168        let sources = if force_monochrome {
169            // Symbol character: try outline first, fall back to color if unavailable
170            // This ensures dingbats like ✳ ✴ ❇ render as monochrome symbols
171            // with the terminal foreground color, not as colorful emoji.
172            [
173                swash::scale::Source::Outline,
174                swash::scale::Source::ColorOutline(0),
175                swash::scale::Source::ColorBitmap(swash::scale::StrikeWith::BestFit),
176            ]
177        } else {
178            // Regular emoji: prefer color sources for colorful rendering
179            [
180                swash::scale::Source::ColorBitmap(swash::scale::StrikeWith::BestFit),
181                swash::scale::Source::ColorOutline(0),
182                swash::scale::Source::Outline,
183            ]
184        };
185
186        // Build the scaler after computing `render_format` to avoid a
187        // mutable+immutable borrow overlap on `self`.
188        let mut scaler = self
189            .scale_context
190            .builder(*font)
191            .size(self.font.font_size_pixels)
192            .hint(self.font.font_hinting)
193            .build();
194
195        let mut image = Render::new(&sources)
196            .format(render_format)
197            .render(&mut scaler, glyph_id)?;
198
199        // Detect degenerate outlines: some fonts (e.g., Apple Color Emoji) have charmap
200        // entries but produce empty outlines (all-zero alpha) when the font only has
201        // bitmap data (sbix).
202        if matches!(image.content, Content::Mask) && image.data.iter().all(|&b| b == 0) {
203            if force_monochrome {
204                // For monochrome symbol rendering, don't fall back to color bitmaps.
205                // Return None so the caller can try the next font in the fallback
206                // chain. If no text font has the character, the caller's last resort
207                // will retry with force_monochrome=false to get colored emoji.
208                return None;
209            }
210            // For normal (non-monochrome) rendering, try color bitmap sources.
211            // Drop `scaler` so the exclusive borrow on `self.scale_context` is
212            // released, allowing us to rebuild a new scaler for the retry pass.
213            #[allow(clippy::drop_non_drop)]
214            // Intentional: ends borrow lifetime on self.scale_context
215            drop(scaler);
216            let mut retry_scaler = self
217                .scale_context
218                .builder(*font)
219                .size(self.font.font_size_pixels)
220                .hint(self.font.font_hinting)
221                .build();
222            let color_sources = [
223                swash::scale::Source::ColorBitmap(swash::scale::StrikeWith::BestFit),
224                swash::scale::Source::ColorOutline(0),
225            ];
226            if let Some(color_image) = Render::new(&color_sources)
227                .format(render_format)
228                .render(&mut retry_scaler, glyph_id)
229            {
230                image = color_image;
231            } else {
232                return None;
233            }
234        }
235
236        let (pixels, is_colored) = match image.content {
237            Content::Color => {
238                if force_monochrome {
239                    // Convert color emoji to monochrome using the original alpha channel.
240                    // This is more accurate than luminance-based conversion: colored
241                    // symbols (e.g., yellow ⭐, colored ✨) keep full opacity, while
242                    // luminance would make non-white colors appear faint.
243                    let pixels = convert_color_to_alpha_mask(&image);
244                    (pixels, false)
245                } else {
246                    (image.data.clone(), true)
247                }
248            }
249            Content::Mask => {
250                let mut pixels = Vec::with_capacity(image.data.len() * 4);
251                for &mask in &image.data {
252                    // If anti-aliasing is disabled, threshold the alpha to create crisp edges
253                    let alpha = if !self.font.font_antialias {
254                        if mask > 127 { 255 } else { 0 }
255                    } else {
256                        mask
257                    };
258                    pixels.extend_from_slice(&[255, 255, 255, alpha]);
259                }
260                (pixels, false)
261            }
262            Content::SubpixelMask => {
263                let pixels = convert_subpixel_mask_to_rgba(&image);
264                (pixels, false)
265            }
266        };
267
268        // Final check: reject glyphs that are still all-transparent after processing.
269        // This catches cases where even color bitmap conversion produced no visible pixels.
270        if !is_colored && pixels.iter().skip(3).step_by(4).all(|&a| a == 0) {
271            return None;
272        }
273
274        Some(RasterizedGlyph {
275            width: image.placement.width,
276            height: image.placement.height,
277            bearing_x: image.placement.left as f32,
278            bearing_y: image.placement.top as f32,
279            pixels,
280            is_colored,
281        })
282    }
283
284    pub(crate) fn upload_glyph(&mut self, _key: u64, raster: &RasterizedGlyph) -> GlyphInfo {
285        let padding = super::ATLAS_GLYPH_PADDING;
286        let atlas_size = self.atlas.atlas_size;
287        if self.atlas.atlas_next_x + raster.width + padding > atlas_size {
288            self.atlas.atlas_next_x = 0;
289            self.atlas.atlas_next_y += self.atlas.atlas_row_height + padding;
290            self.atlas.atlas_row_height = 0;
291        }
292
293        if self.atlas.atlas_next_y + raster.height + padding > atlas_size {
294            self.clear_glyph_cache();
295        }
296
297        let info = GlyphInfo {
298            key: _key,
299            x: self.atlas.atlas_next_x,
300            y: self.atlas.atlas_next_y,
301            width: raster.width,
302            height: raster.height,
303            bearing_x: raster.bearing_x,
304            bearing_y: raster.bearing_y,
305            is_colored: raster.is_colored,
306            prev: None,
307            next: None,
308        };
309
310        self.queue.write_texture(
311            wgpu::TexelCopyTextureInfo {
312                texture: &self.atlas.atlas_texture,
313                mip_level: 0,
314                origin: wgpu::Origin3d {
315                    x: info.x,
316                    y: info.y,
317                    z: 0,
318                },
319                aspect: wgpu::TextureAspect::All,
320            },
321            &raster.pixels,
322            wgpu::TexelCopyBufferLayout {
323                offset: 0,
324                bytes_per_row: Some(4 * raster.width),
325                rows_per_image: Some(raster.height),
326            },
327            wgpu::Extent3d {
328                width: raster.width,
329                height: raster.height,
330                depth_or_array_layers: 1,
331            },
332        );
333
334        // Clear the padding strips with transparent black so bilinear sampling at glyph
335        // edges never bleeds into stale data from previously evicted glyphs.
336        let pad_right_x = info.x + raster.width;
337        let pad_bottom_y = info.y + raster.height;
338
339        // Right border: `padding` columns × glyph height
340        if pad_right_x + padding <= atlas_size && raster.height > 0 {
341            let zero = vec![0u8; (padding * raster.height * 4) as usize];
342            self.queue.write_texture(
343                wgpu::TexelCopyTextureInfo {
344                    texture: &self.atlas.atlas_texture,
345                    mip_level: 0,
346                    origin: wgpu::Origin3d {
347                        x: pad_right_x,
348                        y: info.y,
349                        z: 0,
350                    },
351                    aspect: wgpu::TextureAspect::All,
352                },
353                &zero,
354                wgpu::TexelCopyBufferLayout {
355                    offset: 0,
356                    bytes_per_row: Some(padding * 4),
357                    rows_per_image: Some(raster.height),
358                },
359                wgpu::Extent3d {
360                    width: padding,
361                    height: raster.height,
362                    depth_or_array_layers: 1,
363                },
364            );
365        }
366
367        // Bottom border: glyph width × `padding` rows
368        if pad_bottom_y + padding <= atlas_size && raster.width > 0 {
369            let zero = vec![0u8; (raster.width * padding * 4) as usize];
370            self.queue.write_texture(
371                wgpu::TexelCopyTextureInfo {
372                    texture: &self.atlas.atlas_texture,
373                    mip_level: 0,
374                    origin: wgpu::Origin3d {
375                        x: info.x,
376                        y: pad_bottom_y,
377                        z: 0,
378                    },
379                    aspect: wgpu::TextureAspect::All,
380                },
381                &zero,
382                wgpu::TexelCopyBufferLayout {
383                    offset: 0,
384                    bytes_per_row: Some(raster.width * 4),
385                    rows_per_image: Some(padding),
386                },
387                wgpu::Extent3d {
388                    width: raster.width,
389                    height: padding,
390                    depth_or_array_layers: 1,
391                },
392            );
393        }
394
395        self.atlas.atlas_next_x += raster.width + padding;
396        self.atlas.atlas_row_height = self.atlas.atlas_row_height.max(raster.height);
397
398        info
399    }
400
401    /// Look up a glyph by `cache_key` in the atlas, rasterizing and uploading it on
402    /// a cache miss.  Returns `None` when rasterization produces an empty bitmap.
403    ///
404    /// `cache_key` must be computed by the caller as:
405    ///   `((font_idx as u64) << 32) | (glyph_id as u64)`
406    /// with bit 63 set when querying the colored-emoji variant of a symbol character.
407    ///
408    /// On a cache hit the LRU order is updated before returning.
409    pub(crate) fn get_or_rasterize_glyph(
410        &mut self,
411        font_idx: usize,
412        glyph_id: u16,
413        force_monochrome: bool,
414        cache_key: u64,
415    ) -> Option<GlyphInfo> {
416        if self.atlas.glyph_cache.contains_key(&cache_key) {
417            self.lru_remove(cache_key);
418            self.lru_push_front(cache_key);
419            return Some(
420                self.atlas
421                    .glyph_cache
422                    .get(&cache_key)
423                    .expect("Glyph cache entry must exist after contains_key check")
424                    .clone(),
425            );
426        }
427        let raster = self.rasterize_glyph(font_idx, glyph_id, force_monochrome)?;
428        let info = self.upload_glyph(cache_key, &raster);
429        self.atlas.glyph_cache.insert(cache_key, info.clone());
430        self.lru_push_front(cache_key);
431        Some(info)
432    }
433
434    /// Resolve a renderable glyph for a character, walking font fallbacks until one succeeds.
435    ///
436    /// This is the single canonical implementation of the font-fallback loop previously
437    /// duplicated in `text_instance_builder.rs` and `pane_render/mod.rs` (see ARC-004 / QA-003).
438    ///
439    /// # Arguments
440    /// * `base_char`       — the base Unicode scalar to look up (after stripping VS16 etc.)
441    /// * `grapheme`        — the full grapheme cluster string (may be multi-char for ZWJ/flags)
442    /// * `bold`            — bold style flag
443    /// * `italic`          — italic style flag
444    /// * `force_monochrome` — when true use single-char lookup and suppress colored-emoji
445    ///   rasterization; falls back to colored-emoji as last resort
446    ///
447    /// # Returns
448    /// The first [`GlyphInfo`] that rasterizes successfully, or `None` if every font
449    /// (including the colored-emoji last-resort) fails.
450    ///
451    /// # Caching
452    /// Results are cached in the glyph atlas.  The cache key encodes `(font_idx, glyph_id)`
453    /// as `((font_idx as u64) << 32) | (glyph_id as u64)`, with bit 63 set for the
454    /// colored-emoji fallback variant.
455    pub(crate) fn resolve_glyph_with_fallback(
456        &mut self,
457        base_char: char,
458        grapheme: &str,
459        bold: bool,
460        italic: bool,
461        force_monochrome: bool,
462    ) -> Option<GlyphInfo> {
463        // Initial lookup: use grapheme-aware path for multi-char sequences (flags, ZWJ emoji,
464        // skin-tone modifiers), unless force_monochrome has already stripped VS16 to a single char.
465        let chars: Vec<char> = grapheme.chars().collect();
466        let mut glyph_result = if force_monochrome || chars.len() == 1 {
467            self.font_manager.find_glyph(base_char, bold, italic)
468        } else {
469            self.font_manager
470                .find_grapheme_glyph(grapheme, bold, italic)
471        };
472
473        // Walk font fallbacks until a glyph rasterizes successfully.
474        // Rasterization can fail even when a font has a charmap entry (e.g. Apple Color Emoji
475        // charmap entries exist for some symbols but produce empty outlines).
476        let mut excluded_fonts: Vec<usize> = Vec::new();
477        let resolved = loop {
478            match glyph_result {
479                Some((font_idx, glyph_id)) => {
480                    let cache_key = ((font_idx as u64) << 32) | (glyph_id as u64);
481                    if let Some(info) =
482                        self.get_or_rasterize_glyph(font_idx, glyph_id, force_monochrome, cache_key)
483                    {
484                        break Some(info);
485                    }
486                    // This font's outline was empty — exclude it and retry.
487                    excluded_fonts.push(font_idx);
488                    glyph_result = self.font_manager.find_glyph_excluding(
489                        base_char,
490                        bold,
491                        italic,
492                        &excluded_fonts,
493                    );
494                }
495                None => break None,
496            }
497        };
498
499        // Last resort: if monochrome rendering failed across all fonts (no font has vector
500        // outlines for this char), retry with colored-emoji rasterization.  Characters like ✨
501        // only exist in Apple Color Emoji — rendering them colored is better than nothing.
502        // Bit 63 of the cache key distinguishes the colored-fallback entry from the monochrome
503        // entry for the same (font_idx, glyph_id) pair.
504        if resolved.is_none() && force_monochrome {
505            let mut glyph_result2 = self.font_manager.find_glyph(base_char, bold, italic);
506            loop {
507                match glyph_result2 {
508                    Some((font_idx, glyph_id)) => {
509                        let cache_key =
510                            ((font_idx as u64) << 32) | (glyph_id as u64) | (1u64 << 63);
511                        if let Some(info) =
512                            self.get_or_rasterize_glyph(font_idx, glyph_id, false, cache_key)
513                        {
514                            break Some(info);
515                        }
516                        glyph_result2 = self.font_manager.find_glyph_excluding(
517                            base_char,
518                            bold,
519                            italic,
520                            &[font_idx],
521                        );
522                    }
523                    None => break None,
524                }
525            }
526        } else {
527            resolved
528        }
529    }
530}
531
532/// Convert a swash subpixel mask into an RGBA alpha mask.
533/// Some swash builds emit 3 bytes/pixel (RGB), others 4 bytes/pixel (RGBA).
534/// We derive alpha from luminance of RGB and ignore the packed alpha to avoid
535/// dropping coverage when alpha is zeroed by the rasterizer.
536fn convert_subpixel_mask_to_rgba(image: &swash::scale::image::Image) -> Vec<u8> {
537    let width = image.placement.width as usize;
538    let height = image.placement.height as usize;
539    let mut pixels = Vec::with_capacity(width * height * 4);
540
541    let stride = if width > 0 && height > 0 {
542        image.data.len() / (width * height)
543    } else {
544        0
545    };
546
547    match stride {
548        3 => {
549            for chunk in image.data.chunks_exact(3) {
550                let r = chunk[0];
551                let g = chunk[1];
552                let b = chunk[2];
553                let alpha = ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000) as u8;
554                pixels.extend_from_slice(&[255, 255, 255, alpha]);
555            }
556        }
557        4 => {
558            for chunk in image.data.chunks_exact(4) {
559                let r = chunk[0];
560                let g = chunk[1];
561                let b = chunk[2];
562                // Ignore chunk[3] because it can be zeroed in some builds.
563                let alpha = ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000) as u8;
564                pixels.extend_from_slice(&[255, 255, 255, alpha]);
565            }
566        }
567        _ => {
568            // Fallback: treat as opaque white to avoid invisibility if layout changes.
569            pixels.resize(width * height * 4, 255);
570        }
571    }
572
573    pixels
574}
575
576/// Convert a color RGBA image to a monochrome alpha mask.
577///
578/// This is used when a symbol character (like dingbats ✨ ⭐ ✔) is rendered
579/// from a color emoji font but should be displayed as a monochrome glyph
580/// using the terminal foreground color.
581///
582/// Uses the original alpha channel directly rather than luminance-derived alpha.
583/// This produces more accurate results: colored symbols (e.g., yellow ⭐,
584/// multi-colored ✨) retain full opacity, whereas luminance-based conversion
585/// makes non-white colors appear faint (a red symbol would only be ~30% visible).
586fn convert_color_to_alpha_mask(image: &swash::scale::image::Image) -> Vec<u8> {
587    let width = image.placement.width as usize;
588    let height = image.placement.height as usize;
589    let mut pixels = Vec::with_capacity(width * height * 4);
590
591    // Color emoji images are RGBA (4 bytes per pixel).
592    // Use the original alpha channel directly to preserve the symbol shape.
593    for chunk in image.data.chunks_exact(4) {
594        let a = chunk[3];
595        pixels.extend_from_slice(&[255, 255, 255, a]);
596    }
597
598    pixels
599}
600
601#[cfg(test)]
602mod tests {
603    use super::convert_subpixel_mask_to_rgba;
604    use swash::scale::{Render, ScaleContext, Source};
605    use swash::zeno::Format;
606
607    #[test]
608    fn subpixel_mask_uses_rgba_stride() {
609        let data = std::fs::read("../par-term-fonts/fonts/DejaVuSansMono.ttf").expect("font file");
610        let font = swash::FontRef::from_index(&data, 0).expect("font ref");
611        let mut context = ScaleContext::new();
612        let glyph_id = font.charmap().map('a');
613        let mut scaler = context.builder(font).size(18.0).hint(true).build();
614
615        let image = Render::new(&[
616            Source::ColorOutline(0),
617            Source::ColorBitmap(swash::scale::StrikeWith::BestFit),
618            Source::Outline,
619            Source::Bitmap(swash::scale::StrikeWith::BestFit),
620        ])
621        .format(Format::Subpixel)
622        .render(&mut scaler, glyph_id)
623        .expect("render");
624
625        let converted = convert_subpixel_mask_to_rgba(&image);
626
627        let width = image.placement.width as usize;
628        let height = image.placement.height as usize;
629        let mut expected = Vec::with_capacity(width * height * 4);
630        let stride = if width > 0 && height > 0 {
631            image.data.len() / (width * height)
632        } else {
633            0
634        };
635
636        match stride {
637            3 => {
638                for chunk in image.data.chunks_exact(3) {
639                    let r = chunk[0];
640                    let g = chunk[1];
641                    let b = chunk[2];
642                    let alpha = ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000) as u8;
643                    expected.extend_from_slice(&[255, 255, 255, alpha]);
644                }
645            }
646            4 => {
647                for chunk in image.data.chunks_exact(4) {
648                    let r = chunk[0];
649                    let g = chunk[1];
650                    let b = chunk[2];
651                    let alpha = ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000) as u8;
652                    expected.extend_from_slice(&[255, 255, 255, alpha]);
653                }
654            }
655            _ => expected.resize(width * height * 4, 255),
656        }
657
658        assert_eq!(converted, expected);
659    }
660
661    use super::should_render_as_symbol;
662
663    #[test]
664    fn test_dingbats_are_symbols() {
665        // Dingbats block (U+2700-U+27BF)
666        assert!(
667            should_render_as_symbol('\u{2733}'),
668            "✳ EIGHT SPOKED ASTERISK"
669        );
670        assert!(
671            should_render_as_symbol('\u{2734}'),
672            "✴ EIGHT POINTED BLACK STAR"
673        );
674        assert!(should_render_as_symbol('\u{2747}'), "❇ SPARKLE");
675        assert!(should_render_as_symbol('\u{2744}'), "❄ SNOWFLAKE");
676        assert!(should_render_as_symbol('\u{2702}'), "✂ SCISSORS");
677        assert!(should_render_as_symbol('\u{2714}'), "✔ HEAVY CHECK MARK");
678        assert!(
679            should_render_as_symbol('\u{2716}'),
680            "✖ HEAVY MULTIPLICATION X"
681        );
682        assert!(should_render_as_symbol('\u{2728}'), "✨ SPARKLES");
683    }
684
685    #[test]
686    fn test_misc_symbols_are_symbols() {
687        // Miscellaneous Symbols block (U+2600-U+26FF)
688        assert!(should_render_as_symbol('\u{2600}'), "☀ SUN");
689        assert!(should_render_as_symbol('\u{2601}'), "☁ CLOUD");
690        assert!(should_render_as_symbol('\u{263A}'), "☺ SMILING FACE");
691        assert!(should_render_as_symbol('\u{2665}'), "♥ BLACK HEART SUIT");
692        assert!(should_render_as_symbol('\u{2660}'), "♠ BLACK SPADE SUIT");
693    }
694
695    #[test]
696    fn test_misc_symbols_arrows_are_symbols() {
697        // Miscellaneous Symbols and Arrows block (U+2B00-U+2BFF)
698        assert!(should_render_as_symbol('\u{2B50}'), "⭐ WHITE MEDIUM STAR");
699        assert!(should_render_as_symbol('\u{2B55}'), "⭕ HEAVY LARGE CIRCLE");
700    }
701
702    #[test]
703    fn test_regular_emoji_not_symbols() {
704        // Full emoji characters (outside symbol ranges) should NOT be treated as symbols
705        // They should render as colorful emoji
706        assert!(
707            !should_render_as_symbol('\u{1F600}'),
708            "😀 GRINNING FACE should not be a symbol"
709        );
710        assert!(
711            !should_render_as_symbol('\u{1F389}'),
712            "🎉 PARTY POPPER should not be a symbol"
713        );
714        assert!(
715            !should_render_as_symbol('\u{1F44D}'),
716            "👍 THUMBS UP should not be a symbol"
717        );
718    }
719
720    #[test]
721    fn test_regular_chars_not_symbols() {
722        // Regular text characters should NOT be treated as symbols
723        assert!(
724            !should_render_as_symbol('A'),
725            "Letter A should not be a symbol"
726        );
727        assert!(
728            !should_render_as_symbol('*'),
729            "Asterisk should not be a symbol (it's ASCII)"
730        );
731        assert!(
732            !should_render_as_symbol('1'),
733            "Digit 1 should not be a symbol"
734        );
735    }
736}