atomcode_tuix/render/
qr.rs1use qrcode::render::unicode::Dense1x2;
25use qrcode::{Color, QrCode};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum QrStyle {
29 Braille,
30 Dense1x2,
31}
32
33pub fn render_login_qr(url: &str, style: QrStyle) -> Option<String> {
37 let code = QrCode::new(url.as_bytes()).ok()?;
38 let s = match style {
39 QrStyle::Braille => render_braille(&code),
40 QrStyle::Dense1x2 => code
41 .render::<Dense1x2>()
42 .dark_color(Dense1x2::Dark)
43 .light_color(Dense1x2::Light)
44 .quiet_zone(true)
45 .build(),
46 };
47 Some(s)
48}
49
50pub fn block_cols(rendered: &str) -> usize {
54 rendered
55 .lines()
56 .map(|l| l.chars().count())
57 .max()
58 .unwrap_or(0)
59}
60
61fn render_braille(code: &QrCode) -> String {
73 const BRAILLE_BITS: [[u32; 4]; 2] = [
74 [0x01, 0x02, 0x04, 0x40], [0x08, 0x10, 0x20, 0x80], ];
77 const QUIET: usize = 4;
78
79 let width = code.width();
80 let colors = code.to_colors();
81 let total = width + 2 * QUIET;
82
83 let is_dark = |x: usize, y: usize| -> bool {
85 if x < QUIET || x >= QUIET + width || y < QUIET || y >= QUIET + width {
86 return false;
87 }
88 matches!(colors[(x - QUIET) + (y - QUIET) * width], Color::Dark)
89 };
90
91 let cols = total.div_ceil(2);
92 let rows = total.div_ceil(4);
93 let mut out = String::with_capacity(rows * (cols * 3 + 1));
94
95 for cy in 0..rows {
96 for cx in 0..cols {
97 let mut bits: u32 = 0;
98 for dx in 0..2usize {
99 for dy in 0..4usize {
100 if is_dark(cx * 2 + dx, cy * 4 + dy) {
101 bits |= BRAILLE_BITS[dx][dy];
102 }
103 }
104 }
105 out.push(char::from_u32(0x2800 + bits).unwrap());
108 }
109 if cy + 1 < rows {
110 out.push('\n');
111 }
112 }
113 out
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 const SAMPLE_URL: &str =
121 "https://acs.atomgit.com/auth/login?state=abcd1234efgh5678ijkl9012mnop3456";
122
123 #[test]
124 fn braille_path_emits_braille_chars() {
125 let rendered = render_login_qr(SAMPLE_URL, QrStyle::Braille).expect("qr encodes");
126 assert!(
127 rendered
128 .chars()
129 .any(|c| (c as u32) >= 0x2800 && (c as u32) <= 0x28FF),
130 "expected at least one braille char in QR, got: {:?}",
131 rendered.chars().take(40).collect::<String>()
132 );
133 }
134
135 #[test]
136 fn dense1x2_path_emits_block_glyphs() {
137 let rendered = render_login_qr(SAMPLE_URL, QrStyle::Dense1x2).expect("qr encodes");
138 assert!(
139 rendered
140 .chars()
141 .any(|c| matches!(c, '\u{2588}' | '\u{2580}' | '\u{2584}')),
142 "expected at least one block glyph in dense1x2 QR, got: {:?}",
143 rendered.chars().take(40).collect::<String>()
144 );
145 }
146
147 #[test]
148 fn rendered_lines_have_uniform_width() {
149 for style in [QrStyle::Braille, QrStyle::Dense1x2] {
150 let rendered = render_login_qr(SAMPLE_URL, style).expect("qr encodes");
151 let widths: Vec<usize> = rendered.lines().map(|l| l.chars().count()).collect();
152 let first = widths[0];
153 assert!(
154 widths.iter().all(|w| *w == first),
155 "rendered QR ({:?}) has uneven line widths: {:?}",
156 style,
157 widths
158 );
159 }
160 }
161
162 #[test]
163 fn braille_is_about_half_size_of_dense1x2() {
164 let braille = render_login_qr(SAMPLE_URL, QrStyle::Braille).unwrap();
167 let dense = render_login_qr(SAMPLE_URL, QrStyle::Dense1x2).unwrap();
168 let (bw, bh) = (block_cols(&braille), braille.lines().count());
169 let (dw, dh) = (block_cols(&dense), dense.lines().count());
170 assert!(
172 bw * 2 <= dw + 1,
173 "braille width {} should be ~½ of dense1x2 width {}",
174 bw,
175 dw
176 );
177 assert!(
178 bh * 2 <= dh + 1,
179 "braille height {} should be ~½ of dense1x2 height {}",
180 bh,
181 dh
182 );
183 }
184
185 #[test]
186 fn block_cols_matches_widest_line() {
187 let rendered = "###\n#####\n##";
188 assert_eq!(block_cols(rendered), 5);
189 }
190}