qrcode-render 2.1.1

Pure Rust QR rendering traits, builder, text renderers, ANSI renderer, and color helpers for qrcode-rs.
Documentation
//! ANSI terminal color rendering.
//!
//! Renders QR codes using 24-bit TrueColor ANSI escape codes with half-block
//! characters. Each character represents 2 vertical pixels with independent
//! foreground and background colors.
//!
//! # Example
//!
//! ```
//! use qrcode_core::Color as ModuleColor;
//! use qrcode_render::{Renderer, ansi::Color};
//!
//! let modules = [ModuleColor::Dark, ModuleColor::Light, ModuleColor::Light, ModuleColor::Dark];
//! // Dark modules in black, light modules in white.
//! let text = Renderer::<Color>::new(&modules, 2, 0).build();
//! println!("{}", text);
//!
//! // Custom colors: dark blue on light gray.
//! let text = Renderer::<Color>::new(&modules, 2, 0)
//!     .dark_color(Color::new(0, 51, 102))
//!     .light_color(Color::new(224, 224, 224))
//!     .build();
//! println!("{}", text);
//! ```

#[cfg(not(feature = "std"))]
#[allow(unused_imports)]
use alloc::{
    borrow::ToOwned,
    format,
    string::{String, ToString},
    vec,
    vec::Vec,
};

use crate::{Canvas as RenderCanvas, Pixel, StyledPixel};
use qrcode_core::Color as ModuleColor;

/// An ANSI TrueColor (24-bit) pixel.
///
/// Each `Color` stores an RGB value that will be rendered using ANSI escape
/// codes in the terminal.
#[derive(Copy, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Color {
    r: u8,
    g: u8,
    b: u8,
}

impl Color {
    /// Creates a new ANSI color from RGB components.
    pub const fn new(r: u8, g: u8, b: u8) -> Self {
        Self { r, g, b }
    }

    fn push_fg_ansi(self, out: &mut String) {
        use core::fmt::Write as _;

        write!(out, "\x1b[38;2;{};{};{}m", self.r, self.g, self.b).expect("writing to String cannot fail");
    }

    fn push_bg_ansi(self, out: &mut String) {
        use core::fmt::Write as _;

        write!(out, "\x1b[48;2;{};{};{}m", self.r, self.g, self.b).expect("writing to String cannot fail");
    }
}

impl Pixel for Color {
    type Image = String;
    type Canvas = CanvasAnsi;

    fn default_unit_size() -> (u32, u32) {
        (1, 1)
    }

    fn default_color(color: ModuleColor) -> Self {
        match color {
            ModuleColor::Dark => Color::new(0, 0, 0),
            ModuleColor::Light => Color::new(255, 255, 255),
        }
    }
}

impl StyledPixel for Color {
    fn from_hex(hex: &str) -> Self {
        let (r, g, b) = crate::colors::hex_to_rgb(hex).unwrap_or((0, 0, 0));
        Color::new(r, g, b)
    }
}

/// Canvas for ANSI terminal rendering.
///
/// Uses Unicode half-block characters (▀ U+2580) where the foreground color
/// paints the top half and the background color paints the bottom half.
/// This yields 2 vertical pixels per character.
pub struct CanvasAnsi {
    canvas: Vec<u8>,
    width: u32,
    dark_pixel: u8,
    dark_color: Color,
    light_color: Color,
}

impl RenderCanvas for CanvasAnsi {
    type Pixel = Color;
    type Image = String;

    fn new(width: u32, height: u32, dark_pixel: Color, light_pixel: Color) -> Self {
        CanvasAnsi {
            canvas: vec![0u8; (width * height) as usize],
            width,
            dark_pixel: 1,
            dark_color: dark_pixel,
            light_color: light_pixel,
        }
    }

    fn draw_dark_pixel(&mut self, x: u32, y: u32) {
        self.canvas[(x + y * self.width) as usize] = self.dark_pixel;
    }

