Skip to main content

icon_to_image/
renderer.rs

1//! Icon rendering engine using ab_glyph with SIMD optimizations.
2//!
3//! Handles rasterizing icon glyphs from font files with supersampling
4//! for high-quality antialiased output. SIMD acceleration is applied
5//! when dimensions are multiples of 8.
6
7use crate::color::Color;
8use crate::css_parser::{CssParser, FontStyle, IconMapping};
9use crate::embedded;
10use crate::error::{IconFontError, Result};
11use ab_glyph::{Font, FontArc, GlyphId, PxScale};
12use rayon::prelude::*;
13use rustc_hash::FxHashMap;
14use std::path::Path;
15use std::sync::{Arc, RwLock};
16
17/// Minimum pixel count before parallelizing compositing.
18/// Below this, rayon thread dispatch overhead exceeds the benefit.
19/// Set to 32K to cover 256x256 icons (~240x240 glyph = ~57K pixels).
20const PARALLEL_PIXEL_THRESHOLD: usize = 32 * 1024;
21
22/// Horizontal anchor position for icon placement.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum HorizontalAnchor {
25    Left,
26    #[default]
27    Center,
28    Right,
29}
30
31/// Vertical anchor position for icon placement.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum VerticalAnchor {
34    Top,
35    #[default]
36    Center,
37    Bottom,
38}
39
40/// Configuration for rendering an icon.
41#[derive(Debug, Clone)]
42pub struct RenderConfig {
43    /// Canvas width in pixels
44    pub canvas_width: u32,
45    /// Canvas height in pixels
46    pub canvas_height: u32,
47    /// Icon size in pixels (height of the icon)
48    pub icon_size: u32,
49    /// Supersampling factor for antialiasing (default: 2)
50    pub supersample_factor: u32,
51    /// Icon foreground color
52    pub icon_color: Color,
53    /// Background color (use transparent for no background)
54    pub background_color: Color,
55    /// Horizontal anchor position
56    pub horizontal_anchor: HorizontalAnchor,
57    /// Vertical anchor position
58    pub vertical_anchor: VerticalAnchor,
59    /// Horizontal pixel offset from anchor
60    pub offset_x: i32,
61    /// Vertical pixel offset from anchor
62    pub offset_y: i32,
63    /// Rotation angle in degrees (positive = clockwise, negative = counter-clockwise)
64    pub rotate: f64,
65}
66
67impl Default for RenderConfig {
68    fn default() -> Self {
69        Self {
70            canvas_width: 512,
71            canvas_height: 512,
72            // Default to 486px (about 95% of 512px canvas) for margin between icon and edges
73            icon_size: 486,
74            supersample_factor: 2,
75            icon_color: Color::black(),
76            background_color: Color::white(),
77            horizontal_anchor: HorizontalAnchor::Center,
78            vertical_anchor: VerticalAnchor::Center,
79            offset_x: 0,
80            offset_y: 0,
81            rotate: 0.0,
82        }
83    }
84}
85
86impl RenderConfig {
87    /// Create a new config with default values.
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    /// Set canvas dimensions.
93    pub fn canvas_size(mut self, width: u32, height: u32) -> Self {
94        self.canvas_width = width;
95        self.canvas_height = height;
96        self
97    }
98
99    /// Set icon size.
100    pub fn icon_size(mut self, size: u32) -> Self {
101        self.icon_size = size;
102        self
103    }
104
105    /// Set supersampling factor.
106    pub fn supersample(mut self, factor: u32) -> Self {
107        self.supersample_factor = factor.max(1);
108        self
109    }
110
111    /// Set icon color.
112    pub fn icon_color(mut self, color: Color) -> Self {
113        self.icon_color = color;
114        self
115    }
116
117    /// Set background color.
118    pub fn background_color(mut self, color: Color) -> Self {
119        self.background_color = color;
120        self
121    }
122
123    /// Set anchor positions.
124    pub fn anchor(mut self, horizontal: HorizontalAnchor, vertical: VerticalAnchor) -> Self {
125        self.horizontal_anchor = horizontal;
126        self.vertical_anchor = vertical;
127        self
128    }
129
130    /// Set pixel offset from anchor.
131    pub fn offset(mut self, x: i32, y: i32) -> Self {
132        self.offset_x = x;
133        self.offset_y = y;
134        self
135    }
136
137    /// Set rotation angle in degrees.
138    ///
139    /// Positive values rotate clockwise, negative values rotate counter-clockwise.
140    /// The rotation is applied around the center of the icon before compositing.
141    ///
142    /// # Arguments
143    ///
144    /// * `degrees` - Rotation angle in degrees
145    ///
146    /// # Examples
147    ///
148    /// ```
149    /// use icon_to_image::RenderConfig;
150    ///
151    /// // Rotate 45 degrees clockwise
152    /// let config = RenderConfig::new().rotate(45.0);
153    ///
154    /// // Rotate 90 degrees counter-clockwise
155    /// let config = RenderConfig::new().rotate(-90.0);
156    /// ```
157    pub fn rotate(mut self, degrees: f64) -> Self {
158        self.rotate = degrees;
159        self
160    }
161
162    /// Apply sanity check to icon_size, clamping to 95% of smaller canvas dimension
163    /// if it exceeds either canvas dimension.
164    ///
165    /// This prevents icons from being larger than the canvas, which would cause
166    /// rendering issues.
167    pub fn sanitize_icon_size(mut self) -> Self {
168        let smaller_dim = self.canvas_width.min(self.canvas_height);
169        if self.icon_size > smaller_dim {
170            // Clamp to 95% of smaller dimension
171            self.icon_size = ((smaller_dim as f64) * 0.95) as u32;
172            self.icon_size = self.icon_size.max(1);
173        }
174        self
175    }
176}
177
178/// Icon font renderer that loads fonts and renders icons to images.
179///
180/// Uses ab_glyph for high-quality curve rendering. Font data is stored
181/// as parsed `FontArc` handles so glyph lookup does not reparse font tables
182/// on each render call. Embedded assets are cached globally and shared
183/// across instances.
184pub struct IconRenderer {
185    /// Parsed CSS icon mappings
186    css_parser: CssParser,
187    /// Parsed solid font (fa-solid.otf)
188    font_solid: FontArc,
189    /// Parsed regular font (fa-regular.otf)
190    font_regular: FontArc,
191    /// Parsed brands font (fa-brands.otf)
192    font_brands: FontArc,
193    /// Cache of rasterized glyph alpha masks by style/codepoint/size.
194    /// Uses RwLock to allow concurrent cache reads (the common path in benchmarks).
195    glyph_cache: RwLock<FxHashMap<GlyphCacheKey, Arc<GlyphMask>>>,
196}
197
198#[derive(Debug, Clone)]
199struct GlyphMask {
200    width: u32,
201    height: u32,
202    alpha: Vec<u8>,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
206struct GlyphCacheKey {
207    style: FontStyle,
208    codepoint: u32,
209    ss_icon_size: u32,
210}
211
212impl IconRenderer {
213    /// Create a new renderer using embedded Font Awesome assets.
214    ///
215    /// This is the recommended constructor for most use cases. The Font Awesome
216    /// font files and CSS are compiled directly into the binary, so no external
217    /// asset files are needed. This provides better portability at the cost of
218    /// increased binary size (~700KB).
219    ///
220    /// # Returns
221    ///
222    /// A configured `IconRenderer` ready to render icons.
223    ///
224    /// # Errors
225    ///
226    /// Returns error if the embedded fonts cannot be parsed (should never happen
227    /// with valid embedded data).
228    ///
229    /// # Examples
230    ///
231    /// ```
232    /// use icon_to_image::IconRenderer;
233    ///
234    /// let renderer = IconRenderer::new()?;
235    /// # Ok::<(), icon_to_image::IconFontError>(())
236    /// ```
237    pub fn new() -> Result<Self> {
238        let css_parser = CssParser::parse(embedded::FONTAWESOME_CSS)?;
239        let font_solid = FontArc::try_from_slice(embedded::FONT_SOLID).map_err(|err| {
240            IconFontError::FontLoadError(format!("Failed to parse embedded fa-solid.otf: {}", err))
241        })?;
242        let font_regular = FontArc::try_from_slice(embedded::FONT_REGULAR).map_err(|err| {
243            IconFontError::FontLoadError(format!(
244                "Failed to parse embedded fa-regular.otf: {}",
245                err
246            ))
247        })?;
248        let font_brands = FontArc::try_from_slice(embedded::FONT_BRANDS).map_err(|err| {
249            IconFontError::FontLoadError(format!("Failed to parse embedded fa-brands.otf: {}", err))
250        })?;
251
252        Ok(Self {
253            css_parser,
254            font_solid,
255            font_regular,
256            font_brands,
257            glyph_cache: RwLock::new(FxHashMap::with_capacity_and_hasher(256, Default::default())),
258        })
259    }
260
261    /// Create a new renderer by loading fonts and CSS from a directory.
262    ///
263    /// Use this constructor when you need to use custom or updated Font Awesome
264    /// assets instead of the embedded ones. The directory must contain:
265    /// - `fa-solid.otf`
266    /// - `fa-regular.otf`
267    /// - `fa-brands.otf`
268    /// - `fontawesome.css`
269    ///
270    /// # Arguments
271    ///
272    /// * `assets_dir` - Path to directory containing font files and CSS
273    ///
274    /// # Returns
275    ///
276    /// A configured `IconRenderer` ready to render icons.
277    ///
278    /// # Errors
279    ///
280    /// Returns error if fonts or CSS cannot be loaded.
281    ///
282    /// # Examples
283    ///
284    /// ```no_run
285    /// use icon_to_image::IconRenderer;
286    ///
287    /// let renderer = IconRenderer::from_path("./assets")?;
288    /// # Ok::<(), icon_to_image::IconFontError>(())
289    /// ```
290    pub fn from_path<P: AsRef<Path>>(assets_dir: P) -> Result<Self> {
291        let assets_dir = assets_dir.as_ref();
292
293        let font_solid = Self::load_font(assets_dir.join("fa-solid.otf"), "fa-solid.otf")?;
294        let font_regular = Self::load_font(assets_dir.join("fa-regular.otf"), "fa-regular.otf")?;
295        let font_brands = Self::load_font(assets_dir.join("fa-brands.otf"), "fa-brands.otf")?;
296
297        // Parse CSS
298        let css_path = assets_dir.join("fontawesome.css");
299        let css_content = std::fs::read_to_string(&css_path)
300            .map_err(|e| IconFontError::CssParseError(format!("Failed to read CSS file: {}", e)))?;
301        let css_parser = CssParser::parse(&css_content)?;
302
303        Ok(Self {
304            css_parser,
305            font_solid,
306            font_regular,
307            font_brands,
308            glyph_cache: RwLock::new(FxHashMap::with_capacity_and_hasher(256, Default::default())),
309        })
310    }
311
312    /// Load and parse a font file.
313    fn load_font<P: AsRef<Path>>(path: P, name: &str) -> Result<FontArc> {
314        let path = path.as_ref();
315        let data = std::fs::read(path).map_err(|e| {
316            IconFontError::FontLoadError(format!(
317                "Failed to read font file {}: {}",
318                path.display(),
319                e
320            ))
321        })?;
322        FontArc::try_from_vec(data).map_err(|e| {
323            IconFontError::FontLoadError(format!("Failed to parse font {}: {}", name, e))
324        })
325    }
326
327    /// Get a parsed font handle for the given style.
328    fn get_font(&self, style: FontStyle) -> &FontArc {
329        match style {
330            FontStyle::Solid => &self.font_solid,
331            FontStyle::Regular => &self.font_regular,
332            FontStyle::Brands => &self.font_brands,
333        }
334    }
335
336    /// Get a cached rasterized glyph mask, generating it if needed.
337    #[inline]
338    fn get_or_rasterized_glyph_mask(
339        &self,
340        style: FontStyle,
341        codepoint: char,
342        ss_icon_size: u32,
343    ) -> Option<Arc<GlyphMask>> {
344        let key = GlyphCacheKey {
345            style,
346            codepoint: codepoint as u32,
347            ss_icon_size,
348        };
349
350        // Fast path: read lock for cache hits (no contention with other readers)
351        if let Some(mask) = self
352            .glyph_cache
353            .read()
354            .expect("glyph cache rwlock poisoned")
355            .get(&key)
356            .map(Arc::clone)
357        {
358            return Some(mask);
359        }
360
361        let font = self.get_font(style);
362        let glyph_id = font.glyph_id(codepoint);
363        if glyph_id == GlyphId(0) {
364            return None;
365        }
366
367        let glyph = glyph_id.with_scale_and_position(
368            PxScale::from(ss_icon_size as f32),
369            ab_glyph::point(0.0, 0.0),
370        );
371        let outlined = font.outline_glyph(glyph)?;
372        let bounds = outlined.px_bounds();
373        let width = bounds.width() as u32;
374        let height = bounds.height() as u32;
375        if width == 0 || height == 0 {
376            return None;
377        }
378
379        let width_usize = width as usize;
380        let mut alpha = vec![0u8; width_usize * height as usize];
381        outlined.draw(|gx, gy, coverage| {
382            let idx = gy as usize * width_usize + gx as usize;
383            alpha[idx] = (coverage * 255.0) as u8;
384        });
385
386        let mask = Arc::new(GlyphMask {
387            width,
388            height,
389            alpha,
390        });
391        let mut cache = self
392            .glyph_cache
393            .write()
394            .expect("glyph cache rwlock poisoned");
395        if cache.len() >= 2048 {
396            cache.clear();
397        }
398        let cached = cache.entry(key).or_insert(mask);
399        Some(Arc::clone(cached))
400    }
401
402    /// Check if an icon exists.
403    pub fn has_icon(&self, name: &str) -> bool {
404        self.css_parser.has_icon(name)
405    }
406
407    /// Get the number of available icons.
408    pub fn icon_count(&self) -> usize {
409        self.css_parser.icon_count()
410    }
411
412    /// List all available icon names.
413    pub fn list_icons(&self) -> Vec<&str> {
414        self.css_parser.list_icons()
415    }
416
417    /// Render an icon to an RGBA pixel buffer.
418    ///
419    /// # Arguments
420    ///
421    /// * `icon_name` - Name of the icon (with or without "fa-" prefix)
422    /// * `config` - Rendering configuration
423    ///
424    /// # Returns
425    ///
426    /// A tuple of (width, height, rgba_pixels) where rgba_pixels is a Vec<u8>
427    /// containing width * height * 4 bytes in RGBA order.
428    ///
429    /// # Errors
430    ///
431    /// Returns error if icon not found or rendering fails.
432    #[inline]
433    pub fn render(&self, icon_name: &str, config: &RenderConfig) -> Result<(u32, u32, Vec<u8>)> {
434        // Look up the icon
435        let mapping = self
436            .css_parser
437            .get_icon(icon_name)
438            .ok_or_else(|| IconFontError::IconNotFound(icon_name.to_string()))?;
439
440        self.render_mapping(mapping, config)
441    }
442
443    /// Render an icon with explicit style override.
444    ///
445    /// # Arguments
446    ///
447    /// * `icon_name` - Name of the icon
448    /// * `style` - Font style to use
449    /// * `config` - Rendering configuration
450    ///
451    /// # Returns
452    ///
453    /// Rendered pixel buffer as (width, height, rgba_pixels).
454    pub fn render_with_style(
455        &self,
456        icon_name: &str,
457        style: FontStyle,
458        config: &RenderConfig,
459    ) -> Result<(u32, u32, Vec<u8>)> {
460        let mapping = self
461            .css_parser
462            .get_icon_with_style(icon_name, style)
463            .ok_or_else(|| IconFontError::IconNotFound(icon_name.to_string()))?;
464
465        self.render_mapping(&mapping, config)
466    }
467
468    /// Render an icon mapping to pixels.
469    #[inline]
470    fn render_mapping(
471        &self,
472        mapping: &IconMapping,
473        config: &RenderConfig,
474    ) -> Result<(u32, u32, Vec<u8>)> {
475        let ss_factor = config.supersample_factor.max(1);
476
477        let ss_canvas_width = config.canvas_width * ss_factor;
478        let ss_canvas_height = config.canvas_height * ss_factor;
479        let mut ss_icon_size = config.icon_size * ss_factor;
480        let mut glyph_mask =
481            match self.get_or_rasterized_glyph_mask(mapping.style, mapping.codepoint, ss_icon_size)
482            {
483                Some(mask) => mask,
484                None => return Ok(self.create_background_only(config)),
485            };
486
487        // Some glyphs in newer font versions can exceed the requested em-size bounds.
488        // If that would clip against the canvas, shrink to a safe scale automatically.
489        while glyph_mask.width > ss_canvas_width || glyph_mask.height > ss_canvas_height {
490            let Some(next_size) = scaled_icon_size_to_fit_canvas(
491                ss_icon_size,
492                glyph_mask.width,
493                glyph_mask.height,
494                ss_canvas_width,
495                ss_canvas_height,
496            ) else {
497                break;
498            };
499
500            ss_icon_size = next_size;
501            glyph_mask = match self.get_or_rasterized_glyph_mask(
502                mapping.style,
503                mapping.codepoint,
504                ss_icon_size,
505            ) {
506                Some(mask) => mask,
507                None => return Ok(self.create_background_only(config)),
508            };
509        }
510
511        let glyph_width = glyph_mask.width;
512        let glyph_height = glyph_mask.height;
513
514        if glyph_width == 0 || glyph_height == 0 {
515            return Ok(self.create_background_only(config));
516        }
517
518        let needs_rotation = config.rotate.abs() > 0.001;
519
520        if needs_rotation {
521            let mut ss_canvas = self.create_canvas(ss_canvas_width, ss_canvas_height, config);
522            let (rotated_alpha, rotated_width, rotated_height) =
523                self.render_and_rotate_glyph(&glyph_mask, config.rotate);
524
525            let (x_pos, y_pos) = self.calculate_position(
526                ss_canvas_width,
527                ss_canvas_height,
528                rotated_width,
529                rotated_height,
530                config,
531                ss_factor,
532            );
533
534            self.composite_alpha_buffer(
535                &mut ss_canvas,
536                ss_canvas_width,
537                &rotated_alpha,
538                rotated_width,
539                rotated_height,
540                x_pos,
541                y_pos,
542                &config.icon_color,
543            );
544
545            let final_pixels = match ss_factor {
546                1 => ss_canvas,
547                _ => self.downsample(&ss_canvas, ss_canvas_width, ss_canvas_height, ss_factor),
548            };
549            return Ok((config.canvas_width, config.canvas_height, final_pixels));
550        }
551
552        let (x_pos, y_pos) = self.calculate_position(
553            ss_canvas_width,
554            ss_canvas_height,
555            glyph_width,
556            glyph_height,
557            config,
558            ss_factor,
559        );
560
561        // Fused fill+composite: build the canvas with background and composite
562        // the glyph in a single pass when the background is opaque white and
563        // icon color alpha is 255 (the common benchmark/production case).
564        let bg = config.background_color.to_rgba();
565        let icon_a = config.icon_color.a;
566        // Fuse fill+composite when background is opaque white and icon alpha is 255.
567        // Avoids writing background pixels that will be immediately overwritten.
568        let can_fuse = bg == [255, 255, 255, 255] && icon_a == 255 && ss_factor == 1;
569
570        let ss_canvas = if can_fuse {
571            self.create_canvas_with_glyph(
572                ss_canvas_width,
573                ss_canvas_height,
574                &glyph_mask,
575                x_pos,
576                y_pos,
577                &config.icon_color,
578            )
579        } else {
580            let mut canvas = self.create_canvas(ss_canvas_width, ss_canvas_height, config);
581            self.composite_glyph_mask(
582                &mut canvas,
583                ss_canvas_width,
584                &glyph_mask,
585                x_pos,
586                y_pos,
587                &config.icon_color,
588            );
589            canvas
590        };
591
592        let final_pixels = match ss_factor {
593            1 => ss_canvas,
594            _ => self.downsample(&ss_canvas, ss_canvas_width, ss_canvas_height, ss_factor),
595        };
596
597        Ok((config.canvas_width, config.canvas_height, final_pixels))
598    }
599
600    /// Render a glyph to a buffer and rotate it using bilinear interpolation.
601    fn render_and_rotate_glyph(&self, glyph_mask: &GlyphMask, degrees: f64) -> (Vec<u8>, u32, u32) {
602        let glyph_width = glyph_mask.width;
603        let glyph_height = glyph_mask.height;
604        let glyph_width_usize = glyph_width as usize;
605
606        let radians = (degrees as f32).to_radians();
607        let cos_angle = radians.cos();
608        let sin_angle = radians.sin();
609
610        let w = glyph_width as f32;
611        let h = glyph_height as f32;
612        let half_w = w / 2.0;
613        let half_h = h / 2.0;
614
615        let new_width = (w * cos_angle.abs() + h * sin_angle.abs()).ceil() as u32;
616        let new_height = (w * sin_angle.abs() + h * cos_angle.abs()).ceil() as u32;
617        let new_width = new_width.max(1);
618        let new_height = new_height.max(1);
619        let new_width_usize = new_width as usize;
620
621        let new_half_w = new_width as f32 / 2.0;
622        let new_half_h = new_height as f32 / 2.0;
623
624        let mut rotated_alpha = vec![0u8; (new_width * new_height) as usize];
625        let max_src_x = w - 1.0;
626        let max_src_y = h - 1.0;
627
628        // Parallel row processing with incremental affine stepping and alpha-only sampling.
629        rotated_alpha
630            .par_chunks_mut(new_width_usize)
631            .enumerate()
632            .for_each(|(out_y, row)| {
633                let dy = out_y as f32 - new_half_h;
634                let mut src_x = (-new_half_w) * cos_angle + dy * sin_angle + half_w;
635                let mut src_y = new_half_w * sin_angle + dy * cos_angle + half_h;
636
637                for pixel in row.iter_mut().take(new_width_usize) {
638                    if src_x >= 0.0 && src_x < max_src_x && src_y >= 0.0 && src_y < max_src_y {
639                        *pixel = bilinear_sample_alpha(
640                            &glyph_mask.alpha,
641                            glyph_width_usize,
642                            src_x,
643                            src_y,
644                        );
645                    }
646                    src_x += cos_angle;
647                    src_y -= sin_angle;
648                }
649            });
650
651        (rotated_alpha, new_width, new_height)
652    }
653
654    /// Composite an alpha mask onto the canvas using a constant color.
655    ///
656    /// Parallelized across rows via rayon for large buffers.
657    #[allow(clippy::too_many_arguments)]
658    fn composite_alpha_buffer(
659        &self,
660        canvas: &mut [u8],
661        canvas_width: u32,
662        alpha: &[u8],
663        buffer_width: u32,
664        buffer_height: u32,
665        x_offset: i32,
666        y_offset: i32,
667        color: &Color,
668    ) {
669        let canvas_width_i32 = canvas_width as i32;
670        let canvas_height_i32 = (canvas.len() / (canvas_width as usize * 4)) as i32;
671        let buffer_width_i32 = buffer_width as i32;
672        let buffer_height_i32 = buffer_height as i32;
673
674        let dst_start_x = x_offset.max(0);
675        let dst_start_y = y_offset.max(0);
676        let dst_end_x = (x_offset + buffer_width_i32).min(canvas_width_i32);
677        let dst_end_y = (y_offset + buffer_height_i32).min(canvas_height_i32);
678
679        if dst_start_x >= dst_end_x || dst_start_y >= dst_end_y {
680            return;
681        }
682
683        let canvas_stride = canvas_width as usize * 4;
684        let buffer_stride = buffer_width as usize;
685        let color_a = color.a as u32;
686        let is_opaque = color_a == 255;
687        let color_r = color.r;
688        let color_g = color.g;
689        let color_b = color.b;
690        let bx_start = (dst_start_x - x_offset) as usize;
691        let span = (dst_end_x - dst_start_x) as usize;
692        let px_start = dst_start_x as usize * 4;
693        let px_end = px_start + span * 4;
694        let row_count = (dst_end_y - dst_start_y) as usize;
695        let first_row = dst_start_y as usize;
696        let y_off = y_offset as usize;
697
698        let canvas_region_start = first_row * canvas_stride;
699        let canvas_region_end = (first_row + row_count) * canvas_stride;
700        let canvas_region = &mut canvas[canvas_region_start..canvas_region_end];
701
702        let total_pixels = row_count * span;
703        if total_pixels >= PARALLEL_PIXEL_THRESHOLD {
704            canvas_region
705                .par_chunks_mut(canvas_stride)
706                .enumerate()
707                .for_each(|(row_idx, canvas_row_full)| {
708                    let by = first_row + row_idx - y_off;
709                    let buffer_row = by * buffer_stride;
710                    let alpha_slice = &alpha[buffer_row + bx_start..buffer_row + bx_start + span];
711                    let canvas_row = &mut canvas_row_full[px_start..px_end];
712                    composite_alpha_row(
713                        canvas_row,
714                        alpha_slice,
715                        color_r,
716                        color_g,
717                        color_b,
718                        color_a,
719                        is_opaque,
720                    );
721                });
722        } else {
723            for (row_idx, canvas_row_full) in canvas_region.chunks_mut(canvas_stride).enumerate() {
724                let by = first_row + row_idx - y_off;
725                let buffer_row = by * buffer_stride;
726                let alpha_slice = &alpha[buffer_row + bx_start..buffer_row + bx_start + span];
727                let canvas_row = &mut canvas_row_full[px_start..px_end];
728                composite_alpha_row(
729                    canvas_row,
730                    alpha_slice,
731                    color_r,
732                    color_g,
733                    color_b,
734                    color_a,
735                    is_opaque,
736                );
737            }
738        }
739    }
740
741    /// Fused canvas creation + glyph compositing for white bg, opaque icon, ss=1.
742    ///
743    /// Fills the canvas with white and composites the glyph in a single pass per
744    /// row. For rows outside the glyph region, the initial `vec![255; N]` memset
745    /// already provides the correct white background.
746    #[inline]
747    fn create_canvas_with_glyph(
748        &self,
749        width: u32,
750        height: u32,
751        glyph_mask: &GlyphMask,
752        x_offset: i32,
753        y_offset: i32,
754        color: &Color,
755    ) -> Vec<u8> {
756        let total_bytes = (width * height) as usize * 4;
757        let mut canvas = vec![255u8; total_bytes];
758        let canvas_stride = width as usize * 4;
759
760        let glyph_w = glyph_mask.width as i32;
761        let glyph_h = glyph_mask.height as i32;
762        let canvas_w = width as i32;
763        let canvas_h = height as i32;
764
765        let min_gx = (-x_offset).max(0) as usize;
766        let min_gy = (-y_offset).max(0) as usize;
767        let max_gx = (canvas_w - x_offset).min(glyph_w) as usize;
768        let max_gy = (canvas_h - y_offset).min(glyph_h) as usize;
769
770        if min_gx >= max_gx || min_gy >= max_gy {
771            return canvas;
772        }
773
774        let glyph_stride = glyph_mask.width as usize;
775        let x_off = x_offset as usize;
776        let color_r = color.r;
777        let color_g = color.g;
778        let color_b = color.b;
779        let row_count = max_gy - min_gy;
780        let span = max_gx - min_gx;
781        let total_glyph_pixels = row_count * span;
782        let first_canvas_row = (y_offset as usize) + min_gy;
783
784        // Pre-compute (color_channel - 255) to reduce ops per pixel.
785        // Blend formula: div255(color * c + 255 * (255-c))
786        //              = div255(c * (color - 255) + 65025)
787        let dr = color_r as i32 - 255;
788        let dg = color_g as i32 - 255;
789        let db = color_b as i32 - 255;
790
791        let canvas_region_start = first_canvas_row * canvas_stride;
792        let canvas_region_end = (first_canvas_row + row_count) * canvas_stride;
793        let canvas_region = &mut canvas[canvas_region_start..canvas_region_end];
794
795        let blend_row = |canvas_row_full: &mut [u8], gy: usize| {
796            let glyph_row_start = gy * glyph_stride + min_gx;
797            let alpha_row = &glyph_mask.alpha[glyph_row_start..glyph_row_start + span];
798            let px_start = (x_off + min_gx) * 4;
799            let canvas_row = &mut canvas_row_full[px_start..px_start + span * 4];
800
801            for (i, &coverage) in alpha_row.iter().enumerate() {
802                if coverage == 0 {
803                    continue;
804                }
805                let dst = i * 4;
806                if coverage == 255 {
807                    canvas_row[dst] = color_r;
808                    canvas_row[dst + 1] = color_g;
809                    canvas_row[dst + 2] = color_b;
810                } else {
811                    let c = coverage as i32;
812                    canvas_row[dst] = div255((c * dr + 65025) as u32) as u8;
813                    canvas_row[dst + 1] = div255((c * dg + 65025) as u32) as u8;
814                    canvas_row[dst + 2] = div255((c * db + 65025) as u32) as u8;
815                }
816            }
817        };
818
819        if total_glyph_pixels >= PARALLEL_PIXEL_THRESHOLD {
820            canvas_region
821                .par_chunks_mut(canvas_stride)
822                .enumerate()
823                .for_each(|(row_idx, canvas_row_full)| {
824                    blend_row(canvas_row_full, min_gy + row_idx);
825                });
826        } else {
827            for (row_idx, canvas_row_full) in canvas_region.chunks_mut(canvas_stride).enumerate() {
828                blend_row(canvas_row_full, min_gy + row_idx);
829            }
830        }
831
832        canvas
833    }
834
835    /// Create a canvas filled with background color.
836    fn create_canvas(&self, width: u32, height: u32, config: &RenderConfig) -> Vec<u8> {
837        let bg = config.background_color.to_rgba();
838        let total_bytes = (width * height) as usize * 4;
839
840        if bg == [0, 0, 0, 0] {
841            return vec![0u8; total_bytes];
842        }
843
844        // All channels same: compiles to optimized memset
845        if bg[0] == bg[1] && bg[1] == bg[2] && bg[2] == bg[3] {
846            return vec![bg[0]; total_bytes];
847        }
848
849        // Exponential doubling fill: copies grow geometrically for O(n) with
850        // low loop overhead
851        let mut canvas = vec![0u8; total_bytes];
852        canvas[..4].copy_from_slice(&bg);
853        let mut filled = 4usize;
854        while filled < total_bytes {
855            let copy_len = filled.min(total_bytes - filled);
856            let (src, dst) = canvas.split_at_mut(filled);
857            dst[..copy_len].copy_from_slice(&src[..copy_len]);
858            filled += copy_len;
859        }
860        canvas
861    }
862
863    /// Create an image with only background (for missing glyphs).
864    fn create_background_only(&self, config: &RenderConfig) -> (u32, u32, Vec<u8>) {
865        let pixels = self.create_canvas(config.canvas_width, config.canvas_height, config);
866        (config.canvas_width, config.canvas_height, pixels)
867    }
868
869    /// Calculate icon position based on anchors and offsets.
870    #[inline]
871    fn calculate_position(
872        &self,
873        canvas_width: u32,
874        canvas_height: u32,
875        glyph_width: u32,
876        glyph_height: u32,
877        config: &RenderConfig,
878        ss_factor: u32,
879    ) -> (i32, i32) {
880        let ss_offset_x = config.offset_x * ss_factor as i32;
881        let ss_offset_y = config.offset_y * ss_factor as i32;
882
883        let x = match config.horizontal_anchor {
884            HorizontalAnchor::Left => ss_offset_x,
885            HorizontalAnchor::Center => {
886                (canvas_width as i32 - glyph_width as i32) / 2 + ss_offset_x
887            }
888            HorizontalAnchor::Right => canvas_width as i32 - glyph_width as i32 + ss_offset_x,
889        };
890
891        let y = match config.vertical_anchor {
892            VerticalAnchor::Top => ss_offset_y,
893            VerticalAnchor::Center => {
894                (canvas_height as i32 - glyph_height as i32) / 2 + ss_offset_y
895            }
896            VerticalAnchor::Bottom => canvas_height as i32 - glyph_height as i32 + ss_offset_y,
897        };
898
899        (x, y)
900    }
901
902    /// Composite a cached glyph coverage mask onto the canvas.
903    ///
904    /// Uses parallel row processing via rayon for large images to saturate
905    /// memory bandwidth across multiple cores.
906    fn composite_glyph_mask(
907        &self,
908        canvas: &mut [u8],
909        canvas_width: u32,
910        glyph_mask: &GlyphMask,
911        x_offset: i32,
912        y_offset: i32,
913        color: &Color,
914    ) {
915        let glyph_width = glyph_mask.width as i32;
916        let glyph_height = glyph_mask.height as i32;
917        let canvas_height_i32 = (canvas.len() / (canvas_width as usize * 4)) as i32;
918        let canvas_width_i32 = canvas_width as i32;
919        let canvas_stride = canvas_width as usize * 4;
920
921        let min_gx = (-x_offset).max(0) as usize;
922        let min_gy = (-y_offset).max(0);
923        let max_gx = (canvas_width_i32 - x_offset).min(glyph_width) as usize;
924        let max_gy = (canvas_height_i32 - y_offset).min(glyph_height);
925
926        if min_gx >= max_gx || min_gy >= max_gy {
927            return;
928        }
929
930        let glyph_width_usize = glyph_mask.width as usize;
931        let color_a = color.a as u32;
932        let color_r = color.r;
933        let color_g = color.g;
934        let color_b = color.b;
935        let is_opaque_color = color_a == 255;
936        let x_off = x_offset as usize;
937        let row_count = (max_gy - min_gy) as usize;
938        let first_canvas_row = (y_offset + min_gy) as usize;
939
940        // Slice the canvas to only the rows we need
941        let canvas_region_start = first_canvas_row * canvas_stride;
942        let canvas_region_end = (first_canvas_row + row_count) * canvas_stride;
943        let canvas_region = &mut canvas[canvas_region_start..canvas_region_end];
944
945        // Threshold: parallelize only when total pixel work justifies rayon overhead
946        let total_pixels = row_count * (max_gx - min_gx);
947        if total_pixels >= PARALLEL_PIXEL_THRESHOLD {
948            canvas_region
949                .par_chunks_mut(canvas_stride)
950                .enumerate()
951                .for_each(|(row_idx, canvas_row_full)| {
952                    let gy = min_gy as usize + row_idx;
953                    let glyph_row = gy * glyph_width_usize;
954                    let alpha_row = &glyph_mask.alpha[glyph_row + min_gx..glyph_row + max_gx];
955                    let px_start = (x_off + min_gx) * 4;
956                    let px_end = (x_off + max_gx) * 4;
957                    let canvas_row = &mut canvas_row_full[px_start..px_end];
958                    composite_alpha_row(
959                        canvas_row,
960                        alpha_row,
961                        color_r,
962                        color_g,
963                        color_b,
964                        color_a,
965                        is_opaque_color,
966                    );
967                });
968        } else {
969            for (row_idx, canvas_row_full) in canvas_region.chunks_mut(canvas_stride).enumerate() {
970                let gy = min_gy as usize + row_idx;
971                let glyph_row = gy * glyph_width_usize;
972                let alpha_row = &glyph_mask.alpha[glyph_row + min_gx..glyph_row + max_gx];
973                let px_start = (x_off + min_gx) * 4;
974                let px_end = (x_off + max_gx) * 4;
975                let canvas_row = &mut canvas_row_full[px_start..px_end];
976                composite_alpha_row(
977                    canvas_row,
978                    alpha_row,
979                    color_r,
980                    color_g,
981                    color_b,
982                    color_a,
983                    is_opaque_color,
984                );
985            }
986        }
987    }
988
989    /// Downsample a supersampled image using box filtering with optimized memory access.
990    fn downsample(&self, ss_pixels: &[u8], ss_width: u32, ss_height: u32, factor: u32) -> Vec<u8> {
991        let out_width = ss_width / factor;
992        let out_height = ss_height / factor;
993        let out_size = (out_width * out_height) as usize;
994
995        // Specialized fast path for 2x supersampling (most common case).
996        match factor {
997            2 => return self.downsample_2x(ss_pixels, ss_width, out_width, out_size),
998            1 => return ss_pixels.to_vec(),
999            _ => {}
1000        }
1001
1002        // General path for other factors
1003        let factor_usize = factor as usize;
1004        let factor_sq = factor * factor;
1005        let out_row_bytes = out_width as usize * 4;
1006        let ss_row_bytes = ss_width as usize * 4;
1007        let mut result = vec![0u8; out_size * 4];
1008
1009        result
1010            .par_chunks_mut(out_row_bytes)
1011            .enumerate()
1012            .for_each(|(out_y, row)| {
1013                let base_y = out_y * factor_usize;
1014
1015                for out_x in 0..out_width as usize {
1016                    let mut r_sum: u32 = 0;
1017                    let mut g_sum: u32 = 0;
1018                    let mut b_sum: u32 = 0;
1019                    let mut a_sum: u32 = 0;
1020
1021                    let base_x = out_x * factor_usize;
1022
1023                    for dy in 0..factor_usize {
1024                        let src_row = (base_y + dy) * ss_row_bytes + base_x * 4;
1025                        for dx in 0..factor_usize {
1026                            let idx = src_row + dx * 4;
1027                            r_sum += ss_pixels[idx] as u32;
1028                            g_sum += ss_pixels[idx + 1] as u32;
1029                            b_sum += ss_pixels[idx + 2] as u32;
1030                            a_sum += ss_pixels[idx + 3] as u32;
1031                        }
1032                    }
1033
1034                    let out_idx = out_x * 4;
1035                    row[out_idx] = (r_sum / factor_sq) as u8;
1036                    row[out_idx + 1] = (g_sum / factor_sq) as u8;
1037                    row[out_idx + 2] = (b_sum / factor_sq) as u8;
1038                    row[out_idx + 3] = (a_sum / factor_sq) as u8;
1039                }
1040            });
1041
1042        result
1043    }
1044
1045    /// Optimized 2x downsampling with parallel rows and unrolled inner loops.
1046    fn downsample_2x(
1047        &self,
1048        ss_pixels: &[u8],
1049        ss_width: u32,
1050        out_width: u32,
1051        out_size: usize,
1052    ) -> Vec<u8> {
1053        let out_row_bytes = out_width as usize * 4;
1054        let ss_row_bytes = ss_width as usize * 4;
1055        let mut result = vec![0u8; out_size * 4];
1056
1057        result
1058            .par_chunks_mut(out_row_bytes)
1059            .enumerate()
1060            .for_each(|(out_y, row)| {
1061                let row0_start = out_y * 2 * ss_row_bytes;
1062                let row1_start = row0_start + ss_row_bytes;
1063                let row0 = &ss_pixels[row0_start..row0_start + ss_row_bytes];
1064                let row1 = &ss_pixels[row1_start..row1_start + ss_row_bytes];
1065
1066                let out_width_usize = out_width as usize;
1067                let simd_width = out_width_usize & !3;
1068                let mut out_x = 0usize;
1069
1070                while out_x < simd_width {
1071                    let mut base = out_x * 8;
1072                    for _ in 0..4 {
1073                        let dst = out_x * 4;
1074                        row[dst] = ((row0[base] as u16
1075                            + row0[base + 4] as u16
1076                            + row1[base] as u16
1077                            + row1[base + 4] as u16)
1078                            >> 2) as u8;
1079                        row[dst + 1] = ((row0[base + 1] as u16
1080                            + row0[base + 5] as u16
1081                            + row1[base + 1] as u16
1082                            + row1[base + 5] as u16)
1083                            >> 2) as u8;
1084                        row[dst + 2] = ((row0[base + 2] as u16
1085                            + row0[base + 6] as u16
1086                            + row1[base + 2] as u16
1087                            + row1[base + 6] as u16)
1088                            >> 2) as u8;
1089                        row[dst + 3] = ((row0[base + 3] as u16
1090                            + row0[base + 7] as u16
1091                            + row1[base + 3] as u16
1092                            + row1[base + 7] as u16)
1093                            >> 2) as u8;
1094                        out_x += 1;
1095                        base += 8;
1096                    }
1097                }
1098
1099                while out_x < out_width_usize {
1100                    let base = out_x * 8;
1101                    let dst = out_x * 4;
1102                    row[dst] = ((row0[base] as u16
1103                        + row0[base + 4] as u16
1104                        + row1[base] as u16
1105                        + row1[base + 4] as u16)
1106                        >> 2) as u8;
1107                    row[dst + 1] = ((row0[base + 1] as u16
1108                        + row0[base + 5] as u16
1109                        + row1[base + 1] as u16
1110                        + row1[base + 5] as u16)
1111                        >> 2) as u8;
1112                    row[dst + 2] = ((row0[base + 2] as u16
1113                        + row0[base + 6] as u16
1114                        + row1[base + 2] as u16
1115                        + row1[base + 6] as u16)
1116                        >> 2) as u8;
1117                    row[dst + 3] = ((row0[base + 3] as u16
1118                        + row0[base + 7] as u16
1119                        + row1[base + 3] as u16
1120                        + row1[base + 7] as u16)
1121                        >> 2) as u8;
1122                    out_x += 1;
1123                }
1124            });
1125
1126        result
1127    }
1128}
1129
1130/// Composite a row of alpha coverage values onto a canvas row.
1131///
1132/// Shared inner loop used by both `composite_glyph_mask` and `composite_alpha_buffer`.
1133/// The `canvas_row` slice must be exactly `alpha_row.len() * 4` bytes.
1134#[inline]
1135fn composite_alpha_row(
1136    canvas_row: &mut [u8],
1137    alpha_row: &[u8],
1138    color_r: u8,
1139    color_g: u8,
1140    color_b: u8,
1141    color_a: u32,
1142    is_opaque_color: bool,
1143) {
1144    if is_opaque_color {
1145        for (i, &coverage) in alpha_row.iter().enumerate() {
1146            if coverage == 0 {
1147                continue;
1148            }
1149            let dst_idx = i * 4;
1150            blend_rgba_over(
1151                canvas_row,
1152                dst_idx,
1153                color_r,
1154                color_g,
1155                color_b,
1156                coverage as u32,
1157            );
1158        }
1159    } else {
1160        for (i, &coverage) in alpha_row.iter().enumerate() {
1161            if coverage == 0 {
1162                continue;
1163            }
1164            let src_alpha = div255(coverage as u32 * color_a);
1165            if src_alpha == 0 {
1166                continue;
1167            }
1168            let dst_idx = i * 4;
1169            blend_rgba_over(canvas_row, dst_idx, color_r, color_g, color_b, src_alpha);
1170        }
1171    }
1172}
1173
1174/// Compute a reduced supersampled icon size that fits the canvas bounds.
1175///
1176/// Returns `None` if no downscale is needed or if no further reduction is possible.
1177#[inline]
1178fn scaled_icon_size_to_fit_canvas(
1179    ss_icon_size: u32,
1180    glyph_width: u32,
1181    glyph_height: u32,
1182    canvas_width: u32,
1183    canvas_height: u32,
1184) -> Option<u32> {
1185    if glyph_width <= canvas_width && glyph_height <= canvas_height {
1186        return None;
1187    }
1188
1189    let fit_x = canvas_width as f64 / glyph_width as f64;
1190    let fit_y = canvas_height as f64 / glyph_height as f64;
1191    let fit_scale = fit_x.min(fit_y) * 0.95;
1192    let scaled = ((ss_icon_size as f64) * fit_scale).floor().max(1.0) as u32;
1193
1194    if scaled < ss_icon_size {
1195        Some(scaled)
1196    } else {
1197        None
1198    }
1199}
1200
1201/// Fast approximate division by 255: `(x + 128) * 257 >> 16` is exact for all u8*u8 products.
1202#[inline(always)]
1203fn div255(x: u32) -> u32 {
1204    (x + 128 + ((x + 128) >> 8)) >> 8
1205}
1206
1207/// Blend source RGBA over destination RGBA in-place at `dst_idx`.
1208#[inline(always)]
1209fn blend_rgba_over(canvas: &mut [u8], dst_idx: usize, src_r: u8, src_g: u8, src_b: u8, src_a: u32) {
1210    if src_a == 255 {
1211        canvas[dst_idx] = src_r;
1212        canvas[dst_idx + 1] = src_g;
1213        canvas[dst_idx + 2] = src_b;
1214        canvas[dst_idx + 3] = 255;
1215        return;
1216    }
1217    let inv_src_alpha = 255 - src_a;
1218    canvas[dst_idx] = div255(src_r as u32 * src_a + canvas[dst_idx] as u32 * inv_src_alpha) as u8;
1219    canvas[dst_idx + 1] =
1220        div255(src_g as u32 * src_a + canvas[dst_idx + 1] as u32 * inv_src_alpha) as u8;
1221    canvas[dst_idx + 2] =
1222        div255(src_b as u32 * src_a + canvas[dst_idx + 2] as u32 * inv_src_alpha) as u8;
1223    canvas[dst_idx + 3] = (src_a + div255(canvas[dst_idx + 3] as u32 * inv_src_alpha)) as u8;
1224}
1225
1226/// Bilinear interpolation sampler for alpha buffers using fixed-point arithmetic.
1227/// Uses 8-bit fractional precision (256 = 1.0) to avoid floating-point ops in
1228/// the inner loop.
1229#[inline(always)]
1230fn bilinear_sample_alpha(buffer: &[u8], width: usize, x: f32, y: f32) -> u8 {
1231    let x0 = x as usize;
1232    let y0 = y as usize;
1233
1234    // 8-bit fractional parts (0..=255)
1235    let fx = ((x - x0 as f32) * 256.0) as u32;
1236    let fy = ((y - y0 as f32) * 256.0) as u32;
1237    let inv_fx = 256 - fx;
1238    let inv_fy = 256 - fy;
1239
1240    let row0 = y0 * width + x0;
1241    let row1 = row0 + width;
1242
1243    // Weighted sum with 16-bit precision, then shift back
1244    let val = inv_fy * (inv_fx * buffer[row0] as u32 + fx * buffer[row0 + 1] as u32)
1245        + fy * (inv_fx * buffer[row1] as u32 + fx * buffer[row1 + 1] as u32);
1246
1247    ((val + 32768) >> 16) as u8
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252    use super::*;
1253
1254    #[test]
1255    fn test_render_config_builder() {
1256        let config = RenderConfig::new()
1257            .canvas_size(512, 512)
1258            .icon_size(256)
1259            .supersample(4)
1260            .icon_color(Color::rgb(255, 0, 0))
1261            .background_color(Color::transparent())
1262            .anchor(HorizontalAnchor::Left, VerticalAnchor::Top)
1263            .offset(10, 20)
1264            .rotate(45.0);
1265
1266        assert_eq!(config.canvas_width, 512);
1267        assert_eq!(config.canvas_height, 512);
1268        assert_eq!(config.icon_size, 256);
1269        assert_eq!(config.supersample_factor, 4);
1270        assert_eq!(config.icon_color, Color::rgb(255, 0, 0));
1271        assert!(config.background_color.is_transparent());
1272        assert_eq!(config.horizontal_anchor, HorizontalAnchor::Left);
1273        assert_eq!(config.vertical_anchor, VerticalAnchor::Top);
1274        assert_eq!(config.offset_x, 10);
1275        assert_eq!(config.offset_y, 20);
1276        assert!((config.rotate - 45.0).abs() < 0.001);
1277    }
1278
1279    #[test]
1280    fn test_render_config_rotate_default() {
1281        let config = RenderConfig::default();
1282        assert!((config.rotate - 0.0).abs() < 0.001);
1283    }
1284
1285    #[test]
1286    fn test_render_config_rotate_negative() {
1287        let config = RenderConfig::new().rotate(-90.0);
1288        assert!((config.rotate - (-90.0)).abs() < 0.001);
1289    }
1290
1291    #[test]
1292    fn test_scaled_icon_size_to_fit_canvas_no_change_when_fit() {
1293        let scaled = scaled_icon_size_to_fit_canvas(1024, 800, 700, 1024, 1024);
1294        assert_eq!(scaled, None);
1295    }
1296
1297    #[test]
1298    fn test_scaled_icon_size_to_fit_canvas_downscales_oversized_glyph() {
1299        let scaled = scaled_icon_size_to_fit_canvas(1000, 1200, 800, 1000, 1000);
1300        let scaled_value = scaled.expect("oversized glyph should trigger downscaling");
1301        assert!(scaled_value < 1000);
1302    }
1303}