Skip to main content

qrcode_render/
ansi.rs

1//! ANSI terminal color rendering.
2//!
3//! Renders QR codes using 24-bit TrueColor ANSI escape codes with half-block
4//! characters. Each character represents 2 vertical pixels with independent
5//! foreground and background colors.
6//!
7//! # Example
8//!
9//! ```
10//! use qrcode_core::Color as ModuleColor;
11//! use qrcode_render::{Renderer, ansi::Color};
12//!
13//! let modules = [ModuleColor::Dark, ModuleColor::Light, ModuleColor::Light, ModuleColor::Dark];
14//! // Dark modules in black, light modules in white.
15//! let text = Renderer::<Color>::new(&modules, 2, 0).build();
16//! println!("{}", text);
17//!
18//! // Custom colors: dark blue on light gray.
19//! let text = Renderer::<Color>::new(&modules, 2, 0)
20//!     .dark_color(Color::new(0, 51, 102))
21//!     .light_color(Color::new(224, 224, 224))
22//!     .build();
23//! println!("{}", text);
24//! ```
25
26#[cfg(not(feature = "std"))]
27#[allow(unused_imports)]
28use alloc::{
29    borrow::ToOwned,
30    format,
31    string::{String, ToString},
32    vec,
33    vec::Vec,
34};
35
36use crate::{Canvas as RenderCanvas, Pixel, StyledPixel};
37use qrcode_core::Color as ModuleColor;
38
39/// An ANSI TrueColor (24-bit) pixel.
40///
41/// Each `Color` stores an RGB value that will be rendered using ANSI escape
42/// codes in the terminal.
43#[derive(Copy, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
44pub struct Color {
45    r: u8,
46    g: u8,
47    b: u8,
48}
49
50impl Color {
51    /// Creates a new ANSI color from RGB components.
52    pub const fn new(r: u8, g: u8, b: u8) -> Self {
53        Self { r, g, b }
54    }
55
56    fn push_fg_ansi(self, out: &mut String) {
57        use core::fmt::Write as _;
58
59        write!(out, "\x1b[38;2;{};{};{}m", self.r, self.g, self.b).expect("writing to String cannot fail");
60    }
61
62    fn push_bg_ansi(self, out: &mut String) {
63        use core::fmt::Write as _;
64
65        write!(out, "\x1b[48;2;{};{};{}m", self.r, self.g, self.b).expect("writing to String cannot fail");
66    }
67}
68
69impl Pixel for Color {
70    type Image = String;
71    type Canvas = CanvasAnsi;
72
73    fn default_unit_size() -> (u32, u32) {
74        (1, 1)
75    }
76
77    fn default_color(color: ModuleColor) -> Self {
78        match color {
79            ModuleColor::Dark => Color::new(0, 0, 0),
80            ModuleColor::Light => Color::new(255, 255, 255),
81        }
82    }
83}
84
85impl StyledPixel for Color {
86    fn from_hex(hex: &str) -> Self {
87        let (r, g, b) = crate::colors::hex_to_rgb(hex).unwrap_or((0, 0, 0));
88        Color::new(r, g, b)
89    }
90}
91
92/// Canvas for ANSI terminal rendering.
93///
94/// Uses Unicode half-block characters (▀ U+2580) where the foreground color
95/// paints the top half and the background color paints the bottom half.
96/// This yields 2 vertical pixels per character.
97pub struct CanvasAnsi {
98    canvas: Vec<u8>,
99    width: u32,
100    dark_pixel: u8,
101    dark_color: Color,
102    light_color: Color,
103}
104
105impl RenderCanvas for CanvasAnsi {
106    type Pixel = Color;
107    type Image = String;
108
109    fn new(width: u32, height: u32, dark_pixel: Color, light_pixel: Color) -> Self {
110        CanvasAnsi {
111            canvas: vec![0u8; (width * height) as usize],
112            width,
113            dark_pixel: 1,
114            dark_color: dark_pixel,
115            light_color: light_pixel,
116        }
117    }
118
119    fn draw_dark_pixel(&mut self, x: u32, y: u32) {
120        self.canvas[(x + y * self.width) as usize] = self.dark_pixel;
121    }
122
123    fn into_image(self) -> String {
124        let w = self.width as usize;
125        let dark = 1u8;
126        let reset = "\x1b[0m";
127        let row_count = self.canvas.len() / w;
128        let output_rows = row_count.div_ceil(2);
129        let mut out = String::with_capacity(output_rows * (w * 40 + reset.len() + 1));
130
131        for group_start in (0..row_count).step_by(2) {
132            if group_start > 0 {
133                out.push('\n');
134            }
135
136            let top_start = group_start * w;
137            let top_row = &self.canvas[top_start..top_start + w];
138            let bot_row = if group_start + 1 < row_count {
139                let bot_start = (group_start + 1) * w;
140                &self.canvas[bot_start..bot_start + w]
141            } else {
142                &[][..]
143            };
144
145            let mut last_fg = None;
146            let mut last_bg = None;
147
148            for col in 0..w {
149                let top = top_row.get(col).copied().unwrap_or(0);
150                let bot = bot_row.get(col).copied().unwrap_or(0);
151
152                let (fg, bg) = if top == dark && bot == dark {
153                    (self.dark_color, self.dark_color)
154                } else if top == dark && bot != dark {
155                    (self.dark_color, self.light_color)
156                } else if top != dark && bot == dark {
157                    (self.light_color, self.dark_color)
158                } else {
159                    (self.light_color, self.light_color)
160                };
161
162                if last_bg != Some(bg) {
163                    bg.push_bg_ansi(&mut out);
164                    last_bg = Some(bg);
165                }
166                if last_fg != Some(fg) {
167                    fg.push_fg_ansi(&mut out);
168                    last_fg = Some(fg);
169                }
170
171                if top == dark && bot == dark {
172                    out.push('█');
173                } else if top == dark {
174                    out.push('▀');
175                } else if bot == dark {
176                    out.push('▄');
177                } else {
178                    out.push(' ');
179                }
180            }
181
182            out.push_str(reset);
183        }
184        out
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::Renderer;
192
193    #[test]
194    fn test_ansi_all_dark() {
195        let colors = vec![ModuleColor::Dark; 4];
196        let image: String = Renderer::<Color>::new(&colors, 2, 0).module_dimensions(1, 1).build();
197        // Should contain the full-block character and ANSI codes.
198        assert!(image.contains('█'));
199        assert!(image.contains("\x1b["));
200        assert!(image.contains("\x1b[0m"));
201    }
202
203    #[test]
204    fn test_ansi_all_light() {
205        let colors = vec![ModuleColor::Light; 4];
206        let image: String = Renderer::<Color>::new(&colors, 2, 0).module_dimensions(1, 1).build();
207        assert!(image.contains(' '));
208        assert!(image.contains("\x1b[0m"));
209    }
210
211    #[test]
212    fn test_ansi_mixed() {
213        let colors = vec![ModuleColor::Dark, ModuleColor::Light, ModuleColor::Light, ModuleColor::Dark];
214        let image: String = Renderer::<Color>::new(&colors, 2, 0).module_dimensions(1, 1).build();
215        // Dark on top, light on bottom → '▀' with dark fg, light bg.
216        assert!(image.contains('▀'));
217    }
218
219    #[test]
220    fn test_ansi_custom_colors() {
221        let colors = vec![ModuleColor::Dark, ModuleColor::Light, ModuleColor::Light, ModuleColor::Dark];
222        let image = Renderer::<Color>::new(&colors, 2, 0)
223            .dark_color(Color::new(0, 51, 102))
224            .light_color(Color::new(224, 224, 224))
225            .module_dimensions(1, 1)
226            .build();
227        // Should contain the custom RGB values.
228        assert!(image.contains("0;51;102"));
229        assert!(image.contains("224;224;224"));
230    }
231
232    #[test]
233    fn test_ansi_color_optimization() {
234        // Consecutive same-colored pixels should not emit redundant escape codes.
235        let colors = vec![ModuleColor::Dark; 16]; // 4x4 all dark
236        let image: String = Renderer::<Color>::new(&colors, 4, 0).module_dimensions(1, 1).build();
237        let lines: Vec<&str> = image.split('\n').collect();
238        assert_eq!(lines.len(), 2);
239        // All '█' chars, same fg/bg — only 3 escape sequences per line (fg + bg + reset).
240        for line in &lines {
241            let esc_count = line.matches("\x1b[").count();
242            assert_eq!(esc_count, 3);
243        }
244    }
245}