Skip to main content

falcon_library/
fontmeta.rs

1//! Font metadata stored at import as `fontMetas`, in the shape Eagle's font
2//! parser produces (see the Import chapter of the docs).
3//!
4//! Each `name` table entry becomes a map from language code to text, keyed
5//! by the entry's name (`fontFamily`, `postScriptName`, ...), followed by
6//! `numGlyphs`, `support` and `weight`.
7
8use std::borrow::Cow;
9
10use serde_json::{Map, Value, json};
11use ttf_parser::{Face, PlatformId};
12
13/// Names of the `name` table ids 0..=22, as Eagle's parser keys them.
14const NAME_KEYS: [&str; 23] = [
15    "copyright",
16    "fontFamily",
17    "fontSubfamily",
18    "uniqueID",
19    "fullName",
20    "version",
21    "postScriptName",
22    "trademark",
23    "manufacturer",
24    "designer",
25    "description",
26    "manufacturerURL",
27    "designerURL",
28    "license",
29    "licenseURL",
30    "reserved",
31    "preferredFamily",
32    "preferredSubfamily",
33    "compatibleFullName",
34    "sampleText",
35    "postScriptFindFontName",
36    "wwsFamily",
37    "wwsSubfamily",
38];
39
40/// Weights Eagle accepts, in its order of testing against the PostScript name.
41const WEIGHT_WORDS: [(&str, &str); 16] = [
42    ("thin", "Thin"),
43    ("hairline", "Hairline"),
44    ("extralight", "ExtraLight"),
45    ("ultralight", "UltraLight"),
46    ("light", "Light"),
47    ("regular", "Regular"),
48    ("normal", "Regular"),
49    ("medium", "Medium"),
50    ("demibold", "DemiBold"),
51    ("semibold", "SemiBold"),
52    ("bold", "Bold"),
53    ("extrabold", "ExtraBold"),
54    ("ultrabold", "UltraBold"),
55    ("black", "Black"),
56    ("extrablack", "ExtraBlack"),
57    ("heavy", "Heavy"),
58];
59
60/// Language code for a Windows language id (a common subset of the codes
61/// Eagle's parser knows).
62fn windows_language(id: u16) -> Option<&'static str> {
63    Some(match id {
64        0x0409 => "en",
65        0x0809 => "en-GB",
66        0x0C09 => "en-AU",
67        0x1009 => "en-CA",
68        0x0804 => "zh",
69        0x0404 => "zh-TW",
70        0x0C04 => "zh-HK",
71        0x1004 => "zh-SG",
72        0x1404 => "zh-MO",
73        0x0411 => "ja",
74        0x0412 => "ko",
75        0x040C => "fr",
76        0x0C0C => "fr-CA",
77        0x0407 => "de",
78        0x0410 => "it",
79        0x0C0A => "es",
80        0x040A => "es",
81        0x0416 => "pt",
82        0x0816 => "pt-PT",
83        0x0413 => "nl",
84        0x0419 => "ru",
85        0x041D => "sv",
86        0x0406 => "da",
87        0x0414 => "nb",
88        0x040B => "fi",
89        0x0415 => "pl",
90        0x0405 => "cs",
91        0x040E => "hu",
92        0x0408 => "el",
93        0x041F => "tr",
94        0x040D => "he",
95        0x0401 => "ar",
96        0x041E => "th",
97        0x042A => "vi",
98        0x0421 => "id",
99        0x0422 => "uk",
100        0x0418 => "ro",
101        0x041B => "sk",
102        0x0424 => "sl",
103        0x041A => "hr",
104        0x0402 => "bg",
105        0x0403 => "ca",
106        0x042D => "eu",
107        _ => return None,
108    })
109}
110
111/// Language code for a Macintosh language id written in the Roman script.
112fn mac_language(id: u16) -> Option<&'static str> {
113    Some(match id {
114        0 => "en",
115        1 => "fr",
116        2 => "de",
117        3 => "it",
118        4 => "nl",
119        5 => "sv",
120        6 => "es",
121        7 => "da",
122        8 => "pt",
123        9 => "no",
124        13 => "fi",
125        _ => return None,
126    })
127}
128
129/// The upper half of the Mac OS Roman code page.
130const MAC_ROMAN_HIGH: &str = "ÄÅÇÉÑÖÜáàâäãåçéèêëíìîïñóòôöõúùûü†°¢£§•¶ß®©™´¨≠ÆØ∞±≤≥¥µ∂∑∏π∫ªºΩæø¿¡¬√ƒ≈∆«»…\u{a0}ÀÃÕŒœ–—“”‘’÷◊ÿŸ⁄€‹›fifl‡·‚„‰ÂÊÁËÈÍÎÏÌÓÔ\u{f8ff}ÒÚÛÙıˆ˜¯˘˙˚¸˝˛ˇ";
131
132fn mac_roman(bytes: &[u8]) -> String {
133    let high: Vec<char> = MAC_ROMAN_HIGH.chars().collect();
134    bytes.iter().map(|&b| if b < 0x80 { b as char } else { high.get(usize::from(b - 0x80)).copied().unwrap_or('\u{fffd}') }).collect()
135}
136
137fn utf16_be(bytes: &[u8]) -> String {
138    let units: Vec<u16> = bytes.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect();
139    String::from_utf16_lossy(&units)
140}
141
142/// Unpacks a WOFF or WOFF 2 file into the plain sfnt it wraps; other data
143/// is returned as is. From WOFF 2, tables stored with a transform (the
144/// packed `glyf`/`loca` and `hmtx`) are left out: metadata needs only the
145/// names, `maxp`, `cmap` and the other plain tables.
146pub fn sfnt(data: &[u8]) -> Option<Cow<'_, [u8]>> {
147    match data.get(..4) {
148        Some(b"wOFF") => woff1_tables(data).map(|(flavor, t)| Cow::Owned(build_sfnt(flavor, &t))),
149        Some(b"wOF2") => woff2_tables(data).map(|(flavor, t)| Cow::Owned(build_sfnt(flavor, &t))),
150        _ => Some(Cow::Borrowed(data)),
151    }
152}
153
154fn be16(data: &[u8], o: usize) -> Option<u16> {
155    data.get(o..o + 2).map(|b| u16::from_be_bytes([b[0], b[1]]))
156}
157
158fn be32(data: &[u8], o: usize) -> Option<u32> {
159    data.get(o..o + 4).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
160}
161
162/// One table for [`build_sfnt`]: tag, checksum, bytes.
163type Table = (u32, u32, Vec<u8>);
164
165fn woff1_tables(data: &[u8]) -> Option<(u32, Vec<Table>)> {
166    let flavor = be32(data, 4)?;
167    let num = be16(data, 12)?;
168    let mut tables = Vec::with_capacity(num.into());
169    for i in 0..usize::from(num) {
170        let e = 44 + i * 20;
171        let tag = be32(data, e)?;
172        let (off, comp, orig, sum) = (be32(data, e + 4)? as usize, be32(data, e + 8)? as usize, be32(data, e + 12)? as usize, be32(data, e + 16)?);
173        let raw = data.get(off..off.checked_add(comp)?)?;
174        let bytes = if comp < orig { miniz_oxide::inflate::decompress_to_vec_zlib(raw).ok()? } else { raw.to_vec() };
175        if bytes.len() != orig {
176            return None;
177        }
178        tables.push((tag, sum, bytes));
179    }
180    Some((flavor, tables))
181}
182
183/// Tags a WOFF 2 directory entry can name by index (from the WOFF 2
184/// specification).
185const WOFF2_TAGS: [&[u8; 4]; 63] = [
186    b"cmap", b"head", b"hhea", b"hmtx", b"maxp", b"name", b"OS/2", b"post", b"cvt ", b"fpgm", b"glyf", b"loca", b"prep", b"CFF ", b"VORG",
187    b"EBDT", b"EBLC", b"gasp", b"hdmx", b"kern", b"LTSH", b"PCLT", b"VDMX", b"vhea", b"vmtx", b"BASE", b"GDEF", b"GPOS", b"GSUB", b"EBSC",
188    b"JSTF", b"MATH", b"CBDT", b"CBLC", b"COLR", b"CPAL", b"SVG ", b"sbix", b"acnt", b"avar", b"bdat", b"bloc", b"bsln", b"cvar", b"fdsc",
189    b"feat", b"fmtx", b"fvar", b"gvar", b"hsty", b"just", b"lcar", b"mort", b"morx", b"opbd", b"prop", b"trak", b"Zapf", b"Silf", b"Glat",
190    b"Gloc", b"Feat", b"Sill",
191];
192
193/// A WOFF 2 `UIntBase128`: big-endian groups of 7 bits, at most five bytes,
194/// no leading zero group.
195fn base128(data: &[u8], at: &mut usize) -> Option<u32> {
196    let mut v: u32 = 0;
197    for i in 0..5 {
198        let b = *data.get(*at)?;
199        *at += 1;
200        if i == 0 && b == 0x80 {
201            return None;
202        }
203        if v & 0xFE00_0000 != 0 {
204            return None;
205        }
206        v = (v << 7) | u32::from(b & 0x7F);
207        if b & 0x80 == 0 {
208            return Some(v);
209        }
210    }
211    None
212}
213
214/// Reads a single-font WOFF 2 file's untransformed tables. Collections are
215/// not read.
216fn woff2_tables(data: &[u8]) -> Option<(u32, Vec<Table>)> {
217    use std::io::Read;
218    let flavor = be32(data, 4)?;
219    if &flavor.to_be_bytes() == b"ttcf" {
220        return None;
221    }
222    let num = usize::from(be16(data, 12)?);
223    let compressed_len = be32(data, 20)? as usize;
224    let mut at = 48;
225    // (tag, stored length, keep)
226    let mut entries = Vec::with_capacity(num);
227    for _ in 0..num {
228        let flags = *data.get(at)?;
229        at += 1;
230        let tag = match usize::from(flags & 0x3F) {
231            63 => {
232                let t = be32(data, at)?;
233                at += 4;
234                t
235            }
236            i => u32::from_be_bytes(*WOFF2_TAGS[i]),
237        };
238        let version = flags >> 6;
239        let orig = base128(data, &mut at)?;
240        let glyf_or_loca = &tag.to_be_bytes() == b"glyf" || &tag.to_be_bytes() == b"loca";
241        // Version 0 is the transform for glyf and loca, the null transform
242        // for every other table; glyf and loca use 3 for "none".
243        let transformed = if glyf_or_loca { version != 3 } else { version != 0 };
244        let stored = if transformed { base128(data, &mut at)? } else { orig };
245        entries.push((tag, stored as usize, !transformed));
246    }
247    let total: usize = entries.iter().try_fold(0usize, |acc, e| acc.checked_add(e.1))?;
248    let stream = data.get(at..at.checked_add(compressed_len)?)?;
249    let mut raw = Vec::with_capacity(total.min(64 << 20));
250    brotli_decompressor::Decompressor::new(stream, 4096).take(total as u64).read_to_end(&mut raw).ok()?;
251    if raw.len() != total {
252        return None;
253    }
254    let mut tables = Vec::new();
255    let mut off = 0;
256    for (tag, len, keep) in entries {
257        let bytes = &raw[off..off + len];
258        off += len;
259        if keep {
260            tables.push((tag, checksum(bytes), bytes.to_vec()));
261        }
262    }
263    tables.sort_by_key(|t| t.0);
264    Some((flavor, tables))
265}
266
267/// The sfnt table checksum: the sum of big-endian words, zero-padded.
268fn checksum(bytes: &[u8]) -> u32 {
269    bytes.chunks(4).fold(0u32, |sum, c| {
270        let mut w = [0u8; 4];
271        w[..c.len()].copy_from_slice(c);
272        sum.wrapping_add(u32::from_be_bytes(w))
273    })
274}
275
276/// Lays out an sfnt file: the offset table, the directory, then each table
277/// padded to four bytes.
278fn build_sfnt(flavor: u32, tables: &[Table]) -> Vec<u8> {
279    let num = tables.len() as u16;
280    let mut pow = 1u16;
281    let mut log = 0u16;
282    while pow * 2 <= num {
283        pow *= 2;
284        log += 1;
285    }
286    let mut out = Vec::new();
287    out.extend_from_slice(&flavor.to_be_bytes());
288    for v in [num, pow * 16, log, (num * 16).saturating_sub(pow * 16)] {
289        out.extend_from_slice(&v.to_be_bytes());
290    }
291    let mut offset = 12 + tables.len() * 16;
292    for (tag, sum, bytes) in tables {
293        out.extend_from_slice(&tag.to_be_bytes());
294        out.extend_from_slice(&sum.to_be_bytes());
295        out.extend_from_slice(&(offset as u32).to_be_bytes());
296        out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
297        offset += bytes.len().div_ceil(4) * 4;
298    }
299    for (_, _, bytes) in tables {
300        out.extend_from_slice(bytes);
301        out.resize(out.len().div_ceil(4) * 4, 0);
302    }
303    out
304}
305
306/// Parses a font file (ttf, otf, or the first face of a ttc); unpack
307/// WOFF and WOFF 2 with [`sfnt`] first.
308pub fn parse(data: &[u8]) -> Option<Face<'_>> {
309    Face::parse(data, 0).ok()
310}
311
312/// The `name` table as Eagle's parser reads it: entries in table order, each
313/// a map from language code to text. A later record for the same entry and
314/// language replaces the earlier text but keeps its position.
315pub fn names(face: &Face) -> Map<String, Value> {
316    let mut out = Map::new();
317    for n in face.names() {
318        let (lang, text) = match n.platform_id {
319            PlatformId::Windows if matches!(n.encoding_id, 1 | 10) => (windows_language(n.language_id), utf16_be(n.name)),
320            PlatformId::Macintosh if n.encoding_id == 0 => (mac_language(n.language_id), mac_roman(n.name)),
321            PlatformId::Unicode if n.language_id == 0xFFFF => (Some("und"), utf16_be(n.name)),
322            _ => continue,
323        };
324        let Some(lang) = lang else { continue };
325        if text.is_empty() {
326            continue;
327        }
328        let key = NAME_KEYS.get(usize::from(n.name_id)).map_or_else(|| n.name_id.to_string(), |k| k.to_string());
329        let entry = out.entry(key).or_insert_with(|| Value::Object(Map::new()));
330        if let Value::Object(m) = entry {
331            m.insert(lang.to_string(), Value::String(text));
332        }
333    }
334    out
335}
336
337fn first_value<'a>(m: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
338    m.get(key)?.as_object()?.values().next()?.as_str()
339}
340
341fn en_value<'a>(m: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
342    m.get(key)?.get("en")?.as_str()
343}
344
345/// Eagle's weight label, guessed from the PostScript name, then from the
346/// subfamily, with " Italic" added when the PostScript name says so.
347pub fn weight(metas: &Map<String, Value>) -> String {
348    let ps = first_value(metas, "postScriptName").unwrap_or("").to_lowercase();
349    if ps.is_empty() {
350        return "Regular".into();
351    }
352    let mut w = WEIGHT_WORDS.iter().find(|(word, _)| ps.contains(word)).map(|(_, w)| w.to_string());
353    if w.is_none() {
354        w = Some(en_value(metas, "preferredSubfamily").or_else(|| en_value(metas, "fontSubfamily")).unwrap_or("Regular").to_string());
355    }
356    let mut w = w.filter(|w| WEIGHT_WORDS.iter().any(|(_, x)| x == w)).unwrap_or_else(|| "Regular".into());
357    if !w.to_lowercase().contains("italic") && ps.contains("italic") {
358        w.push_str(" Italic");
359    }
360    w
361}
362
363/// The font viewer's information panel, read from `fontMetas` as Eagle's
364/// viewer reads it: each name in English when the font has it, else its
365/// first language. Missing names are empty.
366#[derive(Debug, Clone, Default, PartialEq)]
367pub struct FontInfo {
368    pub postscript: String,
369    pub full_name: String,
370    pub family: String,
371    /// `weight`, the style label Eagle stores.
372    pub style: String,
373    pub version: String,
374    pub designer: String,
375    pub manufacturer: String,
376    pub description: String,
377    /// The copyright notice, or the licence text when there is none.
378    pub copyright: String,
379    pub trademark: String,
380    pub glyphs: Option<u64>,
381}
382
383impl FontInfo {
384    pub fn from_metas(metas: &Map<String, Value>) -> FontInfo {
385        let name = |k: &str| en_value(metas, k).or_else(|| first_value(metas, k)).unwrap_or("").trim().to_string();
386        let copyright = name("copyright");
387        FontInfo {
388            postscript: name("postScriptName"),
389            full_name: name("fullName"),
390            family: name("fontFamily"),
391            style: metas.get("weight").and_then(Value::as_str).unwrap_or("").to_string(),
392            version: name("version"),
393            designer: name("designer"),
394            manufacturer: name("manufacturer"),
395            description: name("description"),
396            copyright: if copyright.is_empty() { name("license") } else { copyright },
397            trademark: name("trademark"),
398            glyphs: metas.get("numGlyphs").and_then(Value::as_u64),
399        }
400    }
401}
402
403/// The sample languages Eagle's "change language" offers, as stored in
404/// `fontMetas.preferLng`, with their names.
405pub const SAMPLE_LANGUAGES: [(&str, &str); 5] =
406    [("en", "English"), ("jp", "日本語"), ("kr", "한국어"), ("zh_CN", "简体中文"), ("zh_TW", "繁體中文")];
407
408/// Builds `fontMetas` for a font file's bytes, or `None` if it can't be
409/// parsed. `fallback_id` names a font whose table has no names at all.
410pub fn font_metas(data: &[u8], fallback_id: &str) -> Option<Map<String, Value>> {
411    let data = sfnt(data)?;
412    let face = parse(&data)?;
413    let mut metas = names(&face);
414    if metas.is_empty() {
415        metas.insert("postScriptName".into(), json!({ "en": fallback_id }));
416        metas.insert("fullName".into(), json!({ "en": fallback_id }));
417    }
418    if !metas.contains_key("postScriptName") {
419        let id = en_value(&metas, "uniqueID").unwrap_or(fallback_id).to_string();
420        metas.insert("postScriptName".into(), json!({ "en": id }));
421    }
422    metas.insert("numGlyphs".into(), json!(face.number_of_glyphs()));
423    let has = |s: &str| s.chars().all(|c| face.glyph_index(c).is_some_and(|g| g.0 > 0));
424    let mut support = Map::new();
425    if has("龙岁") {
426        support.insert("zh_CN".into(), json!(true));
427    }
428    if has("実匁図") {
429        support.insert("jp".into(), json!(true));
430    }
431    if has("陳鑿發") {
432        support.insert("zh_TW".into(), json!(true));
433    }
434    if has("별았") {
435        support.insert("kr".into(), json!(true));
436    }
437    metas.insert("support".into(), Value::Object(support));
438    let w = weight(&metas);
439    metas.insert("weight".into(), json!(w));
440    Some(metas)
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    fn metas(ps: &str, sub: Option<&str>) -> Map<String, Value> {
448        let mut m = Map::new();
449        m.insert("postScriptName".into(), json!({ "en": ps }));
450        if let Some(s) = sub {
451            m.insert("fontSubfamily".into(), json!({ "en": s }));
452        }
453        m
454    }
455
456    #[test]
457    fn weights() {
458        assert_eq!(weight(&metas("Inter-Thin", None)), "Thin");
459        assert_eq!(weight(&metas("Inter-SemiBoldItalic", None)), "SemiBold Italic");
460        // "bold" is tested before "extrabold", as in Eagle.
461        assert_eq!(weight(&metas("Inter-ExtraBold", None)), "Bold");
462        assert_eq!(weight(&metas("Foo", Some("Medium"))), "Medium");
463        assert_eq!(weight(&metas("Foo", Some("Oblique"))), "Regular");
464        assert_eq!(weight(&metas("Foo-Italic", None)), "Regular Italic");
465        assert_eq!(weight(&Map::new()), "Regular");
466    }
467
468    #[test]
469    fn info_prefers_english_names() {
470        let m = json!({
471            "copyright": {"en": "(c) Someone"},
472            "fontFamily": {"zh": "字体", "en": "Ziti"},
473            "fullName": {"ja": "フル"},
474            "postScriptName": {"en": " Ziti-Bold "},
475            "license": {"en": "OFL"},
476            "numGlyphs": 1234,
477            "weight": "Bold",
478        });
479        let info = FontInfo::from_metas(m.as_object().unwrap());
480        assert_eq!(info.family, "Ziti");
481        assert_eq!(info.full_name, "フル", "the first language when there is no English");
482        assert_eq!(info.postscript, "Ziti-Bold", "trimmed");
483        assert_eq!(info.copyright, "(c) Someone");
484        assert_eq!((info.style.as_str(), info.glyphs), ("Bold", Some(1234)));
485        assert_eq!(info.designer, "");
486        let no_copyright = FontInfo::from_metas(json!({"license": {"en": "OFL"}}).as_object().unwrap());
487        assert_eq!(no_copyright.copyright, "OFL", "the licence stands in for a missing copyright");
488        assert_eq!(FontInfo::from_metas(&Map::new()), FontInfo::default());
489    }
490
491    #[test]
492    fn mac_roman_text() {
493        assert_eq!(MAC_ROMAN_HIGH.chars().count(), 128);
494        assert_eq!(mac_roman(b"Caf\x8e \xa9 \xff"), "Café © ˇ");
495    }
496
497    #[test]
498    fn system_font() {
499        let path = ["/System/Library/Fonts/Supplemental/Arial.ttf", "/System/Library/Fonts/Helvetica.ttc"]
500            .into_iter()
501            .find(|p| std::path::Path::new(p).is_file());
502        let Some(path) = path else { return };
503        let data = std::fs::read(path).unwrap();
504        let m = font_metas(&data, "X").unwrap();
505        assert!(first_value(&m, "fontFamily").is_some());
506        assert!(first_value(&m, "postScriptName").is_some());
507        let keys: Vec<&str> = m.keys().map(|k| k.as_str()).collect();
508        assert_eq!(&keys[keys.len() - 3..], ["numGlyphs", "support", "weight"]);
509        assert!(m["numGlyphs"].as_u64().unwrap() > 100);
510    }
511
512    #[test]
513    fn woff_round_trip() {
514        let Ok(data) = std::fs::read("/System/Library/Fonts/Supplemental/Arial.ttf") else { return };
515        // Wrap the font's tables in a WOFF container, compressing each.
516        let face = ttf_parser::RawFace::parse(&data, 0).unwrap();
517        let records: Vec<_> = face.table_records.into_iter().collect();
518        let mut dir = Vec::new();
519        let mut blobs = Vec::new();
520        let mut offset = 44 + records.len() * 20;
521        for r in &records {
522            let raw = &data[r.offset as usize..(r.offset + r.length) as usize];
523            let comp = miniz_oxide::deflate::compress_to_vec_zlib(raw, 6);
524            let body = if comp.len() < raw.len() { comp } else { raw.to_vec() };
525            dir.extend_from_slice(&r.tag.as_u32().to_be_bytes());
526            dir.extend_from_slice(&(offset as u32).to_be_bytes());
527            dir.extend_from_slice(&(body.len() as u32).to_be_bytes());
528            dir.extend_from_slice(&r.length.to_be_bytes());
529            dir.extend_from_slice(&r.check_sum.to_be_bytes());
530            offset += body.len().div_ceil(4) * 4;
531            let mut body = body;
532            body.resize(body.len().div_ceil(4) * 4, 0);
533            blobs.push(body);
534        }
535        let mut woff = b"wOFF".to_vec();
536        woff.extend_from_slice(&data[..4]);
537        woff.extend_from_slice(&(offset as u32).to_be_bytes());
538        woff.extend_from_slice(&(records.len() as u16).to_be_bytes());
539        woff.resize(44, 0);
540        woff.extend(dir);
541        for b in blobs {
542            woff.extend(b);
543        }
544        let a = font_metas(&data, "X").unwrap();
545        let b = font_metas(&woff, "X").unwrap();
546        assert_eq!(a, b);
547    }
548
549    /// A Brotli stream of stored (uncompressed) meta-blocks, which every
550    /// decoder must accept: a one-bit window header, then per block ISLAST=0,
551    /// four nibbles of length - 1, ISUNCOMPRESSED=1, padding and the bytes,
552    /// then an empty last block.
553    fn brotli_stored(data: &[u8]) -> Vec<u8> {
554        let mut out = Vec::new();
555        let mut first = true;
556        for chunk in data.chunks(65536) {
557            let len = (chunk.len() - 1) as u32;
558            // Bits, least significant first.
559            let (bits, n) = if first { ((len << 4) | (1 << 20), 21) } else { ((len << 3) | (1 << 19), 20) };
560            first = false;
561            for i in 0..(n as usize).div_ceil(8) {
562                out.push((bits >> (8 * i)) as u8);
563            }
564            out.extend_from_slice(chunk);
565        }
566        // ISLAST=1, ISLASTEMPTY=1 (after the window bit if nothing came first).
567        out.push(if first { 0b110 } else { 0b11 });
568        out
569    }
570
571    fn base128_bytes(mut v: u32) -> Vec<u8> {
572        let mut groups = vec![(v & 0x7F) as u8];
573        v >>= 7;
574        while v > 0 {
575            groups.push((v & 0x7F) as u8 | 0x80);
576            v >>= 7;
577        }
578        groups.reverse();
579        groups
580    }
581
582    /// Wraps a font's tables in WOFF 2. `glyf` and `loca` are stored either
583    /// plainly (transform 3) or as if transformed (version 0, with junk data
584    /// the reader must skip).
585    fn woff2(data: &[u8], transform_glyf: bool) -> Vec<u8> {
586        let face = ttf_parser::RawFace::parse(data, 0).unwrap();
587        let mut dir = Vec::new();
588        let mut stream = Vec::new();
589        for r in face.table_records {
590            let raw = &data[r.offset as usize..(r.offset + r.length) as usize];
591            let tag = r.tag.to_bytes();
592            let index = [b"cmap", b"head", b"hhea", b"hmtx", b"maxp", b"name"].iter().position(|t| **t == tag);
593            let glyf_or_loca = &tag == b"glyf" || &tag == b"loca";
594            let version: u8 = if glyf_or_loca && !transform_glyf { 3 } else { 0 };
595            dir.push((version << 6) | index.map_or(63, |i| i as u8));
596            if index.is_none() {
597                dir.extend_from_slice(&tag);
598            }
599            dir.extend(base128_bytes(r.length));
600            if glyf_or_loca && transform_glyf {
601                let junk = if &tag == b"glyf" { vec![7u8; 100] } else { Vec::new() };
602                dir.extend(base128_bytes(junk.len() as u32));
603                stream.extend(junk);
604            } else {
605                stream.extend_from_slice(raw);
606            }
607        }
608        let comp = brotli_stored(&stream);
609        let mut out = b"wOF2".to_vec();
610        out.extend_from_slice(&data[..4]);
611        out.extend_from_slice(&0u32.to_be_bytes()); // length, patched below
612        out.extend_from_slice(&face.table_records.len().to_be_bytes());
613        out.extend_from_slice(&[0, 0]);
614        out.extend_from_slice(&(data.len() as u32).to_be_bytes());
615        out.extend_from_slice(&(comp.len() as u32).to_be_bytes());
616        out.resize(48, 0);
617        out.extend(dir);
618        out.extend(comp);
619        let n = out.len() as u32;
620        out[8..12].copy_from_slice(&n.to_be_bytes());
621        out
622    }
623
624    #[test]
625    fn base128_reads_the_spec_examples() {
626        let read = |b: &[u8]| base128(b, &mut 0);
627        assert_eq!(read(&[0x3F]), Some(63));
628        assert_eq!(read(&[0x81, 0x00]), Some(128));
629        assert_eq!(read(&base128_bytes(1_234_567)), Some(1_234_567));
630        assert_eq!(read(&[0x80, 0x01]), None, "leading zero group");
631        assert_eq!(read(&[0xFF; 6]), None, "too long");
632    }
633
634    #[test]
635    fn woff2_names_match_the_plain_font() {
636        let Ok(data) = std::fs::read("/System/Library/Fonts/Supplemental/Arial.ttf") else { return };
637        let plain = font_metas(&data, "X").unwrap();
638        for transform in [false, true] {
639            let packed = woff2(&data, transform);
640            assert_eq!(font_metas(&packed, "X").as_ref(), Some(&plain), "transformed glyf: {transform}");
641        }
642        // A broken stream is refused rather than misread.
643        let mut bad = woff2(&data, false);
644        let len = bad.len();
645        bad.truncate(len - 1000);
646        assert!(font_metas(&bad, "X").is_none());
647    }
648
649    #[test]
650    fn woff2_imports_with_font_metas() {
651        let Ok(data) = std::fs::read("/System/Library/Fonts/Supplemental/Arial.ttf") else { return };
652        let tmp = tempfile::tempdir().unwrap();
653        let mut lib = crate::Library::create(tmp.path().join("F.library")).unwrap();
654        let src = tmp.path().join("Arial.woff2");
655        std::fs::write(&src, woff2(&data, false)).unwrap();
656        let id = lib.import_file(&src, &crate::import::ImportOptions::default()).unwrap();
657        let item = lib.item(&id).unwrap();
658        let metas = item.extra.get("fontMetas").and_then(Value::as_object).unwrap();
659        assert_eq!(first_value(metas, "fontFamily"), Some("Arial"));
660        assert!(item.tags.iter().any(|t| t == "Regular"), "{:?}", item.tags);
661        assert_eq!((item.width, item.height), (600, 600), "the specimen was drawn");
662        // Packed glyphs: names still read, but there is nothing to draw.
663        let packed = woff2(&data, true);
664        let m = font_metas(&packed, "X").unwrap();
665        assert!(crate::fontpreview::render(&packed, &m).is_none());
666    }
667}