Skip to main content

par_term_render/cell_renderer/
atlas.rs

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