From 89dce86f02e292f43775bee44e69d75ea2a3631b Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 7 Aug 2025 09:20:19 +0000 Subject: [PATCH] Support multiple fonts in math (#6365) --- crates/typst-layout/src/inline/shaping.rs | 3 +- crates/typst-layout/src/math/accent.rs | 28 ++- crates/typst-layout/src/math/attach.rs | 64 ++--- crates/typst-layout/src/math/frac.rs | 63 ++--- crates/typst-layout/src/math/fragment.rs | 273 +++++++++++++++++----- crates/typst-layout/src/math/lr.rs | 13 +- crates/typst-layout/src/math/mat.rs | 20 +- crates/typst-layout/src/math/mod.rs | 131 ++++++----- crates/typst-layout/src/math/root.rs | 72 +++--- crates/typst-layout/src/math/shared.rs | 78 ++----- crates/typst-layout/src/math/text.rs | 66 +++--- crates/typst-layout/src/math/underover.rs | 34 +-- crates/typst-library/src/math/style.rs | 5 +- crates/typst-library/src/text/font/mod.rs | 251 +++++++++++++++++++- crates/typst-library/src/text/shift.rs | 4 +- tests/ref/math-accent-show-rule-1.png | Bin 0 -> 507 bytes tests/ref/math-accent-show-rule-2.png | Bin 0 -> 730 bytes tests/ref/math-accent-show-rule-3.png | Bin 0 -> 355 bytes tests/ref/math-accent-show-rule-4.png | Bin 0 -> 225 bytes tests/ref/math-class-chars.png | Bin 1344 -> 1331 bytes tests/ref/math-delim-show-rule-1.png | Bin 0 -> 1155 bytes tests/ref/math-delim-show-rule-2.png | Bin 0 -> 2186 bytes tests/ref/math-delim-show-rule-3.png | Bin 0 -> 858 bytes tests/ref/math-delim-show-rule-4.png | Bin 0 -> 1517 bytes tests/ref/math-delim-show-rule-5.png | Bin 0 -> 674 bytes tests/ref/math-font-covers.png | Bin 0 -> 387 bytes tests/ref/math-font-fallback-class.png | Bin 0 -> 224 bytes tests/ref/math-font-fallback.png | Bin 402 -> 400 bytes tests/ref/math-font-features-switch.png | Bin 0 -> 968 bytes tests/ref/math-font-warning.png | Bin 0 -> 256 bytes tests/ref/math-glyph-show-rule.png | Bin 0 -> 1164 bytes tests/ref/math-lr-scripts.png | Bin 611 -> 607 bytes tests/ref/math-op-font.png | Bin 0 -> 440 bytes tests/ref/math-op-set-font.png | Bin 0 -> 1172 bytes tests/ref/math-primes-show-rule.png | Bin 0 -> 856 bytes tests/ref/math-root-show-rule-1.png | Bin 0 -> 791 bytes tests/ref/math-root-show-rule-2.png | Bin 0 -> 299 bytes tests/ref/math-root-show-rule-3.png | Bin 0 -> 260 bytes tests/ref/math-root-show-rule-4.png | Bin 0 -> 607 bytes tests/ref/math-root-show-rule-5.png | Bin 0 -> 946 bytes tests/ref/math-size-math-content-1.png | Bin 2098 -> 2109 bytes tests/ref/math-text-size.png | Bin 1265 -> 1237 bytes tests/suite/math/interactions.typ | 79 +++++++ tests/suite/math/op.typ | 17 ++ tests/suite/math/text.typ | 34 +++ 45 files changed, 878 insertions(+), 357 deletions(-) create mode 100644 tests/ref/math-accent-show-rule-1.png create mode 100644 tests/ref/math-accent-show-rule-2.png create mode 100644 tests/ref/math-accent-show-rule-3.png create mode 100644 tests/ref/math-accent-show-rule-4.png create mode 100644 tests/ref/math-delim-show-rule-1.png create mode 100644 tests/ref/math-delim-show-rule-2.png create mode 100644 tests/ref/math-delim-show-rule-3.png create mode 100644 tests/ref/math-delim-show-rule-4.png create mode 100644 tests/ref/math-delim-show-rule-5.png create mode 100644 tests/ref/math-font-covers.png create mode 100644 tests/ref/math-font-fallback-class.png create mode 100644 tests/ref/math-font-features-switch.png create mode 100644 tests/ref/math-font-warning.png create mode 100644 tests/ref/math-glyph-show-rule.png create mode 100644 tests/ref/math-op-font.png create mode 100644 tests/ref/math-op-set-font.png create mode 100644 tests/ref/math-primes-show-rule.png create mode 100644 tests/ref/math-root-show-rule-1.png create mode 100644 tests/ref/math-root-show-rule-2.png create mode 100644 tests/ref/math-root-show-rule-3.png create mode 100644 tests/ref/math-root-show-rule-4.png create mode 100644 tests/ref/math-root-show-rule-5.png diff --git a/crates/typst-layout/src/inline/shaping.rs b/crates/typst-layout/src/inline/shaping.rs index 74996b5be..ded5fddc0 100644 --- a/crates/typst-layout/src/inline/shaping.rs +++ b/crates/typst-layout/src/inline/shaping.rs @@ -1137,8 +1137,9 @@ fn calculate_adjustability(ctx: &mut ShapingContext, lang: Lang, region: Option< /// Difference between non-breaking and normal space. fn nbsp_delta(font: &Font) -> Option { + let space = font.ttf().glyph_index(' ')?.0; let nbsp = font.ttf().glyph_index('\u{00A0}')?.0; - Some(font.x_advance(nbsp)? - font.space_width()?) + Some(font.x_advance(nbsp)? - font.x_advance(space)?) } /// Returns true if all glyphs in `glyphs` have ranges within the range `range`. diff --git a/crates/typst-layout/src/math/accent.rs b/crates/typst-layout/src/math/accent.rs index b9ca0f6dd..84e64e743 100644 --- a/crates/typst-layout/src/math/accent.rs +++ b/crates/typst-layout/src/math/accent.rs @@ -1,11 +1,10 @@ use typst_library::diag::SourceResult; -use typst_library::foundations::{Packed, StyleChain}; +use typst_library::foundations::{Packed, StyleChain, SymbolElem}; use typst_library::layout::{Em, Frame, Point, Size}; use typst_library::math::AccentElem; use super::{ - FrameFragment, GlyphFragment, MathContext, MathFragment, style_cramped, style_dtls, - style_flac, + FrameFragment, MathContext, MathFragment, style_cramped, style_dtls, style_flac, }; /// How much the accent can be shorter than the base. @@ -27,14 +26,17 @@ pub fn layout_accent( if top_accent && elem.dotless.get(styles) { styles.chain(&dtls) } else { styles }; let cramped = style_cramped(); - let base = ctx.layout_into_fragment(&elem.base, base_styles.chain(&cramped))?; + let base_styles = base_styles.chain(&cramped); + let base = ctx.layout_into_fragment(&elem.base, base_styles)?; + + let (font, size) = base.font(ctx, base_styles); // Preserve class to preserve automatic spacing. let base_class = base.class(); let base_attach = base.accent_attach(); // Try to replace the accent glyph with its flattened variant. - let flattened_base_height = scaled!(ctx, styles, flattened_accent_base_height); + let flattened_base_height = font.math().flattened_accent_base_height.at(size); let flac = style_flac(); let accent_styles = if top_accent && base.ascent() > flattened_base_height { styles.chain(&flac) @@ -42,23 +44,25 @@ pub fn layout_accent( styles }; - let mut glyph = - GlyphFragment::new_char(ctx.font, accent_styles, accent.0, elem.span())?; + let mut accent = ctx.layout_into_fragment( + &SymbolElem::packed(accent.0).spanned(elem.span()), + accent_styles, + )?; // Forcing the accent to be at least as large as the base makes it too wide // in many cases. let width = elem.size.resolve(styles).relative_to(base.width()); - let short_fall = ACCENT_SHORT_FALL.at(glyph.item.size); - glyph.stretch_horizontal(ctx, width - short_fall); - let accent_attach = glyph.accent_attach.0; - let accent = glyph.into_frame(); + let short_fall = ACCENT_SHORT_FALL.at(size); + accent.stretch_horizontal(ctx, width - short_fall); + let accent_attach = accent.accent_attach().0; + let accent = accent.into_frame(); let (gap, accent_pos, base_pos) = if top_accent { // Descent is negative because the accent's ink bottom is above the // baseline. Therefore, the default gap is the accent's negated descent // minus the accent base height. Only if the base is very small, we // need a larger gap so that the accent doesn't move too low. - let accent_base_height = scaled!(ctx, styles, accent_base_height); + let accent_base_height = font.math().accent_base_height.at(size); let gap = -accent.descent() - base.ascent().min(accent_base_height); let accent_pos = Point::with_x(base_attach.0 - accent_attach); let base_pos = Point::with_y(accent.height() + gap); diff --git a/crates/typst-layout/src/math/attach.rs b/crates/typst-layout/src/math/attach.rs index 954f840f9..dd3d16f9d 100644 --- a/crates/typst-layout/src/math/attach.rs +++ b/crates/typst-layout/src/math/attach.rs @@ -4,6 +4,7 @@ use typst_library::layout::{Abs, Axis, Corner, Frame, Point, Rel, Size}; use typst_library::math::{ AttachElem, EquationElem, LimitsElem, PrimesElem, ScriptsElem, StretchElem, }; +use typst_library::text::Font; use typst_utils::OptionExt; use super::{ @@ -102,13 +103,19 @@ pub fn layout_primes( 4 => '⁗', _ => unreachable!(), }; - let f = ctx.layout_into_fragment(&SymbolElem::packed(c), styles)?; + let f = ctx.layout_into_fragment( + &SymbolElem::packed(c).spanned(elem.span()), + styles, + )?; ctx.push(f); } count => { // Custom amount of primes let prime = ctx - .layout_into_fragment(&SymbolElem::packed('′'), styles)? + .layout_into_fragment( + &SymbolElem::packed('′').spanned(elem.span()), + styles, + )? .into_frame(); let width = prime.width() * (count + 1) as f64 / 2.0; let mut frame = Frame::soft(Size::new(width, prime.height())); @@ -172,20 +179,22 @@ fn layout_attachments( base: MathFragment, [tl, t, tr, bl, b, br]: [Option; 6], ) -> SourceResult<()> { - let base_class = base.class(); + let class = base.class(); + let (font, size) = base.font(ctx, styles); + let cramped = styles.get(EquationElem::cramped); // Calculate the distance from the base's baseline to the superscripts' and // subscripts' baseline. let (tx_shift, bx_shift) = if [&tl, &tr, &bl, &br].iter().all(|e| e.is_none()) { (Abs::zero(), Abs::zero()) } else { - compute_script_shifts(ctx, styles, &base, [&tl, &tr, &bl, &br]) + compute_script_shifts(&font, size, cramped, &base, [&tl, &tr, &bl, &br]) }; // Calculate the distance from the base's baseline to the top attachment's // and bottom attachment's baseline. let (t_shift, b_shift) = - compute_limit_shifts(ctx, styles, &base, [t.as_ref(), b.as_ref()]); + compute_limit_shifts(&font, size, &base, [t.as_ref(), b.as_ref()]); // Calculate the final frame height. let ascent = base @@ -215,7 +224,7 @@ fn layout_attachments( // `space_after_script` is extra spacing that is at the start before each // pre-script, and at the end after each post-script (see the MathConstants // table in the OpenType MATH spec). - let space_after_script = scaled!(ctx, styles, space_after_script); + let space_after_script = font.math().space_after_script.at(size); // Calculate the distance each pre-script extends to the left of the base's // width. @@ -272,7 +281,7 @@ fn layout_attachments( layout!(b, b_x, b_y); // lower-limit // Done! Note that we retain the class of the base. - ctx.push(FrameFragment::new(styles, frame).with_class(base_class)); + ctx.push(FrameFragment::new(styles, frame).with_class(class)); Ok(()) } @@ -364,8 +373,8 @@ fn compute_limit_widths( /// Returns two lengths, the first being the distance to the upper-limit's /// baseline and the second being the distance to the lower-limit's baseline. fn compute_limit_shifts( - ctx: &MathContext, - styles: StyleChain, + font: &Font, + font_size: Abs, base: &MathFragment, [t, b]: [Option<&MathFragment>; 2], ) -> (Abs, Abs) { @@ -373,16 +382,15 @@ fn compute_limit_shifts( // ascender of the limits respectively, whereas `upper_rise_min` and // `lower_drop_min` give gaps to each limit's baseline (see the // MathConstants table in the OpenType MATH spec). - let t_shift = t.map_or_default(|t| { - let upper_gap_min = scaled!(ctx, styles, upper_limit_gap_min); - let upper_rise_min = scaled!(ctx, styles, upper_limit_baseline_rise_min); + let upper_gap_min = font.math().upper_limit_gap_min.at(font_size); + let upper_rise_min = font.math().upper_limit_baseline_rise_min.at(font_size); base.ascent() + upper_rise_min.max(upper_gap_min + t.descent()) }); let b_shift = b.map_or_default(|b| { - let lower_gap_min = scaled!(ctx, styles, lower_limit_gap_min); - let lower_drop_min = scaled!(ctx, styles, lower_limit_baseline_drop_min); + let lower_gap_min = font.math().lower_limit_gap_min.at(font_size); + let lower_drop_min = font.math().lower_limit_baseline_drop_min.at(font_size); base.descent() + lower_drop_min.max(lower_gap_min + b.ascent()) }); @@ -393,25 +401,27 @@ fn compute_limit_shifts( /// Returns two lengths, the first being the distance to the superscripts' /// baseline and the second being the distance to the subscripts' baseline. fn compute_script_shifts( - ctx: &MathContext, - styles: StyleChain, + font: &Font, + font_size: Abs, + cramped: bool, base: &MathFragment, [tl, tr, bl, br]: [&Option; 4], ) -> (Abs, Abs) { - let sup_shift_up = if styles.get(EquationElem::cramped) { - scaled!(ctx, styles, superscript_shift_up_cramped) + let sup_shift_up = (if cramped { + font.math().superscript_shift_up_cramped } else { - scaled!(ctx, styles, superscript_shift_up) - }; + font.math().superscript_shift_up + }) + .at(font_size); - let sup_bottom_min = scaled!(ctx, styles, superscript_bottom_min); + let sup_bottom_min = font.math().superscript_bottom_min.at(font_size); let sup_bottom_max_with_sub = - scaled!(ctx, styles, superscript_bottom_max_with_subscript); - let sup_drop_max = scaled!(ctx, styles, superscript_baseline_drop_max); - let gap_min = scaled!(ctx, styles, sub_superscript_gap_min); - let sub_shift_down = scaled!(ctx, styles, subscript_shift_down); - let sub_top_max = scaled!(ctx, styles, subscript_top_max); - let sub_drop_min = scaled!(ctx, styles, subscript_baseline_drop_min); + font.math().superscript_bottom_max_with_subscript.at(font_size); + let sup_drop_max = font.math().superscript_baseline_drop_max.at(font_size); + let gap_min = font.math().sub_superscript_gap_min.at(font_size); + let sub_shift_down = font.math().subscript_shift_down.at(font_size); + let sub_top_max = font.math().subscript_top_max.at(font_size); + let sub_drop_min = font.math().subscript_baseline_drop_min.at(font_size); let mut shift_up = Abs::zero(); let mut shift_down = Abs::zero(); diff --git a/crates/typst-layout/src/math/frac.rs b/crates/typst-layout/src/math/frac.rs index 50fd09851..bfc912362 100644 --- a/crates/typst-layout/src/math/frac.rs +++ b/crates/typst-layout/src/math/frac.rs @@ -1,13 +1,13 @@ use typst_library::diag::SourceResult; use typst_library::foundations::{Content, Packed, Resolve, StyleChain, SymbolElem}; use typst_library::layout::{Em, Frame, FrameItem, Point, Size}; -use typst_library::math::{BinomElem, FracElem}; +use typst_library::math::{BinomElem, EquationElem, FracElem, MathSize}; use typst_library::text::TextElem; use typst_library::visualize::{FixedStroke, Geometry}; use typst_syntax::Span; use super::{ - DELIM_SHORT_FALL, FrameFragment, GlyphFragment, MathContext, style_for_denominator, + DELIM_SHORT_FALL, FrameFragment, MathContext, style_for_denominator, style_for_numerator, }; @@ -49,29 +49,30 @@ fn layout_frac_like( binom: bool, span: Span, ) -> SourceResult<()> { - let short_fall = DELIM_SHORT_FALL.resolve(styles); - let axis = scaled!(ctx, styles, axis_height); - let thickness = scaled!(ctx, styles, fraction_rule_thickness); - let shift_up = scaled!( - ctx, styles, - text: fraction_numerator_shift_up, - display: fraction_numerator_display_style_shift_up, - ); - let shift_down = scaled!( - ctx, styles, - text: fraction_denominator_shift_down, - display: fraction_denominator_display_style_shift_down, - ); - let num_min = scaled!( - ctx, styles, - text: fraction_numerator_gap_min, - display: fraction_num_display_style_gap_min, - ); - let denom_min = scaled!( - ctx, styles, - text: fraction_denominator_gap_min, - display: fraction_denom_display_style_gap_min, - ); + let constants = ctx.font().math(); + let axis = constants.axis_height.resolve(styles); + let thickness = constants.fraction_rule_thickness.resolve(styles); + let size = styles.get(EquationElem::size); + let shift_up = match size { + MathSize::Display => constants.fraction_numerator_display_style_shift_up, + _ => constants.fraction_numerator_shift_up, + } + .resolve(styles); + let shift_down = match size { + MathSize::Display => constants.fraction_denominator_display_style_shift_down, + _ => constants.fraction_denominator_shift_down, + } + .resolve(styles); + let num_min = match size { + MathSize::Display => constants.fraction_num_display_style_gap_min, + _ => constants.fraction_numerator_gap_min, + } + .resolve(styles); + let denom_min = match size { + MathSize::Display => constants.fraction_denom_display_style_gap_min, + _ => constants.fraction_denominator_gap_min, + } + .resolve(styles); let num_style = style_for_numerator(styles); let num = ctx.layout_into_frame(num, styles.chain(&num_style))?; @@ -82,7 +83,7 @@ fn layout_frac_like( // Add a comma between each element. denom .iter() - .flat_map(|a| [SymbolElem::packed(','), a.clone()]) + .flat_map(|a| [SymbolElem::packed(',').spanned(span), a.clone()]) .skip(1), ), styles.chain(&denom_style), @@ -109,12 +110,18 @@ fn layout_frac_like( frame.push_frame(denom_pos, denom); if binom { - let mut left = GlyphFragment::new_char(ctx.font, styles, '(', span)?; + let short_fall = DELIM_SHORT_FALL.resolve(styles); + + let mut left = + ctx.layout_into_fragment(&SymbolElem::packed('(').spanned(span), styles)?; left.stretch_vertical(ctx, height - short_fall); left.center_on_axis(); ctx.push(left); + ctx.push(FrameFragment::new(styles, frame)); - let mut right = GlyphFragment::new_char(ctx.font, styles, ')', span)?; + + let mut right = + ctx.layout_into_fragment(&SymbolElem::packed(')').spanned(span), styles)?; right.stretch_vertical(ctx, height - short_fall); right.center_on_axis(); ctx.push(right); diff --git a/crates/typst-layout/src/math/fragment.rs b/crates/typst-layout/src/math/fragment.rs index fce0ca302..610e2905c 100644 --- a/crates/typst-layout/src/math/fragment.rs +++ b/crates/typst-layout/src/math/fragment.rs @@ -1,22 +1,28 @@ use std::fmt::{self, Debug, Formatter}; use az::SaturatingAs; +use comemo::Tracked; use rustybuzz::{BufferFlags, UnicodeBuffer}; use ttf_parser::GlyphId; use ttf_parser::math::{GlyphAssembly, GlyphConstruction, GlyphPart}; -use typst_library::diag::{SourceResult, bail, warning}; +use typst_library::World; +use typst_library::diag::warning; use typst_library::foundations::StyleChain; use typst_library::introspection::Tag; use typst_library::layout::{ Abs, Axes, Axis, Corner, Em, Frame, FrameItem, Point, Size, VAlignment, }; use typst_library::math::{EquationElem, MathSize}; -use typst_library::text::{Font, Glyph, TextElem, TextItem, features, language}; +use typst_library::text::{ + Font, FontFamily, FontVariant, Glyph, TextElem, TextItem, features, language, variant, +}; +use typst_library::visualize::Paint; use typst_syntax::Span; use typst_utils::{Get, default_math_class}; use unicode_math_class::MathClass; +use unicode_segmentation::UnicodeSegmentation; -use super::MathContext; +use super::{MathContext, families}; use crate::inline::create_shape_plan; use crate::modifiers::{FrameModifiers, FrameModify}; @@ -108,6 +114,17 @@ impl MathFragment { } } + #[inline] + pub fn font(&self, ctx: &MathContext, styles: StyleChain) -> (Font, Abs) { + ( + match self { + Self::Glyph(glyph) => glyph.item.font.clone(), + _ => ctx.font().clone(), + }, + self.font_size().unwrap_or_else(|| styles.resolve(TextElem::size)), + ) + } + pub fn font_size(&self) -> Option { match self { Self::Glyph(glyph) => Some(glyph.item.size), @@ -192,6 +209,31 @@ impl MathFragment { } } + pub fn fill(&self) -> Option { + match self { + Self::Glyph(glyph) => Some(glyph.item.fill.clone()), + _ => None, + } + } + + pub fn stretch_vertical(&mut self, ctx: &mut MathContext, height: Abs) { + if let Self::Glyph(glyph) = self { + glyph.stretch_vertical(ctx, height) + } + } + + pub fn stretch_horizontal(&mut self, ctx: &mut MathContext, width: Abs) { + if let Self::Glyph(glyph) = self { + glyph.stretch_horizontal(ctx, width) + } + } + + pub fn center_on_axis(&mut self) { + if let Self::Glyph(glyph) = self { + glyph.center_on_axis() + } + } + /// If no kern table is provided for a corner, a kerning amount of zero is /// assumed. pub fn kern_at_height(&self, corner: Corner, height: Abs) -> Abs { @@ -261,77 +303,43 @@ pub struct GlyphFragment { impl GlyphFragment { /// Calls `new` with the given character. pub fn new_char( - font: &Font, + ctx: &MathContext, styles: StyleChain, c: char, span: Span, - ) -> SourceResult { - Self::new(font, styles, c.encode_utf8(&mut [0; 4]), span) + ) -> Option { + Self::new(ctx.engine.world, styles, c.encode_utf8(&mut [0; 4]), span) } - /// Try to create a new glyph out of the given string. Will bail if the - /// result from shaping the string is not a single glyph or is a tofu. + /// Selects a font to use and then shapes text. #[comemo::memoize] pub fn new( - font: &Font, + world: Tracked, styles: StyleChain, text: &str, span: Span, - ) -> SourceResult { - let mut buffer = UnicodeBuffer::new(); - buffer.push_str(text); - buffer.set_language(language(styles)); - // TODO: Use `rustybuzz::script::MATH` once - // https://github.com/harfbuzz/rustybuzz/pull/165 is released. - buffer.set_script( - rustybuzz::Script::from_iso15924_tag(ttf_parser::Tag::from_bytes(b"math")) - .unwrap(), - ); - buffer.set_direction(rustybuzz::Direction::LeftToRight); - buffer.set_flags(BufferFlags::REMOVE_DEFAULT_IGNORABLES); + ) -> Option { + assert!(text.graphemes(true).count() == 1); - let features = features(styles); - let plan = create_shape_plan( - font, - buffer.direction(), - buffer.script(), - buffer.language().as_ref(), - &features, - ); + let (c, font, mut glyph) = shape( + world, + variant(styles), + features(styles), + language(styles), + styles.get(TextElem::fallback), + text, + families(styles).collect(), + )?; + glyph.span.0 = span; - let buffer = rustybuzz::shape_with_plan(font.rusty(), &plan, buffer); - if buffer.len() != 1 { - bail!(span, "did not get a single glyph after shaping {}", text); - } - - let info = buffer.glyph_infos()[0]; - let pos = buffer.glyph_positions()[0]; - - // TODO: add support for coverage and fallback, like in normal text shaping. - if info.glyph_id == 0 { - bail!(span, "current font is missing a glyph for {}", text); - } - - let cluster = info.cluster as usize; - let c = text[cluster..].chars().next().unwrap(); let limits = Limits::for_char(c); let class = styles .get(EquationElem::class) .or_else(|| default_math_class(c)) .unwrap_or(MathClass::Normal); - let glyph = Glyph { - id: info.glyph_id as u16, - x_advance: font.to_em(pos.x_advance), - x_offset: font.to_em(pos.x_offset), - y_advance: font.to_em(pos.y_advance), - y_offset: font.to_em(pos.y_offset), - range: 0..text.len().saturating_as(), - span: (span, 0), - }; - let item = TextItem { - font: font.clone(), + font, size: styles.resolve(TextElem::size), fill: styles.get_ref(TextElem::fill).as_decoration(), stroke: styles.resolve(TextElem::stroke).map(|s| s.unwrap_or_default()), @@ -361,7 +369,7 @@ impl GlyphFragment { modifiers: FrameModifiers::get_in(styles), }; fragment.update_glyph(); - Ok(fragment) + Some(fragment) } /// Sets element id and boxes in appropriate way without changing other @@ -506,7 +514,7 @@ impl GlyphFragment { /// to the given alignment on the axis. pub fn align_on_axis(&mut self, align: VAlignment) { let h = self.size.y; - let axis = axis_height(&self.item.font).unwrap().at(self.item.size); + let axis = self.item.font.math().axis_height.at(self.item.size); self.align += self.baseline(); self.baseline = Some(align.inv().position(h + axis * 2.0)); self.align -= self.baseline(); @@ -649,10 +657,6 @@ fn kern_at_height(font: &Font, id: GlyphId, corner: Corner, height: Em) -> Optio Some(font.to_em(kern.kern(i)?.value)) } -fn axis_height(font: &Font) -> Option { - Some(font.to_em(font.ttf().tables().math?.constants?.axis_height().value)) -} - pub fn stretch_axes(font: &Font, id: u16) -> Axes { let id = GlyphId(id); let horizontal = font @@ -834,6 +838,153 @@ pub fn has_dtls_feat(font: &Font) -> bool { .is_some() } +#[comemo::memoize] +fn shape( + world: Tracked, + variant: FontVariant, + features: Vec, + language: rustybuzz::Language, + fallback: bool, + text: &str, + families: Vec<&FontFamily>, +) -> Option<(char, Font, Glyph)> { + let mut used = vec![]; + let buffer = UnicodeBuffer::new(); + shape_glyph( + world, + &mut used, + buffer, + variant, + features, + language, + fallback, + text, + families.into_iter(), + ) +} + +#[allow(clippy::too_many_arguments)] +fn shape_glyph<'a>( + world: Tracked<'a, dyn World + 'a>, + used: &mut Vec, + mut buffer: rustybuzz::UnicodeBuffer, + variant: FontVariant, + features: Vec, + language: rustybuzz::Language, + fallback: bool, + text: &str, + mut families: impl Iterator + Clone, +) -> Option<(char, Font, Glyph)> { + // Find the next available family. + let book = world.book(); + let mut selection = None; + let mut covers = None; + for family in families.by_ref() { + selection = book + .select(family.as_str(), variant) + .and_then(|id| world.font(id)) + .filter(|font| !used.contains(font)); + if selection.is_some() { + covers = family.covers(); + break; + } + } + + // Do font fallback if the families are exhausted and fallback is enabled. + if selection.is_none() && fallback { + let first = used.first().map(Font::info); + selection = book + .select_fallback(first, variant, text) + .and_then(|id| world.font(id)) + .filter(|font| !used.contains(font)) + } + + // Extract the font id or shape notdef glyphs if we couldn't find any font. + let Some(font) = selection else { + if let Some(font) = used.first().cloned() { + // Shape tofu. + let glyph = Glyph { + id: 0, + x_advance: font.x_advance(0).unwrap_or_default(), + x_offset: Em::zero(), + y_advance: Em::zero(), + y_offset: Em::zero(), + range: 0..text.len().saturating_as(), + span: (Span::detached(), 0), + }; + let c = text.chars().next().unwrap(); + return Some((c, font, glyph)); + } + return None; + }; + + // This font has been exhausted and will not be used again. + if covers.is_none() { + used.push(font.clone()); + } + + buffer.push_str(text); + buffer.set_language(language.clone()); + // TODO: Use `rustybuzz::script::MATH` once + // https://github.com/harfbuzz/rustybuzz/pull/165 is released. + buffer.set_script( + rustybuzz::Script::from_iso15924_tag(ttf_parser::Tag::from_bytes(b"math")) + .unwrap(), + ); + buffer.set_direction(rustybuzz::Direction::LeftToRight); + buffer.set_flags(BufferFlags::REMOVE_DEFAULT_IGNORABLES); + + let plan = create_shape_plan( + &font, + buffer.direction(), + buffer.script(), + buffer.language().as_ref(), + &features, + ); + + let buffer = rustybuzz::shape_with_plan(font.rusty(), &plan, buffer); + match buffer.len() { + 0 => return None, + 1 => {} + _ => unreachable!(), + } + + let info = buffer.glyph_infos()[0]; + let pos = buffer.glyph_positions()[0]; + let cluster = info.cluster as usize; + let end = text[cluster..] + .char_indices() + .nth(1) + .map(|(i, _)| i) + .unwrap_or(text.len()); + + if info.glyph_id != 0 && covers.is_none_or(|cov| cov.is_match(&text[cluster..end])) { + let glyph = Glyph { + id: info.glyph_id as u16, + x_advance: font.to_em(pos.x_advance), + x_offset: font.to_em(pos.x_offset), + y_advance: font.to_em(pos.y_advance), + y_offset: font.to_em(pos.y_offset), + range: 0..text.len().saturating_as(), + span: (Span::detached(), 0), + }; + let c = text[cluster..].chars().next().unwrap(); + Some((c, font, glyph)) + } else { + shape_glyph( + world, + used, + buffer.clear(), + variant, + features, + language, + fallback, + text, + families, + ) + } +} + /// Describes in which situation a frame should use limits for attachments. #[derive(Debug, Copy, Clone)] pub enum Limits { diff --git a/crates/typst-layout/src/math/lr.rs b/crates/typst-layout/src/math/lr.rs index 2a5dd79c9..e551426cf 100644 --- a/crates/typst-layout/src/math/lr.rs +++ b/crates/typst-layout/src/math/lr.rs @@ -33,12 +33,13 @@ pub fn layout_lr( let (start_idx, end_idx) = fragments.split_prefix_suffix(|f| f.is_ignorant()); let inner_fragments = &mut fragments[start_idx..end_idx]; - let axis = scaled!(ctx, styles, axis_height); - let max_extent = inner_fragments - .iter() - .map(|fragment| (fragment.ascent() - axis).max(fragment.descent() + axis)) - .max() - .unwrap_or_default(); + let mut max_extent = Abs::zero(); + for fragment in inner_fragments.iter() { + let (font, size) = fragment.font(ctx, styles); + let axis = font.math().axis_height.at(size); + let extent = (fragment.ascent() - axis).max(fragment.descent() + axis); + max_extent = max_extent.max(extent); + } let relative_to = 2.0 * max_extent; let height = elem.size.resolve(styles); diff --git a/crates/typst-layout/src/math/mat.rs b/crates/typst-layout/src/math/mat.rs index 1969dda57..97c160cf2 100644 --- a/crates/typst-layout/src/math/mat.rs +++ b/crates/typst-layout/src/math/mat.rs @@ -1,5 +1,5 @@ use typst_library::diag::{SourceResult, bail, warning}; -use typst_library::foundations::{Content, Packed, Resolve, StyleChain}; +use typst_library::foundations::{Content, Packed, Resolve, StyleChain, SymbolElem}; use typst_library::layout::{ Abs, Axes, Em, FixedAlignment, Frame, FrameItem, Point, Ratio, Rel, Size, }; @@ -186,12 +186,10 @@ fn layout_body( // We pad ascent and descent with the ascent and descent of the paren // to ensure that normal matrices are aligned with others unless they are // way too big. - let paren = GlyphFragment::new_char( - ctx.font, - styles.chain(&denom_style), - '(', - Span::detached(), - )?; + // This will never panic as a paren will never shape into nothing. + let paren = + GlyphFragment::new_char(ctx, styles.chain(&denom_style), '(', Span::detached()) + .unwrap(); for (column, col) in columns.iter().zip(&mut cols) { for (cell, (ascent, descent)) in column.iter().zip(&mut heights) { @@ -314,13 +312,14 @@ fn layout_delimiters( span: Span, ) -> SourceResult<()> { let short_fall = DELIM_SHORT_FALL.resolve(styles); - let axis = scaled!(ctx, styles, axis_height); + let axis = ctx.font().math().axis_height.resolve(styles); let height = frame.height(); let target = height + VERTICAL_PADDING.of(height); frame.set_baseline(height / 2.0 + axis); if let Some(left_c) = left { - let mut left = GlyphFragment::new_char(ctx.font, styles, left_c, span)?; + let mut left = + ctx.layout_into_fragment(&SymbolElem::packed(left_c).spanned(span), styles)?; left.stretch_vertical(ctx, target - short_fall); left.center_on_axis(); ctx.push(left); @@ -329,7 +328,8 @@ fn layout_delimiters( ctx.push(FrameFragment::new(styles, frame)); if let Some(right_c) = right { - let mut right = GlyphFragment::new_char(ctx.font, styles, right_c, span)?; + let mut right = + ctx.layout_into_fragment(&SymbolElem::packed(right_c).spanned(span), styles)?; right.stretch_vertical(ctx, target - short_fall); right.center_on_axis(); ctx.push(right); diff --git a/crates/typst-layout/src/math/mod.rs b/crates/typst-layout/src/math/mod.rs index c12af43f9..728e4514f 100644 --- a/crates/typst-layout/src/math/mod.rs +++ b/crates/typst-layout/src/math/mod.rs @@ -13,11 +13,12 @@ mod stretch; mod text; mod underover; +use comemo::Tracked; use typst_library::World; -use typst_library::diag::{SourceResult, bail}; +use typst_library::diag::{At, SourceResult, warning}; use typst_library::engine::Engine; use typst_library::foundations::{ - Content, NativeElement, Packed, Resolve, StyleChain, SymbolElem, + Content, NativeElement, Packed, Resolve, Style, StyleChain, SymbolElem, }; use typst_library::introspection::{Counter, Locator, SplitLocator, TagElem}; use typst_library::layout::{ @@ -29,10 +30,11 @@ use typst_library::math::*; use typst_library::model::ParElem; use typst_library::routines::{Arenas, RealizationKind}; use typst_library::text::{ - Font, LinebreakElem, SpaceElem, TextEdgeBounds, TextElem, families, variant, + Font, FontFlags, LinebreakElem, SpaceElem, TextEdgeBounds, TextElem, variant, }; use typst_syntax::Span; -use typst_utils::Numeric; +use typst_utils::{LazyHash, Numeric}; + use unicode_math_class::MathClass; use self::fragment::{ @@ -53,12 +55,14 @@ pub fn layout_equation_inline( ) -> SourceResult> { assert!(!elem.block.get(styles)); - let font = find_math_font(engine, styles, elem.span())?; + let span = elem.span(); + let font = get_font(engine.world, styles, span)?; + warn_non_math_font(&font, engine, span); let mut locator = locator.split(); - let mut ctx = MathContext::new(engine, &mut locator, region, &font); + let mut ctx = MathContext::new(engine, &mut locator, region, font.clone()); - let scale_style = style_for_script_scale(&ctx); + let scale_style = style_for_script_scale(&font); let styles = styles.chain(&scale_style); let run = ctx.layout_into_run(&elem.body, styles)?; @@ -108,12 +112,13 @@ pub fn layout_equation_block( assert!(elem.block.get(styles)); let span = elem.span(); - let font = find_math_font(engine, styles, span)?; + let font = get_font(engine.world, styles, span)?; + warn_non_math_font(&font, engine, span); let mut locator = locator.split(); - let mut ctx = MathContext::new(engine, &mut locator, regions.base(), &font); + let mut ctx = MathContext::new(engine, &mut locator, regions.base(), font.clone()); - let scale_style = style_for_script_scale(&ctx); + let scale_style = style_for_script_scale(&font); let styles = styles.chain(&scale_style); let full_equation_builder = ctx @@ -234,24 +239,6 @@ pub fn layout_equation_block( Ok(Fragment::frames(frames)) } -fn find_math_font( - engine: &mut Engine<'_>, - styles: StyleChain, - span: Span, -) -> SourceResult { - let variant = variant(styles); - let world = engine.world; - let Some(font) = families(styles).find_map(|family| { - let id = world.book().select(family.as_str(), variant)?; - let font = world.font(id)?; - let _ = font.ttf().tables().math?.constants?; - Some(font) - }) else { - bail!(span, "current font does not support math"); - }; - Ok(font) -} - fn add_equation_number( equation_builder: MathRunFrameBuilder, number: Frame, @@ -370,10 +357,8 @@ struct MathContext<'a, 'v, 'e> { engine: &'v mut Engine<'e>, locator: &'v mut SplitLocator<'a>, region: Region, - // Font-related. - font: &'a Font, - constants: ttf_parser::math::Constants<'a>, // Mutable. + fonts_stack: Vec, fragments: Vec, } @@ -383,23 +368,24 @@ impl<'a, 'v, 'e> MathContext<'a, 'v, 'e> { engine: &'v mut Engine<'e>, locator: &'v mut SplitLocator<'a>, base: Size, - font: &'a Font, + font: Font, ) -> Self { - // These unwraps are safe as the font given is one returned by the - // find_math_font function, which only returns fonts that have a math - // constants table. - let constants = font.ttf().tables().math.unwrap().constants.unwrap(); - Self { engine, locator, region: Region::new(base, Axes::splat(false)), - font, - constants, + fonts_stack: vec![font], fragments: vec![], } } + /// Get the current base font. + #[inline] + fn font(&self) -> &Font { + // Will always be at least one font in the stack. + self.fonts_stack.last().unwrap() + } + /// Push a fragment. fn push(&mut self, fragment: impl Into) { self.fragments.push(fragment.into()); @@ -469,18 +455,20 @@ impl<'a, 'v, 'e> MathContext<'a, 'v, 'e> { styles, )?; - let outer = styles; + let outer_styles = styles; + let outer_font = styles.get_ref(TextElem::font); for (elem, styles) in pairs { - // Hack because the font is fixed in math. - if styles != outer - && styles.get_ref(TextElem::font) != outer.get_ref(TextElem::font) - { - let frame = layout_external(elem, self, styles)?; - self.push(FrameFragment::new(styles, frame).with_spaced(true)); - continue; + // Whilst this check isn't exact, it more or less suffices as a + // change in font variant probably won't have an effect on metrics. + if styles != outer_styles && styles.get_ref(TextElem::font) != outer_font { + self.fonts_stack + .push(get_font(self.engine.world, styles, elem.span())?); + let scale_style = style_for_script_scale(self.font()); + layout_realized(elem, self, styles.chain(&scale_style))?; + self.fonts_stack.pop(); + } else { + layout_realized(elem, self, styles)?; } - - layout_realized(elem, self, styles)?; } Ok(()) @@ -496,8 +484,7 @@ fn layout_realized( if let Some(elem) = elem.to_packed::() { ctx.push(MathFragment::Tag(elem.tag.clone())); } else if elem.is::() { - let space_width = ctx.font.space_width().unwrap_or(THICK); - ctx.push(MathFragment::Space(space_width.resolve(styles))); + ctx.push(MathFragment::Space(ctx.font().math().space_width.resolve(styles))); } else if elem.is::() { ctx.push(MathFragment::Linebreak); } else if let Some(elem) = elem.to_packed::() { @@ -567,7 +554,7 @@ fn layout_realized( } else { let mut frame = layout_external(elem, ctx, styles)?; if !frame.has_baseline() { - let axis = scaled!(ctx, styles, axis_height); + let axis = ctx.font().math().axis_height.resolve(styles); frame.set_baseline(frame.height() / 2.0 + axis); } ctx.push( @@ -667,3 +654,43 @@ fn layout_external( ctx.region, ) } + +/// Styles to add font constants to the style chain. +fn style_for_script_scale(font: &Font) -> LazyHash