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, RenderError, StyledPixel, check_buffer_size, checked_area};
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 escape_sequence(self, foreground: bool) -> EscapeSequence {
57        let mut bytes = [0; 19];
58        bytes[..7].copy_from_slice(if foreground { b"\x1b[38;2;" } else { b"\x1b[48;2;" });
59        let mut len = 7;
60        for component in [self.r, self.g, self.b] {
61            if component >= 100 {
62                bytes[len] = b'0' + component / 100;
63                len += 1;
64            }
65            if component >= 10 {
66                bytes[len] = b'0' + component / 10 % 10;
67                len += 1;
68            }
69            bytes[len] = b'0' + component % 10;
70            bytes[len + 1] = b';';
71            len += 2;
72        }
73        bytes[len - 1] = b'm';
74        EscapeSequence { bytes, len: len as u8 }
75    }
76}
77
78struct EscapeSequence {
79    bytes: [u8; 19],
80    len: u8,
81}
82
83impl EscapeSequence {
84    fn as_str(&self) -> &str {
85        core::str::from_utf8(&self.bytes[..usize::from(self.len)]).expect("ANSI escape sequences contain only ASCII")
86    }
87}
88
89impl Pixel for Color {
90    type Image = String;
91    type Canvas = CanvasAnsi;
92
93    fn default_unit_size() -> (u32, u32) {
94        (1, 1)
95    }
96
97    fn default_color(color: ModuleColor) -> Self {
98        match color {
99            ModuleColor::Dark => Color::new(0, 0, 0),
100            ModuleColor::Light => Color::new(255, 255, 255),
101        }
102    }
103}
104
105impl StyledPixel for Color {
106    fn from_hex(hex: &str) -> Self {
107        let (r, g, b) = crate::colors::hex_to_rgb(hex).unwrap_or((0, 0, 0));
108        Color::new(r, g, b)
109    }
110}
111
112/// Canvas for ANSI terminal rendering.
113///
114/// Uses Unicode half-block characters (▀ U+2580) where the foreground color
115/// paints the top half and the background color paints the bottom half.
116/// This yields 2 vertical pixels per character.
117pub struct CanvasAnsi {
118    canvas: Vec<u8>,
119    width: u32,
120    dark_pixel: u8,
121    dark_color: Color,
122    light_color: Color,
123    output_capacity: usize,
124}
125
126fn layout(width: u32, height: u32) -> Result<(usize, usize), RenderError> {
127    let area = checked_area(width, height)?;
128    if area == 0 {
129        return Ok((0, 0));
130    }
131    let rows = (height as usize).div_ceil(2);
132    // Two TrueColor escapes (19 bytes each), a UTF-8 block (3), and the row reset.
133    let capacity = (width as usize)
134        .checked_mul(41)
135        .and_then(|bytes| bytes.checked_add(4))
136        .and_then(|bytes| bytes.checked_mul(rows))
137        .and_then(|bytes| bytes.checked_add(rows - 1))
138        .ok_or(RenderError::OutputTooLarge)?;
139    check_buffer_size(area.checked_add(capacity).ok_or(RenderError::OutputTooLarge)?)?;
140    Ok((area, capacity))
141}
142
143impl RenderCanvas for CanvasAnsi {
144    type Pixel = Color;
145    type Image = String;
146
147    fn new(width: u32, height: u32, dark_pixel: Color, light_pixel: Color) -> Self {
148        let (area, output_capacity) = layout(width, height).unwrap_or_else(|error| panic!("{error}"));
149        CanvasAnsi {
150            canvas: vec![0u8; area],
151            width,
152            dark_pixel: 1,
153            dark_color: dark_pixel,
154            light_color: light_pixel,
155            output_capacity,
156        }
157    }
158
159    fn validate_dimensions(width: u32, height: u32, _dark: &Color, _light: &Color) -> Result<(), RenderError> {
160        layout(width, height).map(|_| ())
161    }
162
163    fn draw_dark_pixel(&mut self, x: u32, y: u32) {
164        self.canvas[x as usize + y as usize * self.width as usize] = self.dark_pixel;
165    }
166
167    fn into_image(self) -> String {
168        let w = self.width as usize;
169        if self.canvas.is_empty() {
170            return String::new();
171        }
172        let dark = 1u8;
173        let reset = "\x1b[0m";
174        let row_count = self.canvas.len() / w;
175        let mut out = String::with_capacity(self.output_capacity);
176        // Both colors are fixed for this canvas. Encode each escape once on the
177        // stack, preserving the existing heap-buffer budget.
178        let dark_fg = self.dark_color.escape_sequence(true);
179        let dark_bg = self.dark_color.escape_sequence(false);
180        let light_fg = self.light_color.escape_sequence(true);
181        let light_bg = self.light_color.escape_sequence(false);
182        let foregrounds = [dark_fg.as_str(), light_fg.as_str()];
183        let backgrounds = [dark_bg.as_str(), light_bg.as_str()];
184
185        for group_start in (0..row_count).step_by(2) {
186            if group_start > 0 {
187                out.push('\n');
188            }
189
190            let top_start = group_start * w;
191            let top_row = &self.canvas[top_start..top_start + w];
192            let bot_row = if group_start + 1 < row_count {
193                let bot_start = (group_start + 1) * w;
194                &self.canvas[bot_start..bot_start + w]
195            } else {
196                &[][..]
197            };
198
199            let mut last_fg = None;
200            let mut last_bg = None;
201
202            for col in 0..w {
203                let top = top_row.get(col).copied().unwrap_or(0);
204                let bot = bot_row.get(col).copied().unwrap_or(0);
205
206                let (fg, bg) = if top == dark && bot == dark {
207                    (self.dark_color, self.dark_color)
208                } else if top == dark && bot != dark {
209                    (self.dark_color, self.light_color)
210                } else if top != dark && bot == dark {
211                    (self.light_color, self.dark_color)
212                } else {
213                    (self.light_color, self.light_color)
214                };
215
216                if last_bg != Some(bg) {
217                    out.push_str(backgrounds[usize::from(bot != dark)]);
218                    last_bg = Some(bg);
219                }
220                if last_fg != Some(fg) {
221                    out.push_str(foregrounds[usize::from(top != dark)]);
222                    last_fg = Some(fg);
223                }
224
225                if top == dark && bot == dark {
226                    out.push('█');
227                } else if top == dark {
228                    out.push('▀');
229                } else if bot == dark {
230                    out.push('▄');
231                } else {
232                    out.push(' ');
233                }
234            }
235
236            out.push_str(reset);
237        }
238        out
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::Renderer;
246
247    fn legacy_render(canvas: &[u8], width: usize, dark: Color, light: Color) -> String {
248        use core::fmt::Write as _;
249
250        if canvas.is_empty() {
251            return String::new();
252        }
253        let rows = canvas.len() / width;
254        let mut output = String::new();
255        for y in (0..rows).step_by(2) {
256            if y > 0 {
257                output.push('\n');
258            }
259            let mut last_fg = None;
260            let mut last_bg = None;
261            for x in 0..width {
262                let top = canvas[y * width + x] == 1;
263                let bottom = y + 1 < rows && canvas[(y + 1) * width + x] == 1;
264                let fg = if top { dark } else { light };
265                let bg = if bottom { dark } else { light };
266                if last_bg != Some(bg) {
267                    write!(output, "\x1b[48;2;{};{};{}m", bg.r, bg.g, bg.b).unwrap();
268                    last_bg = Some(bg);
269                }
270                if last_fg != Some(fg) {
271                    write!(output, "\x1b[38;2;{};{};{}m", fg.r, fg.g, fg.b).unwrap();
272                    last_fg = Some(fg);
273                }
274                output.push(match (top, bottom) {
275                    (true, true) => '█',
276                    (true, false) => '▀',
277                    (false, true) => '▄',
278                    (false, false) => ' ',
279                });
280            }
281            output.push_str("\x1b[0m");
282        }
283        output
284    }
285
286    #[test]
287    fn cached_escape_sequences_match_decimal_formatting_for_every_component_value() {
288        for component in 0..=255 {
289            for color in [Color::new(component, 0, 255), Color::new(0, component, 255), Color::new(0, 255, component)] {
290                assert_eq!(
291                    color.escape_sequence(true).as_str(),
292                    format!("\x1b[38;2;{};{};{}m", color.r, color.g, color.b)
293                );
294                assert_eq!(
295                    color.escape_sequence(false).as_str(),
296                    format!("\x1b[48;2;{};{};{}m", color.r, color.g, color.b)
297                );
298            }
299        }
300    }
301
302    #[test]
303    fn cached_renderer_matches_independent_formatter_for_patterns_and_odd_rows() {
304        let colors = [
305            (Color::new(0, 0, 0), Color::new(255, 255, 255)),
306            (Color::new(0, 9, 10), Color::new(99, 100, 255)),
307            (Color::new(255, 100, 9), Color::new(10, 99, 0)),
308            (Color::new(7, 128, 250), Color::new(7, 128, 250)),
309        ];
310        for width in [1, 2, 3, 7, 21] {
311            for height in [1, 2, 3, 4, 7] {
312                for (dark, light) in colors {
313                    for pattern in 0..4 {
314                        let mut canvas = CanvasAnsi::new(width, height, dark, light);
315                        for y in 0..height {
316                            for x in 0..width {
317                                let is_dark = match pattern {
318                                    0 => false,
319                                    1 => true,
320                                    2 => (x + y) % 2 == 0,
321                                    _ => (x * 7 + y * 3) % 5 < 2,
322                                };
323                                if is_dark {
324                                    canvas.draw_dark_pixel(x, y);
325                                }
326                            }
327                        }
328                        let expected = legacy_render(&canvas.canvas, width as usize, dark, light);
329                        let capacity = canvas.output_capacity;
330                        let actual = canvas.into_image();
331                        assert_eq!(actual, expected, "width {width}, height {height}, pattern {pattern}");
332                        assert!(actual.len() <= capacity);
333                    }
334                }
335            }
336        }
337    }
338
339    #[test]
340    fn test_ansi_all_dark() {
341        let colors = vec![ModuleColor::Dark; 4];
342        let image: String = Renderer::<Color>::new(&colors, 2, 0).module_dimensions(1, 1).build();
343        // Should contain the full-block character and ANSI codes.
344        assert!(image.contains('█'));
345        assert!(image.contains("\x1b["));
346        assert!(image.contains("\x1b[0m"));
347    }
348
349    #[test]
350    fn test_ansi_all_light() {
351        let colors = vec![ModuleColor::Light; 4];
352        let image: String = Renderer::<Color>::new(&colors, 2, 0).module_dimensions(1, 1).build();
353        assert!(image.contains(' '));
354        assert!(image.contains("\x1b[0m"));
355    }
356
357    #[test]
358    fn test_ansi_mixed() {
359        let colors = vec![ModuleColor::Dark, ModuleColor::Light, ModuleColor::Light, ModuleColor::Dark];
360        let image: String = Renderer::<Color>::new(&colors, 2, 0).module_dimensions(1, 1).build();
361        // Dark on top, light on bottom → '▀' with dark fg, light bg.
362        assert!(image.contains('▀'));
363    }
364
365    #[test]
366    fn test_ansi_custom_colors() {
367        let colors = vec![ModuleColor::Dark, ModuleColor::Light, ModuleColor::Light, ModuleColor::Dark];
368        let image = Renderer::<Color>::new(&colors, 2, 0)
369            .dark_color(Color::new(0, 51, 102))
370            .light_color(Color::new(224, 224, 224))
371            .module_dimensions(1, 1)
372            .build();
373        // Should contain the custom RGB values.
374        assert!(image.contains("0;51;102"));
375        assert!(image.contains("224;224;224"));
376    }
377
378    #[test]
379    fn test_ansi_color_optimization() {
380        // Consecutive same-colored pixels should not emit redundant escape codes.
381        let colors = vec![ModuleColor::Dark; 16]; // 4x4 all dark
382        let image: String = Renderer::<Color>::new(&colors, 4, 0).module_dimensions(1, 1).build();
383        let lines: Vec<&str> = image.split('\n').collect();
384        assert_eq!(lines.len(), 2);
385        // All '█' chars, same fg/bg — only 3 escape sequences per line (fg + bg + reset).
386        for line in &lines {
387            let esc_count = line.matches("\x1b[").count();
388            assert_eq!(esc_count, 3);
389        }
390    }
391
392    #[test]
393    fn ansi_budget_counts_escape_sequences_and_row_reset() {
394        let dark = Color::new(255, 254, 253);
395        let light = Color::new(252, 251, 250);
396        let width = ((crate::MAX_BUFFER_BYTES - 4) / 42) as u32;
397        assert!(CanvasAnsi::validate_dimensions(width, 1, &dark, &light).is_ok());
398        assert_eq!(CanvasAnsi::validate_dimensions(width + 1, 1, &dark, &light), Err(RenderError::OutputTooLarge));
399        assert_eq!(CanvasAnsi::validate_dimensions(65_536, 65_536, &dark, &light), Err(RenderError::OutputTooLarge));
400        let modules = [ModuleColor::Light];
401        assert_eq!(
402            Renderer::<Color>::new(&modules, 1, 0).module_dimensions(65_536, 65_536).try_build(),
403            Err(RenderError::OutputTooLarge)
404        );
405    }
406
407    #[test]
408    fn ansi_empty_canvases_produce_empty_text() {
409        for (width, height) in [(0, 0), (0, u32::MAX), (u32::MAX, 0)] {
410            assert_eq!(CanvasAnsi::new(width, height, Color::new(0, 0, 0), Color::new(255, 255, 255)).into_image(), "");
411        }
412    }
413
414    #[test]
415    fn ansi_odd_rows_stay_within_estimated_output_bytes() {
416        let mut canvas = CanvasAnsi::new(7, 3, Color::new(255, 254, 253), Color::new(252, 251, 250));
417        for y in 0..3 {
418            for x in 0..7 {
419                if (x + y) % 2 == 0 {
420                    canvas.draw_dark_pixel(x, y);
421                }
422            }
423        }
424        let capacity = canvas.output_capacity;
425        let output = canvas.into_image();
426        assert!(output.len() <= capacity);
427        assert_eq!(output.lines().count(), 2);
428        assert!(output.ends_with("\x1b[0m"));
429    }
430}