diff options
| author | Laurenz <laurmaedje@gmail.com> | 2024-10-27 19:04:55 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-10-27 18:04:55 +0000 |
| commit | be7cfc85d08c545abfac08098b7b33b4bd71f37e (patch) | |
| tree | f4137fa2aaa57babae1f7603a9b2ed7e688f43d8 /crates/typst-library/src/text/font | |
| parent | b8034a343831e8609aec2ec81eb7eeda57aa5d81 (diff) | |
Split out four new crates (#5302)
Diffstat (limited to 'crates/typst-library/src/text/font')
| -rw-r--r-- | crates/typst-library/src/text/font/book.rs | 546 | ||||
| -rw-r--r-- | crates/typst-library/src/text/font/color.rs | 627 | ||||
| -rw-r--r-- | crates/typst-library/src/text/font/exceptions.rs | 337 | ||||
| -rw-r--r-- | crates/typst-library/src/text/font/mod.rs | 306 | ||||
| -rw-r--r-- | crates/typst-library/src/text/font/variant.rs | 320 |
5 files changed, 2136 insertions, 0 deletions
diff --git a/crates/typst-library/src/text/font/book.rs b/crates/typst-library/src/text/font/book.rs new file mode 100644 index 00000000..23e27f64 --- /dev/null +++ b/crates/typst-library/src/text/font/book.rs @@ -0,0 +1,546 @@ +use std::cmp::Reverse; +use std::collections::BTreeMap; +use std::fmt::{self, Debug, Formatter}; + +use serde::{Deserialize, Serialize}; +use ttf_parser::{name_id, PlatformId, Tag}; +use unicode_segmentation::UnicodeSegmentation; + +use super::exceptions::find_exception; +use crate::text::{Font, FontStretch, FontStyle, FontVariant, FontWeight}; + +/// Metadata about a collection of fonts. +#[derive(Debug, Default, Clone, Hash)] +pub struct FontBook { + /// Maps from lowercased family names to font indices. + families: BTreeMap<String, Vec<usize>>, + /// Metadata about each font in the collection. + infos: Vec<FontInfo>, +} + +impl FontBook { + /// Create a new, empty font book. + pub fn new() -> Self { + Self { families: BTreeMap::new(), infos: vec![] } + } + + /// Create a font book from a collection of font infos. + pub fn from_infos(infos: impl IntoIterator<Item = FontInfo>) -> Self { + let mut book = Self::new(); + for info in infos { + book.push(info); + } + book + } + + /// Create a font book for a collection of fonts. + pub fn from_fonts<'a>(fonts: impl IntoIterator<Item = &'a Font>) -> Self { + Self::from_infos(fonts.into_iter().map(|font| font.info().clone())) + } + + /// 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); + } + + /// Get the font info for the given index. + pub fn info(&self, index: usize) -> Option<&FontInfo> { + self.infos.get(index) + } + + /// Returns true if the book contains a font family with the given name. + pub fn contains_family(&self, family: &str) -> bool { + self.families.contains_key(family) + } + + /// An ordered iterator over all font families this book knows and details + /// about the fonts that are part of them. + pub fn families( + &self, + ) -> impl Iterator<Item = (&str, impl Iterator<Item = &FontInfo>)> + '_ { + // 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 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<usize> { + let ids = self.families.get(family)?; + self.find_best_variant(None, variant, ids.iter().copied()) + } + + /// Iterate over all variants of a family. + pub fn select_family(&self, family: &str) -> impl Iterator<Item = usize> + '_ { + self.families + .get(family) + .map(|vec| vec.as_slice()) + .unwrap_or_default() + .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<usize> { + // Find the fonts that contain the text's first non-space char ... + let c = text.chars().find(|c| !c.is_whitespace())?; + 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<Item = usize>, + ) -> Option<usize> { + 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, Hash, 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(Copy, Clone, Eq, PartialEq, Hash, Debug)] + #[derive(Serialize, Deserialize)] + #[serde(transparent)] + 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 font at the `index` of the given data. + pub fn new(data: &[u8], index: u32) -> Option<Self> { + let ttf = ttf_parser::Face::parse(data, index).ok()?; + Self::from_ttf(&ttf) + } + + /// Compute metadata for all fonts in the given data. + pub fn iter(data: &[u8]) -> impl Iterator<Item = FontInfo> + '_ { + let count = ttf_parser::fonts_in_collection(data).unwrap_or(1); + (0..count).filter_map(move |index| Self::new(data, index)) + } + + /// Compute metadata for a single ttf-parser face. + pub(super) fn from_ttf(ttf: &ttf_parser::Face) -> Option<Self> { + let ps_name = find_name(ttf, name_id::POST_SCRIPT_NAME); + let exception = ps_name.as_deref().and_then(find_exception); + // 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"). + let family = + exception.and_then(|c| c.family.map(str::to_string)).or_else(|| { + let family = find_name(ttf, name_id::FAMILY)?; + Some(typographic_family(&family).to_string()) + })?; + + let variant = { + let style = exception.and_then(|c| c.style).unwrap_or_else(|| { + 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"); + + match (italic, oblique) { + (false, false) => FontStyle::Normal, + (true, _) => FontStyle::Italic, + (_, true) => FontStyle::Oblique, + } + }); + + let weight = exception.and_then(|c| c.weight).unwrap_or_else(|| { + let number = ttf.weight().to_number(); + FontWeight::from_number(number) + }); + + let stretch = exception + .and_then(|c| c.stretch) + .unwrap_or_else(|| 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 + .raw_face() + .table(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), + }) + } + + /// Whether this is the macOS LastResort font. It can yield tofus with + /// glyph ID != 0. + pub fn is_last_resort(&self) -> bool { + self.family == "LastResort" + } +} + +/// Try to find and decode the name with the given id. +pub(super) fn find_name(ttf: &ttf_parser::Face, name_id: u16) -> Option<String> { + 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 and fix bad names. +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 = trimmed; + let mut shortened = false; + while let Some(s) = SUFFIXES.iter().find_map(|s| t.strip_suffix(s)) { + shortened = true; + t = s; + } + + if !shortened { + 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; + } + } + } + + // Apply style suffix trimming. + family = &family[..len]; + + family +} + +/// 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(Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Coverage(Vec<u32>); + +impl Coverage { + /// Encode a vector of codepoints. + pub fn from_vec(mut codepoints: Vec<u32>) -> 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 + } + + /// Iterate over all covered codepoints. + pub fn iter(&self) -> impl Iterator<Item = u32> + '_ { + let mut inside = false; + let mut cursor = 0; + self.0.iter().flat_map(move |run| { + let range = if inside { cursor..cursor + run } else { 0..0 }; + inside = !inside; + cursor += run; + range + }) + } +} + +impl Debug for Coverage { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + f.pad("Coverage(..)") + } +} + +#[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], + ) + } + + #[test] + fn test_coverage_iter() { + let codepoints = vec![2, 3, 7, 8, 9, 14, 15, 19, 21]; + let coverage = Coverage::from_vec(codepoints.clone()); + assert_eq!(coverage.iter().collect::<Vec<_>>(), codepoints); + } +} diff --git a/crates/typst-library/src/text/font/color.rs b/crates/typst-library/src/text/font/color.rs new file mode 100644 index 00000000..08f6fe0a --- /dev/null +++ b/crates/typst-library/src/text/font/color.rs @@ -0,0 +1,627 @@ +//! Utilities for color font handling + +use std::io::Read; + +use ttf_parser::{GlyphId, RgbaColor}; +use typst_syntax::Span; +use usvg::tiny_skia_path; +use xmlwriter::XmlWriter; + +use crate::layout::{Abs, Frame, FrameItem, Point, Size}; +use crate::text::{Font, Glyph}; +use crate::visualize::{FixedStroke, Geometry, Image, RasterFormat, VectorFormat}; + +/// Whether this glyph should be rendered via simple outlining instead of via +/// `glyph_frame`. +pub fn should_outline(font: &Font, glyph: &Glyph) -> bool { + let ttf = font.ttf(); + let glyph_id = GlyphId(glyph.id); + (ttf.tables().glyf.is_some() || ttf.tables().cff.is_some()) + && !ttf + .glyph_raster_image(glyph_id, u16::MAX) + .is_some_and(|img| img.format == ttf_parser::RasterImageFormat::PNG) + && !ttf.is_color_glyph(glyph_id) + && ttf.glyph_svg_image(glyph_id).is_none() +} + +/// Returns a frame representing a glyph and whether it is a fallback tofu +/// frame. +/// +/// Should only be called on glyphs for which [`should_outline`] returns false. +/// +/// The glyphs are sized in font units, [`text.item.size`] is not taken into +/// account. +#[comemo::memoize] +pub fn glyph_frame(font: &Font, glyph_id: u16) -> (Frame, bool) { + let upem = Abs::pt(font.units_per_em()); + let glyph_id = GlyphId(glyph_id); + + let mut frame = Frame::soft(Size::splat(upem)); + let mut tofu = false; + + if draw_glyph(&mut frame, font, upem, glyph_id).is_none() + && font.ttf().glyph_index(' ') != Some(glyph_id) + { + // Generate a fallback tofu if the glyph couldn't be drawn, unless it is + // the space glyph. Then, an empty frame does the job. (This happens for + // some rare CBDT fonts, which don't define a bitmap for the space, but + // also don't have a glyf or CFF table.) + draw_fallback_tofu(&mut frame, font, upem, glyph_id); + tofu = true; + } + + (frame, tofu) +} + +/// Tries to draw a glyph. +fn draw_glyph( + frame: &mut Frame, + font: &Font, + upem: Abs, + glyph_id: GlyphId, +) -> Option<()> { + let ttf = font.ttf(); + if let Some(raster_image) = ttf + .glyph_raster_image(glyph_id, u16::MAX) + .filter(|img| img.format == ttf_parser::RasterImageFormat::PNG) + { + draw_raster_glyph(frame, font, upem, raster_image) + } else if ttf.is_color_glyph(glyph_id) { + draw_colr_glyph(frame, font, upem, glyph_id) + } else if ttf.glyph_svg_image(glyph_id).is_some() { + draw_svg_glyph(frame, font, upem, glyph_id) + } else { + None + } +} + +/// Draws a fallback tofu box with the advance width of the glyph. +fn draw_fallback_tofu(frame: &mut Frame, font: &Font, upem: Abs, glyph_id: GlyphId) { + let advance = font + .ttf() + .glyph_hor_advance(glyph_id) + .map(|advance| Abs::pt(advance as f64)) + .unwrap_or(upem / 3.0); + let inset = 0.15 * advance; + let height = 0.7 * upem; + let pos = Point::new(inset, upem - height); + let size = Size::new(advance - inset * 2.0, height); + let thickness = upem / 20.0; + let stroke = FixedStroke { thickness, ..Default::default() }; + let shape = Geometry::Rect(size).stroked(stroke); + frame.push(pos, FrameItem::Shape(shape, Span::detached())); +} + +/// Draws a raster glyph in a frame. +/// +/// Supports only PNG images. +fn draw_raster_glyph( + frame: &mut Frame, + font: &Font, + upem: Abs, + raster_image: ttf_parser::RasterGlyphImage, +) -> Option<()> { + let image = + Image::new(raster_image.data.into(), RasterFormat::Png.into(), None).ok()?; + + // Apple Color emoji doesn't provide offset information (or at least + // not in a way ttf-parser understands), so we artificially shift their + // baseline to make it look good. + let y_offset = if font.info().family.to_lowercase() == "apple color emoji" { + 20.0 + } else { + -(raster_image.y as f64) + }; + + let position = Point::new( + upem * raster_image.x as f64 / raster_image.pixels_per_em as f64, + upem * y_offset / raster_image.pixels_per_em as f64, + ); + let aspect_ratio = image.width() / image.height(); + let size = Size::new(upem, upem * aspect_ratio); + frame.push(position, FrameItem::Image(image, size, Span::detached())); + + Some(()) +} + +/// Draws a glyph from the COLR table into the frame. +fn draw_colr_glyph( + frame: &mut Frame, + font: &Font, + upem: Abs, + glyph_id: GlyphId, +) -> Option<()> { + let mut svg = XmlWriter::new(xmlwriter::Options::default()); + + let ttf = font.ttf(); + let width = ttf.global_bounding_box().width() as f64; + let height = ttf.global_bounding_box().height() as f64; + let x_min = ttf.global_bounding_box().x_min as f64; + let y_max = ttf.global_bounding_box().y_max as f64; + let tx = -x_min; + let ty = -y_max; + + svg.start_element("svg"); + svg.write_attribute("xmlns", "http://www.w3.org/2000/svg"); + svg.write_attribute("xmlns:xlink", "http://www.w3.org/1999/xlink"); + svg.write_attribute("width", &width); + svg.write_attribute("height", &height); + svg.write_attribute_fmt("viewBox", format_args!("0 0 {width} {height}")); + + let mut path_buf = String::with_capacity(256); + let gradient_index = 1; + let clip_path_index = 1; + + svg.start_element("g"); + svg.write_attribute_fmt( + "transform", + format_args!("matrix(1 0 0 -1 0 0) matrix(1 0 0 1 {tx} {ty})"), + ); + + let mut glyph_painter = GlyphPainter { + face: ttf, + svg: &mut svg, + path_buf: &mut path_buf, + gradient_index, + clip_path_index, + palette_index: 0, + transform: ttf_parser::Transform::default(), + outline_transform: ttf_parser::Transform::default(), + transforms_stack: vec![ttf_parser::Transform::default()], + }; + + ttf.paint_color_glyph(glyph_id, 0, RgbaColor::new(0, 0, 0, 255), &mut glyph_painter)?; + svg.end_element(); + + let data = svg.end_document().into_bytes(); + + let image = Image::new(data.into(), VectorFormat::Svg.into(), None).ok()?; + + let y_shift = Abs::pt(upem.to_pt() - y_max); + let position = Point::new(Abs::pt(x_min), y_shift); + let size = Size::new(Abs::pt(width), Abs::pt(height)); + frame.push(position, FrameItem::Image(image, size, Span::detached())); + + Some(()) +} + +/// Draws an SVG glyph in a frame. +fn draw_svg_glyph( + frame: &mut Frame, + font: &Font, + upem: Abs, + glyph_id: GlyphId, +) -> Option<()> { + // TODO: Our current conversion of the SVG table works for Twitter Color Emoji, + // but might not work for others. See also: https://github.com/RazrFalcon/resvg/pull/776 + let mut data = font.ttf().glyph_svg_image(glyph_id)?.data; + + // Decompress SVGZ. + let mut decoded = vec![]; + if data.starts_with(&[0x1f, 0x8b]) { + let mut decoder = flate2::read::GzDecoder::new(data); + decoder.read_to_end(&mut decoded).ok()?; + data = &decoded; + } + + // Parse XML. + let xml = std::str::from_utf8(data).ok()?; + let document = roxmltree::Document::parse(xml).ok()?; + + // Parse SVG. + let opts = usvg::Options::default(); + let tree = usvg::Tree::from_xmltree(&document, &opts).ok()?; + + let bbox = tree.root().bounding_box(); + let width = bbox.width() as f64; + let height = bbox.height() as f64; + let left = bbox.left() as f64; + let top = bbox.top() as f64; + + let mut data = tree.to_string(&usvg::WriteOptions::default()); + + // The SVG coordinates and the font coordinates are not the same: the Y axis + // is mirrored. But the origin of the axes are the same (which means that + // the horizontal axis in the SVG document corresponds to the baseline). See + // the reference for more details: + // https://learn.microsoft.com/en-us/typography/opentype/spec/svg#coordinate-systems-and-glyph-metrics + // + // If we used the SVG document as it is, svg2pdf would produce a cropped + // glyph (only what is under the baseline would be visible). So we need to + // embed the original SVG in another one that has the exact dimensions of + // the glyph, with a transform to make it fit. We also need to remove the + // viewBox, height and width attributes from the inner SVG, otherwise usvg + // takes into account these values to clip the embedded SVG. + make_svg_unsized(&mut data); + let wrapper_svg = format!( + r#" + <svg + width="{width}" + height="{height}" + viewBox="0 0 {width} {height}" + xmlns="http://www.w3.org/2000/svg"> + <g transform="matrix(1 0 0 1 {tx} {ty})"> + {inner} + </g> + </svg> + "#, + inner = data, + tx = -left, + ty = -top, + ); + + let image = + Image::new(wrapper_svg.into_bytes().into(), VectorFormat::Svg.into(), None) + .ok()?; + + let position = Point::new(Abs::pt(left), Abs::pt(top) + upem); + let size = Size::new(Abs::pt(width), Abs::pt(height)); + frame.push(position, FrameItem::Image(image, size, Span::detached())); + + Some(()) +} + +/// Remove all size specifications (viewBox, width and height attributes) from a +/// SVG document. +fn make_svg_unsized(svg: &mut String) { + let mut viewbox_range = None; + let mut width_range = None; + let mut height_range = None; + + let mut s = unscanny::Scanner::new(svg); + + s.eat_until("<svg"); + s.eat_if("<svg"); + while !s.eat_if('>') && !s.done() { + s.eat_whitespace(); + let start = s.cursor(); + let attr_name = s.eat_until('=').trim(); + // Eat the equal sign and the quote. + s.eat(); + s.eat(); + let mut escaped = false; + while (escaped || !s.eat_if('"')) && !s.done() { + escaped = s.eat() == Some('\\'); + } + match attr_name { + "viewBox" => viewbox_range = Some(start..s.cursor()), + "width" => width_range = Some(start..s.cursor()), + "height" => height_range = Some(start..s.cursor()), + _ => {} + } + } + + // Remove the `viewBox` attribute. + if let Some(range) = viewbox_range { + svg.replace_range(range.clone(), &" ".repeat(range.len())); + } + + // Remove the `width` attribute. + if let Some(range) = width_range { + svg.replace_range(range.clone(), &" ".repeat(range.len())); + } + + // Remove the `height` attribute. + if let Some(range) = height_range { + svg.replace_range(range, ""); + } +} + +struct ColrBuilder<'a>(&'a mut String); + +impl ColrBuilder<'_> { + fn finish(&mut self) { + if !self.0.is_empty() { + self.0.pop(); // remove trailing space + } + } +} + +impl ttf_parser::OutlineBuilder for ColrBuilder<'_> { + fn move_to(&mut self, x: f32, y: f32) { + use std::fmt::Write; + write!(self.0, "M {x} {y} ").unwrap() + } + + fn line_to(&mut self, x: f32, y: f32) { + use std::fmt::Write; + write!(self.0, "L {x} {y} ").unwrap() + } + + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + use std::fmt::Write; + write!(self.0, "Q {x1} {y1} {x} {y} ").unwrap() + } + + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + use std::fmt::Write; + write!(self.0, "C {x1} {y1} {x2} {y2} {x} {y} ").unwrap() + } + + fn close(&mut self) { + self.0.push_str("Z ") + } +} + +// NOTE: This is only a best-effort translation of COLR into SVG. It's not feature-complete +// and it's also not possible to make it feature-complete using just raw SVG features. +pub(crate) struct GlyphPainter<'a> { + pub(crate) face: &'a ttf_parser::Face<'a>, + pub(crate) svg: &'a mut xmlwriter::XmlWriter, + pub(crate) path_buf: &'a mut String, + pub(crate) gradient_index: usize, + pub(crate) clip_path_index: usize, + pub(crate) palette_index: u16, + pub(crate) transform: ttf_parser::Transform, + pub(crate) outline_transform: ttf_parser::Transform, + pub(crate) transforms_stack: Vec<ttf_parser::Transform>, +} + +impl<'a> GlyphPainter<'a> { + fn write_gradient_stops(&mut self, stops: ttf_parser::colr::GradientStopsIter) { + for stop in stops { + self.svg.start_element("stop"); + self.svg.write_attribute("offset", &stop.stop_offset); + self.write_color_attribute("stop-color", stop.color); + let opacity = f32::from(stop.color.alpha) / 255.0; + self.svg.write_attribute("stop-opacity", &opacity); + self.svg.end_element(); + } + } + + fn write_color_attribute(&mut self, name: &str, color: ttf_parser::RgbaColor) { + self.svg.write_attribute_fmt( + name, + format_args!("rgb({}, {}, {})", color.red, color.green, color.blue), + ); + } + + fn write_transform_attribute(&mut self, name: &str, ts: ttf_parser::Transform) { + if ts.is_default() { + return; + } + + self.svg.write_attribute_fmt( + name, + format_args!("matrix({} {} {} {} {} {})", ts.a, ts.b, ts.c, ts.d, ts.e, ts.f), + ); + } + + fn write_spread_method_attribute( + &mut self, + extend: ttf_parser::colr::GradientExtend, + ) { + self.svg.write_attribute( + "spreadMethod", + match extend { + ttf_parser::colr::GradientExtend::Pad => &"pad", + ttf_parser::colr::GradientExtend::Repeat => &"repeat", + ttf_parser::colr::GradientExtend::Reflect => &"reflect", + }, + ); + } + + fn paint_solid(&mut self, color: ttf_parser::RgbaColor) { + self.svg.start_element("path"); + self.write_color_attribute("fill", color); + let opacity = f32::from(color.alpha) / 255.0; + self.svg.write_attribute("fill-opacity", &opacity); + self.write_transform_attribute("transform", self.outline_transform); + self.svg.write_attribute("d", self.path_buf); + self.svg.end_element(); + } + + fn paint_linear_gradient(&mut self, gradient: ttf_parser::colr::LinearGradient<'a>) { + let gradient_id = format!("lg{}", self.gradient_index); + self.gradient_index += 1; + + let gradient_transform = paint_transform(self.outline_transform, self.transform); + + // TODO: We ignore x2, y2. Have to apply them somehow. + // TODO: The way spreadMode works in ttf and svg is a bit different. In SVG, the spreadMode + // will always be applied based on x1/y1 and x2/y2. However, in TTF the spreadMode will + // be applied from the first/last stop. So if we have a gradient with x1=0 x2=1, and + // a stop at x=0.4 and x=0.6, then in SVG we will always see a padding, while in ttf + // we will see the actual spreadMode. We need to account for that somehow. + self.svg.start_element("linearGradient"); + self.svg.write_attribute("id", &gradient_id); + self.svg.write_attribute("x1", &gradient.x0); + self.svg.write_attribute("y1", &gradient.y0); + self.svg.write_attribute("x2", &gradient.x1); + self.svg.write_attribute("y2", &gradient.y1); + self.svg.write_attribute("gradientUnits", &"userSpaceOnUse"); + self.write_spread_method_attribute(gradient.extend); + self.write_transform_attribute("gradientTransform", gradient_transform); + self.write_gradient_stops( + gradient.stops(self.palette_index, self.face.variation_coordinates()), + ); + self.svg.end_element(); + + self.svg.start_element("path"); + self.svg + .write_attribute_fmt("fill", format_args!("url(#{gradient_id})")); + self.write_transform_attribute("transform", self.outline_transform); + self.svg.write_attribute("d", self.path_buf); + self.svg.end_element(); + } + + fn paint_radial_gradient(&mut self, gradient: ttf_parser::colr::RadialGradient<'a>) { + let gradient_id = format!("rg{}", self.gradient_index); + self.gradient_index += 1; + + let gradient_transform = paint_transform(self.outline_transform, self.transform); + + self.svg.start_element("radialGradient"); + self.svg.write_attribute("id", &gradient_id); + self.svg.write_attribute("cx", &gradient.x1); + self.svg.write_attribute("cy", &gradient.y1); + self.svg.write_attribute("r", &gradient.r1); + self.svg.write_attribute("fr", &gradient.r0); + self.svg.write_attribute("fx", &gradient.x0); + self.svg.write_attribute("fy", &gradient.y0); + self.svg.write_attribute("gradientUnits", &"userSpaceOnUse"); + self.write_spread_method_attribute(gradient.extend); + self.write_transform_attribute("gradientTransform", gradient_transform); + self.write_gradient_stops( + gradient.stops(self.palette_index, self.face.variation_coordinates()), + ); + self.svg.end_element(); + + self.svg.start_element("path"); + self.svg + .write_attribute_fmt("fill", format_args!("url(#{gradient_id})")); + self.write_transform_attribute("transform", self.outline_transform); + self.svg.write_attribute("d", self.path_buf); + self.svg.end_element(); + } + + fn paint_sweep_gradient(&mut self, _: ttf_parser::colr::SweepGradient<'a>) {} +} + +fn paint_transform( + outline_transform: ttf_parser::Transform, + transform: ttf_parser::Transform, +) -> ttf_parser::Transform { + let outline_transform = tiny_skia_path::Transform::from_row( + outline_transform.a, + outline_transform.b, + outline_transform.c, + outline_transform.d, + outline_transform.e, + outline_transform.f, + ); + + let gradient_transform = tiny_skia_path::Transform::from_row( + transform.a, + transform.b, + transform.c, + transform.d, + transform.e, + transform.f, + ); + + let gradient_transform = outline_transform + .invert() + // In theory, we should error out. But the transform shouldn't ever be uninvertible, so let's ignore it. + .unwrap_or_default() + .pre_concat(gradient_transform); + + ttf_parser::Transform { + a: gradient_transform.sx, + b: gradient_transform.ky, + c: gradient_transform.kx, + d: gradient_transform.sy, + e: gradient_transform.tx, + f: gradient_transform.ty, + } +} + +impl GlyphPainter<'_> { + fn clip_with_path(&mut self, path: &str) { + let clip_id = format!("cp{}", self.clip_path_index); + self.clip_path_index += 1; + + self.svg.start_element("clipPath"); + self.svg.write_attribute("id", &clip_id); + self.svg.start_element("path"); + self.write_transform_attribute("transform", self.outline_transform); + self.svg.write_attribute("d", &path); + self.svg.end_element(); + self.svg.end_element(); + + self.svg.start_element("g"); + self.svg + .write_attribute_fmt("clip-path", format_args!("url(#{clip_id})")); + } +} + +impl<'a> ttf_parser::colr::Painter<'a> for GlyphPainter<'a> { + fn outline_glyph(&mut self, glyph_id: ttf_parser::GlyphId) { + self.path_buf.clear(); + let mut builder = ColrBuilder(self.path_buf); + match self.face.outline_glyph(glyph_id, &mut builder) { + Some(v) => v, + None => return, + }; + builder.finish(); + + // We have to write outline using the current transform. + self.outline_transform = self.transform; + } + + fn push_layer(&mut self, mode: ttf_parser::colr::CompositeMode) { + self.svg.start_element("g"); + + use ttf_parser::colr::CompositeMode; + // TODO: Need to figure out how to represent the other blend modes + // in SVG. + let mode = match mode { + CompositeMode::SourceOver => "normal", + CompositeMode::Screen => "screen", + CompositeMode::Overlay => "overlay", + CompositeMode::Darken => "darken", + CompositeMode::Lighten => "lighten", + CompositeMode::ColorDodge => "color-dodge", + CompositeMode::ColorBurn => "color-burn", + CompositeMode::HardLight => "hard-light", + CompositeMode::SoftLight => "soft-light", + CompositeMode::Difference => "difference", + CompositeMode::Exclusion => "exclusion", + CompositeMode::Multiply => "multiply", + CompositeMode::Hue => "hue", + CompositeMode::Saturation => "saturation", + CompositeMode::Color => "color", + CompositeMode::Luminosity => "luminosity", + _ => "normal", + }; + self.svg.write_attribute_fmt( + "style", + format_args!("mix-blend-mode: {mode}; isolation: isolate"), + ); + } + + fn pop_layer(&mut self) { + self.svg.end_element(); // g + } + + fn push_transform(&mut self, transform: ttf_parser::Transform) { + self.transforms_stack.push(self.transform); + self.transform = ttf_parser::Transform::combine(self.transform, transform); + } + + fn paint(&mut self, paint: ttf_parser::colr::Paint<'a>) { + match paint { + ttf_parser::colr::Paint::Solid(color) => self.paint_solid(color), + ttf_parser::colr::Paint::LinearGradient(lg) => self.paint_linear_gradient(lg), + ttf_parser::colr::Paint::RadialGradient(rg) => self.paint_radial_gradient(rg), + ttf_parser::colr::Paint::SweepGradient(sg) => self.paint_sweep_gradient(sg), + } + } + + fn pop_transform(&mut self) { + if let Some(ts) = self.transforms_stack.pop() { + self.transform = ts + } + } + + fn push_clip(&mut self) { + self.clip_with_path(&self.path_buf.clone()); + } + + fn pop_clip(&mut self) { + self.svg.end_element(); + } + + fn push_clip_box(&mut self, clipbox: ttf_parser::colr::ClipBox) { + let x_min = clipbox.x_min; + let x_max = clipbox.x_max; + let y_min = clipbox.y_min; + let y_max = clipbox.y_max; + + let clip_path = format!( + "M {x_min} {y_min} L {x_max} {y_min} L {x_max} {y_max} L {x_min} {y_max} Z" + ); + + self.clip_with_path(&clip_path); + } +} diff --git a/crates/typst-library/src/text/font/exceptions.rs b/crates/typst-library/src/text/font/exceptions.rs new file mode 100644 index 00000000..6393df4b --- /dev/null +++ b/crates/typst-library/src/text/font/exceptions.rs @@ -0,0 +1,337 @@ +use serde::Deserialize; + +use super::{FontStretch, FontStyle, FontWeight}; + +pub fn find_exception(postscript_name: &str) -> Option<&'static Exception> { + EXCEPTION_MAP.get(postscript_name) +} + +#[derive(Debug, Default, Deserialize)] +pub struct Exception { + pub family: Option<&'static str>, + pub style: Option<FontStyle>, + pub weight: Option<FontWeight>, + pub stretch: Option<FontStretch>, +} + +impl Exception { + const fn new() -> Self { + Self { + family: None, + style: None, + weight: None, + stretch: None, + } + } + + const fn family(self, family: &'static str) -> Self { + Self { family: Some(family), ..self } + } + + const fn style(self, style: FontStyle) -> Self { + Self { style: Some(style), ..self } + } + + const fn weight(self, weight: u16) -> Self { + Self { weight: Some(FontWeight(weight)), ..self } + } + + #[allow(unused)] // left for future use + const fn stretch(self, stretch: u16) -> Self { + Self { stretch: Some(FontStretch(stretch)), ..self } + } +} + +/// A map which keys are PostScript name and values are override entries. +static EXCEPTION_MAP: phf::Map<&'static str, Exception> = phf::phf_map! { + // The old version of Arial-Black, published by Microsoft in 1996 in their + // "core fonts for the web" project, has a wrong weight of 400. + // See https://corefonts.sourceforge.net/. + "Arial-Black" => Exception::new() + .weight(900), + // Archivo Narrow is different from Archivo and Archivo Black. Since Archivo Black + // seems identical to Archivo weight 900, only differentiate between Archivo and + // Archivo Narrow. + "ArchivoNarrow-Regular" => Exception::new() + .family("Archivo Narrow"), + "ArchivoNarrow-Italic" => Exception::new() + .family("Archivo Narrow"), + "ArchivoNarrow-Bold" => Exception::new() + .family("Archivo Narrow"), + "ArchivoNarrow-BoldItalic" => Exception::new() + .family("Archivo Narrow"), + // Fandol fonts designed for Chinese typesetting. + // See https://ctan.org/tex-archive/fonts/fandol/. + "FandolHei-Bold" => Exception::new() + .weight(700), + "FandolSong-Bold" => Exception::new() + .weight(700), + // Noto fonts + "NotoNaskhArabicUISemi-Bold" => Exception::new() + .family("Noto Naskh Arabic UI") + .weight(600), + "NotoSansSoraSompengSemi-Bold" => Exception::new() + .family("Noto Sans Sora Sompeng") + .weight(600), + "NotoSans-DisplayBlackItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayCondensedBlackItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayCondensedBold" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayCondensedBoldItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayCondensedExtraBoldItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayCondensedExtraLightItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayCondensedItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayCondensedLightItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayCondensedMediumItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayCondensedSemiBoldItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayCondensedThinItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayExtraBoldItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayExtraCondensedBlackItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayExtraCondensedBold" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayExtraCondensedBoldItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayExtraCondensedExtraBoldItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayExtraCondensedExtraLightItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayExtraCondensedItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayExtraCondensedLightItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayExtraCondensedMediumItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayExtraCondensedSemiBoldItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayExtraCondensedThinItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayExtraLightItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayLightItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayMediumItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplaySemiBoldItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplaySemiCondensedBlackItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplaySemiCondensedBold" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplaySemiCondensedBoldItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplaySemiCondensedExtraBoldItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplaySemiCondensedExtraLightItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplaySemiCondensedItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplaySemiCondensedLightItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplaySemiCondensedMediumItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplaySemiCondensedSemiBoldItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplaySemiCondensedThinItalic" => Exception::new() + .family("Noto Sans Display"), + "NotoSans-DisplayThinItalic" => Exception::new() + .family("Noto Sans Display"), + // The following three postscript names are only used in the version 2.007 + // of the Noto Sans font. Other versions, while have different postscript + // name, happen to have correct metadata. + "NotoSerif-DisplayCondensedBold" => Exception::new() + .family("Noto Serif Display"), + "NotoSerif-DisplayExtraCondensedBold" => Exception::new() + .family("Noto Serif Display"), + "NotoSerif-DisplaySemiCondensedBold" => Exception::new() + .family("Noto Serif Display"), + // New Computer Modern + "NewCM08-Book" => Exception::new() + .family("New Computer Modern 08") + .weight(450), + "NewCM08-BookItalic" => Exception::new() + .family("New Computer Modern 08") + .weight(450), + "NewCM08-Italic" => Exception::new() + .family("New Computer Modern 08"), + "NewCM08-Regular" => Exception::new() + .family("New Computer Modern 08"), + "NewCM10-Bold" => Exception::new() + .family("New Computer Modern"), + "NewCM10-BoldItalic" => Exception::new() + .family("New Computer Modern"), + "NewCM10-Book" => Exception::new() + .family("New Computer Modern") + .weight(450), + "NewCM10-BookItalic" => Exception::new() + .family("New Computer Modern") + .weight(450), + "NewCM10-Italic" => Exception::new() + .family("New Computer Modern"), + "NewCM10-Regular" => Exception::new() + .family("New Computer Modern"), + "NewCMMath-Bold" => Exception::new() + .family("New Computer Modern Math") + .weight(700), + "NewCMMath-Book" => Exception::new() + .family("New Computer Modern Math") + .weight(450), + "NewCMMath-Regular" => Exception::new() + .family("New Computer Modern Math"), + "NewCMMono10-Bold" => Exception::new() + .family("New Computer Modern Mono"), + "NewCMMono10-BoldOblique" => Exception::new() + .family("New Computer Modern Mono"), + "NewCMMono10-Book" => Exception::new() + .family("New Computer Modern Mono") + .weight(450), + "NewCMMono10-BookItalic" => Exception::new() + .family("New Computer Modern Mono") + .weight(450), + "NewCMMono10-Italic" => Exception::new() + .family("New Computer Modern Mono"), + "NewCMMono10-Regular" => Exception::new() + .family("New Computer Modern Mono"), + "NewCMSans08-Book" => Exception::new() + .family("New Computer Modern Sans 08") + .weight(450), + "NewCMSans08-BookOblique" => Exception::new() + .family("New Computer Modern Sans 08") + .weight(450), + "NewCMSans08-Oblique" => Exception::new() + .family("New Computer Modern Sans 08"), + "NewCMSans08-Regular" => Exception::new() + .family("New Computer Modern Sans 08"), + "NewCMSans10-Bold" => Exception::new() + .family("New Computer Modern Sans"), + "NewCMSans10-BoldOblique" => Exception::new() + .family("New Computer Modern Sans"), + "NewCMSans10-Book" => Exception::new() + .family("New Computer Modern Sans") + .weight(450), + "NewCMSans10-BookOblique" => Exception::new() + .family("New Computer Modern Sans") + .weight(450) + .style(FontStyle::Oblique), + "NewCMSans10-Oblique" => Exception::new() + .family("New Computer Modern Sans") + .style(FontStyle::Oblique), + "NewCMSans10-Regular" => Exception::new() + .family("New Computer Modern Sans"), + "NewCMUncial08-Bold" => Exception::new() + .family("New Computer Modern Uncial 08"), + "NewCMUncial08-Book" => Exception::new() + .family("New Computer Modern Uncial 08") + .weight(450), + "NewCMUncial08-Regular" => Exception::new() + .family("New Computer Modern Uncial 08"), + "NewCMUncial10-Bold" => Exception::new() + .family("New Computer Modern Uncial"), + "NewCMUncial10-Book" => Exception::new() + .family("New Computer Modern Uncial") + .weight(450), + "NewCMUncial10-Regular" => Exception::new() + .family("New Computer Modern Uncial"), + // Latin Modern + "LMMono8-Regular" => Exception::new() + .family("Latin Modern Mono 8"), + "LMMono9-Regular" => Exception::new() + .family("Latin Modern Mono 9"), + "LMMono12-Regular" => Exception::new() + .family("Latin Modern Mono 12"), + "LMMonoLt10-BoldOblique" => Exception::new() + .style(FontStyle::Oblique), + "LMMonoLt10-Regular" => Exception::new() + .weight(300), + "LMMonoLt10-Oblique" => Exception::new() + .weight(300) + .style(FontStyle::Oblique), + "LMMonoLtCond10-Regular" => Exception::new() + .weight(300) + .stretch(666), + "LMMonoLtCond10-Oblique" => Exception::new() + .weight(300) + .style(FontStyle::Oblique) + .stretch(666), + "LMMonoPropLt10-Regular" => Exception::new() + .weight(300), + "LMMonoPropLt10-Oblique" => Exception::new() + .weight(300), + "LMRoman5-Regular" => Exception::new() + .family("Latin Modern Roman 5"), + "LMRoman6-Regular" => Exception::new() + .family("Latin Modern Roman 6"), + "LMRoman7-Regular" => Exception::new() + .family("Latin Modern Roman 7"), + "LMRoman8-Regular" => Exception::new() + .family("Latin Modern Roman 8"), + "LMRoman9-Regular" => Exception::new() + .family("Latin Modern Roman 9"), + "LMRoman12-Regular" => Exception::new() + .family("Latin Modern Roman 12"), + "LMRoman17-Regular" => Exception::new() + .family("Latin Modern Roman 17"), + "LMRoman7-Italic" => Exception::new() + .family("Latin Modern Roman 7"), + "LMRoman8-Italic" => Exception::new() + .family("Latin Modern Roman 8"), + "LMRoman9-Italic" => Exception::new() + .family("Latin Modern Roman 9"), + "LMRoman12-Italic" => Exception::new() + .family("Latin Modern Roman 12"), + "LMRoman5-Bold" => Exception::new() + .family("Latin Modern Roman 5"), + "LMRoman6-Bold" => Exception::new() + .family("Latin Modern Roman 6"), + "LMRoman7-Bold" => Exception::new() + .family("Latin Modern Roman 7"), + "LMRoman8-Bold" => Exception::new() + .family("Latin Modern Roman 8"), + "LMRoman9-Bold" => Exception::new() + .family("Latin Modern Roman 9"), + "LMRoman12-Bold" => Exception::new() + .family("Latin Modern Roman 12"), + "LMRomanSlant8-Regular" => Exception::new() + .family("Latin Modern Roman 8"), + "LMRomanSlant9-Regular" => Exception::new() + .family("Latin Modern Roman 9"), + "LMRomanSlant12-Regular" => Exception::new() + .family("Latin Modern Roman 12"), + "LMRomanSlant17-Regular" => Exception::new() + .family("Latin Modern Roman 17"), + "LMSans8-Regular" => Exception::new() + .family("Latin Modern Sans 8"), + "LMSans9-Regular" => Exception::new() + .family("Latin Modern Sans 9"), + "LMSans12-Regular" => Exception::new() + .family("Latin Modern Sans 12"), + "LMSans17-Regular" => Exception::new() + .family("Latin Modern Sans 17"), + "LMSans8-Oblique" => Exception::new() + .family("Latin Modern Sans 8"), + "LMSans9-Oblique" => Exception::new() + .family("Latin Modern Sans 9"), + "LMSans12-Oblique" => Exception::new() + .family("Latin Modern Sans 12"), + "LMSans17-Oblique" => Exception::new() + .family("Latin Modern Sans 17"), + // STKaiti is a set of Kai fonts. Their weight values need to be corrected + // according to their PostScript names. + "STKaitiSC-Regular" => Exception::new().weight(400), + "STKaitiTC-Regular" => Exception::new().weight(400), + "STKaitiSC-Bold" => Exception::new().weight(700), + "STKaitiTC-Bold" => Exception::new().weight(700), + "STKaitiSC-Black" => Exception::new().weight(900), + "STKaitiTC-Black" => Exception::new().weight(900), +}; diff --git a/crates/typst-library/src/text/font/mod.rs b/crates/typst-library/src/text/font/mod.rs new file mode 100644 index 00000000..09837312 --- /dev/null +++ b/crates/typst-library/src/text/font/mod.rs @@ -0,0 +1,306 @@ +//! Font handling. + +pub mod color; + +mod book; +mod exceptions; +mod variant; + +pub use self::book::{Coverage, FontBook, FontFlags, FontInfo}; +pub use self::variant::{FontStretch, FontStyle, FontVariant, FontWeight}; + +use std::cell::OnceCell; +use std::fmt::{self, Debug, Formatter}; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +use ttf_parser::GlyphId; + +use self::book::find_name; +use crate::foundations::{Bytes, Cast}; +use crate::layout::{Abs, Em, Frame}; +use crate::text::{BottomEdge, TopEdge}; + +/// An OpenType font. +/// +/// Values of this type are cheap to clone and hash. +#[derive(Clone)] +pub struct Font(Arc<Repr>); + +/// 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: Bytes, + /// 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 face. + ttf: ttf_parser::Face<'static>, + /// The underlying rustybuzz face. + rusty: rustybuzz::Face<'static>, +} + +impl Font { + /// Parse a font from data and collection index. + pub fn new(data: Bytes, index: u32) -> Option<Self> { + // 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 = ttf_parser::Face::parse(slice, index).ok()?; + let rusty = 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, metrics, ttf, rusty }))) + } + + /// Parse all fonts in the given data. + pub fn iter(data: Bytes) -> impl Iterator<Item = Self> { + let count = ttf_parser::fonts_in_collection(&data).unwrap_or(1); + (0..count).filter_map(move |index| Self::new(data.clone(), index)) + } + + /// The underlying buffer. + pub fn data(&self) -> &Bytes { + &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<f64>) -> 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<Em> { + 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<String> { + find_name(&self.0.ttf, id) + } + + /// A reference to the underlying `ttf-parser` face. + pub fn ttf(&self) -> &ttf_parser::Face<'_> { + // We can't implement Deref because that would leak the + // internal 'static lifetime. + &self.0.ttf + } + + /// A reference to the underlying `rustybuzz` face. + pub fn rusty(&self) -> &rustybuzz::Face<'_> { + // We can't implement Deref because that would leak the + // internal 'static lifetime. + &self.0.rusty + } + + /// Resolve the top and bottom edges of text. + pub fn edges( + &self, + top_edge: TopEdge, + bottom_edge: BottomEdge, + font_size: Abs, + bounds: TextEdgeBounds, + ) -> (Abs, Abs) { + let cell = OnceCell::new(); + let bbox = |gid, f: fn(ttf_parser::Rect) -> i16| { + cell.get_or_init(|| self.ttf().glyph_bounding_box(GlyphId(gid))) + .map(|bbox| self.to_em(f(bbox)).at(font_size)) + .unwrap_or_default() + }; + + let top = match top_edge { + TopEdge::Metric(metric) => match metric.try_into() { + Ok(metric) => self.metrics().vertical(metric).at(font_size), + Err(_) => match bounds { + TextEdgeBounds::Zero => Abs::zero(), + TextEdgeBounds::Frame(frame) => frame.ascent(), + TextEdgeBounds::Glyph(gid) => bbox(gid, |b| b.y_max), + }, + }, + TopEdge::Length(length) => length.at(font_size), + }; + + let bottom = match bottom_edge { + BottomEdge::Metric(metric) => match metric.try_into() { + Ok(metric) => -self.metrics().vertical(metric).at(font_size), + Err(_) => match bounds { + TextEdgeBounds::Zero => Abs::zero(), + TextEdgeBounds::Frame(frame) => frame.descent(), + TextEdgeBounds::Glyph(gid) => -bbox(gid, |b| b.y_min), + }, + }, + BottomEdge::Length(length) => -length.at(font_size), + }; + + (top, bottom) + } +} + +impl Hash for Font { + fn hash<H: Hasher>(&self, state: &mut H) { + self.0.data.hash(state); + self.0.index.hash(state); + } +} + +impl Debug for Font { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "Font({}, {:?})", self.info().family, self.info().variant) + } +} + +impl Eq for Font {} + +impl PartialEq for Font { + fn eq(&self, other: &Self) -> bool { + self.0.data == other.0.data && self.0.index == other.0.index + } +} + +/// 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, Cast)] +pub enum VerticalFontMetric { + /// The font's ascender, which typically exceeds the height of all glyphs. + 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 font's ascender, which typically exceeds the depth of all glyphs. + Descender, +} + +/// Defines how to resolve a `Bounds` text edge. +#[derive(Debug, Copy, Clone)] +pub enum TextEdgeBounds<'a> { + /// Set the bounds to zero. + Zero, + /// Use the bounding box of the given glyph for the bounds. + Glyph(u16), + /// Use the dimension of the given frame for the bounds. + Frame(&'a Frame), +} diff --git a/crates/typst-library/src/text/font/variant.rs b/crates/typst-library/src/text/font/variant.rs new file mode 100644 index 00000000..c7a00fb3 --- /dev/null +++ b/crates/typst-library/src/text/font/variant.rs @@ -0,0 +1,320 @@ +use std::fmt::{self, Debug, Formatter}; + +use ecow::EcoString; +use serde::{Deserialize, Serialize}; + +use crate::foundations::{cast, Cast, IntoValue, Repr}; +use crate::layout::Ratio; + +/// 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, Cast)] +#[serde(rename_all = "kebab-case")] +pub enum FontStyle { + /// The default, typically upright style. + Normal, + /// A cursive style with custom letterform. + Italic, + /// Just a slanted version of the normal 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 + } +} + +impl From<usvg::FontStyle> for FontStyle { + fn from(style: usvg::FontStyle) -> Self { + match style { + usvg::FontStyle::Normal => Self::Normal, + usvg::FontStyle::Italic => Self::Italic, + usvg::FontStyle::Oblique => Self::Oblique, + } + } +} + +/// The weight of a font. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[derive(Serialize, Deserialize)] +#[serde(transparent)] +pub struct FontWeight(pub(super) u16); + +/// Font weight names and numbers. +/// See `<https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-weight#common_weight_name_mapping>` +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.clamp(100, 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).clamp(100, 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).unsigned_abs() + } +} + +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) + } +} + +impl From<fontdb::Weight> for FontWeight { + fn from(weight: fontdb::Weight) -> Self { + Self::from_number(weight.0) + } +} + +cast! { + FontWeight, + self => IntoValue::into_value(match self { + FontWeight::THIN => "thin", + FontWeight::EXTRALIGHT => "extralight", + FontWeight::LIGHT => "light", + FontWeight::REGULAR => "regular", + FontWeight::MEDIUM => "medium", + FontWeight::SEMIBOLD => "semibold", + FontWeight::BOLD => "bold", + FontWeight::EXTRABOLD => "extrabold", + FontWeight::BLACK => "black", + _ => return self.to_number().into_value(), + }), + v: i64 => Self::from_number(v.clamp(0, u16::MAX as i64) as u16), + /// Thin weight (100). + "thin" => Self::THIN, + /// Extra light weight (200). + "extralight" => Self::EXTRALIGHT, + /// Light weight (300). + "light" => Self::LIGHT, + /// Regular weight (400). + "regular" => Self::REGULAR, + /// Medium weight (500). + "medium" => Self::MEDIUM, + /// Semibold weight (600). + "semibold" => Self::SEMIBOLD, + /// Bold weight (700). + "bold" => Self::BOLD, + /// Extrabold weight (800). + "extrabold" => Self::EXTRABOLD, + /// Black weight (900). + "black" => Self::BLACK, +} + +/// The width of a font. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[derive(Serialize, Deserialize)] +#[serde(transparent)] +pub struct FontStretch(pub(super) 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: Ratio) -> Self { + Self((ratio.get().clamp(0.5, 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) -> Ratio { + Ratio::new(self.0 as f64 / 1000.0) + } + + /// Round to one of the pre-defined variants. + pub fn round(self) -> Self { + match self.0 { + ..=562 => Self::ULTRA_CONDENSED, + 563..=687 => Self::EXTRA_CONDENSED, + 688..=812 => Self::CONDENSED, + 813..=937 => Self::SEMI_CONDENSED, + 938..=1062 => Self::NORMAL, + 1063..=1187 => Self::SEMI_EXPANDED, + 1188..=1374 => Self::EXPANDED, + 1375..=1749 => Self::EXTRA_EXPANDED, + 1750.. => Self::ULTRA_EXPANDED, + } + } + + /// The absolute ratio distance between this and another font stretch. + pub fn distance(self, other: Self) -> Ratio { + (self.to_ratio() - other.to_ratio()).abs() + } +} + +impl Default for FontStretch { + fn default() -> Self { + Self::NORMAL + } +} + +impl Repr for FontStretch { + fn repr(&self) -> EcoString { + self.to_ratio().repr() + } +} + +impl From<usvg::FontStretch> for FontStretch { + fn from(stretch: usvg::FontStretch) -> Self { + match stretch { + usvg::FontStretch::UltraCondensed => Self::ULTRA_CONDENSED, + usvg::FontStretch::ExtraCondensed => Self::EXTRA_CONDENSED, + usvg::FontStretch::Condensed => Self::CONDENSED, + usvg::FontStretch::SemiCondensed => Self::SEMI_CONDENSED, + usvg::FontStretch::Normal => Self::NORMAL, + usvg::FontStretch::SemiExpanded => Self::SEMI_EXPANDED, + usvg::FontStretch::Expanded => Self::EXPANDED, + usvg::FontStretch::ExtraExpanded => Self::EXTRA_EXPANDED, + usvg::FontStretch::UltraExpanded => Self::ULTRA_EXPANDED, + } + } +} + +cast! { + FontStretch, + self => self.to_ratio().into_value(), + v: Ratio => Self::from_ratio(v), +} + +#[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!(FontStretch::EXPANDED.repr(), "125%") + } +} |
