summaryrefslogtreecommitdiff
path: root/crates/typst-library/src/text/font
diff options
context:
space:
mode:
Diffstat (limited to 'crates/typst-library/src/text/font')
-rw-r--r--crates/typst-library/src/text/font/book.rs546
-rw-r--r--crates/typst-library/src/text/font/color.rs627
-rw-r--r--crates/typst-library/src/text/font/exceptions.rs337
-rw-r--r--crates/typst-library/src/text/font/mod.rs306
-rw-r--r--crates/typst-library/src/text/font/variant.rs320
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(&current.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%")
+ }
+}