Skip to main content

qrcode_render/
unicode.rs

1//! UTF-8 rendering, with various pixel densities.
2
3#[cfg(not(feature = "std"))]
4#[allow(unused_imports)]
5use alloc::{
6    borrow::ToOwned,
7    format,
8    string::{String, ToString},
9    vec,
10    vec::Vec,
11};
12
13use crate::{Canvas as RenderCanvas, Color, Pixel, RenderError, check_buffer_size, checked_area};
14
15fn layout(
16    width: u32,
17    height: u32,
18    row_group: usize,
19    col_step: usize,
20    glyph_bytes: usize,
21) -> Result<(usize, usize), RenderError> {
22    let area = checked_area(width, height)?;
23    if area == 0 {
24        return Ok((0, 0));
25    }
26    let rows = (height as usize).div_ceil(row_group);
27    let cols = (width as usize).div_ceil(col_step);
28    let capacity = rows
29        .checked_mul(cols)
30        .and_then(|glyphs| glyphs.checked_mul(glyph_bytes))
31        .and_then(|bytes| bytes.checked_add(rows - 1))
32        .ok_or(RenderError::OutputTooLarge)?;
33    check_buffer_size(area.checked_add(capacity).ok_or(RenderError::OutputTooLarge)?)?;
34    Ok((area, capacity))
35}
36
37//{{{ Shared macro for bit-packed canvas
38
39/// Generates a `Canvas` implementation for Unicode renderers that pack multiple
40/// vertical pixels into a single `u8` cell. The `into_image` method processes
41/// `ROW_GROUP` rows at a time with zero intermediate allocations.
42macro_rules! impl_bit_canvas {
43    ($canvas:ident, $pixel:ident, $row_group:expr, $col_step:expr, $glyph_bytes:expr, $encode:expr) => {
44        #[doc(hidden)]
45        pub struct $canvas {
46            canvas: Vec<u8>,
47            width: u32,
48            height: u32,
49            dark_pixel: u8,
50            output_capacity: usize,
51        }
52
53        impl RenderCanvas for $canvas {
54            type Pixel = $pixel;
55            type Image = String;
56
57            fn new(width: u32, height: u32, dark_pixel: $pixel, light_pixel: $pixel) -> Self {
58                let (area, output_capacity) = layout(width, height, $row_group, $col_step, $glyph_bytes)
59                    .unwrap_or_else(|error| panic!("{error}"));
60                let a = vec![light_pixel.value(); area];
61                $canvas { width, height, canvas: a, dark_pixel: dark_pixel.value(), output_capacity }
62            }
63
64            fn validate_dimensions(
65                width: u32,
66                height: u32,
67                _dark: &$pixel,
68                _light: &$pixel,
69            ) -> Result<(), RenderError> {
70                layout(width, height, $row_group, $col_step, $glyph_bytes).map(|_| ())
71            }
72
73            fn draw_dark_pixel(&mut self, x: u32, y: u32) {
74                self.canvas[x as usize + y as usize * self.width as usize] = self.dark_pixel;
75            }
76
77            fn draw_dark_rect(&mut self, left: u32, top: u32, width: u32, height: u32) {
78                if width == 0 || height == 0 {
79                    return;
80                }
81                if width == 1 && height == 1 {
82                    self.draw_dark_pixel(left, top);
83                    return;
84                }
85                assert!(
86                    left < self.width && top < self.height && width <= self.width - left && height <= self.height - top,
87                    "rectangle exceeds canvas dimensions"
88                );
89                let stride = self.width as usize;
90                let left = left as usize;
91                let width = width as usize;
92                for y in top..top + height {
93                    let start = y as usize * stride + left;
94                    self.canvas[start..start + width].fill(self.dark_pixel);
95                }
96            }
97
98            fn into_image(self) -> String {
99                let w = self.width as usize;
100                let data = &self.canvas;
101                if data.is_empty() {
102                    return String::new();
103                }
104                let row_group = $row_group;
105                let empty: &[u8] = &[];
106                let col_step: usize = $col_step;
107                let row_count = data.len() / w;
108                let mut out = String::with_capacity(self.output_capacity);
109
110                for group_start in (0..row_count).step_by(row_group) {
111                    let actual = row_group.min(row_count - group_start);
112                    let mut group: [&[u8]; $row_group] = [empty; $row_group];
113                    for i in 0..actual {
114                        let row_start = (group_start + i) * w;
115                        group[i] = &data[row_start..row_start + w];
116                    }
117                    for col in (0..w).step_by(col_step) {
118                        out.push_str($encode(&group, col));
119                    }
120                    if group_start + actual < row_count {
121                        out.push('\n');
122                    }
123                }
124                out
125            }
126        }
127    };
128}
129
130//}}}
131//{{{ Dense1x2 — half-block, 2 rows per character
132
133const CODEPAGE: [&str; 4] = [" ", "\u{2584}", "\u{2580}", "\u{2588}"];
134
135/// Unicode renderer packing 2 vertical pixels per character using half-block
136/// elements (U+2580–U+2588). Use with `QrCode::render::<Dense1x2>()`.
137#[derive(Copy, Clone, PartialEq, Eq)]
138pub enum Dense1x2 {
139    /// A dark module.
140    Dark,
141    /// A light module.
142    Light,
143}
144
145impl Pixel for Dense1x2 {
146    type Image = String;
147    type Canvas = Canvas1x2;
148    fn default_unit_size() -> (u32, u32) {
149        (1, 1)
150    }
151    fn default_color(color: Color) -> Dense1x2 {
152        color.select(Dense1x2::Dark, Dense1x2::Light)
153    }
154}
155
156impl Dense1x2 {
157    const fn value(self) -> u8 {
158        match self {
159            Dense1x2::Dark => 1,
160            Dense1x2::Light => 0,
161        }
162    }
163}
164
165fn encode_1x2(rows: &[&[u8]], col: usize) -> &'static str {
166    let top = rows[0].get(col).copied().unwrap_or(0);
167    let bot = rows[1].get(col).copied().unwrap_or(0);
168    CODEPAGE[usize::from(top * 2 + bot)]
169}
170
171impl_bit_canvas!(Canvas1x2, Dense1x2, 2, 1, 3, encode_1x2 as fn(&[&[u8]], usize) -> &'static str);
172
173//}}}
174//{{{ Dense2x2 — quadrant blocks (U+2596–U+259F), 2×2 per character
175
176/// The 16 quadrant characters.
177/// Bit layout: bit 0 = top-left, bit 1 = top-right, bit 2 = bottom-left, bit 3 = bottom-right.
178const QUADRANT: [&str; 16] = [
179    " ",        // 0b0000
180    "\u{2598}", // 0b0001 top-left
181    "\u{259D}", // 0b0010 top-right
182    "\u{2580}", // 0b0011 top
183    "\u{2596}", // 0b0100 bottom-left
184    "\u{258C}", // 0b0101 left
185    "\u{259E}", // 0b0110 anti-diagonal
186    "\u{259B}", // 0b0111 all except bottom-right
187    "\u{2597}", // 0b1000 bottom-right
188    "\u{259A}", // 0b1001 diagonal
189    "\u{2590}", // 0b1010 right
190    "\u{259C}", // 0b1011 all except bottom-left
191    "\u{2584}", // 0b1100 bottom
192    "\u{2599}", // 0b1101 all except top-right
193    "\u{259F}", // 0b1110 all except top-left
194    "\u{2588}", // 0b1111 full
195];
196
197/// Unicode renderer packing a 2×2 block of pixels per character using
198/// quadrant elements (U+2596–U+259F).
199#[derive(Copy, Clone, PartialEq, Eq)]
200pub enum Dense2x2 {
201    /// A dark module.
202    Dark,
203    /// A light module.
204    Light,
205}
206
207impl Pixel for Dense2x2 {
208    type Image = String;
209    type Canvas = Canvas2x2;
210    fn default_unit_size() -> (u32, u32) {
211        (1, 1)
212    }
213    fn default_color(color: Color) -> Dense2x2 {
214        color.select(Dense2x2::Dark, Dense2x2::Light)
215    }
216}
217
218impl Dense2x2 {
219    const fn value(self) -> u8 {
220        match self {
221            Dense2x2::Dark => 1,
222            Dense2x2::Light => 0,
223        }
224    }
225}
226
227fn encode_2x2(rows: &[&[u8]], col: usize) -> &'static str {
228    let tl = rows[0][col] & 1;
229    let tr = rows[0].get(col + 1).copied().unwrap_or(0) & 1;
230    let bl = rows[1].get(col).copied().unwrap_or(0) & 1;
231    let br = rows[1].get(col + 1).copied().unwrap_or(0) & 1;
232    QUADRANT[(tl | (tr << 1) | (bl << 2) | (br << 3)) as usize]
233}
234
235impl_bit_canvas!(Canvas2x2, Dense2x2, 2, 2, 3, encode_2x2 as fn(&[&[u8]], usize) -> &'static str);
236
237//}}}
238//{{{ Braille (U+2800–U+28FF), 2×4 dots per character
239
240/// UTF-8 rendering using Braille characters (U+2800–U+28FF).
241///
242/// Each character encodes a 2×4 grid of 8 dots, yielding 8 pixels per
243/// character — the highest density among Unicode renderers.
244///
245/// # Example
246///
247/// ```
248/// use qrcode_core::Color as ModuleColor;
249/// use qrcode_render::{Renderer, unicode::Braille};
250///
251/// let modules = [ModuleColor::Dark; 16];
252/// let text = Renderer::<Braille>::new(&modules, 4, 0).module_dimensions(1, 1).build();
253/// println!("{}", text);
254/// ```
255/// Unicode renderer packing a 2×4 block of pixels per character using Braille
256/// patterns (U+2800–U+28FF) — the densest text output.
257#[derive(Copy, Clone, PartialEq, Eq)]
258pub enum Braille {
259    /// A dark module.
260    Dark,
261    /// A light module.
262    Light,
263}
264
265impl Pixel for Braille {
266    type Image = String;
267    type Canvas = CanvasBraille;
268    fn default_unit_size() -> (u32, u32) {
269        (1, 1)
270    }
271    fn default_color(color: Color) -> Braille {
272        color.select(Braille::Dark, Braille::Light)
273    }
274}
275
276impl Braille {
277    const fn value(self) -> u8 {
278        match self {
279            Braille::Dark => 1,
280            Braille::Light => 0,
281        }
282    }
283}
284
285/// Precomputed UTF-8 encodings for all 256 Braille code points (U+2800–U+28FF).
286const BRAILLE_UTF8: [[u8; 3]; 256] = {
287    let mut table = [[0u8; 3]; 256];
288    let mut i = 0usize;
289    while i < 256 {
290        let cp = 0x2800u32 + i as u32;
291        table[i][0] = ((cp >> 12) & 0x0F) as u8 | 0xE0;
292        table[i][1] = ((cp >> 6) & 0x3F) as u8 | 0x80;
293        table[i][2] = (cp & 0x3F) as u8 | 0x80;
294        i += 1;
295    }
296    table
297};
298
299fn encode_braille(rows: &[&[u8]], col: usize) -> &'static str {
300    let d1 = rows[0].get(col).copied().unwrap_or(0) & 1;
301    let d2 = rows[1].get(col).copied().unwrap_or(0) & 1;
302    let d3 = rows[2].get(col).copied().unwrap_or(0) & 1;
303    let d4 = rows[0].get(col + 1).copied().unwrap_or(0) & 1;
304    let d5 = rows[1].get(col + 1).copied().unwrap_or(0) & 1;
305    let d6 = rows[2].get(col + 1).copied().unwrap_or(0) & 1;
306    let d7 = rows[3].get(col).copied().unwrap_or(0) & 1;
307    let d8 = rows[3].get(col + 1).copied().unwrap_or(0) & 1;
308
309    let bits = d1 | (d2 << 1) | (d3 << 2) | (d4 << 3) | (d5 << 4) | (d6 << 5) | (d7 << 6) | (d8 << 7);
310    // SAFETY: BRAILLE_UTF8[bits] is valid UTF-8 for U+2800+bits.
311    unsafe { core::str::from_utf8_unchecked(&BRAILLE_UTF8[bits as usize]) }
312}
313
314impl_bit_canvas!(CanvasBraille, Braille, 4, 2, 3, encode_braille as fn(&[&[u8]], usize) -> &'static str);
315
316//}}}
317//{{{ Dense3x2 — sextant characters (U+1FB00–U+1FB3F), 3×2 per character
318
319/// UTF-8 rendering using Unicode sextant characters (U+1FB00–U+1FB3F).
320///
321/// Each character encodes a 3×2 grid of 6 cells, yielding 6 pixels per
322/// character — between Dense2x2 (4 px) and Braille (8 px) in density.
323///
324/// Bit layout (per Unicode sextant specification):
325///
326/// ```text
327/// bit0 bit3     row0[col] row0[col+1]
328/// bit1 bit4  =  row1[col] row1[col+1]
329/// bit2 bit5     row2[col] row2[col+1]
330/// ```
331///
332/// # Example
333///
334/// ```
335/// use qrcode_core::Color as ModuleColor;
336/// use qrcode_render::{Renderer, unicode::Dense3x2};
337///
338/// let modules = [ModuleColor::Dark; 36];
339/// let text = Renderer::<Dense3x2>::new(&modules, 6, 0).module_dimensions(1, 1).build();
340/// println!("{}", text);
341/// ```
342/// Unicode renderer packing a 3×2 block of pixels per character using
343/// sextant elements (U+1FB00–U+1FB3F).
344#[derive(Copy, Clone, PartialEq, Eq)]
345pub enum Dense3x2 {
346    /// A dark module.
347    Dark,
348    /// A light module.
349    Light,
350}
351
352impl Pixel for Dense3x2 {
353    type Image = String;
354    type Canvas = Canvas3x2;
355    fn default_unit_size() -> (u32, u32) {
356        (1, 1)
357    }
358    fn default_color(color: Color) -> Dense3x2 {
359        color.select(Dense3x2::Dark, Dense3x2::Light)
360    }
361}
362
363impl Dense3x2 {
364    const fn value(self) -> u8 {
365        match self {
366            Dense3x2::Dark => 1,
367            Dense3x2::Light => 0,
368        }
369    }
370}
371
372/// Precomputed UTF-8 encodings for all 64 sextant code points (U+1FB00–U+1FB3F).
373/// Each entry is 4 bytes (these are supplementary plane characters).
374const SEXTANT_UTF8: [[u8; 4]; 64] = {
375    let mut table = [[0u8; 4]; 64];
376    let mut i = 0usize;
377    while i < 64 {
378        let cp = 0x1FB00u32 + i as u32;
379        table[i][0] = 0xF0u8 | ((cp >> 18) & 0x07) as u8;
380        table[i][1] = 0x80u8 | ((cp >> 12) & 0x3F) as u8;
381        table[i][2] = 0x80u8 | ((cp >> 6) & 0x3F) as u8;
382        table[i][3] = 0x80u8 | (cp & 0x3F) as u8;
383        i += 1;
384    }
385    table
386};
387
388/// Encodes a 3×2 block of pixels into a sextant character.
389/// Pattern 0 (all light) maps to ASCII space for visual consistency.
390fn encode_3x2(rows: &[&[u8]], col: usize) -> &'static str {
391    let d0 = rows[0].get(col).copied().unwrap_or(0) & 1;
392    let d1 = rows[1].get(col).copied().unwrap_or(0) & 1;
393    let d2 = rows[2].get(col).copied().unwrap_or(0) & 1;
394    let d3 = rows[0].get(col + 1).copied().unwrap_or(0) & 1;
395    let d4 = rows[1].get(col + 1).copied().unwrap_or(0) & 1;
396    let d5 = rows[2].get(col + 1).copied().unwrap_or(0) & 1;
397
398    let bits = d0 | (d1 << 1) | (d2 << 2) | (d3 << 3) | (d4 << 4) | (d5 << 5);
399    if bits == 0 {
400        " "
401    } else {
402        // SAFETY: SEXTANT_UTF8[bits] is valid UTF-8 for U+1FB00+bits (bits > 0).
403        unsafe { core::str::from_utf8_unchecked(&SEXTANT_UTF8[bits as usize]) }
404    }
405}
406
407impl_bit_canvas!(Canvas3x2, Dense3x2, 3, 2, 4, encode_3x2 as fn(&[&[u8]], usize) -> &'static str);
408
409//}}}
410
411#[cfg(test)]
412mod budget_tests {
413    use super::*;
414    use crate::{MAX_BUFFER_BYTES, Renderer};
415
416    fn assert_safe_dimensions<P: Pixel<Image = String>>() {
417        let dark = P::default_color(Color::Dark);
418        let light = P::default_color(Color::Light);
419        for (width, height) in [(65_536, 65_536), (u32::MAX, u32::MAX)] {
420            assert_eq!(P::Canvas::validate_dimensions(width, height, &dark, &light), Err(RenderError::OutputTooLarge));
421        }
422        for (width, height) in [(0, 0), (0, u32::MAX), (u32::MAX, 0)] {
423            assert_eq!(P::Canvas::new(width, height, dark, light).into_image(), "");
424        }
425        let modules = [Color::Dark];
426        assert_eq!(
427            Renderer::<P>::new(&modules, 1, 0).module_dimensions(65_536, 65_536).try_build(),
428            Err(RenderError::OutputTooLarge)
429        );
430    }
431
432    #[test]
433    fn all_unicode_backends_guard_large_and_empty_dimensions() {
434        assert_safe_dimensions::<Dense1x2>();
435        assert_safe_dimensions::<Dense2x2>();
436        assert_safe_dimensions::<Braille>();
437        assert_safe_dimensions::<Dense3x2>();
438    }
439
440    fn assert_rectangles_match_pixels<P: Pixel<Image = String>>() {
441        for inverted in [false, true] {
442            let dark = P::default_color(if inverted { Color::Light } else { Color::Dark });
443            let light = P::default_color(if inverted { Color::Dark } else { Color::Light });
444            for (left, top, width, height) in [(0, 0, 1, 1), (1, 1, 3, 5), (6, 0, 1, 9), (0, 8, 7, 1), (0, 0, 7, 9)] {
445                let mut actual = P::Canvas::new(7, 9, dark, light);
446                let mut expected = P::Canvas::new(7, 9, dark, light);
447                actual.draw_dark_rect(left, top, width, height);
448                actual.draw_dark_rect(left, top, width, height);
449                for y in top..top + height {
450                    for x in left..left + width {
451                        expected.draw_dark_pixel(x, y);
452                    }
453                }
454                assert_eq!(actual.into_image(), expected.into_image());
455            }
456        }
457    }
458
459    #[test]
460    fn unicode_rectangles_preserve_all_density_layouts_and_inverted_padding() {
461        assert_rectangles_match_pixels::<Dense1x2>();
462        assert_rectangles_match_pixels::<Dense2x2>();
463        assert_rectangles_match_pixels::<Braille>();
464        assert_rectangles_match_pixels::<Dense3x2>();
465    }
466
467    #[test]
468    #[should_panic(expected = "rectangle exceeds canvas dimensions")]
469    fn unicode_rectangle_cannot_alias_the_next_row() {
470        let mut canvas = Canvas1x2::new(3, 2, Dense1x2::Dark, Dense1x2::Light);
471        canvas.draw_dark_rect(3, 0, 2, 1);
472    }
473
474    #[test]
475    fn unicode_budget_counts_multibyte_output() {
476        let width = (MAX_BUFFER_BYTES / 4) as u32;
477        assert!(Canvas1x2::validate_dimensions(width, 1, &Dense1x2::Dark, &Dense1x2::Light).is_ok());
478        assert_eq!(
479            Canvas1x2::validate_dimensions(width + 1, 1, &Dense1x2::Dark, &Dense1x2::Light),
480            Err(RenderError::OutputTooLarge)
481        );
482    }
483
484    #[test]
485    fn odd_rows_preserve_padding_and_have_no_trailing_newline() {
486        let mut canvas = Canvas1x2::new(3, 3, Dense1x2::Dark, Dense1x2::Light);
487        canvas.draw_dark_pixel(0, 2);
488        assert_eq!(canvas.into_image(), "   \n▀  ");
489        let modules = [Color::Dark; 9];
490        let output = Renderer::<Dense3x2>::new(&modules, 3, 0).build();
491        assert_eq!(output.len(), 8);
492        assert!(!output.ends_with('\n'));
493    }
494
495    #[test]
496    #[should_panic(expected = "rendered output exceeds coordinate or backend resource limits")]
497    fn direct_unicode_constructor_rejects_excessive_area() {
498        let _ = Canvas1x2::new(65_536, 65_536, Dense1x2::Dark, Dense1x2::Light);
499    }
500}
501
502#[test]
503fn test_render_to_utf8_string() {
504    use crate::Renderer;
505    let colors = &[Color::Dark, Color::Light, Color::Light, Color::Dark];
506    let image: String = Renderer::<Dense1x2>::new(colors, 2, 1).build();
507
508    assert_eq!(&image, " ▄  \n  ▀ ");
509
510    let image2 = Renderer::<Dense1x2>::new(colors, 2, 1).module_dimensions(2, 2).build();
511
512    assert_eq!(&image2, "        \n  ██    \n    ██  \n        ");
513}
514
515#[test]
516fn integration_render_utf8_1x2() {
517    use crate::Renderer;
518    use crate::unicode::Dense1x2;
519
520    let colors = [Color::Dark, Color::Light, Color::Light, Color::Dark];
521    let image = Renderer::<Dense1x2>::new(&colors, 2, 0).module_dimensions(1, 1).build();
522    assert_eq!(image, "▀▄");
523}
524
525#[test]
526fn integration_render_utf8_1x2_inverted() {
527    use crate::Renderer;
528    use crate::unicode::Dense1x2;
529
530    let colors = [Color::Dark, Color::Light, Color::Light, Color::Dark];
531    let image = Renderer::<Dense1x2>::new(&colors, 2, 0)
532        .dark_color(Dense1x2::Light)
533        .light_color(Dense1x2::Dark)
534        .module_dimensions(1, 1)
535        .build();
536    assert_eq!(image, "▄▀");
537}
538
539#[test]
540fn test_dense2x2_basic() {
541    use crate::Renderer;
542    let colors = &[Color::Dark, Color::Light, Color::Light, Color::Dark];
543    let image: String = Renderer::<Dense2x2>::new(colors, 2, 0).module_dimensions(1, 1).build();
544    assert_eq!(&image, "\u{259A}");
545}
546
547#[test]
548fn test_dense2x2_with_quiet_zone() {
549    use crate::Renderer;
550    let colors = &[Color::Dark, Color::Light, Color::Light, Color::Dark];
551    let image: String = Renderer::<Dense2x2>::new(colors, 2, 1).build();
552    assert!(image.chars().count() >= 1);
553}
554
555#[test]
556fn test_dense2x2_all_dark() {
557    use crate::Renderer;
558    let colors = vec![Color::Dark; 4];
559    let image: String = Renderer::<Dense2x2>::new(&colors, 2, 0).module_dimensions(1, 1).build();
560    assert_eq!(&image, "\u{2588}");
561}
562
563#[test]
564fn test_dense2x2_all_light() {
565    use crate::Renderer;
566    let colors = vec![Color::Light; 4];
567    let image: String = Renderer::<Dense2x2>::new(&colors, 2, 0).module_dimensions(1, 1).build();
568    assert_eq!(&image, " ");
569}
570
571#[test]
572fn integration_render_utf8_2x2() {
573    use crate::Renderer;
574    use crate::unicode::Dense2x2;
575
576    let colors = vec![
577        Color::Dark,
578        Color::Light,
579        Color::Light,
580        Color::Dark,
581        Color::Light,
582        Color::Dark,
583        Color::Dark,
584        Color::Light,
585        Color::Dark,
586        Color::Dark,
587        Color::Light,
588        Color::Light,
589        Color::Light,
590        Color::Light,
591        Color::Dark,
592        Color::Dark,
593    ];
594    let image = Renderer::<Dense2x2>::new(&colors, 4, 0).module_dimensions(1, 1).build();
595    assert!(!image.is_empty());
596    let dense1x2 = Renderer::<Dense1x2>::new(&colors, 4, 0).module_dimensions(1, 1).build();
597    assert!(image.len() < dense1x2.len());
598}
599
600#[test]
601fn test_braille_all_dark() {
602    use crate::Renderer;
603    let colors = vec![Color::Dark; 16];
604    let image: String = Renderer::<Braille>::new(&colors, 4, 0).module_dimensions(1, 1).build();
605    assert_eq!(&image, "\u{28FF}\u{28FF}");
606}
607
608#[test]
609fn test_braille_all_light() {
610    use crate::Renderer;
611    let colors = vec![Color::Light; 16];
612    let image: String = Renderer::<Braille>::new(&colors, 4, 0).module_dimensions(1, 1).build();
613    assert_eq!(&image, "\u{2800}\u{2800}");
614}
615
616#[test]
617fn test_braille_top_left_dot() {
618    use crate::Renderer;
619    let mut colors = vec![Color::Light; 16];
620    colors[0] = Color::Dark;
621    let image: String = Renderer::<Braille>::new(&colors, 4, 0).module_dimensions(1, 1).build();
622    assert_eq!(&image, "\u{2801}\u{2800}");
623}
624
625#[test]
626fn test_braille_density() {
627    use crate::Renderer;
628    use crate::unicode::{Braille, Dense1x2};
629
630    let colors = (0..64).map(|i| if i % 3 == 0 { Color::Dark } else { Color::Light }).collect::<Vec<_>>();
631    let braille = Renderer::<Braille>::new(&colors, 8, 0).module_dimensions(1, 1).build();
632    let dense1x2 = Renderer::<Dense1x2>::new(&colors, 8, 0).module_dimensions(1, 1).build();
633    assert!(braille.len() < dense1x2.len());
634}
635
636#[test]
637fn test_dense3x2_all_dark() {
638    use crate::Renderer;
639    // 6×6 all dark = 2 row groups × 3 cols = 6 full sextant chars (pattern 63 = U+1FB3F)
640    let colors = vec![Color::Dark; 36];
641    let image: String = Renderer::<Dense3x2>::new(&colors, 6, 0).module_dimensions(1, 1).build();
642    assert_eq!(&image, "\u{1FB3F}\u{1FB3F}\u{1FB3F}\n\u{1FB3F}\u{1FB3F}\u{1FB3F}");
643}
644
645#[test]
646fn test_dense3x2_all_light() {
647    use crate::Renderer;
648    let colors = vec![Color::Light; 36];
649    let image: String = Renderer::<Dense3x2>::new(&colors, 6, 0).module_dimensions(1, 1).build();
650    assert_eq!(&image, "   \n   ");
651}
652
653#[test]
654fn test_dense3x2_top_left_cell() {
655    use crate::Renderer;
656    // 6×6 grid with only (0,0) dark → bit0 set → pattern 1 = U+1FB01
657    let mut colors = vec![Color::Light; 36];
658    colors[0] = Color::Dark;
659    let image: String = Renderer::<Dense3x2>::new(&colors, 6, 0).module_dimensions(1, 1).build();
660    // First char: U+1FB01, rest are spaces
661    assert!(image.starts_with('\u{1FB01}'));
662}
663
664#[test]
665fn test_dense3x2_density() {
666    use crate::Renderer;
667    use crate::unicode::{Dense1x2, Dense3x2};
668
669    let colors = (0..36).map(|i| if i % 2 == 0 { Color::Dark } else { Color::Light }).collect::<Vec<_>>();
670    let sextant = Renderer::<Dense3x2>::new(&colors, 6, 0).module_dimensions(1, 1).build();
671    let dense1x2 = Renderer::<Dense1x2>::new(&colors, 6, 0).module_dimensions(1, 1).build();
672    // Sextant should be smaller due to higher density (3 rows per char vs 2).
673    assert!(sextant.len() < dense1x2.len());
674}