From e29f55bb294cc298daad97accf6d8a76976b409c Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 19 Sep 2022 17:44:40 +0200 Subject: Remove font store --- src/font/book.rs | 476 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/font/mod.rs | 256 ++++++++++++++++++++++++++++ src/font/variant.rs | 226 +++++++++++++++++++++++++ 3 files changed, 958 insertions(+) create mode 100644 src/font/book.rs create mode 100644 src/font/mod.rs create mode 100644 src/font/variant.rs (limited to 'src/font') diff --git a/src/font/book.rs b/src/font/book.rs new file mode 100644 index 00000000..8f19faf7 --- /dev/null +++ b/src/font/book.rs @@ -0,0 +1,476 @@ +use std::cmp::Reverse; +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use ttf_parser::{name_id, PlatformId, Tag}; +use unicode_segmentation::UnicodeSegmentation; + +use super::{Font, FontStretch, FontStyle, FontVariant, FontWeight}; + +/// Metadata about a collection of fonts. +#[derive(Default)] +pub struct FontBook { + /// Maps from lowercased family names to font indices. + families: BTreeMap>, + /// Metadata about each font in the collection. + infos: Vec, +} + +impl FontBook { + /// Create a new, empty font book. + pub fn new() -> Self { + Self { families: BTreeMap::new(), infos: vec![] } + } + + /// Create a font book for a collection of fonts. + pub fn from_fonts<'a>(fonts: impl IntoIterator) -> Self { + let mut book = Self::new(); + for font in fonts { + book.push(font.info().clone()); + } + book + } + + /// Insert metadata into the font book. + pub fn push(&mut self, info: FontInfo) { + let index = self.infos.len(); + let family = info.family.to_lowercase(); + self.families.entry(family).or_default().push(index); + self.infos.push(info); + } + + /// An ordered iterator over all font families this loader knows and details + /// about the faces that are part of them. + pub fn families( + &self, + ) -> impl Iterator)> + '_ { + // Since the keys are lowercased, we instead use the family field of the + // first face's info. + self.families.values().map(|ids| { + let family = self.infos[ids[0]].family.as_str(); + let infos = ids.iter().map(|&id| &self.infos[id]); + (family, infos) + }) + } + + /// Try to find and load a font from the given `family` that matches + /// the given `variant` as closely as possible. + /// + /// The `family` should be all lowercase. + pub fn select(&self, family: &str, variant: FontVariant) -> Option { + let ids = self.families.get(family)?; + self.find_best_variant(None, variant, ids.iter().copied()) + } + + /// Try to find and load a fallback font that + /// - is as close as possible to the font `like` (if any) + /// - is as close as possible to the given `variant` + /// - is suitable for shaping the given `text` + pub fn select_fallback( + &self, + like: Option<&FontInfo>, + variant: FontVariant, + text: &str, + ) -> Option { + // Find the fonts that contain the text's first char ... + let c = text.chars().next()?; + let ids = self + .infos + .iter() + .enumerate() + .filter(|(_, info)| info.coverage.contains(c as u32)) + .map(|(index, _)| index); + + // ... and find the best variant among them. + self.find_best_variant(like, variant, ids) + } + + /// Find the font in the passed iterator that + /// - is closest to the font `like` (if any) + /// - is closest to the given `variant` + /// + /// To do that we compute a key for all variants and select the one with the + /// minimal key. This key prioritizes: + /// - If `like` is some other font: + /// - Are both fonts (not) monospaced? + /// - Do both fonts (not) have serifs? + /// - How many words do the families share in their prefix? E.g. "Noto + /// Sans" and "Noto Sans Arabic" share two words, whereas "IBM Plex + /// Arabic" shares none with "Noto Sans", so prefer "Noto Sans Arabic" + /// if `like` is "Noto Sans". In case there are two equally good + /// matches, we prefer the shorter one because it is less special (e.g. + /// if `like` is "Noto Sans Arabic", we prefer "Noto Sans" over "Noto + /// Sans CJK HK".) + /// - The style (normal / italic / oblique). If we want italic or oblique + /// but it doesn't exist, the other one of the two is still better than + /// normal. + /// - The absolute distance to the target stretch. + /// - The absolute distance to the target weight. + fn find_best_variant( + &self, + like: Option<&FontInfo>, + variant: FontVariant, + ids: impl IntoIterator, + ) -> Option { + let mut best = None; + let mut best_key = None; + + for id in ids { + let current = &self.infos[id]; + let key = ( + like.map(|like| { + ( + current.flags.contains(FontFlags::MONOSPACE) + != like.flags.contains(FontFlags::MONOSPACE), + current.flags.contains(FontFlags::SERIF) + != like.flags.contains(FontFlags::SERIF), + Reverse(shared_prefix_words(¤t.family, &like.family)), + current.family.len(), + ) + }), + current.variant.style.distance(variant.style), + current.variant.stretch.distance(variant.stretch), + current.variant.weight.distance(variant.weight), + ); + + if best_key.map_or(true, |b| key < b) { + best = Some(id); + best_key = Some(key); + } + } + + best + } +} + +/// Properties of a single font. +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub struct FontInfo { + /// The typographic font family this font is part of. + pub family: String, + /// Properties that distinguish this font from other fonts in the same + /// family. + pub variant: FontVariant, + /// Properties of the font. + pub flags: FontFlags, + /// The unicode coverage of the font. + pub coverage: Coverage, +} + +bitflags::bitflags! { + /// Bitflags describing characteristics of a font. + #[derive(Serialize, Deserialize)] + pub struct FontFlags: u32 { + /// All glyphs have the same width. + const MONOSPACE = 1 << 0; + /// Glyphs have short strokes at their stems. + const SERIF = 1 << 1; + } +} + +impl FontInfo { + /// Compute metadata for all fonts in the given data. + pub fn from_data<'a>(data: &'a [u8]) -> impl Iterator + 'a { + let count = ttf_parser::fonts_in_collection(data).unwrap_or(1); + (0 .. count).filter_map(move |index| { + let ttf = ttf_parser::Face::from_slice(data, index).ok()?; + Self::from_ttf(&ttf) + }) + } + + /// Compute metadata for a single ttf-parser face. + pub fn from_ttf(ttf: &ttf_parser::Face) -> Option { + // We cannot use Name ID 16 "Typographic Family", because for some + // fonts it groups together more than just Style / Weight / Stretch + // variants (e.g. Display variants of Noto fonts) and then some + // variants become inaccessible from Typst. And even though the + // fsSelection bit WWS should help us decide whether that is the + // case, it's wrong for some fonts (e.g. for certain variants of "Noto + // Sans Display"). + // + // So, instead we use Name ID 1 "Family" and trim many common + // suffixes for which know that they just describe styling (e.g. + // "ExtraBold"). + // + // Also, for Noto fonts we use Name ID 4 "Full Name" instead, + // because Name ID 1 "Family" sometimes contains "Display" and + // sometimes doesn't for the Display variants and that mixes things + // up. + let family = { + let mut family = find_name(ttf, name_id::FAMILY)?; + if family.starts_with("Noto") { + family = find_name(ttf, name_id::FULL_NAME)?; + } + typographic_family(&family).to_string() + }; + + let variant = { + let mut full = find_name(ttf, name_id::FULL_NAME).unwrap_or_default(); + full.make_ascii_lowercase(); + + // Some fonts miss the relevant bits for italic or oblique, so + // we also try to infer that from the full name. + let italic = ttf.is_italic() || full.contains("italic"); + let oblique = + ttf.is_oblique() || full.contains("oblique") || full.contains("slanted"); + + let style = match (italic, oblique) { + (false, false) => FontStyle::Normal, + (true, _) => FontStyle::Italic, + (_, true) => FontStyle::Oblique, + }; + + let weight = FontWeight::from_number(ttf.weight().to_number()); + let stretch = FontStretch::from_number(ttf.width().to_number()); + + FontVariant { style, weight, stretch } + }; + + // Determine the unicode coverage. + let mut codepoints = vec![]; + for subtable in ttf.tables().cmap.into_iter().flat_map(|table| table.subtables) { + if subtable.is_unicode() { + subtable.codepoints(|c| codepoints.push(c)); + } + } + + let mut flags = FontFlags::empty(); + flags.set(FontFlags::MONOSPACE, ttf.is_monospaced()); + + // Determine whether this is a serif or sans-serif font. + if let Some(panose) = ttf + .table_data(Tag::from_bytes(b"OS/2")) + .and_then(|os2| os2.get(32 .. 45)) + { + if matches!(panose, [2, 2 ..= 10, ..]) { + flags.insert(FontFlags::SERIF); + } + } + + Some(FontInfo { + family, + variant, + flags, + coverage: Coverage::from_vec(codepoints), + }) + } +} + +/// Try to find and decode the name with the given id. +pub(super) fn find_name(ttf: &ttf_parser::Face, name_id: u16) -> Option { + ttf.names().into_iter().find_map(|entry| { + if entry.name_id == name_id { + if let Some(string) = entry.to_string() { + return Some(string); + } + + if entry.platform_id == PlatformId::Macintosh && entry.encoding_id == 0 { + return Some(decode_mac_roman(entry.name)); + } + } + + None + }) +} + +/// Decode mac roman encoded bytes into a string. +fn decode_mac_roman(coded: &[u8]) -> String { + #[rustfmt::skip] + const TABLE: [char; 128] = [ + 'Ä', 'Å', 'Ç', 'É', 'Ñ', 'Ö', 'Ü', 'á', 'à', 'â', 'ä', 'ã', 'å', 'ç', 'é', 'è', + 'ê', 'ë', 'í', 'ì', 'î', 'ï', 'ñ', 'ó', 'ò', 'ô', 'ö', 'õ', 'ú', 'ù', 'û', 'ü', + '†', '°', '¢', '£', '§', '•', '¶', 'ß', '®', '©', '™', '´', '¨', '≠', 'Æ', 'Ø', + '∞', '±', '≤', '≥', '¥', 'µ', '∂', '∑', '∏', 'π', '∫', 'ª', 'º', 'Ω', 'æ', 'ø', + '¿', '¡', '¬', '√', 'ƒ', '≈', '∆', '«', '»', '…', '\u{a0}', 'À', 'Ã', 'Õ', 'Œ', 'œ', + '–', '—', '“', '”', '‘', '’', '÷', '◊', 'ÿ', 'Ÿ', '⁄', '€', '‹', '›', 'fi', 'fl', + '‡', '·', '‚', '„', '‰', 'Â', 'Ê', 'Á', 'Ë', 'È', 'Í', 'Î', 'Ï', 'Ì', 'Ó', 'Ô', + '\u{f8ff}', 'Ò', 'Ú', 'Û', 'Ù', 'ı', 'ˆ', '˜', '¯', '˘', '˙', '˚', '¸', '˝', '˛', 'ˇ', + ]; + + fn char_from_mac_roman(code: u8) -> char { + if code < 128 { + code as char + } else { + TABLE[(code - 128) as usize] + } + } + + coded.iter().copied().map(char_from_mac_roman).collect() +} + +/// Trim style naming from a family name. +fn typographic_family(mut family: &str) -> &str { + // Separators between names, modifiers and styles. + const SEPARATORS: [char; 3] = [' ', '-', '_']; + + // Modifiers that can appear in combination with suffixes. + const MODIFIERS: &[&str] = &[ + "extra", "ext", "ex", "x", "semi", "sem", "sm", "demi", "dem", "ultra", + ]; + + // Style suffixes. + #[rustfmt::skip] + const SUFFIXES: &[&str] = &[ + "normal", "italic", "oblique", "slanted", + "thin", "th", "hairline", "light", "lt", "regular", "medium", "med", + "md", "bold", "bd", "demi", "extb", "black", "blk", "bk", "heavy", + "narrow", "condensed", "cond", "cn", "cd", "compressed", "expanded", "exp" + ]; + + // Trim spacing and weird leading dots in Apple fonts. + family = family.trim().trim_start_matches('.'); + + // Lowercase the string so that the suffixes match case-insensitively. + let lower = family.to_ascii_lowercase(); + let mut len = usize::MAX; + let mut trimmed = lower.as_str(); + + // Trim style suffixes repeatedly. + while trimmed.len() < len { + len = trimmed.len(); + + // Find style suffix. + let mut t = match SUFFIXES.iter().find_map(|s| trimmed.strip_suffix(s)) { + Some(t) => t, + None => break, + }; + + // Strip optional separator. + if let Some(s) = t.strip_suffix(SEPARATORS) { + trimmed = s; + t = s; + } + + // Also allow an extra modifier, but apply it only if it is separated it + // from the text before it (to prevent false positives). + if let Some(t) = MODIFIERS.iter().find_map(|s| t.strip_suffix(s)) { + if let Some(stripped) = t.strip_suffix(SEPARATORS) { + trimmed = stripped; + } + } + } + + &family[.. len] +} + +/// How many words the two strings share in their prefix. +fn shared_prefix_words(left: &str, right: &str) -> usize { + left.unicode_words() + .zip(right.unicode_words()) + .take_while(|(l, r)| l == r) + .count() +} + +/// A compactly encoded set of codepoints. +/// +/// The set is represented by alternating specifications of how many codepoints +/// are not in the set and how many are in the set. +/// +/// For example, for the set `{2, 3, 4, 9, 10, 11, 15, 18, 19}`, there are: +/// - 2 codepoints not inside (0, 1) +/// - 3 codepoints inside (2, 3, 4) +/// - 4 codepoints not inside (5, 6, 7, 8) +/// - 3 codepoints inside (9, 10, 11) +/// - 3 codepoints not inside (12, 13, 14) +/// - 1 codepoint inside (15) +/// - 2 codepoints not inside (16, 17) +/// - 2 codepoints inside (18, 19) +/// +/// So the resulting encoding is `[2, 3, 4, 3, 3, 1, 2, 2]`. +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Coverage(Vec); + +impl Coverage { + /// Encode a vector of codepoints. + pub fn from_vec(mut codepoints: Vec) -> Self { + codepoints.sort(); + codepoints.dedup(); + + let mut runs = Vec::new(); + let mut next = 0; + + for c in codepoints { + if let Some(run) = runs.last_mut().filter(|_| c == next) { + *run += 1; + } else { + runs.push(c - next); + runs.push(1); + } + + next = c + 1; + } + + Self(runs) + } + + /// Whether the codepoint is covered. + pub fn contains(&self, c: u32) -> bool { + let mut inside = false; + let mut cursor = 0; + + for &run in &self.0 { + if (cursor .. cursor + run).contains(&c) { + return inside; + } + cursor += run; + inside = !inside; + } + + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_trim_styles() { + assert_eq!(typographic_family("Atma Light"), "Atma"); + assert_eq!(typographic_family("eras bold"), "eras"); + assert_eq!(typographic_family("footlight mt light"), "footlight mt"); + assert_eq!(typographic_family("times new roman"), "times new roman"); + assert_eq!( + typographic_family("noto sans mono cond sembd"), + "noto sans mono" + ); + assert_eq!(typographic_family("noto serif SEMCOND sembd"), "noto serif"); + assert_eq!(typographic_family("crimson text"), "crimson text"); + assert_eq!(typographic_family("footlight light"), "footlight"); + assert_eq!(typographic_family("Noto Sans"), "Noto Sans"); + assert_eq!(typographic_family("Noto Sans Light"), "Noto Sans"); + assert_eq!( + typographic_family("Noto Sans Semicondensed Heavy"), + "Noto Sans" + ); + assert_eq!(typographic_family("Familx"), "Familx"); + assert_eq!(typographic_family("Font Ultra"), "Font Ultra"); + assert_eq!(typographic_family("Font Ultra Bold"), "Font"); + } + + #[test] + fn test_coverage() { + #[track_caller] + fn test(set: &[u32], runs: &[u32]) { + let coverage = Coverage::from_vec(set.to_vec()); + assert_eq!(coverage.0, runs); + + let max = 5 + set.iter().copied().max().unwrap_or_default(); + for c in 0 .. max { + assert_eq!(set.contains(&c), coverage.contains(c)); + } + } + + test(&[], &[]); + test(&[0], &[0, 1]); + test(&[1], &[1, 1]); + test(&[0, 1], &[0, 2]); + test(&[0, 1, 3], &[0, 2, 1, 1]); + test( + // {2, 3, 4, 9, 10, 11, 15, 18, 19} + &[18, 19, 2, 4, 9, 11, 15, 3, 3, 10], + &[2, 3, 4, 3, 3, 1, 2, 2], + ) + } +} diff --git a/src/font/mod.rs b/src/font/mod.rs new file mode 100644 index 00000000..917fea62 --- /dev/null +++ b/src/font/mod.rs @@ -0,0 +1,256 @@ +//! Font handling. + +mod book; +mod variant; + +pub use book::*; +pub use variant::*; + +use std::fmt::{self, Debug, Formatter}; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +use once_cell::sync::OnceCell; +use rex::font::MathHeader; +use ttf_parser::{GlyphId, Tag}; + +use crate::geom::Em; +use crate::loading::Buffer; + +/// An OpenType font. +#[derive(Clone)] +pub struct Font(Arc); + +/// The internal representation of a font. +struct Repr { + /// The raw font data, possibly shared with other fonts from the same + /// collection. The vector's allocation must not move, because `ttf` points + /// into it using unsafe code. + data: Buffer, + /// The font's index in the buffer. + index: u32, + /// Metadata about the font. + info: FontInfo, + /// The font's metrics. + metrics: FontMetrics, + /// The underlying ttf-parser/rustybuzz face. + ttf: rustybuzz::Face<'static>, + /// The parsed ReX math header. + math: OnceCell>, +} + +impl Font { + /// Parse a font from data and collection index. + pub fn new(data: Buffer, index: u32) -> Option { + // Safety: + // - The slices's location is stable in memory: + // - We don't move the underlying vector + // - Nobody else can move it since we have a strong ref to the `Arc`. + // - The internal 'static lifetime is not leaked because its rewritten + // to the self-lifetime in `ttf()`. + let slice: &'static [u8] = + unsafe { std::slice::from_raw_parts(data.as_ptr(), data.len()) }; + + let ttf = rustybuzz::Face::from_slice(slice, index)?; + let metrics = FontMetrics::from_ttf(&ttf); + let info = FontInfo::from_ttf(&ttf)?; + + Some(Self(Arc::new(Repr { + data, + index, + info, + ttf, + metrics, + math: OnceCell::new(), + }))) + } + + /// The underlying buffer. + pub fn data(&self) -> &Buffer { + &self.0.data + } + + /// The font's index in the buffer. + pub fn index(&self) -> u32 { + self.0.index + } + + /// The font's metadata. + pub fn info(&self) -> &FontInfo { + &self.0.info + } + + /// The font's metrics. + pub fn metrics(&self) -> &FontMetrics { + &self.0.metrics + } + + /// The number of font units per one em. + pub fn units_per_em(&self) -> f64 { + self.0.metrics.units_per_em + } + + /// Convert from font units to an em length. + pub fn to_em(&self, units: impl Into) -> Em { + Em::from_units(units, self.units_per_em()) + } + + /// Look up the horizontal advance width of a glyph. + pub fn advance(&self, glyph: u16) -> Option { + self.0 + .ttf + .glyph_hor_advance(GlyphId(glyph)) + .map(|units| self.to_em(units)) + } + + /// Lookup a name by id. + pub fn find_name(&self, id: u16) -> Option { + find_name(&self.0.ttf, id) + } + + /// A reference to the underlying `ttf-parser` / `rustybuzz` face. + pub fn ttf(&self) -> &rustybuzz::Face<'_> { + // We can't implement Deref because that would leak the internal 'static + // lifetime. + &self.0.ttf + } + + /// Access the math header, if any. + pub fn math(&self) -> Option<&MathHeader> { + self.0 + .math + .get_or_init(|| { + let data = self.ttf().table_data(Tag::from_bytes(b"MATH"))?; + MathHeader::parse(data).ok() + }) + .as_ref() + } +} + +impl Hash for Font { + fn hash(&self, state: &mut H) { + self.0.data.hash(state); + } +} + +impl Debug for Font { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "Font({})", self.info().family) + } +} + +impl Eq for Font {} + +impl PartialEq for Font { + fn eq(&self, other: &Self) -> bool { + self.0.data.eq(&other.0.data) + } +} + +/// Metrics of a font. +#[derive(Debug, Copy, Clone)] +pub struct FontMetrics { + /// How many font units represent one em unit. + pub units_per_em: f64, + /// The distance from the baseline to the typographic ascender. + pub ascender: Em, + /// The approximate height of uppercase letters. + pub cap_height: Em, + /// The approximate height of non-ascending lowercase letters. + pub x_height: Em, + /// The distance from the baseline to the typographic descender. + pub descender: Em, + /// Recommended metrics for a strikethrough line. + pub strikethrough: LineMetrics, + /// Recommended metrics for an underline. + pub underline: LineMetrics, + /// Recommended metrics for an overline. + pub overline: LineMetrics, +} + +impl FontMetrics { + /// Extract the font's metrics. + pub fn from_ttf(ttf: &ttf_parser::Face) -> Self { + let units_per_em = f64::from(ttf.units_per_em()); + let to_em = |units| Em::from_units(units, units_per_em); + + let ascender = to_em(ttf.typographic_ascender().unwrap_or(ttf.ascender())); + let cap_height = ttf.capital_height().filter(|&h| h > 0).map_or(ascender, to_em); + let x_height = ttf.x_height().filter(|&h| h > 0).map_or(ascender, to_em); + let descender = to_em(ttf.typographic_descender().unwrap_or(ttf.descender())); + let strikeout = ttf.strikeout_metrics(); + let underline = ttf.underline_metrics(); + + let strikethrough = LineMetrics { + position: strikeout.map_or(Em::new(0.25), |s| to_em(s.position)), + thickness: strikeout + .or(underline) + .map_or(Em::new(0.06), |s| to_em(s.thickness)), + }; + + let underline = LineMetrics { + position: underline.map_or(Em::new(-0.2), |s| to_em(s.position)), + thickness: underline + .or(strikeout) + .map_or(Em::new(0.06), |s| to_em(s.thickness)), + }; + + let overline = LineMetrics { + position: cap_height + Em::new(0.1), + thickness: underline.thickness, + }; + + Self { + units_per_em, + ascender, + cap_height, + x_height, + descender, + strikethrough, + underline, + overline, + } + } + + /// Look up a vertical metric. + pub fn vertical(&self, metric: VerticalFontMetric) -> Em { + match metric { + VerticalFontMetric::Ascender => self.ascender, + VerticalFontMetric::CapHeight => self.cap_height, + VerticalFontMetric::XHeight => self.x_height, + VerticalFontMetric::Baseline => Em::zero(), + VerticalFontMetric::Descender => self.descender, + } + } +} + +/// Metrics for a decorative line. +#[derive(Debug, Copy, Clone)] +pub struct LineMetrics { + /// The vertical offset of the line from the baseline. Positive goes + /// upwards, negative downwards. + pub position: Em, + /// The thickness of the line. + pub thickness: Em, +} + +/// Identifies a vertical metric of a font. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum VerticalFontMetric { + /// The distance from the baseline to the typographic ascender. + /// + /// Corresponds to the typographic ascender from the `OS/2` table if present + /// and falls back to the ascender from the `hhea` table otherwise. + Ascender, + /// The approximate height of uppercase letters. + CapHeight, + /// The approximate height of non-ascending lowercase letters. + XHeight, + /// The baseline on which the letters rest. + Baseline, + /// The distance from the baseline to the typographic descender. + /// + /// Corresponds to the typographic descender from the `OS/2` table if + /// present and falls back to the descender from the `hhea` table otherwise. + Descender, +} diff --git a/src/font/variant.rs b/src/font/variant.rs new file mode 100644 index 00000000..9e16afc8 --- /dev/null +++ b/src/font/variant.rs @@ -0,0 +1,226 @@ +use std::fmt::{self, Debug, Formatter}; + +use serde::{Deserialize, Serialize}; + +/// Properties that distinguish a font from other fonts in the same family. +#[derive(Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[derive(Serialize, Deserialize)] +pub struct FontVariant { + /// The style of the font (normal / italic / oblique). + pub style: FontStyle, + /// How heavy the font is (100 - 900). + pub weight: FontWeight, + /// How condensed or expanded the font is (0.5 - 2.0). + pub stretch: FontStretch, +} + +impl FontVariant { + /// Create a variant from its three components. + pub fn new(style: FontStyle, weight: FontWeight, stretch: FontStretch) -> Self { + Self { style, weight, stretch } + } +} + +impl Debug for FontVariant { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "{:?}-{:?}-{:?}", self.style, self.weight, self.stretch) + } +} + +/// The style of a font. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum FontStyle { + /// The default style. + Normal, + /// A cursive style. + Italic, + /// A slanted style. + Oblique, +} + +impl FontStyle { + /// The conceptual distance between the styles, expressed as a number. + pub fn distance(self, other: Self) -> u16 { + if self == other { + 0 + } else if self != Self::Normal && other != Self::Normal { + 1 + } else { + 2 + } + } +} + +impl Default for FontStyle { + fn default() -> Self { + Self::Normal + } +} + +/// The weight of a font. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[derive(Serialize, Deserialize)] +#[serde(transparent)] +pub struct FontWeight(u16); + +impl FontWeight { + /// Thin weight (100). + pub const THIN: Self = Self(100); + + /// Extra light weight (200). + pub const EXTRALIGHT: Self = Self(200); + + /// Light weight (300). + pub const LIGHT: Self = Self(300); + + /// Regular weight (400). + pub const REGULAR: Self = Self(400); + + /// Medium weight (500). + pub const MEDIUM: Self = Self(500); + + /// Semibold weight (600). + pub const SEMIBOLD: Self = Self(600); + + /// Bold weight (700). + pub const BOLD: Self = Self(700); + + /// Extrabold weight (800). + pub const EXTRABOLD: Self = Self(800); + + /// Black weight (900). + pub const BLACK: Self = Self(900); + + /// Create a font weight from a number between 100 and 900, clamping it if + /// necessary. + pub fn from_number(weight: u16) -> Self { + Self(weight.max(100).min(900)) + } + + /// The number between 100 and 900. + pub fn to_number(self) -> u16 { + self.0 + } + + /// Add (or remove) weight, saturating at the boundaries of 100 and 900. + pub fn thicken(self, delta: i16) -> Self { + Self((self.0 as i16).saturating_add(delta).max(100).min(900) as u16) + } + + /// The absolute number distance between this and another font weight. + pub fn distance(self, other: Self) -> u16 { + (self.0 as i16 - other.0 as i16).abs() as u16 + } +} + +impl Default for FontWeight { + fn default() -> Self { + Self::REGULAR + } +} + +impl Debug for FontWeight { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// The width of a font. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[derive(Serialize, Deserialize)] +#[serde(transparent)] +pub struct FontStretch(u16); + +impl FontStretch { + /// Ultra-condensed stretch (50%). + pub const ULTRA_CONDENSED: Self = Self(500); + + /// Extra-condensed stretch weight (62.5%). + pub const EXTRA_CONDENSED: Self = Self(625); + + /// Condensed stretch (75%). + pub const CONDENSED: Self = Self(750); + + /// Semi-condensed stretch (87.5%). + pub const SEMI_CONDENSED: Self = Self(875); + + /// Normal stretch (100%). + pub const NORMAL: Self = Self(1000); + + /// Semi-expanded stretch (112.5%). + pub const SEMI_EXPANDED: Self = Self(1125); + + /// Expanded stretch (125%). + pub const EXPANDED: Self = Self(1250); + + /// Extra-expanded stretch (150%). + pub const EXTRA_EXPANDED: Self = Self(1500); + + /// Ultra-expanded stretch (200%). + pub const ULTRA_EXPANDED: Self = Self(2000); + + /// Create a font stretch from a ratio between 0.5 and 2.0, clamping it if + /// necessary. + pub fn from_ratio(ratio: f32) -> Self { + Self((ratio.max(0.5).min(2.0) * 1000.0) as u16) + } + + /// Create a font stretch from an OpenType-style number between 1 and 9, + /// clamping it if necessary. + pub fn from_number(stretch: u16) -> Self { + match stretch { + 0 | 1 => Self::ULTRA_CONDENSED, + 2 => Self::EXTRA_CONDENSED, + 3 => Self::CONDENSED, + 4 => Self::SEMI_CONDENSED, + 5 => Self::NORMAL, + 6 => Self::SEMI_EXPANDED, + 7 => Self::EXPANDED, + 8 => Self::EXTRA_EXPANDED, + _ => Self::ULTRA_EXPANDED, + } + } + + /// The ratio between 0.5 and 2.0 corresponding to this stretch. + pub fn to_ratio(self) -> f32 { + self.0 as f32 / 1000.0 + } + + /// The absolute ratio distance between this and another font stretch. + pub fn distance(self, other: Self) -> f32 { + (self.to_ratio() - other.to_ratio()).abs() + } +} + +impl Default for FontStretch { + fn default() -> Self { + Self::NORMAL + } +} + +impl Debug for FontStretch { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "{}%", 100.0 * self.to_ratio()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_font_weight_distance() { + let d = |a, b| FontWeight(a).distance(FontWeight(b)); + assert_eq!(d(500, 200), 300); + assert_eq!(d(500, 500), 0); + assert_eq!(d(500, 900), 400); + assert_eq!(d(10, 100), 90); + } + + #[test] + fn test_font_stretch_debug() { + assert_eq!(format!("{:?}", FontStretch::EXPANDED), "125%") + } +} -- cgit v1.2.3