diff --git a/crates/typst/src/foundations/styles.rs b/crates/typst/src/foundations/styles.rs index 42be8922927..865864eb536 100644 --- a/crates/typst/src/foundations/styles.rs +++ b/crates/typst/src/foundations/styles.rs @@ -15,7 +15,7 @@ use crate::foundations::{ }; use crate::introspection::Locatable; use crate::syntax::Span; -use crate::text::{FontFamily, FontList, TextElem}; +use crate::text::{FontList, FontListEntry, TextElem}; use crate::utils::LazyHash; /// Provides access to active styles. @@ -139,7 +139,7 @@ impl Styles { /// Set a font family composed of a preferred family and existing families /// from a style chain. - pub fn set_family(&mut self, preferred: FontFamily, existing: StyleChain) { + pub fn set_family(&mut self, preferred: FontListEntry, existing: StyleChain) { self.set(TextElem::set_font(FontList( std::iter::once(preferred) .chain(TextElem::font_in(existing).into_iter().cloned()) diff --git a/crates/typst/src/layout/inline/shaping.rs b/crates/typst/src/layout/inline/shaping.rs index 15752f1ba50..e8f7449e197 100644 --- a/crates/typst/src/layout/inline/shaping.rs +++ b/crates/typst/src/layout/inline/shaping.rs @@ -16,8 +16,8 @@ use crate::foundations::StyleChain; use crate::layout::{Abs, Dir, Em, Frame, FrameItem, Point, Size}; use crate::syntax::Span; use crate::text::{ - decorate, families, features, variant, Font, FontVariant, Glyph, Lang, Region, - TextElem, TextItem, + decorate, families, features, font_list_entries, variant, Font, FontListEntry, + FontVariant, Glyph, Lang, Region, TextElem, TextItem, }; use crate::utils::SliceExt; use crate::World; @@ -591,15 +591,30 @@ impl Debug for ShapedText<'_> { /// Holds shaping results and metadata common to all shaped segments. struct ShapingContext<'a, 'v> { + /// The parent [`Engine`]. engine: &'a Engine<'v>, + /// The parent [`SpanMapper`]. spans: &'a SpanMapper, + /// The list of glyphs in the output. glyphs: Vec, + /// A stack of fonts that are already used. + /// + /// This is used by [`shape_segment`], which calls itself recursively + /// to shape text that could not be shaped in the first pass. used: Vec, + /// The style chain for this context. styles: StyleChain<'a>, + /// The size of the text. size: Abs, + /// The font variant used for selecting a font. variant: FontVariant, + /// A list of base features. + /// + /// This does not include any features listed in a [`FontListEntry`]. features: Vec, + /// Whether to use fallback fonts. fallback: bool, + /// The writing direction to use. dir: Dir, } @@ -630,7 +645,7 @@ pub(super) fn shape<'a>( }; if !text.is_empty() { - shape_segment(&mut ctx, base, text, families(styles)); + shape_segment(&mut ctx, base, text, font_list_entries(styles)); } track_and_space(&mut ctx); @@ -660,7 +675,7 @@ fn shape_segment<'a>( ctx: &mut ShapingContext, base: usize, text: &str, - mut families: impl Iterator + Clone, + mut families: impl Iterator + Clone, ) { // Fonts dont have newlines and tabs. if text.chars().all(|c| c == '\n' || c == '\t') { @@ -671,9 +686,10 @@ fn shape_segment<'a>( let world = ctx.engine.world; let book = world.book(); let mut selection = families.find_map(|family| { - book.select(family, ctx.variant) + book.select(family.family.as_str(), ctx.variant) .and_then(|id| world.font(id)) .filter(|font| !ctx.used.contains(font)) + .map(|d| (d, family.features())) }); // Do font fallback if the families are exhausted and fallback is enabled. @@ -682,11 +698,12 @@ fn shape_segment<'a>( selection = book .select_fallback(first, ctx.variant, text) .and_then(|id| world.font(id)) - .filter(|font| !ctx.used.contains(font)); + .filter(|font| !ctx.used.contains(font)) + .map(|d| (d, Vec::new())); } // Extract the font id or shape notdef glyphs if we couldn't find any font. - let Some(font) = selection else { + let Some((font, mut features)) = selection else { if let Some(font) = ctx.used.first().cloned() { shape_tofus(ctx, base, text, font); } @@ -714,12 +731,13 @@ fn shape_segment<'a>( // Prepare the shape plan. This plan depends on direction, script, language, // and features, but is independent from the text and can thus be // memoized. + features.extend_from_slice(&ctx.features); let plan = create_shape_plan( &font, buffer.direction(), buffer.script(), buffer.language().as_ref(), - &ctx.features, + &features, ); // Shape! diff --git a/crates/typst/src/math/ctx.rs b/crates/typst/src/math/ctx.rs index ac3fbca851e..83ba8590d50 100644 --- a/crates/typst/src/math/ctx.rs +++ b/crates/typst/src/math/ctx.rs @@ -22,8 +22,8 @@ use crate::model::ParElem; use crate::realize::StyleVec; use crate::syntax::{is_newline, Span}; use crate::text::{ - features, BottomEdge, BottomEdgeMetric, Font, TextElem, TextSize, TopEdge, - TopEdgeMetric, + features, BottomEdge, BottomEdgeMetric, Font, FontListEntry, TextElem, TextSize, + TopEdge, TopEdgeMetric, }; macro_rules! scaled { @@ -71,6 +71,7 @@ impl<'a, 'b, 'v> MathContext<'a, 'b, 'v> { styles: StyleChain<'a>, base: Size, font: &'a Font, + fle: &FontListEntry, ) -> Self { let math_table = font.ttf().tables().math.unwrap(); let gsub_table = font.ttf().tables().gsub; @@ -89,7 +90,9 @@ impl<'a, 'b, 'v> MathContext<'a, 'b, 'v> { _ => None, }); - let features = features(styles); + let base_features = features(styles); + let mut features = fle.features(); + features.extend_from_slice(&base_features); let glyphwise_tables = gsub_table.map(|gsub| { features .into_iter() diff --git a/crates/typst/src/math/equation.rs b/crates/typst/src/math/equation.rs index 054b2823025..e860c2c1894 100644 --- a/crates/typst/src/math/equation.rs +++ b/crates/typst/src/math/equation.rs @@ -20,7 +20,8 @@ use crate::math::{ use crate::model::{Numbering, Outlinable, ParElem, Refable, Supplement}; use crate::syntax::Span; use crate::text::{ - families, variant, Font, FontFamily, FontList, FontWeight, LocalName, TextElem, + font_list_entries, variant, Font, FontFamily, FontList, FontListEntry, FontWeight, + LocalName, TextElem, }; use crate::utils::{NonZeroExt, Numeric}; use crate::World; @@ -188,8 +189,8 @@ impl ShowSet for Packed { out.set(EquationElem::set_size(MathSize::Text)); } out.set(TextElem::set_weight(FontWeight::from_number(450))); - out.set(TextElem::set_font(FontList(vec![FontFamily::new( - "New Computer Modern Math", + out.set(TextElem::set_font(FontList(vec![FontListEntry::from_family( + FontFamily::new("New Computer Modern Math"), )]))); out } @@ -276,9 +277,9 @@ fn layout_equation_inline( ) -> SourceResult> { assert!(!elem.block(styles)); - let font = find_math_font(engine, styles, elem.span())?; + let (font, fle) = find_math_font(engine, styles, elem.span())?; - let mut ctx = MathContext::new(engine, locator, styles, region, &font); + let mut ctx = MathContext::new(engine, locator, styles, region, &font, fle); let run = ctx.layout_into_run(elem, styles)?; let mut items = if run.row_count() == 1 { @@ -323,11 +324,11 @@ fn layout_equation_block( assert!(elem.block(styles)); let span = elem.span(); - let font = find_math_font(engine, styles, span)?; + let (font, fle) = find_math_font(engine, styles, span)?; let mut locator = locator.split(); let mut ctx = - MathContext::new(engine, locator.next(&()), styles, regions.base(), &font); + MathContext::new(engine, locator.next(&()), styles, regions.base(), &font, fle); let full_equation_builder = ctx .layout_into_run(elem, styles)? .multiline_frame_builder(&ctx, styles); @@ -433,18 +434,18 @@ fn layout_equation_block( Ok(Fragment::frames(frames)) } -fn find_math_font( +fn find_math_font<'a>( engine: &mut Engine<'_>, - styles: StyleChain, + styles: StyleChain<'a>, span: Span, -) -> SourceResult { +) -> SourceResult<(Font, &'a FontListEntry)> { let variant = variant(styles); let world = engine.world; - let Some(font) = families(styles).find_map(|family| { - let id = world.book().select(family, variant)?; + let Some(font) = font_list_entries(styles).find_map(|family| { + let id = world.book().select(family.family.as_str(), variant)?; let font = world.font(id)?; let _ = font.ttf().tables().math?.constants?; - Some(font) + Some((font, family)) }) else { bail!(span, "current font does not support math"); }; diff --git a/crates/typst/src/text/mod.rs b/crates/typst/src/text/mod.rs index 7648f08fa1c..e3a875db33d 100644 --- a/crates/typst/src/text/mod.rs +++ b/crates/typst/src/text/mod.rs @@ -29,14 +29,19 @@ pub use self::smartquote::*; pub use self::space::*; use std::fmt::{self, Debug, Formatter}; +use std::sync::OnceLock; use ecow::{eco_format, EcoString}; use rustybuzz::Feature; use smallvec::SmallVec; use ttf_parser::{Rect, Tag}; +use typst_macros::func; +use typst_macros::scope; +use typst_macros::ty; use crate::diag::{bail, warning, HintedStrResult, SourceResult}; use crate::engine::Engine; +use crate::foundations::Value; use crate::foundations::{ cast, category, dict, elem, Args, Array, Cast, Category, Construct, Content, Dict, Fold, NativeElement, Never, Packed, PlainText, Repr, Resolve, Scope, Set, Smart, @@ -131,18 +136,18 @@ pub struct TextElem { if let Some(font_list) = &font_list { let book = engine.world.book(); for family in &font_list.v { - if !book.contains_family(family.as_str()) { + if !book.contains_family(family.family.as_str()) { engine.sink.warn(warning!( font_list.span, "unknown font family: {}", - family.as_str(), + family.family.as_str(), )); } } } font_list.map(|font_list| font_list.v) })] - #[default(FontList(vec![FontFamily::new("Linux Libertine")]))] + #[default(FontList(vec![FontListEntry::from_family(FontFamily::new("Linux Libertine"))]))] #[borrowed] #[ghost] pub font: FontList, @@ -790,13 +795,73 @@ cast! { string: EcoString => Self::new(&string), } +/// A specification for a font. +#[ty(scope, cast, name = "font")] +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct FontListEntry { + pub family: FontFamily, + pub features: FontFeatures, +} + +impl FontListEntry { + pub fn from_family(family: FontFamily) -> Self { + FontListEntry { family, features: FontFeatures::default() } + } + + pub fn features(&self) -> Vec { + self.features + .0 + .iter() + .map(|(tag, value)| Feature::new(*tag, *value, ..)) + .collect() + } +} + +#[scope] +impl FontListEntry { + /// Constructs a new font. + #[func(constructor)] + pub fn construct( + /// The font family. + family: FontFamily, + /// Raw OpenType features to apply to this font. + /// + /// - If given an array of strings, sets the features identified by the + /// strings to `{1}`. + /// - If given a dictionary mapping to numbers, sets the features + /// identified by the keys to the values. + /// + /// ```example + /// // Enable the `frac` feature manually. + /// #set text(features: ("frac",)) + /// 1/2 + /// ``` + // TODO: this currently always takes precedence over `text`’s features. + features: FontFeatures, + ) -> FontListEntry { + FontListEntry { family, features } + } +} + +impl Repr for FontListEntry { + fn repr(&self) -> EcoString { + eco_format!("font({})", self.family.0.repr()) + } +} + +cast! { + FontListEntry, + self => Value::dynamic(self), + string: EcoString => Self::from_family(FontFamily::new(&string)), +} + /// Font family fallback list. #[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] -pub struct FontList(pub Vec); +pub struct FontList(pub Vec); impl<'a> IntoIterator for &'a FontList { - type IntoIter = std::slice::Iter<'a, FontFamily>; - type Item = &'a FontFamily; + type IntoIter = std::slice::Iter<'a, FontListEntry>; + type Item = &'a FontListEntry; fn into_iter(self) -> Self::IntoIter { self.0.iter() @@ -806,31 +871,55 @@ impl<'a> IntoIterator for &'a FontList { cast! { FontList, self => if self.0.len() == 1 { - self.0.into_iter().next().unwrap().0.into_value() + self.0.into_iter().next().unwrap().into_value() } else { self.0.into_value() }, - family: FontFamily => Self(vec![family]), + family: FontFamily => Self(vec![FontListEntry::from_family(family)]), + entry: FontListEntry => Self(vec![entry]), values: Array => Self(values.into_iter().map(|v| v.cast()).collect::>()?), } +const FALLBACKS: &[&str] = &[ + "linux libertine", + "twitter color emoji", + "noto color emoji", + "apple color emoji", + "segoe ui emoji", +]; + /// Resolve a prioritized iterator over the font families. +/// +/// This should be preferred over [`font_list_entries`] when you only need the family names. pub(crate) fn families(styles: StyleChain) -> impl Iterator + Clone { - const FALLBACKS: &[&str] = &[ - "linux libertine", - "twitter color emoji", - "noto color emoji", - "apple color emoji", - "segoe ui emoji", - ]; - let tail = if TextElem::fallback_in(styles) { FALLBACKS } else { &[] }; TextElem::font_in(styles) .into_iter() - .map(|family| family.as_str()) + .map(|family| family.family.as_str()) .chain(tail.iter().copied()) } +/// Resolve a prioritized iterator over the font list entries. +pub(crate) fn font_list_entries( + styles: StyleChain, +) -> impl Iterator + Clone { + static FALLBACKS_OL: OnceLock> = OnceLock::new(); + + let tail = if TextElem::fallback_in(styles) { + FALLBACKS_OL + .get_or_init(|| { + FALLBACKS + .iter() + .map(|family| FontListEntry::from_family(FontFamily::new(family))) + .collect() + }) + .as_slice() + } else { + &[] + }; + TextElem::font_in(styles).into_iter().chain(tail.iter()) +} + /// Resolve the font variant. pub(crate) fn variant(styles: StyleChain) -> FontVariant { let mut variant = FontVariant::new( diff --git a/crates/typst/src/text/raw.rs b/crates/typst/src/text/raw.rs index e8cb3b99738..47a701658ec 100644 --- a/crates/typst/src/text/raw.rs +++ b/crates/typst/src/text/raw.rs @@ -9,7 +9,7 @@ use syntect::highlighting::{self as synt, Theme}; use syntect::parsing::{SyntaxDefinition, SyntaxSet, SyntaxSetBuilder}; use unicode_segmentation::UnicodeSegmentation; -use super::Lang; +use super::{FontListEntry, Lang}; use crate::diag::{At, FileError, HintedStrResult, SourceResult, StrResult}; use crate::engine::Engine; use crate::foundations::{ @@ -461,7 +461,9 @@ impl ShowSet for Packed { out.set(TextElem::set_lang(Lang::ENGLISH)); out.set(TextElem::set_hyphenate(Hyphenate(Smart::Custom(false)))); out.set(TextElem::set_size(TextSize(Em::new(0.8).into()))); - out.set(TextElem::set_font(FontList(vec![FontFamily::new("DejaVu Sans Mono")]))); + out.set(TextElem::set_font(FontList(vec![FontListEntry::from_family( + FontFamily::new("DejaVu Sans Mono"), + )]))); out.set(SmartQuoteElem::set_enabled(false)); if self.block(styles) { out.set(ParElem::set_shrink(false)); diff --git a/crates/typst/src/text/shift.rs b/crates/typst/src/text/shift.rs index 2dbfd2b6d48..0d5fcd4f8fa 100644 --- a/crates/typst/src/text/shift.rs +++ b/crates/typst/src/text/shift.rs @@ -155,7 +155,7 @@ fn is_shapable(engine: &Engine, text: &str, styles: StyleChain) -> bool { for family in TextElem::font_in(styles) { if let Some(font) = world .book() - .select(family.as_str(), variant(styles)) + .select(family.family.as_str(), variant(styles)) .and_then(|id| world.font(id)) { return text.chars().all(|c| font.ttf().glyph_index(c).is_some());