    fn into_image(self) -> String {
        let w = self.width as usize;
        let dark = 1u8;
        let reset = "\x1b[0m";
        let row_count = self.canvas.len() / w;
        let output_rows = row_count.div_ceil(2);
        let mut out = String::with_capacity(output_rows * (w * 40 + reset.len() + 1));

        for group_start in (0..row_count).step_by(2) {
            if group_start > 0 {
                out.push('\n');
            }

            let top_start = group_start * w;
            let top_row = &self.canvas[top_start..top_start + w];
            let bot_row = if group_start + 1 < row_count {
                let bot_start = (group_start + 1) * w;
                &self.canvas[bot_start..bot_start + w]
            } else {
                &[][..]
            };

            let mut last_fg = None;
            let mut last_bg = None;

            for col in 0..w {
                let top = top_row.get(col).copied().unwrap_or(0);
                let bot = bot_row.get(col).copied().unwrap_or(0);

                let (fg, bg) = if top == dark && bot == dark {
                    (self.dark_color, self.dark_color)
                } else if top == dark && bot != dark {
                    (self.dark_color, self.light_color)
                } else if top != dark && bot == dark {
                    (self.light_color, self.dark_color)
                } else {
                    (self.light_color, self.light_color)
                };

                if last_bg != Some(bg) {
                    bg.push_bg_ansi(&mut out);
                    last_bg = Some(bg);
                }
                if last_fg != Some(fg) {
                    fg.push_fg_ansi(&mut out);
                    last_fg = Some(fg);
                }

                if top == dark && bot == dark {
                    out.push('');
                } else if top == dark {
                    out.push('');
                } else if bot == dark {
                    out.push('');
                } else {
                    out.push(' ');
                }
            }

            out.push_str(reset);
        }
        out
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Renderer;

    #[test]
    fn test_ansi_all_dark() {
        let colors = vec![ModuleColor::Dark; 4];
        let image: String = Renderer::<Color>::new(&colors, 2, 0).module_dimensions(1, 1).build();
        // Should contain the full-block character and ANSI codes.
        assert!(image.contains(''));
        assert!(image.contains("\x1b["));
        assert!(image.contains("\x1b[0m"));
    }

    #[test]
    fn test_ansi_all_light() {
        let colors = vec![ModuleColor::Light; 4];
        let image: String = Renderer::<Color>::new(&colors, 2, 0).module_dimensions(1, 1).build();
        assert!(image.contains(' '));
        assert!(image.contains("\x1b[0m"));
    }

    #[test]
    fn test_ansi_mixed() {
        let colors = vec![ModuleColor::Dark, ModuleColor::Light, ModuleColor::Light, ModuleColor::Dark];
        let image: String = Renderer::<Color>::new(&colors, 2, 0).module_dimensions(1, 1).build();
        // Dark on top, light on bottom → '▀' with dark fg, light bg.
        assert!(image.contains(''));
    }

    #[test]
    fn test_ansi_custom_colors() {
        let colors = vec![ModuleColor::Dark, ModuleColor::Light, ModuleColor::Light, ModuleColor::Dark];
        let image = Renderer::<Color>::new(&colors, 2, 0)
            .dark_color(Color::new(0, 51, 102))
            .light_color(Color::new(224, 224, 224))
            .module_dimensions(1, 1)
            .build();
        // Should contain the custom RGB values.
        assert!(image.contains("0;51;102"));
        assert!(image.contains("224;224;224"));
    }

    #[test]
    fn test_ansi_color_optimization() {
        // Consecutive same-colored pixels should not emit redundant escape codes.
        let colors = vec![ModuleColor::Dark; 16]; // 4x4 all dark
        let image: String = Renderer::<Color>::new(&colors, 4, 0).module_dimensions(1, 1).build();
        let lines: Vec<&str> = image.split('\n').collect();
        assert_eq!(lines.len(), 2);
        // All '█' chars, same fg/bg — only 3 escape sequences per line (fg + bg + reset).
        for line in &lines {
            let esc_count = line.matches("\x1b[").count();
            assert_eq!(esc_count, 3);
        }
    }
}