okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawnrequests: fit history images over the provider's edge by appending image fit rows to the transcriptb2e29fd

main
8.6 KiB261 linesraw
1//! Dense 1-bit pixel-font text frames on a fixed grid, bundled X11 8x13.
2
3use std::collections::HashMap;
4use std::sync::OnceLock;
5
6const FONT: &[u8] = include_bytes!("../fonts/8x13.bdf");
7const MARGIN: usize = 0;
8
9/// The 8x13 font box's 11px ascent; extra pitch leading sits below it.
10const BASELINE: usize = 11;
11const MAX_EDGE: usize = 8000;
12
13pub struct Frame {
14    pub width: u32,
15    pub height: u32,
16    pub png: Vec<u8>,
17}
18
19struct Glyph {
20    width: usize,
21    height: usize,
22    off_x: i32,
23    off_y: i32,
24    /// One packed bit row per glyph row; column c is bit (width-1-c).
25    rows: Vec<u16>,
26}
27
28struct Bdf {
29    glyphs: HashMap<u32, Glyph>,
30}
31
32fn bdf() -> &'static Bdf {
33    static BDF: OnceLock<Bdf> = OnceLock::new();
34    BDF.get_or_init(|| parse_bdf(FONT).expect("bundled 8x13.bdf parses"))
35}
36
37fn parse_bdf(bytes: &[u8]) -> Result<Bdf, String> {
38    let text = std::str::from_utf8(bytes).map_err(|e| e.to_string())?;
39    let mut glyphs = HashMap::new();
40    let mut encoding: Option<u32> = None;
41    let mut box_: Option<(usize, usize, i32, i32)> = None;
42    let mut bitmap: Option<Vec<u16>> = None;
43    for line in text.lines() {
44        let mut fields = line.split_whitespace();
45        match fields.next() {
46            Some("ENCODING") => {
47                encoding = fields
48                    .next()
49                    .and_then(|n| n.parse::<i64>().ok())
50                    .and_then(|n| {
51                        if (0..=0x10FFFF).contains(&n) {
52                            Some(n as u32)
53                        } else {
54                            None
55                        }
56                    });
57            }
58            Some("BBX") => {
59                let mut number = || fields.next().and_then(|n| n.parse::<i64>().ok());
60                box_ = match (number(), number(), number(), number()) {
61                    (Some(w), Some(h), Some(x), Some(y)) if w > 0 && h > 0 => {
62                        Some((w as usize, h as usize, x as i32, y as i32))
63                    }
64                    _ => None,
65                };
66            }
67            Some("BITMAP") => {
68                let (w, h, x, y) = box_.ok_or("BITMAP without a BBX")?;
69                bitmap = Some(Vec::with_capacity(h));
70                let _ = (w, x, y);
71            }
72            Some("ENDCHAR") => {
73                if let (Some(code), Some((w, h, x, y)), Some(rows)) =
74                    (encoding, box_, bitmap.take())
75                {
76                    if rows.len() == h {
77                        glyphs.insert(
78                            code,
79                            Glyph {
80                                width: w,
81                                height: h,
82                                off_x: x,
83                                off_y: y,
84                                rows,
85                            },
86                        );
87                    }
88                }
89                encoding = None;
90                box_ = None;
91            }
92            Some(hex) if bitmap.is_some() && !hex.is_empty() => {
93                if let Some(row) = bitmap.as_mut() {
94                    row.push(parse_hex_row(hex));
95                }
96            }
97            _ => {}
98        }
99    }
100    if glyphs.is_empty() {
101        return Err("no glyphs in the bundled font".into());
102    }
103    Ok(Bdf { glyphs })
104}
105
106/// Hex bitmap bytes packed big-endian into one bit row (fonts here are
107/// <= 16 wide). Column c of a glyph of BBX width w is bit
108/// `(w + 7) / 8 * 8 - 1 - c` of the row.
109fn parse_hex_row(hex: &str) -> u16 {
110    let mut row = 0u16;
111    for i in 0..hex.len() / 2 {
112        match u16::from_str_radix(&hex[2 * i..2 * i + 2], 16) {
113            Ok(byte) => row = (row << 8) | byte,
114            Err(_) => return row,
115        }
116    }
117    row
118}
119
120pub fn render(text: &str, advance: usize, pitch: usize, width: usize) -> Result<Frame, String> {
121    let (width, height, pixels) = paint(text, advance, pitch, width)?;
122    encode(width, height, pixels)
123}
124
125fn paint(
126    text: &str,
127    advance: usize,
128    pitch: usize,
129    width: usize,
130) -> Result<(usize, usize, Vec<u8>), String> {
131    if !(6..=24).contains(&advance) {
132        return Err(format!("advance must be 6 to 24, not {advance}"));
133    }
134    if !(10..=64).contains(&pitch) {
135        return Err(format!("pitch must be 10 to 64, not {pitch}"));
136    }
137    if !(256..=4096).contains(&width) || width < 2 * MARGIN + advance {
138        return Err(format!("width must be 256 to 4096, not {width}"));
139    }
140    let columns = (width - 2 * MARGIN) / advance;
141    let font = &bdf().glyphs;
142    let question = font.get(&u32::from(b'?')).ok_or("the font lacks '?'")?;
143
144    let mut rows: Vec<Vec<char>> = Vec::new();
145    for line in text.split('\n') {
146        let chars: Vec<char> = line.chars().collect();
147        if chars.is_empty() {
148            rows.push(Vec::new());
149            continue;
150        }
151        for chunk in chars.chunks(columns) {
152            rows.push(chunk.to_vec());
153        }
154    }
155    if rows.is_empty() {
156        rows.push(Vec::new());
157    }
158    let height = 2 * MARGIN + rows.len() * pitch;
159    if height > MAX_EDGE {
160        return Err(format!(
161            "{height}px exceeds the {MAX_EDGE}px image edge; chunk the text into smaller frames"
162        ));
163    }
164    let mut pixels = vec![255u8; width * height];
165    for (index, row) in rows.iter().enumerate() {
166        let top = MARGIN + index * pitch;
167        for (column, ch) in row.iter().enumerate() {
168            let cell = MARGIN + column * advance;
169            // U+2588 marks a newline: fill the whole cell box with ink.
170            if *ch as u32 == 0x2588 {
171                for py in top..top + pitch {
172                    for px in cell..cell + advance {
173                        if px < width && py < height {
174                            pixels[py * width + px] = 0;
175                        }
176                    }
177                }
178                continue;
179            }
180            let glyph = font.get(&(*ch as u32)).unwrap_or(question);
181            let x = cell as isize + glyph.off_x as isize;
182            let baseline = (top + BASELINE) as isize;
183            let glyph_top = baseline - (glyph.height as isize + glyph.off_y as isize);
184            let byte_bits = 8 * ((glyph.width + 7) / 8);
185            for (r, bits) in glyph.rows.iter().enumerate() {
186                for c in 0..glyph.width {
187                    if bits & (1 << (byte_bits - 1 - c)) != 0 {
188                        let px = x + c as isize;
189                        let py = glyph_top + r as isize;
190                        if px >= 0 && py >= 0 && (px as usize) < width && (py as usize) < height {
191                            pixels[py as usize * width + px as usize] = 0;
192                        }
193                    }
194                }
195            }
196        }
197    }
198
199    Ok((width, height, pixels))
200}
201
202fn encode(width: usize, height: usize, pixels: Vec<u8>) -> Result<Frame, String> {
203    // One bit per pixel, MSB first: ink is the 0 bit, white the 1 bit.
204    let stride = (width + 7) / 8;
205    let mut bits = vec![0xFFu8; stride * height];
206    for (y, row) in pixels.chunks(width).enumerate() {
207        for (x, ink) in row.iter().enumerate() {
208            if *ink < 128 {
209                bits[y * stride + x / 8] &= !(0x80 >> (x % 8));
210            }
211        }
212    }
213    let mut out = Vec::new();
214    let mut encoder = png::Encoder::new(&mut out, width as u32, height as u32);
215    encoder.set_color(png::ColorType::Grayscale);
216    encoder.set_depth(png::BitDepth::One);
217    encoder.set_compression(png::Compression::High);
218    let mut writer = encoder.write_header().expect("header fits in memory");
219    writer
220        .write_image_data(&bits)
221        .expect("pixels match the header");
222    drop(writer);
223    Ok(Frame {
224        width: width as u32,
225        height: height as u32,
226        png: out,
227    })
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    fn ink(text: &str) -> usize {
235        let (_, _, pixels) = paint(text, 11, 16, 1568).unwrap();
236        pixels.iter().filter(|&&p| p < 128).count()
237    }
238
239    #[test]
240    fn glyphs_leave_ink() {
241        assert!(ink("hello world") > 50, "text must leave ink");
242        assert_eq!(ink(""), 0);
243        assert!(ink("A") > 5, "one glyph leaves ink");
244    }
245
246    #[test]
247    fn full_block_fills_its_whole_cell() {
248        let (width, height, pixels) = paint("\u{2588}", 11, 16, 1568).unwrap();
249        assert_eq!((width, height), (1568, 16));
250        let ink = pixels.iter().filter(|&&p| p < 128).count();
251        assert_eq!(ink, 11 * 16, "the block fills exactly one cell box");
252    }
253
254    #[test]
255    fn every_ascii_glyph_leaves_ink() {
256        let text: String = (0x21u32..=0x7e)
257            .map(|c| char::from_u32(c).unwrap())
258            .collect();
259        assert!(ink(&text) > 1000, "the printable ASCII range is covered");
260    }
261}