card.rsannotatedcard.rssource247 lines · 9.9 KB · raw
1//! The look of a link preview, as SVG: the dither field, the pink band, the
2//! window with its black title bar, and type set a pixel at a time in an
3//! 8 by 8 bitmap face. It is lmjtfy's card (`packages/card`, drawn there
4//! straight into a four-colour PNG) as shapes a site can put things inside,
5//! so a site's card looks like the page it unfurls.
6//!
7//! Nothing here reads a file or a font: a card is the same bytes everywhere.
8//! A site rasterises what it composes (resvg, for whiskers) and owns what
9//! goes inside the window.
10//!
11//! Every measure is lmjtfy's, on a 600 by 315 sheet, times a whole `unit`:
12//! `Chrome::new(2)` is the 1200 by 630 a large card wants.
13
14use std::fmt::Write;
15
16use font8x8::UnicodeFonts;
17
18pub const PAPER: &str = "#fefefe";
19pub const INK: &str = "#1e1e1e";
20pub const PINK: &str = "#f386a1";
21pub const GREY: &str = "#dedede";
22
23/// A rectangle, in picture pixels.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub struct Rect {
26    pub x: u32,
27    pub y: u32,
28    pub w: u32,
29    pub h: u32,
30}
31
32/// A 4 by 4 ordered dither: how dark a field has to be before each cell of the pattern takes ink.
33const BAYER: [[u8; 4]; 4] = [[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]];
34
35/// The sheet's geometry at a whole-number scale.
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub struct Chrome {
38    unit: u32,
39}
40
41impl Chrome {
42    pub const BASE_WIDTH: u32 = 600;
43    pub const BASE_HEIGHT: u32 = 315;
44
45    /// `unit` of 0 has nothing to draw.
46    pub fn new(unit: u32) -> Chrome {
47        assert!(unit >= 1, "a card is at least 600 by 315");
48        Chrome { unit }
49    }
50
51    pub fn unit(&self) -> u32 {
52        self.unit
53    }
54
55    pub fn width(&self) -> u32 {
56        Self::BASE_WIDTH * self.unit
57    }
58
59    pub fn height(&self) -> u32 {
60        Self::BASE_HEIGHT * self.unit
61    }
62
63    fn margin(&self) -> u32 {
64        20 * self.unit
65    }
66
67    fn bar(&self) -> u32 {
68        28 * self.unit
69    }
70
71    /// The pink band along the bottom.
72    pub fn band(&self) -> u32 {
73        40 * self.unit
74    }
75
76    /// The window: paper with a one-pixel ink frame, over the dither.
77    pub fn window(&self) -> Rect {
78        Rect { x: self.margin(), y: self.margin(), w: self.width() - 2 * self.margin(), h: self.height() - self.band() - 2 * self.margin() }
79    }
80
81    /// Where a card's own content goes: inside the window, under its title bar, with lmjtfy's padding.
82    pub fn content(&self) -> Rect {
83        let window = self.window();
84        let (side, top) = (16 * self.unit, self.bar() + 14 * self.unit);
85        Rect { x: window.x + side, y: window.y + top, w: window.w - 2 * side, h: window.h - top - 14 * self.unit }
86    }
87
88    /// The dot field, the pink band and the site's name on it, as a whole SVG
89    /// document. It is the part of a card that never changes, so a site
90    /// rasterises it once and lays the result under every frame (`background_image`).
91    pub fn background_svg(&self, site: &str) -> String {
92        let (w, h, u) = (self.width(), self.height(), self.unit);
93        let mut out = format!(
94            "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\" shape-rendering=\"crispEdges\">\
95             <rect width=\"{w}\" height=\"{h}\" fill=\"{PAPER}\"/>"
96        );
97        out.push_str(&dither(0, 0, w, h - self.band(), 2 * u, GREY));
98        let _ = write!(out, "<rect y=\"{}\" width=\"{w}\" height=\"{}\" fill=\"{PINK}\"/>", h - self.band(), self.band());
99        out.push_str(&dither(0, h - self.band(), w, self.band(), 2 * u, INK));
100        let site = site.to_uppercase();
101        let scale = 2 * u;
102        let at = (w - width(scale, &site)) / 2;
103        let _ = write!(out, "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{PINK}\"/>", at - 8 * u, h - self.band() + 8 * u, width(scale, &site) + 16 * u, 24 * u);
104        out.push_str(&text(at, h - self.band() + 12 * u, scale, INK, &site));
105        out.push_str("</svg>");
106        out
107    }
108
109    /// The background laid as a picture, from a file the site rasterised beside the card.
110    pub fn background_image(&self, href: &str) -> String {
111        format!("<image href=\"{href}\" x=\"0\" y=\"0\" width=\"{}\" height=\"{}\"/>", self.width(), self.height())
112    }
113
114    /// The window: paper, its frame, and a black title bar with `title` in it.
115    pub fn window_svg(&self, title: &str) -> String {
116        let (Rect { x, y, w, h }, u) = (self.window(), self.unit);
117        let mut out = format!("<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" fill=\"{PAPER}\"/>");
118        let _ = write!(out, "<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{}\" fill=\"{INK}\"/>", self.bar());
119        let _ = write!(out, "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"none\" stroke=\"{INK}\" stroke-width=\"{u}\"/>", x as f32 + u as f32 / 2.0, y as f32 + u as f32 / 2.0, w - u, h - u);
120        out.push_str(&text(x + 10 * u, y + 6 * u, 2 * u, PAPER, title));
121        out
122    }
123}
124
125/// The dot field: dense at the top left and bottom right corners, thinning to nothing across the
126/// middle, in `cell`-pixel dots, as lmjtfy's card. One path, so a large field stays a small file.
127pub fn dither(x0: u32, y0: u32, w: u32, h: u32, cell: u32, colour: &str) -> String {
128    let mut d = String::new();
129    for y in (y0..y0 + h).step_by(cell as usize) {
130        for x in (x0..x0 + w).step_by(cell as usize) {
131            let across = f64::from(x - x0) / f64::from(w);
132            let down = f64::from(y - y0) / f64::from(h);
133            let from_corner = (across + down).min(2.0 - across - down);
134            let dark = (1.0 - from_corner * 1.6).max(0.0) * 16.0;
135            if f64::from(BAYER[((y / cell) % 4) as usize][((x / cell) % 4) as usize]) < dark {
136                let _ = write!(d, "M{x} {y}h{cell}v{cell}h-{cell}z");
137            }
138        }
139    }
140    format!("<path fill=\"{colour}\" d=\"{d}\"/>")
141}
142
143fn glyph(ch: char) -> [u8; 8] {
144    font8x8::BASIC_FONTS.get(ch).or_else(|| font8x8::LATIN_FONTS.get(ch)).or_else(|| font8x8::BASIC_FONTS.get('?')).unwrap_or([0; 8])
145}
146
147/// Pixel text: `scale` output pixels to a font pixel, from the top left at (`x`, `y`). Runs of lit
148/// pixels in a row are one rectangle. A character the face lacks is a question mark.
149pub fn text(x: u32, y: u32, scale: u32, colour: &str, words: &str) -> String {
150    let mut out = String::new();
151    for (i, ch) in words.chars().enumerate() {
152        for (row, bits) in glyph(ch).iter().enumerate() {
153            // The least significant bit is the leftmost pixel.
154            let mut col = 0;
155            while col < 8 {
156                if bits >> col & 1 == 1 {
157                    let start = col;
158                    while col < 8 && bits >> col & 1 == 1 {
159                        col += 1;
160                    }
161                    let _ = write!(
162                        out,
163                        "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{scale}\" fill=\"{colour}\"/>",
164                        x + (i as u32 * 8 + start) * scale,
165                        y + row as u32 * scale,
166                        (col - start) * scale,
167                    );
168                } else {
169                    col += 1;
170                }
171            }
172        }
173    }
174    out
175}
176
177/// How wide `words` is at `scale`.
178pub fn width(scale: u32, words: &str) -> u32 {
179    words.chars().count() as u32 * 8 * scale
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn a_glyph_is_drawn_from_its_bitmap() {
188        assert!(text(0, 0, 1, "#fff", "I").contains("<rect"));
189        assert_eq!(text(0, 0, 1, "#fff", " "), "");
190        // A character outside the face draws the question mark rather than nothing.
191        assert_eq!(text(0, 0, 2, "#fff", "\u{1f408}"), text(0, 0, 2, "#fff", "?"));
192    }
193
194    #[test]
195    fn the_chrome_is_lmjtfys_sheet_times_the_unit() {
196        let chrome = Chrome::new(2);
197        assert_eq!((chrome.width(), chrome.height()), (1200, 630));
198        let window = chrome.window();
199        assert_eq!(window, Rect { x: 40, y: 40, w: 1120, h: 470 });
200        let content = chrome.content();
201        assert!(content.x > window.x && content.y > window.y + 56);
202        assert!(content.x + content.w < window.x + window.w);
203        assert!(content.y + content.h <= window.y + window.h);
204        // The band sits under the window and the window never reaches it.
205        assert!(window.y + window.h < chrome.height() - chrome.band());
206    }
207
208    #[test]
209    #[should_panic(expected = "at least 600 by 315")]
210    fn there_is_no_card_of_no_size() {
211        Chrome::new(0);
212    }
213
214    #[test]
215    fn the_background_is_one_svg_the_size_of_the_card_with_the_site_on_the_band() {
216        let svg = Chrome::new(2).background_svg("Let Me Jev That For You");
217        assert!(svg.starts_with("<svg") && svg.ends_with("</svg>"));
218        assert!(svg.contains("width=\"1200\" height=\"630\""));
219        assert_eq!(svg.matches("<path ").count(), 2, "one dot field above the band and one on it");
220        let name = "LET ME JEV THAT FOR YOU";
221        let at = (1200 - width(4, name)) / 2;
222        assert!(svg.contains(&text(at, 630 - 80 + 24, 4, INK, name)), "the site's name is set in ink, centred, in capitals");
223        assert!(!svg.contains("NaN"));
224    }
225
226    #[test]
227    fn the_dither_is_densest_at_the_corners_and_empty_across_the_middle() {
228        let field = dither(0, 0, 400, 400, 4, INK);
229        let corner = field.matches('M').filter(|_| true).count();
230        assert!(corner > 0);
231        // Along the anti-diagonal the field is as far from both corners as it gets: no dot there.
232        let d = field.split("d=\"").nth(1).unwrap();
233        for at in (160..240).step_by(4) {
234            let y = 400 - at;
235            assert!(!d.contains(&format!("M{at} {y}h")), "a dot at ({at}, {y})");
236        }
237        // Dots are `cell` pixels square.
238        assert!(d.contains("h4v4h-4z"));
239    }
240
241    #[test]
242    fn the_window_has_its_title_in_a_bar() {
243        let svg = Chrome::new(2).window_svg("Jev");
244        assert!(svg.contains(&format!("fill=\"{INK}\"")));
245        assert!(svg.contains(&text(40 + 20, 40 + 12, 4, PAPER, "Jev")));
246    }
247}