diff --git a/.gitignore b/.gitignore index 6a294a7bb..2cb6e97df 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .vscode _things desktop.ini +.DS_Store # Tests and benchmarks tests/png diff --git a/NOTICE b/NOTICE index f678a3f73..3d3658c1e 100644 --- a/NOTICE +++ b/NOTICE @@ -3,16 +3,15 @@ Licenses for third party components used by this project can be found below. ================================================================================ The SIL Open Font License Version 1.1 applies to: -* IBM Plex fonts in fonts/IBMPlex-*.ttf +* IBM Plex fonts in fonts/IBMPlex*.ttf Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" (https://github.com/IBM/plex) -* Noto fonts in fonts/Noto-*.ttf +* Noto fonts in fonts/Noto*.ttf Copyright 2018 The Noto Project Authors - (https://github.com/googlefonts/noto-fonts) - (https://github.com/googlefonts/noto-cjk) + (github.com/googlei18n/noto-fonts) -* PT Sans fonts in fonts/PTSans-*.ttf +* PT Sans fonts in fonts/PTSans*.ttf Copyright (c) 2010, ParaType Ltd. (http://www.paratype.com/public), with Reserved Font Names "PT Sans" and "ParaType". @@ -135,7 +134,7 @@ THE SOFTWARE. ================================================================================ The Apache License Version 2.0 applies to: -* Roboto fonts in fonts/Roboto-*.ttf +* Roboto fonts in fonts/Roboto*.ttf (https://github.com/googlefonts/roboto) Apache License @@ -319,7 +318,7 @@ The Apache License Version 2.0 applies to: ================================================================================ The GUST Font License Version 1.0 applies to: -* Latin Modern Math font in fonts/LatinModernMath.otf +* Latin Modern fonts in fonts/LatinModern*.otf (http://www.gust.org.pl/projects/e-foundry/lm-math) % This is version 1.0, dated 22 June 2009, of the GUST Font License. diff --git a/fonts/CMU-Serif-Bold.ttf b/fonts/CMU-Serif-Bold.ttf deleted file mode 100755 index 2c7198e5d..000000000 Binary files a/fonts/CMU-Serif-Bold.ttf and /dev/null differ diff --git a/fonts/CMU-Serif-Regular.ttf b/fonts/CMU-Serif-Regular.ttf deleted file mode 100755 index 1c3fff0a6..000000000 Binary files a/fonts/CMU-Serif-Regular.ttf and /dev/null differ diff --git a/fonts/LatinModernRoman-Bold.otf b/fonts/LatinModernRoman-Bold.otf new file mode 100644 index 000000000..7d6afa73e Binary files /dev/null and b/fonts/LatinModernRoman-Bold.otf differ diff --git a/fonts/LatinModernRoman-Regular.otf b/fonts/LatinModernRoman-Regular.otf new file mode 100644 index 000000000..6a96b4682 Binary files /dev/null and b/fonts/LatinModernRoman-Regular.otf differ diff --git a/fonts/NotoColorEmoji.ttf b/fonts/NotoColorEmoji.ttf new file mode 100644 index 000000000..2c1f10435 Binary files /dev/null and b/fonts/NotoColorEmoji.ttf differ diff --git a/src/eval/content.rs b/src/eval/content.rs index 5f8c5861c..1cdd4bb03 100644 --- a/src/eval/content.rs +++ b/src/eval/content.rs @@ -13,7 +13,7 @@ use crate::diag::StrResult; use crate::library::layout::{FlowChild, FlowNode, PageNode, PlaceNode, Spacing}; use crate::library::prelude::*; use crate::library::structure::{ListItem, ListKind, ListNode, ORDERED, UNORDERED}; -use crate::library::text::{DecoNode, ParChild, ParNode, TextNode, UNDERLINE}; +use crate::library::text::{DecoNode, ParChild, ParNode, UNDERLINE}; use crate::util::EcoString; /// Composable representation of styled content. @@ -133,11 +133,6 @@ impl Content { Self::Styled(Arc::new((self, styles))) } - /// Style this content in monospace. - pub fn monospaced(self) -> Self { - self.styled(TextNode::MONOSPACED, true) - } - /// Underline this content. pub fn underlined(self) -> Self { Self::show(DecoNode::(self)) diff --git a/src/eval/styles.rs b/src/eval/styles.rs index 7fcaf7341..a0dc263c3 100644 --- a/src/eval/styles.rs +++ b/src/eval/styles.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use super::{Args, Content, Func, Span, Value}; use crate::diag::{At, TypResult}; use crate::library::layout::PageNode; -use crate::library::text::ParNode; +use crate::library::text::{FontFamily, ParNode, TextNode}; use crate::Context; /// A map of style properties. @@ -48,6 +48,17 @@ impl StyleMap { } } + /// Set a font family composed of a preferred family and existing families + /// from a style chain. + pub fn set_family(&mut self, family: FontFamily, existing: StyleChain) { + self.set( + TextNode::FAMILY, + std::iter::once(family) + .chain(existing.get_ref(TextNode::FAMILY).iter().cloned()) + .collect(), + ); + } + /// Set a recipe. pub fn set_recipe(&mut self, node: TypeId, func: Func, span: Span) { self.recipes.push(Recipe { node, func, span }); diff --git a/src/eval/value.rs b/src/eval/value.rs index a76b377de..300444ded 100644 --- a/src/eval/value.rs +++ b/src/eval/value.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use super::{ops, Args, Array, Content, Context, Dict, Func, Layout, StrExt}; use crate::diag::{with_alternative, At, StrResult, TypResult}; use crate::geom::{Angle, Color, Fractional, Length, Linear, Relative, RgbaColor}; +use crate::library::text::RawNode; use crate::syntax::{Span, Spanned}; use crate::util::EcoString; @@ -115,9 +116,10 @@ impl Value { Value::Float(v) => Content::Text(format_eco!("{}", v)), Value::Str(v) => Content::Text(v), Value::Content(v) => v, - // For values which can't be shown "naturally", we print the - // representation in monospace. - v => Content::Text(v.repr()).monospaced(), + + // For values which can't be shown "naturally", we return the raw + // representation. + v => Content::show(RawNode { text: v.repr(), block: false }), } } diff --git a/src/export/pdf.rs b/src/export/pdf.rs index b39f47eac..2550519be 100644 --- a/src/export/pdf.rs +++ b/src/export/pdf.rs @@ -90,9 +90,10 @@ impl<'a> PdfExporter<'a> { let glyphs = &self.glyph_sets[&face_id]; let face = self.fonts.get(face_id); + let metrics = face.metrics(); let ttf = face.ttf(); - let postscript_name = find_name(ttf.names(), name_id::POST_SCRIPT_NAME) + let postscript_name = find_name(ttf, name_id::POST_SCRIPT_NAME) .unwrap_or_else(|| "unknown".to_string()); let base_font = format_eco!("ABCDEF+{}", postscript_name); @@ -155,9 +156,9 @@ impl<'a> PdfExporter<'a> { ); let italic_angle = ttf.italic_angle().unwrap_or(0.0); - let ascender = face.ascender.to_font_units(); - let descender = face.descender.to_font_units(); - let cap_height = face.cap_height.to_font_units(); + let ascender = metrics.ascender.to_font_units(); + let descender = metrics.descender.to_font_units(); + let cap_height = metrics.cap_height.to_font_units(); let stem_v = 10.0 + 0.244 * (f32::from(ttf.weight().to_number()) - 50.0); // Write the font descriptor (contains metrics about the font). diff --git a/src/export/render.rs b/src/export/render.rs index ff35390e0..89a17eeaa 100644 --- a/src/export/render.rs +++ b/src/export/render.rs @@ -160,7 +160,7 @@ fn render_svg_glyph( // If there's no viewbox defined, use the em square for our scale // transformation ... - let upem = face.units_per_em as f32; + let upem = face.units_per_em() as f32; let (mut width, mut height) = (upem, upem); // ... but if there's a viewbox or width, use that. @@ -232,7 +232,7 @@ fn render_outline_glyph( // Flip vertically because font design coordinate // system is Y-up. - let scale = text.size.to_f32() / face.units_per_em as f32; + let scale = text.size.to_f32() / face.units_per_em() as f32; let ts = ts.pre_scale(scale, -scale); canvas.fill_path(&path, &paint, rule, ts, mask)?; return Some(()); diff --git a/src/font.rs b/src/font.rs index d8fc0f455..ff00bbef9 100644 --- a/src/font.rs +++ b/src/font.rs @@ -1,12 +1,14 @@ //! Font handling. +use std::cmp::Reverse; use std::collections::{hash_map::Entry, BTreeMap, HashMap}; use std::fmt::{self, Debug, Formatter}; use std::path::{Path, PathBuf}; use std::sync::Arc; use serde::{Deserialize, Serialize}; -use ttf_parser::{name_id, GlyphId, PlatformId}; +use ttf_parser::{name_id, GlyphId, PlatformId, Tag}; +use unicode_segmentation::UnicodeSegmentation; use crate::geom::{Em, Length, Linear}; use crate::loading::{FileHash, Loader}; @@ -47,13 +49,19 @@ impl FontStore { let mut failed = vec![]; let mut families = BTreeMap::>::new(); - for (i, info) in loader.faces().iter().enumerate() { + let infos = loader.faces(); + for (i, info) in infos.iter().enumerate() { let id = FaceId(i as u32); faces.push(None); failed.push(false); families.entry(info.family.to_lowercase()).or_default().push(id); } + for faces in families.values_mut() { + faces.sort_by_key(|id| infos[id.0 as usize].variant); + faces.dedup_by_key(|id| infos[id.0 as usize].variant); + } + Self { loader, faces, @@ -63,33 +71,88 @@ impl FontStore { } } - /// Query for and load the font face from the given `family` that most - /// closely matches the given `variant`. + /// Try to find and load a font face from the given `family` that matches + /// the given `variant` as closely as possible. pub fn select(&mut self, family: &str, variant: FontVariant) -> Option { - // Check whether a family with this name exists. let ids = self.families.get(family)?; + let id = self.find_best_variant(None, variant, ids.iter().copied())?; + self.load(id) + } + + /// Try to find and load a fallback font that + /// - is as close as possible to the face `like` (if any) + /// - is as close as possible to the given `variant` + /// - is suitable for shaping the given `text` + pub fn select_fallback( + &mut self, + like: Option, + variant: FontVariant, + text: &str, + ) -> Option { + // Find the faces that contain the text's first char ... + let c = text.chars().next()?; + let ids = self + .loader + .faces() + .iter() + .enumerate() + .filter(|(_, info)| info.coverage.contains(c as u32)) + .map(|(i, _)| FaceId(i as u32)); + + // ... and find the best variant among them. + let id = self.find_best_variant(like, variant, ids)?; + self.load(id) + } + + /// Find the face in the passed iterator that + /// - is closest to the face `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 face: + /// - Are both faces (not) monospaced. + /// - Do both faces (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, + variant: FontVariant, + ids: impl IntoIterator, + ) -> Option { let infos = self.loader.faces(); + let like = like.map(|id| &infos[id.0 as usize]); let mut best = None; let mut best_key = None; // Find the best matching variant of this font. - for &id in ids { - let current = infos[id.0 as usize].variant; + for id in ids { + let current = &infos[id.0 as usize]; - // This is a perfect match, no need to search further. - if current == variant { - best = Some(id); - break; - } - - // If this is not a perfect match, we compute a key that we want to - // minimize among all variants. This key prioritizes style, then - // stretch distance and then weight distance. let key = ( - current.style != variant.style, - current.stretch.distance(variant.stretch), - current.weight.distance(variant.weight), + like.map(|like| { + ( + current.monospaced != like.monospaced, + like.serif.is_some() && current.serif != like.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) { @@ -98,59 +161,79 @@ impl FontStore { } } - let id = best?; + best + } + + /// Load the face with the given id. + /// + /// Returns `Some(id)` if the face was loaded successfully. + fn load(&mut self, id: FaceId) -> Option { let idx = id.0 as usize; let slot = &mut self.faces[idx]; + if slot.is_some() { + return Some(id); + } + if self.failed[idx] { return None; } - // Load the face if it's not already loaded. - if slot.is_none() { - let FaceInfo { ref path, index, .. } = infos[idx]; - self.failed[idx] = true; + let FaceInfo { ref path, index, .. } = self.loader.faces()[idx]; + self.failed[idx] = true; - // Check the buffer cache since multiple faces may - // refer to the same data (font collection). - let hash = self.loader.resolve(path).ok()?; - let buffer = match self.buffers.entry(hash) { - Entry::Occupied(entry) => entry.into_mut(), - Entry::Vacant(entry) => { - let buffer = self.loader.load(path).ok()?; - entry.insert(Arc::new(buffer)) - } - }; + // Check the buffer cache since multiple faces may + // refer to the same data (font collection). + let hash = self.loader.resolve(path).ok()?; + let buffer = match self.buffers.entry(hash) { + Entry::Occupied(entry) => entry.into_mut(), + Entry::Vacant(entry) => { + let buffer = self.loader.load(path).ok()?; + entry.insert(Arc::new(buffer)) + } + }; - let face = Face::new(Arc::clone(buffer), index)?; - *slot = Some(face); - self.failed[idx] = false; - } + let face = Face::new(Arc::clone(buffer), index)?; + *slot = Some(face); + self.failed[idx] = false; Some(id) } /// Get a reference to a loaded face. /// - /// This panics if no face with this `id` was loaded. This function should - /// only be called with ids returned by this store's - /// [`select()`](Self::select) method. + /// This panics if the face with this `id` was not loaded. This function + /// should only be called with ids returned by this store's + /// [`select()`](Self::select) and + /// [`select_fallback()`](Self::select_fallback) methods. #[track_caller] pub fn get(&self, id: FaceId) -> &Face { self.faces[id.0 as usize].as_ref().expect("font face was not loaded") } - /// Returns an ordered iterator over all font family names this loader - /// knows. - pub fn families(&self) -> impl Iterator + '_ { + /// An ordered iterator over all font families this loader knows and details + /// about the faces that are part of them. + pub fn families( + &self, + ) -> impl Iterator)> + '_ { // Since the keys are lowercased, we instead use the family field of the // first face's info. let faces = self.loader.faces(); - self.families - .values() - .map(move |id| faces[id[0].0 as usize].family.as_str()) + self.families.values().map(|ids| { + let family = faces[ids[0].0 as usize].family.as_str(); + let infos = ids.iter().map(|&id| &faces[id.0 as usize]); + (family, infos) + }) } } +/// 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 font face. pub struct Face { /// The raw face data, possibly shared with other faces from the same @@ -161,6 +244,71 @@ pub struct Face { index: u32, /// The underlying ttf-parser/rustybuzz face. ttf: rustybuzz::Face<'static>, + /// The faces metrics. + metrics: FaceMetrics, +} + +impl Face { + /// Parse a font face from a buffer and collection index. + pub fn new(buffer: Arc>, index: u32) -> Option { + // Safety: + // - The slices's location is stable in memory: + // - We don't move the underlying vector + // - Nobody else can move it since we have a strong ref to the `Arc`. + // - The internal static lifetime is not leaked because its rewritten + // to the self-lifetime in `ttf()`. + let slice: &'static [u8] = + unsafe { std::slice::from_raw_parts(buffer.as_ptr(), buffer.len()) }; + + let ttf = rustybuzz::Face::from_slice(slice, index)?; + let metrics = FaceMetrics::from_ttf(&ttf); + + Some(Self { buffer, index, ttf, metrics }) + } + + /// The underlying buffer. + pub fn buffer(&self) -> &Arc> { + &self.buffer + } + + /// The collection index. + pub fn index(&self) -> u32 { + self.index + } + + /// A reference to the underlying `ttf-parser` / `rustybuzz` face. + pub fn ttf(&self) -> &rustybuzz::Face<'_> { + // We can't implement Deref because that would leak the internal 'static + // lifetime. + &self.ttf + } + + /// The number of units per em. + pub fn units_per_em(&self) -> f64 { + self.metrics.units_per_em + } + + /// Access the face's metrics. + pub fn metrics(&self) -> &FaceMetrics { + &self.metrics + } + + /// Convert from font units to an em length. + pub fn to_em(&self, units: impl Into) -> Em { + Em::from_units(units, self.units_per_em()) + } + + /// Look up the horizontal advance width of a glyph. + pub fn advance(&self, glyph: u16) -> Option { + self.ttf + .glyph_hor_advance(GlyphId(glyph)) + .map(|units| self.to_em(units)) + } +} + +/// Metrics for a font face. +#[derive(Debug, Copy, Clone)] +pub struct FaceMetrics { /// How many font units represent one em unit. pub units_per_em: f64, /// The distance from the baseline to the typographic ascender. @@ -179,30 +327,10 @@ pub struct Face { pub overline: LineMetrics, } -/// 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, -} - -impl Face { - /// Parse a font face from a buffer and collection index. - pub fn new(buffer: Arc>, index: u32) -> Option { - // Safety: - // - The slices's location is stable in memory: - // - We don't move the underlying vector - // - Nobody else can move it since we have a strong ref to the `Arc`. - // - The internal static lifetime is not leaked because its rewritten - // to the self-lifetime in `ttf()`. - let slice: &'static [u8] = - unsafe { std::slice::from_raw_parts(buffer.as_ptr(), buffer.len()) }; - - let ttf = rustybuzz::Face::from_slice(slice, index)?; - let units_per_em = f64::from(ttf.units_per_em()); +impl FaceMetrics { + /// Extract the face's metrics. + pub fn from_ttf(ttf: &ttf_parser::Face) -> Self { + let units_per_em = f64::from(ttf.units_per_em().unwrap_or(0)); let to_em = |units| Em::from_units(units, units_per_em); let ascender = to_em(ttf.typographic_ascender().unwrap_or(ttf.ascender())); @@ -231,10 +359,7 @@ impl Face { thickness: underline.thickness, }; - Some(Self { - buffer, - index, - ttf, + Self { units_per_em, ascender, cap_height, @@ -243,40 +368,11 @@ impl Face { strikethrough, underline, overline, - }) - } - - /// The underlying buffer. - pub fn buffer(&self) -> &Arc> { - &self.buffer - } - - /// The collection index. - pub fn index(&self) -> u32 { - self.index - } - - /// A reference to the underlying `ttf-parser` / `rustybuzz` face. - pub fn ttf(&self) -> &rustybuzz::Face<'_> { - // We can't implement Deref because that would leak the internal 'static - // lifetime. - &self.ttf - } - - /// Convert from font units to an em length. - pub fn to_em(&self, units: impl Into) -> Em { - Em::from_units(units, self.units_per_em) - } - - /// Look up the horizontal advance width of a glyph. - pub fn advance(&self, glyph: u16) -> Option { - self.ttf - .glyph_hor_advance(GlyphId(glyph)) - .map(|units| self.to_em(units)) + } } /// Look up a vertical metric at the given font size. - pub fn vertical_metric(&self, metric: VerticalFontMetric, size: Length) -> Length { + pub fn vertical(&self, metric: VerticalFontMetric, size: Length) -> Length { match metric { VerticalFontMetric::Ascender => self.ascender.resolve(size), VerticalFontMetric::CapHeight => self.cap_height.resolve(size), @@ -288,6 +384,16 @@ impl Face { } } +/// Metrics for a decorative line. +#[derive(Debug, Copy, Clone)] +pub struct LineMetrics { + /// The vertical offset of the line from the baseline. Positive goes + /// upwards, negative downwards. + pub position: Em, + /// The thickness of the line. + pub thickness: Em, +} + /// Identifies a vertical metric of a font. #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub enum VerticalFontMetric { @@ -323,50 +429,112 @@ pub struct FaceInfo { pub family: String, /// Properties that distinguish this face from other faces in the same /// family. - #[serde(flatten)] pub variant: FontVariant, + /// Whether the face is monospaced. + pub monospaced: bool, + /// Whether the face has serifs (if known). + pub serif: Option, + /// The unicode coverage of the face. + pub coverage: Coverage, } impl FaceInfo { - /// Determine metadata about all faces that are found in the given data. - pub fn parse<'a>( + /// Compute metadata for all faces in the given data. + pub fn from_data<'a>( path: &'a Path, data: &'a [u8], ) -> impl Iterator + 'a { let count = ttf_parser::fonts_in_collection(data).unwrap_or(1); (0 .. count).filter_map(move |index| { let face = ttf_parser::Face::from_slice(data, index).ok()?; - let mut family = find_name(face.names(), name_id::TYPOGRAPHIC_FAMILY) - .or_else(|| find_name(face.names(), name_id::FAMILY))?; + Self::from_ttf(path, index, &face) + }) + } - // Remove weird leading dot appearing in some fonts. - if let Some(undotted) = family.strip_prefix('.') { - family = undotted.to_string(); + /// Compute metadata for a single ttf-parser face. + pub fn from_ttf(path: &Path, index: u32, ttf: &ttf_parser::Face) -> Option { + // We cannot use Name ID 16 "Typographic Family", because for some + // fonts it groups together more than just Style / Weight / Stretch + // variants (e.g. Display variants of Noto fonts) and then some + // variants become inaccessible from Typst. And even though the + // fsSelection bit WWS should help us decide whether that is the + // case, it's wrong for some fonts (e.g. for some faces of "Noto + // Sans Display"). + // + // So, instead we use Name ID 1 "Family" and trim many common + // suffixes for which know that they just describe styling (e.g. + // "ExtraBold"). + // + // Also, for Noto fonts we use Name ID 4 "Full Name" instead, + // because Name ID 1 "Family" sometimes contains "Display" and + // sometimes doesn't for the Display variants and that mixes things + // up. + let family = { + let mut family = find_name(ttf, name_id::FAMILY)?; + if family.starts_with("Noto") { + family = find_name(ttf, name_id::FULL_NAME)?; } + trim_styles(&family).to_string() + }; - let variant = FontVariant { - style: match (face.is_italic(), face.is_oblique()) { - (false, false) => FontStyle::Normal, - (true, _) => FontStyle::Italic, - (_, true) => FontStyle::Oblique, - }, - weight: FontWeight::from_number(face.weight().to_number()), - stretch: FontStretch::from_number(face.width().to_number()), + let variant = { + let mut full = find_name(ttf, name_id::FULL_NAME).unwrap_or_default(); + full.make_ascii_lowercase(); + + // Some fonts miss the relevant bits for italic or oblique, so + // we also try to infer that from the full name. + let italic = ttf.is_italic() || full.contains("italic"); + let oblique = + ttf.is_oblique() || full.contains("oblique") || full.contains("slanted"); + + let style = match (italic, oblique) { + (false, false) => FontStyle::Normal, + (true, _) => FontStyle::Italic, + (_, true) => FontStyle::Oblique, }; - Some(FaceInfo { - path: path.to_owned(), - index, - family, - variant, - }) + let weight = FontWeight::from_number(ttf.weight().to_number()); + let stretch = FontStretch::from_number(ttf.width().to_number()); + + FontVariant { style, weight, stretch } + }; + + // Determine the unicode coverage. + let mut codepoints = vec![]; + for subtable in ttf.character_mapping_subtables() { + if subtable.is_unicode() { + subtable.codepoints(|c| codepoints.push(c)); + } + } + + // Determine whether this is a serif or sans-serif font. + let mut serif = None; + if let Some(panose) = ttf + .table_data(Tag::from_bytes(b"OS/2")) + .and_then(|os2| os2.get(32 .. 45)) + { + match panose { + [2, 2 ..= 10, ..] => serif = Some(true), + [2, 11 ..= 15, ..] => serif = Some(false), + _ => {} + } + } + + Some(FaceInfo { + path: path.to_owned(), + index, + family, + variant, + monospaced: ttf.is_monospaced(), + serif, + coverage: Coverage::from_vec(codepoints), }) } } -/// Find a decodable entry in a name table iterator. -pub fn find_name(mut names: ttf_parser::Names<'_>, name_id: u16) -> Option { - names.find_map(|entry| { +/// Try to find and decode the name with the given id. +pub fn find_name(ttf: &ttf_parser::Face, name_id: u16) -> Option { + ttf.names().find_map(|entry| { if entry.name_id() == name_id { if let Some(string) = entry.to_string() { return Some(string); @@ -381,8 +549,63 @@ pub fn find_name(mut names: ttf_parser::Names<'_>, name_id: u16) -> Option &str { + // Separators between names, modifiers and styles. + const SEPARATORS: [char; 3] = [' ', '-', '_']; + + // Modifiers that can appear in combination with suffixes. + const MODIFIERS: &[&str] = &[ + "extra", "ext", "ex", "x", "semi", "sem", "sm", "demi", "dem", "ultra", + ]; + + // Style suffixes. + #[rustfmt::skip] + const SUFFIXES: &[&str] = &[ + "normal", "italic", "oblique", "slanted", + "thin", "th", "hairline", "light", "lt", "regular", "medium", "med", + "md", "bold", "bd", "demi", "extb", "black", "blk", "bk", "heavy", + "narrow", "condensed", "cond", "cn", "cd", "compressed", "expanded", "exp" + ]; + + // Trim spacing and weird leading dots in Apple fonts. + family = family.trim().trim_start_matches('.'); + + // Lowercase the string so that the suffixes match case-insensitively. + let lower = family.to_ascii_lowercase(); + let mut len = usize::MAX; + let mut trimmed = lower.as_str(); + + // Trim style suffixes repeatedly. + while trimmed.len() < len { + len = trimmed.len(); + + // Find style suffix. + let mut t = match SUFFIXES.iter().find_map(|s| trimmed.strip_suffix(s)) { + Some(t) => t, + None => break, + }; + + // Strip optional separator. + if let Some(s) = t.strip_suffix(SEPARATORS) { + trimmed = s; + t = s; + } + + // Also allow an extra modifier, but apply it only if it is separated it + // from the text before it (to prevent false positives). + if let Some(t) = MODIFIERS.iter().find_map(|s| t.strip_suffix(s)) { + if let Some(stripped) = t.strip_suffix(SEPARATORS) { + trimmed = stripped; + } + } + } + + &family[.. len] +} + /// Properties that distinguish a face from other faces in the same family. -#[derive(Default, Copy, Clone, Eq, PartialEq, Hash)] +#[derive(Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] #[derive(Serialize, Deserialize)] pub struct FontVariant { /// The style of the face (normal / italic / oblique). @@ -419,6 +642,19 @@ pub enum FontStyle { 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 @@ -572,6 +808,66 @@ impl Debug for FontStretch { } } +/// A compactly encoded set of codepoints. +/// +/// The set is represented by alternating specifications of how many codepoints +/// are not in the set and how many are in the set. +/// +/// For example, for the set `{2, 3, 4, 9, 10, 11, 15, 18, 19}`, there are: +/// - 2 codepoints not inside (0, 1) +/// - 3 codepoints inside (2, 3, 4) +/// - 4 codepoints not inside (5, 6, 7, 8) +/// - 3 codepoints inside (9, 10, 11) +/// - 3 codepoints not inside (12, 13, 14) +/// - 1 codepoint inside (15) +/// - 2 codepoints not inside (16, 17) +/// - 2 codepoints inside (18, 19) +/// +/// So the resulting encoding is `[2, 3, 4, 3, 3, 1, 2, 2]`. +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Coverage(Vec); + +impl Coverage { + /// Encode a vector of codepoints. + pub fn from_vec(mut codepoints: Vec) -> Self { + codepoints.sort(); + codepoints.dedup(); + + let mut runs = Vec::new(); + let mut next = 0; + + for c in codepoints { + if let Some(run) = runs.last_mut().filter(|_| c == next) { + *run += 1; + } else { + runs.push(c - next); + runs.push(1); + } + + next = c + 1; + } + + Self(runs) + } + + /// Whether the codepoint is covered. + pub fn contains(&self, c: u32) -> bool { + let mut inside = false; + let mut cursor = 0; + + for &run in &self.0 { + if (cursor .. cursor + run).contains(&c) { + return inside; + } + cursor += run; + inside = !inside; + } + + false + } +} + #[cfg(test)] mod tests { use super::*; @@ -589,4 +885,47 @@ mod tests { fn test_font_stretch_debug() { assert_eq!(format!("{:?}", FontStretch::EXPANDED), "125%") } + + #[test] + fn test_trim_styles() { + assert_eq!(trim_styles("Atma Light"), "Atma"); + assert_eq!(trim_styles("eras bold"), "eras"); + assert_eq!(trim_styles("footlight mt light"), "footlight mt"); + assert_eq!(trim_styles("times new roman"), "times new roman"); + assert_eq!(trim_styles("noto sans mono cond sembd"), "noto sans mono"); + assert_eq!(trim_styles("noto serif SEMCOND sembd"), "noto serif"); + assert_eq!(trim_styles("crimson text"), "crimson text"); + assert_eq!(trim_styles("footlight light"), "footlight"); + assert_eq!(trim_styles("Noto Sans"), "Noto Sans"); + assert_eq!(trim_styles("Noto Sans Light"), "Noto Sans"); + assert_eq!(trim_styles("Noto Sans Semicondensed Heavy"), "Noto Sans"); + assert_eq!(trim_styles("Familx"), "Familx"); + assert_eq!(trim_styles("Font Ultra"), "Font Ultra"); + assert_eq!(trim_styles("Font Ultra Bold"), "Font"); + } + + #[test] + fn test_coverage() { + #[track_caller] + fn test(set: &[u32], runs: &[u32]) { + let coverage = Coverage::from_vec(set.to_vec()); + assert_eq!(coverage.0, runs); + + let max = 5 + set.iter().copied().max().unwrap_or_default(); + for c in 0 .. max { + assert_eq!(set.contains(&c), coverage.contains(c)); + } + } + + test(&[], &[]); + test(&[0], &[0, 1]); + test(&[1], &[1, 1]); + test(&[0, 1], &[0, 2]); + test(&[0, 1, 3], &[0, 2, 1, 1]); + test( + // [2, 3, 4, 9, 10, 11, 15, 18, 19] + &[18, 19, 2, 4, 9, 11, 15, 3, 3, 10], + &[2, 3, 4, 3, 3, 1, 2, 2], + ) + } } diff --git a/src/library/math/mod.rs b/src/library/math/mod.rs index f20d65438..666e40a7b 100644 --- a/src/library/math/mod.rs +++ b/src/library/math/mod.rs @@ -1,6 +1,7 @@ //! Mathematical formulas. use crate::library::prelude::*; +use crate::library::text::FontFamily; /// A mathematical formula. #[derive(Debug, Hash)] @@ -13,6 +14,10 @@ pub struct MathNode { #[node(showable)] impl MathNode { + /// The raw text's font family. Just the normal text family if `none`. + pub const FAMILY: Smart = + Smart::Custom(FontFamily::new("Latin Modern Math")); + fn construct(_: &mut Context, args: &mut Args) -> TypResult { Ok(Content::show(Self { formula: args.expect("formula")?, @@ -23,17 +28,24 @@ impl MathNode { impl Show for MathNode { fn show(&self, ctx: &mut Context, styles: StyleChain) -> TypResult { - Ok(styles + let mut content = styles .show(self, ctx, [ Value::Str(self.formula.clone()), Value::Bool(self.display), ])? - .unwrap_or_else(|| { - let mut content = Content::Text(self.formula.trim().into()); - if self.display { - content = Content::Block(content.pack()); - } - content.monospaced() - })) + .unwrap_or_else(|| Content::Text(self.formula.trim().into())); + + let mut map = StyleMap::new(); + if let Smart::Custom(family) = styles.get_cloned(Self::FAMILY) { + map.set_family(family, styles); + } + + content = content.styled_with_map(map); + + if self.display { + content = Content::Block(content.pack()); + } + + Ok(content) } } diff --git a/src/library/mod.rs b/src/library/mod.rs index 528a2ce7f..bba002dee 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -119,9 +119,6 @@ pub fn new() -> Scope { std.def_const("top", Align::Top); std.def_const("horizon", Align::Horizon); std.def_const("bottom", Align::Bottom); - std.def_const("serif", text::FontFamily::Serif); - std.def_const("sans-serif", text::FontFamily::SansSerif); - std.def_const("monospace", text::FontFamily::Monospace); std } diff --git a/src/library/structure/heading.rs b/src/library/structure/heading.rs index f1bc795f6..7d3273f5d 100644 --- a/src/library/structure/heading.rs +++ b/src/library/structure/heading.rs @@ -63,12 +63,7 @@ impl Show for HeadingNode { map.set(TextNode::SIZE, resolve!(Self::SIZE)); if let Smart::Custom(family) = resolve!(Self::FAMILY) { - map.set( - TextNode::FAMILY, - std::iter::once(family) - .chain(styles.get_ref(TextNode::FAMILY).iter().cloned()) - .collect(), - ); + map.set_family(family, styles); } if let Smart::Custom(fill) = resolve!(Self::FILL) { @@ -101,6 +96,7 @@ impl Show for HeadingNode { } let mut content = Content::sequence(seq).styled_with_map(map); + if resolve!(Self::BLOCK) { content = Content::block(content); } diff --git a/src/library/text/deco.rs b/src/library/text/deco.rs index 29c04b2db..b98eb0b28 100644 --- a/src/library/text/deco.rs +++ b/src/library/text/deco.rs @@ -94,10 +94,11 @@ pub fn decorate( width: Length, ) { let face = fonts.get(text.face_id); + let face_metrics = face.metrics(); let metrics = match deco.line { - STRIKETHROUGH => face.strikethrough, - OVERLINE => face.overline, - UNDERLINE | _ => face.underline, + STRIKETHROUGH => face_metrics.strikethrough, + OVERLINE => face_metrics.overline, + UNDERLINE | _ => face_metrics.underline, }; let evade = deco.evade && deco.line != STRIKETHROUGH; @@ -146,7 +147,8 @@ pub fn decorate( for glyph in text.glyphs.iter() { let dx = glyph.x_offset.resolve(text.size) + x; - let mut builder = BezPathBuilder::new(face.units_per_em, text.size, dx.to_raw()); + let mut builder = + BezPathBuilder::new(face_metrics.units_per_em, text.size, dx.to_raw()); let bbox = face.ttf().outline_glyph(GlyphId(glyph.id), &mut builder); let path = builder.finish(); diff --git a/src/library/text/mod.rs b/src/library/text/mod.rs index eef7f6fb2..2c163a59a 100644 --- a/src/library/text/mod.rs +++ b/src/library/text/mod.rs @@ -29,13 +29,7 @@ pub struct TextNode; impl TextNode { /// A prioritized sequence of font families. #[variadic] - pub const FAMILY: Vec = vec![FontFamily::SansSerif]; - /// The serif font family/families. - pub const SERIF: Vec = vec![NamedFamily::new("IBM Plex Serif")]; - /// The sans-serif font family/families. - pub const SANS_SERIF: Vec = vec![NamedFamily::new("IBM Plex Sans")]; - /// The monospace font family/families. - pub const MONOSPACE: Vec = vec![NamedFamily::new("IBM Plex Mono")]; + pub const FAMILY: Vec = vec![FontFamily::new("IBM Plex Sans")]; /// Whether to allow font fallback when the primary font list contains no /// match. pub const FALLBACK: bool = true; @@ -100,9 +94,6 @@ impl TextNode { #[skip] #[fold(bool::bitxor)] pub const EMPH: bool = false; - /// Whether a monospace font should be preferred. - #[skip] - pub const MONOSPACED: bool = false; /// The case transformation that should be applied to the next. #[skip] pub const CASE: Option = None; @@ -160,50 +151,11 @@ impl Show for EmphNode { } } -/// A generic or named font family. +/// A font family like "Arial". #[derive(Clone, Eq, PartialEq, Hash)] -pub enum FontFamily { - /// A family that has "serifs", small strokes attached to letters. - Serif, - /// A family in which glyphs do not have "serifs", small attached strokes. - SansSerif, - /// A family in which (almost) all glyphs are of equal width. - Monospace, - /// A specific font family like "Arial". - Named(NamedFamily), -} +pub struct FontFamily(EcoString); -impl Debug for FontFamily { - fn fmt(&self, f: &mut Formatter) -> fmt::Result { - match self { - Self::Serif => f.pad("serif"), - Self::SansSerif => f.pad("sans-serif"), - Self::Monospace => f.pad("monospace"), - Self::Named(s) => s.fmt(f), - } - } -} - -dynamic! { - FontFamily: "font family", - Value::Str(string) => Self::Named(NamedFamily::new(&string)), -} - -castable! { - Vec, - Expected: "string, generic family or array thereof", - Value::Str(string) => vec![FontFamily::Named(NamedFamily::new(&string))], - Value::Array(values) => { - values.into_iter().filter_map(|v| v.cast().ok()).collect() - }, - @family: FontFamily => vec![family.clone()], -} - -/// A specific font family like "Arial". -#[derive(Clone, Eq, PartialEq, Hash)] -pub struct NamedFamily(EcoString); - -impl NamedFamily { +impl FontFamily { /// Create a named font family variant. pub fn new(string: &str) -> Self { Self(string.to_lowercase().into()) @@ -215,20 +167,26 @@ impl NamedFamily { } } -impl Debug for NamedFamily { +impl Debug for FontFamily { fn fmt(&self, f: &mut Formatter) -> fmt::Result { self.0.fmt(f) } } castable! { - Vec, + FontFamily, + Expected: "string", + Value::Str(string) => Self::new(&string), +} + +castable! { + Vec, Expected: "string or array of strings", - Value::Str(string) => vec![NamedFamily::new(&string)], + Value::Str(string) => vec![FontFamily::new(&string)], Value::Array(values) => values .into_iter() .filter_map(|v| v.cast().ok()) - .map(|string: EcoString| NamedFamily::new(&string)) + .map(|string: EcoString| FontFamily::new(&string)) .collect(), } diff --git a/src/library/text/raw.rs b/src/library/text/raw.rs index e225803f6..5c2133c24 100644 --- a/src/library/text/raw.rs +++ b/src/library/text/raw.rs @@ -3,8 +3,8 @@ use syntect::easy::HighlightLines; use syntect::highlighting::{FontStyle, Highlighter, Style, Theme, ThemeSet}; use syntect::parsing::SyntaxSet; +use super::{FontFamily, TextNode}; use crate::library::prelude::*; -use crate::library::text::TextNode; use crate::source::SourceId; use crate::syntax::{self, RedNode}; @@ -26,6 +26,8 @@ pub struct RawNode { #[node(showable)] impl RawNode { + /// The raw text's font family. Just the normal text family if `none`. + pub const FAMILY: Smart = Smart::Custom(FontFamily::new("IBM Plex Mono")); /// The language to syntax-highlight in. pub const LANG: Option = None; @@ -40,18 +42,6 @@ impl RawNode { impl Show for RawNode { fn show(&self, ctx: &mut Context, styles: StyleChain) -> TypResult { let lang = styles.get_ref(Self::LANG).as_ref(); - - if let Some(content) = styles.show(self, ctx, [ - Value::Str(self.text.clone()), - match lang { - Some(lang) => Value::Str(lang.clone()), - None => Value::None, - }, - Value::Bool(self.block), - ])? { - return Ok(content); - } - let foreground = THEME .settings .foreground @@ -59,7 +49,16 @@ impl Show for RawNode { .unwrap_or(Color::BLACK) .into(); - let mut content = if matches!( + let mut content = if let Some(content) = styles.show(self, ctx, [ + Value::Str(self.text.clone()), + match lang { + Some(lang) => Value::Str(lang.clone()), + None => Value::None, + }, + Value::Bool(self.block), + ])? { + content + } else if matches!( lang.map(|s| s.to_lowercase()).as_deref(), Some("typ" | "typst") ) { @@ -93,11 +92,18 @@ impl Show for RawNode { Content::Text(self.text.clone()) }; + let mut map = StyleMap::new(); + if let Smart::Custom(family) = styles.get_cloned(Self::FAMILY) { + map.set_family(family, styles); + } + + content = content.styled_with_map(map); + if self.block { content = Content::Block(content.pack()); } - Ok(content.monospaced()) + Ok(content) } } diff --git a/src/library/text/shaping.rs b/src/library/text/shaping.rs index 291413311..6087032f2 100644 --- a/src/library/text/shaping.rs +++ b/src/library/text/shaping.rs @@ -236,6 +236,18 @@ impl<'a> ShapedText<'a> { } } +/// Holds shaping results and metadata common to all shaped segments. +struct ShapingContext<'a> { + fonts: &'a mut FontStore, + glyphs: Vec, + used: Vec, + styles: StyleChain<'a>, + variant: FontVariant, + tags: Vec, + fallback: bool, + dir: Dir, +} + /// Shape text into [`ShapedText`]. pub fn shape<'a>( fonts: &mut FontStore, @@ -248,28 +260,24 @@ pub fn shape<'a>( None => Cow::Borrowed(text), }; - let mut glyphs = vec![]; + let mut ctx = ShapingContext { + fonts, + glyphs: vec![], + used: vec![], + styles, + variant: variant(styles), + tags: tags(styles), + fallback: styles.get(TextNode::FALLBACK), + dir, + }; + if !text.is_empty() { - shape_segment( - fonts, - &mut glyphs, - 0, - &text, - variant(styles), - families(styles), - None, - dir, - &tags(styles), - ); + shape_segment(&mut ctx, 0, &text, families(styles)); } - track_and_space( - &mut glyphs, - styles.get(TextNode::TRACKING), - styles.get(TextNode::SPACING), - ); + track_and_space(&mut ctx); - let (size, baseline) = measure(fonts, &glyphs, styles); + let (size, baseline) = measure(ctx.fonts, &ctx.glyphs, styles); ShapedText { text, @@ -277,10 +285,223 @@ pub fn shape<'a>( styles, size, baseline, - glyphs: Cow::Owned(glyphs), + glyphs: Cow::Owned(ctx.glyphs), } } +/// Shape text with font fallback using the `families` iterator. +fn shape_segment<'a>( + ctx: &mut ShapingContext, + base: usize, + text: &str, + mut families: impl Iterator + Clone, +) { + // Fonts dont have newlines and tabs. + if text.chars().all(|c| c == '\n' || c == '\t') { + return; + } + + // Find the next available family. + let mut selection = families.find_map(|family| { + ctx.fonts + .select(family, ctx.variant) + .filter(|id| !ctx.used.contains(id)) + }); + + // Do font fallback if the families are exhausted and fallback is enabled. + if selection.is_none() && ctx.fallback { + let first = ctx.used.first().copied(); + selection = ctx + .fonts + .select_fallback(first, ctx.variant, text) + .filter(|id| !ctx.used.contains(id)); + } + + // Extract the face id or shape notdef glyphs if we couldn't find any face. + let face_id = if let Some(id) = selection { + id + } else { + if let Some(&face_id) = ctx.used.first() { + shape_tofus(ctx, base, text, face_id); + } + return; + }; + + ctx.used.push(face_id); + + // Fill the buffer with our text. + let mut buffer = UnicodeBuffer::new(); + buffer.push_str(text); + buffer.set_direction(match ctx.dir { + Dir::LTR => rustybuzz::Direction::LeftToRight, + Dir::RTL => rustybuzz::Direction::RightToLeft, + _ => unimplemented!("vertical text layout"), + }); + + // Shape! + let mut face = ctx.fonts.get(face_id); + let buffer = rustybuzz::shape(face.ttf(), &ctx.tags, buffer); + let infos = buffer.glyph_infos(); + let pos = buffer.glyph_positions(); + + // Collect the shaped glyphs, doing fallback and shaping parts again with + // the next font if necessary. + let mut i = 0; + while i < infos.len() { + let info = &infos[i]; + let cluster = info.cluster as usize; + + if info.glyph_id != 0 { + // Add the glyph to the shaped output. + // TODO: Don't ignore y_advance and y_offset. + ctx.glyphs.push(ShapedGlyph { + face_id, + glyph_id: info.glyph_id as u16, + x_advance: face.to_em(pos[i].x_advance), + x_offset: face.to_em(pos[i].x_offset), + cluster: base + cluster, + safe_to_break: !info.unsafe_to_break(), + c: text[cluster ..].chars().next().unwrap(), + }); + } else { + // Determine the source text range for the tofu sequence. + let range = { + // First, search for the end of the tofu sequence. + let k = i; + while infos.get(i + 1).map_or(false, |info| info.glyph_id == 0) { + i += 1; + } + + // Then, determine the start and end text index. + // + // Examples: + // Everything is shown in visual order. Tofus are written as "_". + // We want to find out that the tofus span the text `2..6`. + // Note that the clusters are longer than 1 char. + // + // Left-to-right: + // Text: h a l i h a l l o + // Glyphs: A _ _ C E + // Clusters: 0 2 4 6 8 + // k=1 i=2 + // + // Right-to-left: + // Text: O L L A H I L A H + // Glyphs: E C _ _ A + // Clusters: 8 6 4 2 0 + // k=2 i=3 + let ltr = ctx.dir.is_positive(); + let first = if ltr { k } else { i }; + let start = infos[first].cluster as usize; + let last = if ltr { i.checked_add(1) } else { k.checked_sub(1) }; + let end = last + .and_then(|last| infos.get(last)) + .map_or(text.len(), |info| info.cluster as usize); + + start .. end + }; + + // Trim half-baked cluster. + let remove = base + range.start .. base + range.end; + while ctx.glyphs.last().map_or(false, |g| remove.contains(&g.cluster)) { + ctx.glyphs.pop(); + } + + // Recursively shape the tofu sequence with the next family. + shape_segment(ctx, base + range.start, &text[range], families.clone()); + + face = ctx.fonts.get(face_id); + } + + i += 1; + } + + ctx.used.pop(); +} + +/// Shape the text with tofus from the given face. +fn shape_tofus(ctx: &mut ShapingContext, base: usize, text: &str, face_id: FaceId) { + let face = ctx.fonts.get(face_id); + let x_advance = face.advance(0).unwrap_or_default(); + for (cluster, c) in text.char_indices() { + ctx.glyphs.push(ShapedGlyph { + face_id, + glyph_id: 0, + x_advance, + x_offset: Em::zero(), + cluster: base + cluster, + safe_to_break: true, + c, + }); + } +} + +/// Apply tracking and spacing to a slice of shaped glyphs. +fn track_and_space(ctx: &mut ShapingContext) { + let tracking = ctx.styles.get(TextNode::TRACKING); + let spacing = ctx.styles.get(TextNode::SPACING); + if tracking.is_zero() && spacing.is_one() { + return; + } + + let mut glyphs = ctx.glyphs.iter_mut().peekable(); + while let Some(glyph) = glyphs.next() { + if glyph.is_space() { + glyph.x_advance *= spacing.get(); + } + + if glyphs.peek().map_or(false, |next| glyph.cluster != next.cluster) { + glyph.x_advance += tracking; + } + } +} + +/// Measure the size and baseline of a run of shaped glyphs with the given +/// properties. +fn measure( + fonts: &mut FontStore, + glyphs: &[ShapedGlyph], + styles: StyleChain, +) -> (Size, Length) { + let mut width = Length::zero(); + let mut top = Length::zero(); + let mut bottom = Length::zero(); + + let size = styles.get(TextNode::SIZE).abs; + let top_edge = styles.get(TextNode::TOP_EDGE); + let bottom_edge = styles.get(TextNode::BOTTOM_EDGE); + + // Expand top and bottom by reading the face's vertical metrics. + let mut expand = |face: &Face| { + let metrics = face.metrics(); + top.set_max(metrics.vertical(top_edge, size)); + bottom.set_max(-metrics.vertical(bottom_edge, size)); + }; + + if glyphs.is_empty() { + // When there are no glyphs, we just use the vertical metrics of the + // first available font. + let variant = variant(styles); + for family in families(styles) { + if let Some(face_id) = fonts.select(family, variant) { + expand(fonts.get(face_id)); + break; + } + } + } else { + for (face_id, group) in glyphs.group_by_key(|g| g.face_id) { + let face = fonts.get(face_id); + expand(face); + + for glyph in group { + width += glyph.x_advance.resolve(size); + } + } + } + + (Size::new(width, top + bottom), top) +} + /// Resolve the font variant with `STRONG` and `EMPH` factored in. fn variant(styles: StyleChain) -> FontVariant { let mut variant = FontVariant::new( @@ -306,30 +527,19 @@ fn variant(styles: StyleChain) -> FontVariant { /// Resolve a prioritized iterator over the font families. fn families(styles: StyleChain) -> impl Iterator + Clone { - let head = if styles.get(TextNode::MONOSPACED) { - styles.get_ref(TextNode::MONOSPACE).as_slice() - } else { - &[] - }; + const FALLBACKS: &[&str] = &[ + "ibm plex sans", + "twitter color emoji", + "noto color emoji", + "apple color emoji", + "segoe ui emoji", + ]; - let core = styles.get_ref(TextNode::FAMILY).iter().flat_map(move |family| { - match family { - FontFamily::Named(name) => std::slice::from_ref(name), - FontFamily::Serif => styles.get_ref(TextNode::SERIF), - FontFamily::SansSerif => styles.get_ref(TextNode::SANS_SERIF), - FontFamily::Monospace => styles.get_ref(TextNode::MONOSPACE), - } - }); - - let tail: &[&str] = if styles.get(TextNode::FALLBACK) { - &["ibm plex sans", "latin modern math", "twitter color emoji"] - } else { - &[] - }; - - head.iter() - .chain(core) - .map(|named| named.as_str()) + let tail = if styles.get(TextNode::FALLBACK) { FALLBACKS } else { &[] }; + styles + .get_ref(TextNode::FAMILY) + .iter() + .map(|family| family.as_str()) .chain(tail.iter().copied()) } @@ -405,197 +615,3 @@ fn tags(styles: StyleChain) -> Vec { tags } - -/// Shape text with font fallback using the `families` iterator. -fn shape_segment<'a>( - fonts: &mut FontStore, - glyphs: &mut Vec, - base: usize, - text: &str, - variant: FontVariant, - mut families: impl Iterator + Clone, - mut first_face: Option, - dir: Dir, - tags: &[rustybuzz::Feature], -) { - // No font has newlines. - if text.chars().all(|c| c == '\n') { - return; - } - - // Select the font family. - let (face_id, fallback) = loop { - // Try to load the next available font family. - match families.next() { - Some(family) => { - if let Some(id) = fonts.select(family, variant) { - break (id, true); - } - } - // We're out of families, so we don't do any more fallback and just - // shape the tofus with the first face we originally used. - None => match first_face { - Some(id) => break (id, false), - None => return, - }, - } - }; - - // Remember the id if this the first available face since we use that one to - // shape tofus. - first_face.get_or_insert(face_id); - - // Fill the buffer with our text. - let mut buffer = UnicodeBuffer::new(); - buffer.push_str(text); - buffer.set_direction(match dir { - Dir::LTR => rustybuzz::Direction::LeftToRight, - Dir::RTL => rustybuzz::Direction::RightToLeft, - _ => unimplemented!(), - }); - - // Shape! - let mut face = fonts.get(face_id); - let buffer = rustybuzz::shape(face.ttf(), tags, buffer); - let infos = buffer.glyph_infos(); - let pos = buffer.glyph_positions(); - - // Collect the shaped glyphs, doing fallback and shaping parts again with - // the next font if necessary. - let mut i = 0; - while i < infos.len() { - let info = &infos[i]; - let cluster = info.cluster as usize; - - if info.glyph_id != 0 || !fallback { - // Add the glyph to the shaped output. - // TODO: Don't ignore y_advance and y_offset. - glyphs.push(ShapedGlyph { - face_id, - glyph_id: info.glyph_id as u16, - x_advance: face.to_em(pos[i].x_advance), - x_offset: face.to_em(pos[i].x_offset), - cluster: base + cluster, - safe_to_break: !info.unsafe_to_break(), - c: text[cluster ..].chars().next().unwrap(), - }); - } else { - // Determine the source text range for the tofu sequence. - let range = { - // First, search for the end of the tofu sequence. - let k = i; - while infos.get(i + 1).map_or(false, |info| info.glyph_id == 0) { - i += 1; - } - - // Then, determine the start and end text index. - // - // Examples: - // Everything is shown in visual order. Tofus are written as "_". - // We want to find out that the tofus span the text `2..6`. - // Note that the clusters are longer than 1 char. - // - // Left-to-right: - // Text: h a l i h a l l o - // Glyphs: A _ _ C E - // Clusters: 0 2 4 6 8 - // k=1 i=2 - // - // Right-to-left: - // Text: O L L A H I L A H - // Glyphs: E C _ _ A - // Clusters: 8 6 4 2 0 - // k=2 i=3 - let ltr = dir.is_positive(); - let first = if ltr { k } else { i }; - let start = infos[first].cluster as usize; - let last = if ltr { i.checked_add(1) } else { k.checked_sub(1) }; - let end = last - .and_then(|last| infos.get(last)) - .map_or(text.len(), |info| info.cluster as usize); - - start .. end - }; - - // Recursively shape the tofu sequence with the next family. - shape_segment( - fonts, - glyphs, - base + range.start, - &text[range], - variant, - families.clone(), - first_face, - dir, - tags, - ); - - face = fonts.get(face_id); - } - - i += 1; - } -} - -/// Apply tracking and spacing to a slice of shaped glyphs. -fn track_and_space(glyphs: &mut [ShapedGlyph], tracking: Em, spacing: Relative) { - if tracking.is_zero() && spacing.is_one() { - return; - } - - let mut glyphs = glyphs.iter_mut().peekable(); - while let Some(glyph) = glyphs.next() { - if glyph.is_space() { - glyph.x_advance *= spacing.get(); - } - - if glyphs.peek().map_or(false, |next| glyph.cluster != next.cluster) { - glyph.x_advance += tracking; - } - } -} - -/// Measure the size and baseline of a run of shaped glyphs with the given -/// properties. -fn measure( - fonts: &mut FontStore, - glyphs: &[ShapedGlyph], - styles: StyleChain, -) -> (Size, Length) { - let mut width = Length::zero(); - let mut top = Length::zero(); - let mut bottom = Length::zero(); - - let size = styles.get(TextNode::SIZE).abs; - let top_edge = styles.get(TextNode::TOP_EDGE); - let bottom_edge = styles.get(TextNode::BOTTOM_EDGE); - - // Expand top and bottom by reading the face's vertical metrics. - let mut expand = |face: &Face| { - top.set_max(face.vertical_metric(top_edge, size)); - bottom.set_max(-face.vertical_metric(bottom_edge, size)); - }; - - if glyphs.is_empty() { - // When there are no glyphs, we just use the vertical metrics of the - // first available font. - let variant = variant(styles); - for family in families(styles) { - if let Some(face_id) = fonts.select(family, variant) { - expand(fonts.get(face_id)); - break; - } - } - } else { - for (face_id, group) in glyphs.group_by_key(|g| g.face_id) { - let face = fonts.get(face_id); - expand(face); - - for glyph in group { - width += glyph.x_advance.resolve(size); - } - } - } - - (Size::new(width, top + bottom), top) -} diff --git a/src/loading/fs.rs b/src/loading/fs.rs index 58f2b1e2b..3398ebd37 100644 --- a/src/loading/fs.rs +++ b/src/loading/fs.rs @@ -116,7 +116,7 @@ impl FsLoader { let path = path.strip_prefix(".").unwrap_or(path); if let Ok(file) = File::open(path) { if let Ok(mmap) = unsafe { Mmap::map(&file) } { - self.faces.extend(FaceInfo::parse(path, &mmap)); + self.faces.extend(FaceInfo::from_data(path, &mmap)); } } } @@ -142,34 +142,3 @@ impl Loader for FsLoader { fs::read(path) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_index_font_dir() { - let faces = FsLoader::new().with_path("fonts").faces; - let mut paths: Vec<_> = faces.into_iter().map(|info| info.path).collect(); - paths.sort(); - - assert_eq!(paths, [ - Path::new("fonts/CMU-Serif-Bold.ttf"), - Path::new("fonts/CMU-Serif-Regular.ttf"), - Path::new("fonts/IBMPlexMono-Regular.ttf"), - Path::new("fonts/IBMPlexSans-Bold.ttf"), - Path::new("fonts/IBMPlexSans-BoldItalic.ttf"), - Path::new("fonts/IBMPlexSans-Italic.ttf"), - Path::new("fonts/IBMPlexSans-Regular.ttf"), - Path::new("fonts/IBMPlexSerif-Regular.ttf"), - Path::new("fonts/LatinModernMath.otf"), - Path::new("fonts/NotoSansArabic-Regular.ttf"), - Path::new("fonts/NotoSerifCJKsc-Regular.otf"), - Path::new("fonts/NotoSerifHebrew-Bold.ttf"), - Path::new("fonts/NotoSerifHebrew-Regular.ttf"), - Path::new("fonts/PTSans-Regular.ttf"), - Path::new("fonts/Roboto-Regular.ttf"), - Path::new("fonts/TwitterColorEmoji.ttf"), - ]); - } -} diff --git a/src/loading/mem.rs b/src/loading/mem.rs index 5e3e78d11..d4c0e7e45 100644 --- a/src/loading/mem.rs +++ b/src/loading/mem.rs @@ -49,7 +49,7 @@ impl MemLoader { { let path = path.as_ref().normalize(); let data = data.into(); - self.faces.extend(FaceInfo::parse(&path, &data)); + self.faces.extend(FaceInfo::from_data(&path, &data)); self.files.insert(path, data); } } diff --git a/tests/ref/math/basic.png b/tests/ref/math/basic.png index 77ec8d514..2c1dd3244 100644 Binary files a/tests/ref/math/basic.png and b/tests/ref/math/basic.png differ diff --git a/tests/ref/text/emoji.png b/tests/ref/text/emoji.png new file mode 100644 index 000000000..3f3f698d1 Binary files /dev/null and b/tests/ref/text/emoji.png differ diff --git a/tests/ref/text/fallback.png b/tests/ref/text/fallback.png new file mode 100644 index 000000000..ae840734b Binary files /dev/null and b/tests/ref/text/fallback.png differ diff --git a/tests/ref/text/font.png b/tests/ref/text/font.png index cb86f9292..1c1a71eef 100644 Binary files a/tests/ref/text/font.png and b/tests/ref/text/font.png differ diff --git a/tests/ref/text/knuth.png b/tests/ref/text/knuth.png index 38e4b38fb..8595fe291 100644 Binary files a/tests/ref/text/knuth.png and b/tests/ref/text/knuth.png differ diff --git a/tests/ref/text/shaping.png b/tests/ref/text/shaping.png index 1e57afca4..a95f44e00 100644 Binary files a/tests/ref/text/shaping.png and b/tests/ref/text/shaping.png differ diff --git a/tests/ref/utility/string.png b/tests/ref/utility/string.png index 67563668c..021843163 100644 Binary files a/tests/ref/utility/string.png and b/tests/ref/utility/string.png differ diff --git a/tests/typ/layout/columns.typ b/tests/typ/layout/columns.typ index 7868ac39a..0a5858683 100644 --- a/tests/typ/layout/columns.typ +++ b/tests/typ/layout/columns.typ @@ -4,7 +4,7 @@ // Test normal operation and RTL directions. #set page(height: 3.25cm, width: 7.05cm, columns: 2) #set columns(gutter: 30pt) -#set text("Noto Sans Arabic", serif) +#set text("Noto Sans Arabic", "IBM Plex Serif") #set par(lang: "ar") #rect(fill: conifer, height: 8pt, width: 6pt) وتحفيز diff --git a/tests/typ/style/closure.typ b/tests/typ/style/closure.typ index 2c9edd810..226264723 100644 --- a/tests/typ/style/closure.typ +++ b/tests/typ/style/closure.typ @@ -23,7 +23,7 @@ )[Level 3] --- -// Error: 22-26 expected font family or auto or function, found length +// Error: 22-26 expected string or auto or function, found length #set heading(family: 10pt) = Heading @@ -33,7 +33,7 @@ = Heading --- -// Error: 22-34 expected font family or auto, found boolean +// Error: 22-34 expected string or auto, found boolean #set heading(family: lvl => false) = Heading diff --git a/tests/typ/text/bidi.typ b/tests/typ/text/bidi.typ index 09e235586..5c6663814 100644 --- a/tests/typ/text/bidi.typ +++ b/tests/typ/text/bidi.typ @@ -3,7 +3,7 @@ --- // Test reordering with different top-level paragraph directions. #let content = [Text טֶקסט] -#set text(serif, "Noto Serif Hebrew") +#set text("IBM Plex Serif") #par(lang: "he", content) #par(lang: "de", content) @@ -11,7 +11,7 @@ // Test that consecutive, embedded LTR runs stay LTR. // Here, we have two runs: "A" and italic "B". #let content = [أنت A#emph[B]مطرC] -#set text(serif, "Noto Sans Arabic") +#set text("IBM Plex Serif", "Noto Sans Arabic") #par(lang: "ar", content) #par(lang: "de", content) @@ -19,32 +19,32 @@ // Test that consecutive, embedded RTL runs stay RTL. // Here, we have three runs: "גֶ", bold "שֶׁ", and "ם". #let content = [Aגֶ#strong[שֶׁ]םB] -#set text(serif, "Noto Serif Hebrew") +#set text("IBM Plex Serif", "Noto Serif Hebrew") #par(lang: "he", content) #par(lang: "de", content) --- // Test embedding up to level 4 with isolates. -#set text(serif, "Noto Serif Hebrew", "Twitter Color Emoji") +#set text("IBM Plex Serif") #set par(dir: rtl) א\u{2066}A\u{2067}Bב\u{2069}? --- // Test hard line break (leads to two paragraphs in unicode-bidi). -#set text("Noto Sans Arabic", serif) +#set text("Noto Sans Arabic", "IBM Plex Serif") #set par(lang: "ar") Life المطر هو الحياة \ الحياة تمطر is rain. --- // Test spacing. -#set text(serif, "Noto Serif Hebrew") +#set text("IBM Plex Serif") L #h(1cm) ריווחR \ Lריווח #h(1cm) R --- // Test inline object. -#set text("Noto Serif Hebrew", serif) +#set text("IBM Plex Serif") #set par(lang: "he") קרנפיםRh#image("../../res/rhino.png", height: 11pt)inoחיים diff --git a/tests/typ/text/emoji.typ b/tests/typ/text/emoji.typ new file mode 100644 index 000000000..f8e0d5b66 --- /dev/null +++ b/tests/typ/text/emoji.typ @@ -0,0 +1,18 @@ +// Test emoji shaping. + +--- +// This should form a three-member family. +👩‍👩‍👦 + +// This should form a pride flag. +🏳️‍🌈 + +// Skin tone modifier should be applied. +👍🏿 + +// This should be a 1 in a box. +1️⃣ + +--- +// These two shouldn't be affected by a zero-width joiner. +🏞‍🌋 diff --git a/tests/typ/text/fallback.typ b/tests/typ/text/fallback.typ new file mode 100644 index 000000000..1db859457 --- /dev/null +++ b/tests/typ/text/fallback.typ @@ -0,0 +1,20 @@ +// Test font fallback. + +--- +// Font fallback for emoji. +A😀B + +// Font fallback for entire text. +دع النص يمطر عليك + +// Font fallback in right-to-left text. +ب🐈😀سم + +// Multi-layer font fallback. +Aب😀🏞سمB + +// Font fallback with composed emojis and multiple fonts. +01️⃣2 + +// Tofus are rendered with the first font. +A🐈ዲሞB diff --git a/tests/typ/text/font.typ b/tests/typ/text/font.typ index 71ac8d3db..18469b7a8 100644 --- a/tests/typ/text/font.typ +++ b/tests/typ/text/font.typ @@ -19,7 +19,7 @@ #text(stretch: 50%)[Condensed] // Set family. -#text(family: serif)[Serif] +#text(family: "IBM Plex Serif")[Serif] // Emoji. Emoji: 🐪, 🌋, 🏞 @@ -38,21 +38,13 @@ Emoji: 🐪, 🌋, 🏞 #set text("PT Sans", "Twitter Color Emoji", fallback: false) 2π = 𝛼 + 𝛽. ✅ ---- -// Test class definitions. -#set text(sans-serif: "PT Sans") -#text(family: sans-serif)[Sans-serif.] \ -#text(monospace)[Monospace.] \ -#text(monospace, monospace: ("Nope", "Latin Modern Math"))[Math.] - --- // Test top and bottom edge. - #set page(width: 150pt) #set text(size: 8pt) #let try(top, bottom) = rect(fill: conifer)[ - #set text(monospace, top-edge: top, bottom-edge: bottom) + #set text("IBM Plex Mono", top-edge: top, bottom-edge: bottom) From #top to #bottom ] @@ -79,10 +71,6 @@ Emoji: 🐪, 🌋, 🏞 // Error: 21-23 unknown font metric #set text(top-edge: "") ---- -// Error: 18-19 expected string or array of strings, found integer -#set text(serif: 0) - --- // Error: 23-27 unexpected argument #set text(size: 10pt, 12pt) diff --git a/tests/typ/text/indent.typ b/tests/typ/text/indent.typ index 97eae190f..6da562f6e 100644 --- a/tests/typ/text/indent.typ +++ b/tests/typ/text/indent.typ @@ -19,7 +19,7 @@ starts a paragraph without indent. Except if you have another paragraph in them. -#set text(8pt, "Noto Sans Arabic") +#set text(8pt, "Noto Sans Arabic", "IBM Plex Sans") #set par(lang: "ar", leading: 8pt) = Arabic diff --git a/tests/typ/text/knuth.typ b/tests/typ/text/knuth.typ index 63b7c50da..d5af87f84 100644 --- a/tests/typ/text/knuth.typ +++ b/tests/typ/text/knuth.typ @@ -1,6 +1,6 @@ #set page(width: auto, height: auto) -#set par(lang: "en", leading: 3pt, justify: true) -#set text(family: "CMU Serif") +#set par(lang: "en", leading: 4pt, justify: true) +#set text(family: "Latin Modern Roman") #let story = [ In olden times when wishing still helped one, there lived a king whose diff --git a/tests/typ/text/microtype.typ b/tests/typ/text/microtype.typ index add5e5012..dbcb49988 100644 --- a/tests/typ/text/microtype.typ +++ b/tests/typ/text/microtype.typ @@ -24,7 +24,7 @@ A#box["]B --- #set par(lang: "ar") -#set text("Noto Sans Arabic") +#set text("Noto Sans Arabic", "IBM Plex Sans") "المطر هو الحياة" \ المطر هو الحياة diff --git a/tests/typ/text/shaping.typ b/tests/typ/text/shaping.typ deleted file mode 100644 index bb8f4ce92..000000000 --- a/tests/typ/text/shaping.typ +++ /dev/null @@ -1,45 +0,0 @@ -// Test complex text shaping. - ---- -// Test ligatures. - -// This should create an "fi" ligature. -Le fira - -// This should just shape nicely. -#set text("Noto Sans Arabic") -دع النص يمطر عليك - -// This should form a three-member family. -#set text("Twitter Color Emoji") -👩‍👩‍👦 🤚🏿 - -// These two shouldn't be affected by a zero-width joiner. -🏞‍🌋 - ---- -// Test font fallback. - -#set text(sans-serif, "Noto Sans Arabic", "Twitter Color Emoji") - -// Font fallback for emoji. -A😀B - -// Font fallback for entire text. -دع النص يمطر عليك - -// Font fallback in right-to-left text. -ب🐈😀سم - -// Multi-layer font fallback. -Aب😀🏞سمB - -// Tofus are rendered with the first font. -A🐈中文B - ---- -// Test reshaping. - -#set text("Noto Serif Hebrew") -#set par(lang: "he") -ס \ טֶ diff --git a/tests/typ/text/whitespace.typ b/tests/typ/text/whitespace.typ index 831a55438..8dcc59e36 100644 --- a/tests/typ/text/whitespace.typ +++ b/tests/typ/text/whitespace.typ @@ -19,11 +19,11 @@ A /**/B/**/ C --- // Test that a run consisting only of whitespace isn't trimmed. -A[#set text(serif); ]B +A[#set text("IBM Plex Serif"); ]B --- // Test font change after space. -Left [#set text(serif);Right]. +Left [#set text("IBM Plex Serif");Right]. --- // Test that linebreak consumed surrounding spaces. diff --git a/tests/typ/utility/string.typ b/tests/typ/utility/string.typ index 9b57e833c..e002b2070 100644 --- a/tests/typ/utility/string.typ +++ b/tests/typ/utility/string.typ @@ -38,6 +38,7 @@ --- // Test integrated lower, upper and symbols. // Ref: true + #upper("Abc 8") #upper[def]