Skip to main content

weirdnet_render/
table.rs

1//! Tables: a header row on grey, a grid, and cell text in an 8x8 bitmap font (cells that read
2//! as numbers are right-aligned).
3use crate::canvas::Canvas;
4use crate::{Error, Limits, Result, Table, check_size};
5use font8x8::{BASIC_FONTS, BLOCK_FONTS, BOX_FONTS, GREEK_FONTS, HIRAGANA_FONTS, LATIN_FONTS, UnicodeFonts};
6
7const GLYPH: usize = 8;
8const PAD: usize = 4;
9const ROW: usize = GLYPH + 2 * PAD;
10const INK: [u8; 3] = [20, 20, 20];
11const GRID: [u8; 3] = [200, 200, 200];
12const HEAD: [u8; 3] = [235, 235, 235];
13
14/// The glyph drawn for a character: its own, or `?` (controls and characters the font lacks).
15pub(crate) fn glyph(c: char) -> [u8; 8] {
16    let c = if c == '\t' { ' ' } else { c };
17    if c.is_control() {
18        return BASIC_FONTS.get('?').unwrap_or_default();
19    }
20    BASIC_FONTS
21        .get(c)
22        .or_else(|| LATIN_FONTS.get(c))
23        .or_else(|| GREEK_FONTS.get(c))
24        .or_else(|| BOX_FONTS.get(c))
25        .or_else(|| BLOCK_FONTS.get(c))
26        .or_else(|| HIRAGANA_FONTS.get(c))
27        .unwrap_or_else(|| BASIC_FONTS.get('?').unwrap_or_default())
28}
29
30/// The characters drawn for a cell: line breaks as spaces, at most `max` (a cut cell ends in `~`).
31pub(crate) fn cell_text(s: &str, max: usize) -> Vec<char> {
32    let mut c: Vec<char> = s.chars().map(|c| if c == '\n' || c == '\r' { ' ' } else { c }).collect();
33    if c.len() > max {
34        c.truncate(max.saturating_sub(1));
35        if max > 0 {
36            c.push('~');
37        }
38    }
39    c
40}
41
42fn numeric(s: &str) -> bool {
43    let t = s.trim();
44    !t.is_empty() && t.parse::<f64>().is_ok()
45}
46
47fn check(t: &Table, l: &Limits) -> Result<()> {
48    if t.columns.is_empty() {
49        return Err(Error::Invalid("a table needs at least one column".into()));
50    }
51    if t.columns.len() > l.max_columns {
52        return Err(Error::TooLarge(format!("{} columns (at most {})", t.columns.len(), l.max_columns)));
53    }
54    if t.rows.len() > l.max_rows {
55        return Err(Error::TooLarge(format!("{} rows (at most {})", t.rows.len(), l.max_rows)));
56    }
57    if let Some(i) = t.rows.iter().position(|r| r.len() != t.columns.len()) {
58        return Err(Error::Invalid(format!("row {} has {} cells for {} columns", i + 1, t.rows[i].len(), t.columns.len())));
59    }
60    Ok(())
61}
62
63/// Draw a table as PNG; its size follows from the text (checked against the limits first).
64pub fn table_png(t: &Table, limits: &Limits) -> Result<Vec<u8>> {
65    check(t, limits)?;
66    let head: Vec<Vec<char>> = t.columns.iter().map(|c| cell_text(c, limits.max_cell_chars)).collect();
67    let body: Vec<Vec<Vec<char>>> = t.rows.iter().map(|r| r.iter().map(|c| cell_text(c, limits.max_cell_chars)).collect()).collect();
68    let widths: Vec<usize> =
69        (0..head.len()).map(|j| body.iter().map(|r| r[j].len()).chain([head[j].len()]).max().unwrap_or(0) * GLYPH + 2 * PAD).collect();
70    let w = widths.iter().sum::<usize>() + 1;
71    let h = (body.len() + 1) * ROW + 1;
72    check_size(w, h, limits)?;
73    let mut c = Canvas::new(w, h, [255; 3]);
74    c.fill(0, 0, w, ROW, HEAD);
75    let mut y = 0;
76    for (r, cells) in std::iter::once(&head).chain(body.iter()).enumerate() {
77        let mut x = 0;
78        for (j, text) in cells.iter().enumerate() {
79            let right = r > 0 && numeric(&t.rows[r - 1][j]);
80            let start = if right { x + widths[j] - PAD - text.len() * GLYPH } else { x + PAD };
81            for (k, ch) in text.iter().enumerate() {
82                c.glyph(start + k * GLYPH, y + PAD, glyph(*ch), INK);
83            }
84            x += widths[j];
85        }
86        y += ROW;
87    }
88    for r in 0..=body.len() + 1 {
89        c.hline(0, w - 1, r * ROW, GRID);
90    }
91    let mut x = 0;
92    for wd in std::iter::once(0).chain(widths.iter().copied()) {
93        x += wd;
94        c.vline(x, 0, h - 1, GRID);
95    }
96    c.png()
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    fn decode(b: &[u8]) -> (usize, usize, Vec<u8>) {
104        let mut r = ::png::Decoder::new(std::io::Cursor::new(b)).read_info().unwrap();
105        let mut buf = vec![0; r.output_buffer_size().unwrap()];
106        let i = r.next_frame(&mut buf).unwrap();
107        buf.truncate(i.buffer_size());
108        (i.width as usize, i.height as usize, buf)
109    }
110
111    fn t(cols: &[&str], rows: &[&[&str]]) -> Table {
112        Table {
113            columns: cols.iter().map(|s| s.to_string()).collect(),
114            rows: rows.iter().map(|r| r.iter().map(|s| s.to_string()).collect()).collect(),
115        }
116    }
117
118    #[test]
119    fn a_table_is_a_grid_of_text() {
120        let tb = t(&["name", "temp"], &[&["north", "21.5"], &["south", "-3"]]);
121        let (w, h, px) = decode(&table_png(&tb, &Limits::default()).unwrap());
122        // name/north: 5 chars, temp/21.5: 4 chars; 3 rows of 16 px, 1 px closing grid line
123        assert_eq!((w, h), (5 * 8 + 8 + 4 * 8 + 8 + 1, 3 * 16 + 1));
124        let at = |x: usize, y: usize| px[(y * w + x) * 3..][..3].to_vec();
125        assert_eq!(at(0, 0), GRID.to_vec());
126        assert_eq!(at(2, 2), HEAD.to_vec(), "header background");
127        assert_eq!(at(2, 18), vec![255; 3], "body background");
128        let ink =
129            |x0: usize, y0: usize| (y0..y0 + 8).flat_map(|y| (x0..x0 + 8).map(move |x| (x, y))).filter(|&(x, y)| at(x, y) == INK.to_vec()).count();
130        // 'n' of "name" is drawn at the header's padding; "-3" is right-aligned in its column
131        assert!(ink(4, 4) > 0);
132        let col2 = 48;
133        assert_eq!(ink(col2 + 4, 36), 0, "the number sits at the right, not the left");
134        assert!(ink(col2 + 40 - 4 - 8, 36) > 0, "its last digit ends at the right padding");
135    }
136
137    #[test]
138    fn same_table_same_bytes_and_text_matters() {
139        let l = Limits::default();
140        let a = table_png(&t(&["a"], &[&["1"]]), &l).unwrap();
141        assert_eq!(a, table_png(&t(&["a"], &[&["1"]]), &l).unwrap());
142        assert_ne!(a, table_png(&t(&["a"], &[&["2"]]), &l).unwrap());
143        assert_eq!(&a[1..4], b"PNG");
144    }
145
146    #[test]
147    fn malformed_tables_and_limits_are_errors() {
148        let l = Limits::default();
149        assert!(matches!(table_png(&Table::default(), &l), Err(Error::Invalid(_))));
150        assert_eq!(table_png(&t(&["a", "b"], &[&["1"]]), &l), Err(Error::Invalid("row 1 has 1 cells for 2 columns".into())));
151        assert!(matches!(table_png(&t(&["a", "b"], &[]), &Limits { max_columns: 1, ..l }), Err(Error::TooLarge(_))));
152        assert!(matches!(table_png(&t(&["a"], &[&["1"], &["2"]]), &Limits { max_rows: 1, ..l }), Err(Error::TooLarge(_))));
153        assert!(matches!(table_png(&t(&["a"], &[&["1"]]), &Limits { max_pixels: 10, ..l }), Err(Error::TooLarge(_))));
154        assert!(matches!(table_png(&t(&["a"], &[&["1"]]), &Limits { max_height: 20, ..l }), Err(Error::TooLarge(_))));
155        // a huge cell is cut to the cell limit, so the image stays bounded
156        let long = "x".repeat(100_000);
157        let (w, _, _) = decode(&table_png(&t(&["a"], &[&[long.as_str()]]), &l).unwrap());
158        assert_eq!(w, 80 * 8 + 8 + 1);
159    }
160
161    #[test]
162    fn cells_are_cut_and_unknown_characters_shown() {
163        assert_eq!(cell_text("abcdef", 4).iter().collect::<String>(), "abc~");
164        assert_eq!(cell_text("a\nb", 9).iter().collect::<String>(), "a b");
165        assert_eq!(cell_text("abc", 0), Vec::<char>::new());
166        assert_eq!(glyph('\u{7}'), glyph('?'));
167        assert_eq!(glyph('\u{1F600}'), glyph('?'));
168        assert_ne!(glyph('é'), glyph('?'));
169        assert_ne!(glyph('A'), glyph('?'));
170    }
171}