Skip to main content

falcon_library/
fontpreview.rs

1//! Font specimens: the 600×600 picture Eagle renders as a font item's
2//! thumbnail (see the Thumbnails chapter of the docs), drawn with resvg
3//! using only the font itself.
4
5use std::sync::Arc;
6
7use image::{DynamicImage, RgbaImage};
8use resvg::tiny_skia::{Pixmap, Transform};
9use resvg::usvg;
10use serde_json::{Map, Value};
11
12use crate::fontmeta;
13
14/// Size of the specimen, which Eagle also records as the item's size.
15pub const SIZE: u32 = 600;
16/// Text colour, #333.
17const INK: &str = "#333333";
18/// Line height as a multiple of each line's font size.
19const LINE_HEIGHT: f32 = 1.35;
20
21/// Which specimen to draw, chosen from the font's script support the way
22/// Eagle chooses it.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Specimen {
25    Latin,
26    SimplifiedChinese,
27    TraditionalChinese,
28    Japanese,
29    Korean,
30}
31
32impl Specimen {
33    /// Picks the specimen from a font's `fontMetas`.
34    pub fn for_metas(metas: &Map<String, Value>) -> Specimen {
35        let support = |k: &str| metas.get("support").and_then(|s| s.get(k)).and_then(Value::as_bool) == Some(true);
36        let ps_lang = |k: &str| metas.get("postScriptName").and_then(|p| p.get(k)).is_some();
37        let (cn, tw, jp, kr) = (support("zh_CN"), support("zh_TW"), support("jp"), support("kr"));
38        if let Some(lang) = metas.get("preferLng").and_then(Value::as_str) {
39            return match lang {
40                "zh_CN" => Specimen::SimplifiedChinese,
41                "zh_TW" => Specimen::TraditionalChinese,
42                "jp" => Specimen::Japanese,
43                "kr" => Specimen::Korean,
44                _ => Specimen::Latin,
45            };
46        }
47        if cn && tw {
48            Specimen::SimplifiedChinese
49        } else if ps_lang("ja") {
50            Specimen::Japanese
51        } else if cn && ps_lang("zh") {
52            Specimen::SimplifiedChinese
53        } else if tw && ps_lang("zh_TW") {
54            Specimen::TraditionalChinese
55        } else if cn {
56            Specimen::SimplifiedChinese
57        } else if kr {
58            Specimen::Korean
59        } else if tw {
60            Specimen::TraditionalChinese
61        } else if jp {
62            Specimen::Japanese
63        } else {
64            Specimen::Latin
65        }
66    }
67
68    /// The lines with their font sizes, and the width of the text block.
69    /// The sample words are falcon's own; the sizes and layout follow
70    /// Eagle's.
71    fn lines(self) -> (Vec<(&'static str, f32)>, f32) {
72        let cjk = |a: &'static str, b: &'static str| (vec![(a, 100.0), (b, 100.0), ("AaBbCcDd", 82.0), ("01234567", 82.0)], 380.0);
73        match self {
74            Specimen::Latin => (vec![("ABCDEFG", 90.0), ("abcdefg", 90.0), ("0123456", 90.0)], 400.0),
75            Specimen::SimplifiedChinese => cjk("字体样张", "汉字之美"),
76            Specimen::TraditionalChinese => cjk("字體樣張", "漢字之美"),
77            Specimen::Japanese => cjk("あいうえ", "日本語文"),
78            Specimen::Korean => (vec![("가나다라마", 90.0), ("AaBbCcDd", 82.0), ("01234567", 82.0)], 380.0),
79        }
80    }
81}
82
83fn escape(s: &str) -> String {
84    s.replace('&', "&amp;").replace('<', "&lt;").replace('"', "&quot;")
85}
86
87/// Renders the specimen of a font file (ttf, otf, ttc face 0, woff):
88/// dark grey lines on white, left-aligned in a block centred on the canvas.
89pub fn render(data: &[u8], metas: &Map<String, Value>) -> Option<DynamicImage> {
90    let sfnt = fontmeta::sfnt(data)?.into_owned();
91    let face = fontmeta::parse(&sfnt)?;
92    // A WOFF 2 font with packed glyphs unpacks without outlines; drawing it
93    // would give a blank card.
94    let t = face.tables();
95    if t.glyf.is_none() && t.cff.is_none() && t.cff2.is_none() {
96        return None;
97    }
98    let upm = f32::from(face.units_per_em());
99    let (asc, desc) = (f32::from(face.ascender()) / upm, -f32::from(face.descender()) / upm);
100    let mut db = usvg::fontdb::Database::new();
101    db.load_font_data(sfnt.clone());
102    let family = db.faces().next()?.families.first()?.0.clone();
103
104    let (lines, block_width) = Specimen::for_metas(metas).lines();
105    let block_height: f32 = lines.iter().map(|(_, s)| s * LINE_HEIGHT).sum();
106    let left = (SIZE as f32 - block_width) / 2.0;
107    let mut y = (SIZE as f32 - block_height) / 2.0;
108    let mut spans = String::new();
109    for (text, size) in &lines {
110        let line = size * LINE_HEIGHT;
111        // CSS puts half the leading above the ascent.
112        let baseline = y + (line - (asc + desc) * size) / 2.0 + asc * size;
113        spans.push_str(&format!(r#"<text x="{left}" y="{baseline}" font-size="{size}">{}</text>"#, escape(text)));
114        y += line;
115    }
116    let svg = format!(
117        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{SIZE}" height="{SIZE}"><rect width="100%" height="100%" fill="#ffffff"/><g font-family="&quot;{}&quot;" fill="{INK}">{spans}</g></svg>"##,
118        escape(&family)
119    );
120    let opts = usvg::Options { fontdb: Arc::new(db), ..Default::default() };
121    let tree = usvg::Tree::from_str(&svg, &opts).ok()?;
122    let mut pixmap = Pixmap::new(SIZE, SIZE)?;
123    resvg::render(&tree, Transform::identity(), &mut pixmap.as_mut());
124    let raw: Vec<u8> = pixmap.pixels().iter().flat_map(|p| [p.red(), p.green(), p.blue(), 255]).collect();
125    RgbaImage::from_raw(SIZE, SIZE, raw).map(|i| DynamicImage::ImageRgb8(DynamicImage::ImageRgba8(i).to_rgb8()))
126}
127
128impl crate::Library {
129    /// Sets the language of a font item's specimen, as Eagle's "change
130    /// language" does: `fontMetas.preferLng` becomes `lang` (one of
131    /// [`fontmeta::SAMPLE_LANGUAGES`]) and the specimen thumbnail and its
132    /// palette are drawn again in that language.
133    pub fn set_font_language(&mut self, id: &str, lang: &str) -> crate::Result<()> {
134        if !fontmeta::SAMPLE_LANGUAGES.iter().any(|(code, _)| *code == lang) {
135            return Err(self.invalid(format!("unknown sample language {lang}")));
136        }
137        let mut item = self.item(id).cloned().ok_or_else(|| self.invalid(format!("no item {id}")))?;
138        let Some(Value::Object(metas)) = item.extra.get_mut("fontMetas") else {
139            return Err(self.invalid(format!("{}.{} has no font metadata", item.name, item.ext)));
140        };
141        metas.insert("preferLng".into(), Value::String(lang.into()));
142        let metas = metas.clone();
143        let raw = item.original_path(self.root());
144        let data = std::fs::read(&raw).map_err(|e| crate::Error::io(&raw, e))?;
145        if let Some(img) = render(&data, &metas) {
146            let thumb = self.info_dir(&item).join(item.thumbnail_name());
147            item.palettes = crate::palette::extract(&img);
148            crate::thumbnail::write_thumbnail(img, crate::thumbnail::max_side(&item.ext), &thumb).map_err(|e| self.invalid(e.0))?;
149        }
150        self.update_item(item)
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use serde_json::json;
158
159    #[test]
160    fn picks_specimens() {
161        let m = |v: Value| v.as_object().unwrap().clone();
162        assert_eq!(Specimen::for_metas(&m(json!({}))), Specimen::Latin);
163        assert_eq!(Specimen::for_metas(&m(json!({"support": {"zh_CN": true, "zh_TW": true}}))), Specimen::SimplifiedChinese);
164        assert_eq!(Specimen::for_metas(&m(json!({"support": {"zh_TW": true}}))), Specimen::TraditionalChinese);
165        assert_eq!(Specimen::for_metas(&m(json!({"postScriptName": {"ja": "x"}}))), Specimen::Japanese);
166        assert_eq!(Specimen::for_metas(&m(json!({"support": {"kr": true, "jp": true}}))), Specimen::Korean);
167    }
168
169    #[test]
170    fn changing_the_language_redraws_the_specimen() {
171        let Ok(data) = std::fs::read("/System/Library/Fonts/Supplemental/Arial.ttf") else { return };
172        let tmp = tempfile::tempdir().unwrap();
173        let mut lib = crate::Library::create(tmp.path().join("T.library")).unwrap();
174        let src = tmp.path().join("Arial.ttf");
175        std::fs::write(&src, &data).unwrap();
176        let id = lib.import_file(&src, &crate::import::ImportOptions::default()).unwrap();
177        let thumb = lib.item(&id).unwrap().thumbnail_path(lib.root()).unwrap();
178        let before = std::fs::read(&thumb).unwrap();
179        assert!(lib.set_font_language(&id, "xx").is_err());
180        lib.set_font_language(&id, "zh_CN").unwrap();
181        let item = lib.item(&id).unwrap();
182        assert_eq!(item.extra["fontMetas"]["preferLng"], "zh_CN");
183        assert_ne!(std::fs::read(&thumb).unwrap(), before, "the specimen is drawn again");
184        lib.set_font_language(&id, "en").unwrap();
185        assert_eq!(lib.item(&id).unwrap().extra["fontMetas"]["preferLng"], "en");
186    }
187
188    #[test]
189    fn renders_a_system_font() {
190        let Ok(data) = std::fs::read("/System/Library/Fonts/Supplemental/Arial.ttf") else { return };
191        let metas = fontmeta::font_metas(&data, "X").unwrap();
192        let img = render(&data, &metas).unwrap().to_rgb8();
193        assert_eq!(img.dimensions(), (SIZE, SIZE));
194        let dark = img.pixels().filter(|p| p.0[0] < 128).count();
195        assert!(dark > 5_000, "text was drawn: {dark} dark pixels");
196        // The corners stay white; the text sits in the middle.
197        assert_eq!(img.get_pixel(5, 5).0, [255, 255, 255]);
198        assert_eq!(img.get_pixel(594, 594).0, [255, 255, 255]);
199        let dir = tempfile::tempdir().unwrap();
200        let out = dir.path().join("specimen_thumbnail.png");
201        crate::thumbnail::write_thumbnail(DynamicImage::ImageRgb8(img), crate::thumbnail::DEFAULT_MAX_SIDE, &out).unwrap();
202        assert_eq!(crate::thumbnail::decode(&out).unwrap().width(), 320);
203    }
204}