Skip to main content

retroglyph_core/symbols/
subcell.rs

1//! Posterizes small blocks of raw pixels to the best-matching Unicode block-element glyph.
2//!
3//! Splits a source image into one small pixel block per terminal cell, then pick whichever glyph
4//! plus foreground/background color pair best reconstructs that block. Three block shapes are
5//! supported, in increasing fidelity (and decreasing terminal compatibility):
6//!
7//! - [`quantize_half_block`](crate::symbols::quantize_half_block): 1x2 pixels -> `' '`/`▀`/`▄`/`█` (Unicode Block Elements, supported
8//!   almost everywhere monospace fonts render at all).
9//! - [`quantize_quadrant`](crate::symbols::quantize_quadrant): 2x2 pixels -> the 16 quadrant block characters (`▘▝▀▖▌▞▛...`).
10//! - [`quantize_sextant`](crate::symbols::quantize_sextant): 2x3 pixels -> the 64 "Symbols for Legacy Computing" sextant
11//!   characters, doubling vertical resolution again over quadrants. Newest and least
12//!   universally supported of the three (a 2022 Unicode addition).
13//!
14//! Callers own the fallback chain: probe terminal/font support (or just take a caller-supplied
15//! capability flag) and call whichever function matches, sampling the source image at that
16//! function's pixel geometry. There's no single "auto-detect and degrade" entry point here,
17//! matching every other terminal-capability decision in retroglyph (e.g. `egc` support):
18//! detection policy lives with the backend, not with this pure geometry/color utility.
19//!
20//! # Algorithm
21//!
22//! For an N-pixel block, every one of the `2^N` ways to split the block into a "foreground set"
23//! and "background set" is scored: average the two sets' colors, then sum each pixel's squared
24//! distance to whichever average it was assigned to. The split with the lowest total error wins,
25//! and its bit pattern selects the glyph directly (the glyph tables below are indexed by that
26//! same pattern, foreground bits set, read row-major). This exhaustive search is cheap here (at
27//! most 64 candidates, 6 pixels each, for [`quantize_sextant`](crate::symbols::quantize_sextant)) and is the same technique
28//! notcurses' blitter chain documents using for its own 3x2 sextant solver.
29//!
30//! Ties (multiple patterns reconstructing a block with equally minimal error) resolve to the
31//! lower-numbered pattern, matching the tie-break convention `retroglyph_core::color`'s own
32//! nearest-color search already uses. Two tie shapes come up often enough to call out: a flat,
33//! single-color block ties across every pattern (all give zero error) and always resolves to
34//! pattern `0`, the cheapest glyph, a plain space colored by `bg`. And any block with exactly
35//! two distinct pixel colors has exactly two zero-error patterns, one the bitwise complement of
36//! the other (swap which color is called `fg` and which is `bg` and the reconstruction is
37//! identical); the lower pattern number wins there too.
38//!
39//! # Example
40//!
41//! ```
42//! use retroglyph_core::symbols::quantize_quadrant;
43//!
44//! // A block that's white in the top-left corner, black everywhere else.
45//! let black = (0, 0, 0);
46//! let white = (255, 255, 255);
47//! let glyph = quantize_quadrant([white, black, black, black]);
48//! assert_eq!(glyph.ch, '▘'); // top-left quadrant
49//! assert_eq!(glyph.fg, retroglyph_core::color::Color::Rgb { r: 255, g: 255, b: 255 });
50//! assert_eq!(glyph.bg, retroglyph_core::color::Color::Rgb { r: 0, g: 0, b: 0 });
51//! ```
52
53use crate::color::Color;
54
55/// A raw 24-bit RGB pixel sample: `(r, g, b)`, one byte per channel.
56pub type Pixel = (u8, u8, u8);
57
58/// The Unicode Block Elements glyphs for a 1-wide x 2-tall pixel block, indexed by a 2-bit
59/// pattern (bit 0 = top pixel set, bit 1 = bottom pixel set).
60pub const HALF_BLOCKS: [char; 4] = [' ', '▀', '▄', '█'];
61
62/// The 16 quadrant block glyphs for a 2x2 pixel block, indexed by a 4-bit pattern in row-major
63/// order (bit 0 = top-left, bit 1 = top-right, bit 2 = bottom-left, bit 3 = bottom-right).
64///
65/// Adapted from [ratatui-core's `symbols::pixel::QUADRANTS`][ratatui].
66///
67/// [ratatui]: https://github.com/ratatui/ratatui/blob/main/ratatui-core/src/symbols/pixel.rs
68pub const QUADRANTS: [char; 16] = [
69    ' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█',
70];
71
72/// The 64 sextant glyphs for a 2x3 pixel block.
73///
74/// Indexed by a 6-bit pattern in row-major order (bit 0 = top-left, bit 1 = top-right, bit 2 =
75/// mid-left, bit 3 = mid-right, bit 4 = bottom-left, bit 5 = bottom-right). Mostly from Unicode's
76/// "Symbols for Legacy Computing" block; adapted from [ratatui-core's
77/// `symbols::pixel::SEXTANTS`][ratatui].
78///
79/// [ratatui]: https://github.com/ratatui/ratatui/blob/main/ratatui-core/src/symbols/pixel.rs
80#[rustfmt::skip]
81pub const SEXTANTS: [char; 64] = [
82    ' ', '🬀', '🬁', '🬂', '🬃', '🬄', '🬅', '🬆', '🬇', '🬈', '🬉', '🬊', '🬋', '🬌', '🬍', '🬎',
83    '🬏', '🬐', '🬑', '🬒', '🬓', '▌', '🬔', '🬕', '🬖', '🬗', '🬘', '🬙', '🬚', '🬛', '🬜', '🬝',
84    '🬞', '🬟', '🬠', '🬡', '🬢', '🬣', '🬤', '🬥', '🬦', '🬧', '▐', '🬨', '🬩', '🬪', '🬫', '🬬',
85    '🬭', '🬮', '🬯', '🬰', '🬱', '🬲', '🬳', '🬴', '🬵', '🬶', '🬷', '🬸', '🬹', '🬺', '🬻', '█',
86];
87
88/// A posterized pixel block: the best-matching glyph plus its foreground and background colors.
89///
90/// The background color is only meaningful for glyphs that don't cover the full cell (anything
91/// but `' '` and `'█'`); the foreground color is only meaningful for glyphs other than `' '`.
92/// Both are still populated for those edge cases (as the block's overall average color) so a
93/// caller never has to special-case `Glyph` before styling a cell with it.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub struct Glyph {
96    /// The selected block-element/quadrant/sextant character.
97    pub ch: char,
98    /// The color assigned to the "on" pixels (those set in the glyph's bit pattern).
99    pub fg: Color,
100    /// The color assigned to the "off" pixels (those clear in the glyph's bit pattern).
101    pub bg: Color,
102}
103
104/// Averages the `pixels` selected by `mask` (bit `i` set means `pixels[i]` is included), rounding
105/// each channel to the nearest integer. Returns `None` if `mask` selects no pixels.
106fn average(pixels: &[Pixel], mask: usize) -> Option<Pixel> {
107    let (mut r, mut g, mut b, mut n) = (0u32, 0u32, 0u32, 0u32);
108    for (i, &(pr, pg, pb)) in pixels.iter().enumerate() {
109        if mask & (1 << i) != 0 {
110            r += u32::from(pr);
111            g += u32::from(pg);
112            b += u32::from(pb);
113            n += 1;
114        }
115    }
116    if n == 0 {
117        return None;
118    }
119    let round = |sum: u32| u8::try_from((sum + n / 2) / n).unwrap_or(u8::MAX);
120    Some((round(r), round(g), round(b)))
121}
122
123/// Posterizes `pixels` to the glyph (from `table`, indexed by row-major bit pattern) and two
124/// representative colors that minimize total squared color error, by exhaustive search over
125/// every `2^N` way to split the block into a foreground and background set.
126///
127/// `table.len()` must be `2^pixels.len()`; every caller in this module upholds that by
128/// construction, so this stays a plain slice rather than a const-generic-sized array (which
129/// would need unstable `generic_const_exprs` to relate `N` to `table`'s length at the type
130/// level).
131fn posterize(pixels: &[Pixel], table: &[char]) -> Glyph {
132    let full_mask = table.len() - 1;
133    let overall = average(pixels, full_mask).unwrap_or((0, 0, 0));
134
135    let mut best_pattern = 0usize;
136    let mut best_error = u32::MAX;
137    for mask in 0..table.len() {
138        let fg = average(pixels, mask).unwrap_or(overall);
139        let bg = average(pixels, !mask & full_mask).unwrap_or(overall);
140        let mut error = 0u32;
141        for (i, &pixel) in pixels.iter().enumerate() {
142            let assigned = if mask & (1 << i) != 0 { fg } else { bg };
143            error += gem::rgb::distance_sq(pixel, assigned);
144        }
145        if error < best_error {
146            best_error = error;
147            best_pattern = mask;
148        }
149    }
150
151    let fg = average(pixels, best_pattern).unwrap_or(overall);
152    let bg = average(pixels, !best_pattern & full_mask).unwrap_or(overall);
153    Glyph {
154        ch: table[best_pattern],
155        fg: Color::Rgb {
156            r: fg.0,
157            g: fg.1,
158            b: fg.2,
159        },
160        bg: Color::Rgb {
161            r: bg.0,
162            g: bg.1,
163            b: bg.2,
164        },
165    }
166}
167
168/// Posterizes a 1-wide x 2-tall pixel block (`[top, bottom]`) to `' '`/`▀`/`▄`/`█` plus two
169/// representative colors.
170///
171/// This is the lowest-fidelity, most compatible option: plain Unicode Block Elements, supported
172/// by essentially every monospace terminal font.
173///
174/// See the `16_subcell_image` example for `quantize_half_block` in action:
175/// <https://main.retroglyph.dev/examples/16_subcell_image/terminal/>.
176///
177/// # Examples
178///
179/// ```
180/// use retroglyph_core::symbols::quantize_half_block;
181///
182/// let glyph = quantize_half_block([(255, 255, 255), (0, 0, 0)]);
183/// assert_eq!(glyph.ch, '▀'); // top half set, bottom clear
184/// ```
185///
186/// Never panics: `pixels` is a fixed-size 2-element array, so there is no length to validate
187/// and no index into it that can be out of bounds.
188#[must_use]
189pub fn quantize_half_block(pixels: [impl Into<Pixel>; 2]) -> Glyph {
190    let pixels = pixels.map(Into::into);
191    posterize(&pixels, &HALF_BLOCKS)
192}
193
194/// Posterizes a 2x2 pixel block (`[top_left, top_right, bottom_left, bottom_right]`) to one of
195/// the 16 quadrant block glyphs plus two representative colors.
196///
197/// Doubles both horizontal and vertical resolution over [`quantize_half_block`](crate::symbols::quantize_half_block).
198///
199/// On the bundled pixel backends (`retroglyph-software`, `retroglyph-gl`), rendering these
200/// glyphs correctly requires a font that actually declares coverage for the quadrant block
201/// characters: CP437 has no mapping for them. A font built with `retroglyph_window`'s
202/// `BitmapFont::new` (CP437-only) renders every quadrant glyph as a solid block. Supply quadrant
203/// coverage by passing either a primary font or a `BitmapFont::with_charset` fallback in a
204/// `FontChain` to those backends' `font()` builder method; the glyph then takes the cell's
205/// foreground color, which a tileset sprite (the other way to draw a non-CP437 shape) does not.
206///
207/// See the `16_subcell_image` example for `quantize_quadrant` in action:
208/// <https://main.retroglyph.dev/examples/16_subcell_image/terminal/>.
209///
210/// # Examples
211///
212/// ```
213/// use retroglyph_core::symbols::quantize_quadrant;
214///
215/// let black = (0, 0, 0);
216/// let white = (255, 255, 255);
217/// let glyph = quantize_quadrant([black, white, black, black]);
218/// assert_eq!(glyph.ch, '▝'); // top-right quadrant
219/// ```
220///
221/// See [`quantize_half_block`](crate::symbols::quantize_half_block) for why this never panics.
222#[must_use]
223pub fn quantize_quadrant(pixels: [impl Into<Pixel>; 4]) -> Glyph {
224    let pixels = pixels.map(Into::into);
225    posterize(&pixels, &QUADRANTS)
226}
227
228/// Posterizes a 2-wide x 3-tall pixel block (`[top_left, top_right, mid_left, mid_right,
229/// bottom_left, bottom_right]`) to one of the 64 sextant glyphs plus two representative colors.
230///
231/// The highest-fidelity option (doubles vertical resolution again over [`quantize_quadrant`](crate::symbols::quantize_quadrant)),
232/// and the newest/least universally supported: sextant glyphs come from a 2022 Unicode addition
233/// and need a font with "Symbols for Legacy Computing" coverage to render as blocks rather than
234/// tofu/replacement characters.
235///
236/// Font coverage works the same way as [`quantize_quadrant`](crate::symbols::quantize_quadrant)'s: CP437 has no mapping for sextant
237/// glyphs either, so see that function's docs for the `FontChain` setup needed to render them.
238///
239/// See the `16_subcell_image` example for `quantize_sextant` in action:
240/// <https://main.retroglyph.dev/examples/16_subcell_image/terminal/>.
241///
242/// # Examples
243///
244/// ```
245/// use retroglyph_core::symbols::quantize_sextant;
246///
247/// let black = (0, 0, 0);
248/// let white = (255, 255, 255);
249/// let glyph = quantize_sextant([white, black, black, black, black, black]);
250/// assert_eq!(glyph.ch, '🬀'); // top-left sextant only
251/// ```
252///
253/// See [`quantize_half_block`](crate::symbols::quantize_half_block) for why this never panics.
254#[must_use]
255pub fn quantize_sextant(pixels: [impl Into<Pixel>; 6]) -> Glyph {
256    let pixels = pixels.map(Into::into);
257    posterize(&pixels, &SEXTANTS)
258}
259
260#[cfg(test)]
261mod tests {
262    use super::{Color, average, quantize_half_block, quantize_quadrant, quantize_sextant};
263
264    const BLACK: (u8, u8, u8) = (0, 0, 0);
265    const WHITE: (u8, u8, u8) = (255, 255, 255);
266    const RED: (u8, u8, u8) = (200, 0, 0);
267
268    #[test]
269    fn distance_sq_matches_manual_euclidean() {
270        assert_eq!(gem::rgb::distance_sq(BLACK, WHITE), 255 * 255 * 3);
271        assert_eq!(gem::rgb::distance_sq(BLACK, BLACK), 0);
272    }
273
274    #[test]
275    fn average_rounds_to_nearest_and_handles_empty_mask() {
276        assert_eq!(average(&[BLACK, WHITE], 0b11), Some((128, 128, 128)));
277        assert_eq!(average(&[BLACK, WHITE], 0b01), Some(BLACK));
278        assert_eq!(average(&[BLACK, WHITE], 0b00), None);
279    }
280
281    #[test]
282    fn half_block_uniform_color_picks_space_with_that_color() {
283        // A flat block ties across every pattern (all zero error); pattern 0 (the cheapest
284        // glyph, a space) always wins that tie. `fg` still comes back populated so a caller
285        // never has to special-case a uniform block before styling with it.
286        let glyph = quantize_half_block([WHITE, WHITE]);
287        assert_eq!(glyph.ch, ' ');
288        assert_eq!(
289            glyph.bg,
290            Color::Rgb {
291                r: 255,
292                g: 255,
293                b: 255
294            }
295        );
296        assert_eq!(glyph.fg, glyph.bg);
297    }
298
299    #[test]
300    fn half_block_top_bottom_split() {
301        let glyph = quantize_half_block([WHITE, BLACK]);
302        assert_eq!(glyph.ch, '▀');
303        assert_eq!(
304            glyph.fg,
305            Color::Rgb {
306                r: 255,
307                g: 255,
308                b: 255
309            }
310        );
311        assert_eq!(glyph.bg, Color::Rgb { r: 0, g: 0, b: 0 });
312    }
313
314    #[test]
315    fn quadrant_picks_bottom_left_glyph() {
316        // Bit 2 (bottom-left) is the only set pixel.
317        let glyph = quantize_quadrant([BLACK, BLACK, WHITE, BLACK]);
318        assert_eq!(glyph.ch, '▖');
319    }
320
321    #[test]
322    fn quadrant_uniform_color_picks_space_with_that_color() {
323        let glyph = quantize_quadrant([RED, RED, RED, RED]);
324        assert_eq!(glyph.ch, ' ');
325        assert_eq!(glyph.bg, Color::Rgb { r: 200, g: 0, b: 0 });
326        assert_eq!(glyph.fg, glyph.bg);
327    }
328
329    #[test]
330    fn sextant_prefers_lower_pattern_on_exact_ties() {
331        // A perfect flat block ties every pattern at zero error; pattern 0 (space) always wins.
332        let grey = (128, 128, 128);
333        let glyph = quantize_sextant([grey, grey, grey, grey, grey, grey]);
334        assert_eq!(glyph.ch, ' ');
335    }
336
337    #[test]
338    fn sextant_single_pixel_set() {
339        let glyph = quantize_sextant([BLACK, BLACK, BLACK, BLACK, WHITE, BLACK]);
340        assert_eq!(glyph.ch, '🬏'); // bottom-left sextant only (bit 4)
341        assert_eq!(
342            glyph.fg,
343            Color::Rgb {
344                r: 255,
345                g: 255,
346                b: 255
347            }
348        );
349        assert_eq!(glyph.bg, Color::Rgb { r: 0, g: 0, b: 0 });
350    }
351
352    #[test]
353    fn quantize_fns_accept_rgb888_directly() {
354        // The whole point of `impl Into<Pixel>`: a caller holding `gem::rgb::Rgb888` (as
355        // opposed to a bare `(u8, u8, u8)`) passes it with no `.to_rgb()` call, via gem 0.2.2's
356        // `From<Rgb888> for (u8, u8, u8)`.
357        use gem::rgb::Rgb888;
358
359        let white = Rgb888::from_rgb(255, 255, 255);
360        let black = Rgb888::from_rgb(0, 0, 0);
361
362        assert_eq!(quantize_half_block([white, black]).ch, '▀');
363        assert_eq!(quantize_quadrant([black, black, white, black]).ch, '▖');
364        assert_eq!(
365            quantize_sextant([black, black, black, black, white, black]).ch,
366            '🬏'
367        );
368    }
369}