diff options
Diffstat (limited to 'crates/typst-layout/src/math')
| -rw-r--r-- | crates/typst-layout/src/math/accent.rs | 75 | ||||
| -rw-r--r-- | crates/typst-layout/src/math/attach.rs | 515 | ||||
| -rw-r--r-- | crates/typst-layout/src/math/cancel.rs | 144 | ||||
| -rw-r--r-- | crates/typst-layout/src/math/frac.rs | 136 | ||||
| -rw-r--r-- | crates/typst-layout/src/math/fragment.rs | 704 | ||||
| -rw-r--r-- | crates/typst-layout/src/math/lr.rs | 135 | ||||
| -rw-r--r-- | crates/typst-layout/src/math/mat.rs | 333 | ||||
| -rw-r--r-- | crates/typst-layout/src/math/mod.rs | 703 | ||||
| -rw-r--r-- | crates/typst-layout/src/math/root.rs | 126 | ||||
| -rw-r--r-- | crates/typst-layout/src/math/run.rs | 469 | ||||
| -rw-r--r-- | crates/typst-layout/src/math/shared.rs | 207 | ||||
| -rw-r--r-- | crates/typst-layout/src/math/stretch.rs | 291 | ||||
| -rw-r--r-- | crates/typst-layout/src/math/text.rs | 344 | ||||
| -rw-r--r-- | crates/typst-layout/src/math/underover.rs | 327 |
14 files changed, 4509 insertions, 0 deletions
diff --git a/crates/typst-layout/src/math/accent.rs b/crates/typst-layout/src/math/accent.rs new file mode 100644 index 00000000..9fa7a5a0 --- /dev/null +++ b/crates/typst-layout/src/math/accent.rs @@ -0,0 +1,75 @@ +use typst_library::diag::SourceResult; +use typst_library::foundations::{Packed, StyleChain}; +use typst_library::layout::{Em, Frame, Point, Rel, Size}; +use typst_library::math::{Accent, AccentElem}; + +use super::{ + scaled_font_size, style_cramped, FrameFragment, GlyphFragment, MathContext, + MathFragment, +}; + +/// How much the accent can be shorter than the base. +const ACCENT_SHORT_FALL: Em = Em::new(0.5); + +/// Lays out an [`AccentElem`]. +#[typst_macros::time(name = "math.accent", span = elem.span())] +pub fn layout_accent( + elem: &Packed<AccentElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let cramped = style_cramped(); + let base = ctx.layout_into_fragment(elem.base(), styles.chain(&cramped))?; + + // Preserve class to preserve automatic spacing. + let base_class = base.class(); + let base_attach = base.accent_attach(); + + let width = elem + .size(styles) + .unwrap_or(Rel::one()) + .at(scaled_font_size(ctx, styles)) + .relative_to(base.width()); + + // Forcing the accent to be at least as large as the base makes it too + // wide in many case. + let Accent(c) = elem.accent(); + let glyph = GlyphFragment::new(ctx, styles, *c, elem.span()); + let short_fall = ACCENT_SHORT_FALL.at(glyph.font_size); + let variant = glyph.stretch_horizontal(ctx, width, short_fall); + let accent = variant.frame; + let accent_attach = variant.accent_attach; + + // 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 gap = -accent.descent() - base.height().min(accent_base_height); + let size = Size::new(base.width(), accent.height() + gap + base.height()); + let accent_pos = Point::with_x(base_attach - accent_attach); + let base_pos = Point::with_y(accent.height() + gap); + let baseline = base_pos.y + base.ascent(); + let base_italics_correction = base.italics_correction(); + let base_text_like = base.is_text_like(); + + let base_ascent = match &base { + MathFragment::Frame(frame) => frame.base_ascent, + _ => base.ascent(), + }; + + let mut frame = Frame::soft(size); + frame.set_baseline(baseline); + frame.push_frame(accent_pos, accent); + frame.push_frame(base_pos, base.into_frame()); + ctx.push( + FrameFragment::new(ctx, styles, frame) + .with_class(base_class) + .with_base_ascent(base_ascent) + .with_italics_correction(base_italics_correction) + .with_accent_attach(base_attach) + .with_text_like(base_text_like), + ); + + Ok(()) +} diff --git a/crates/typst-layout/src/math/attach.rs b/crates/typst-layout/src/math/attach.rs new file mode 100644 index 00000000..0f9090f7 --- /dev/null +++ b/crates/typst-layout/src/math/attach.rs @@ -0,0 +1,515 @@ +use typst_library::diag::SourceResult; +use typst_library::foundations::{Packed, Smart, StyleChain}; +use typst_library::layout::{Abs, Axis, Corner, Frame, Length, Point, Rel, Size}; +use typst_library::math::{ + AttachElem, EquationElem, LimitsElem, PrimesElem, ScriptsElem, StretchElem, +}; +use typst_library::text::TextElem; +use typst_utils::OptionExt; + +use super::{ + stretch_fragment, style_for_subscript, style_for_superscript, FrameFragment, Limits, + MathContext, MathFragment, +}; + +macro_rules! measure { + ($e: ident, $attr: ident) => { + $e.as_ref().map(|e| e.$attr()).unwrap_or_default() + }; +} + +/// Lays out an [`AttachElem`]. +#[typst_macros::time(name = "math.attach", span = elem.span())] +pub fn layout_attach( + elem: &Packed<AttachElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let merged = elem.merge_base(); + let elem = merged.as_ref().unwrap_or(elem); + let stretch = stretch_size(styles, elem); + + let mut base = ctx.layout_into_fragment(elem.base(), styles)?; + let sup_style = style_for_superscript(styles); + let sup_style_chain = styles.chain(&sup_style); + let tl = elem.tl(sup_style_chain); + let tr = elem.tr(sup_style_chain); + let primed = tr.as_ref().is_some_and(|content| content.is::<PrimesElem>()); + let t = elem.t(sup_style_chain); + + let sub_style = style_for_subscript(styles); + let sub_style_chain = styles.chain(&sub_style); + let bl = elem.bl(sub_style_chain); + let br = elem.br(sub_style_chain); + let b = elem.b(sub_style_chain); + + let limits = base.limits().active(styles); + let (t, tr) = match (t, tr) { + (Some(t), Some(tr)) if primed && !limits => (None, Some(tr + t)), + (Some(t), None) if !limits => (None, Some(t)), + (t, tr) => (t, tr), + }; + let (b, br) = if limits || br.is_some() { (b, br) } else { (None, b) }; + + macro_rules! layout { + ($content:ident, $style_chain:ident) => { + $content + .map(|elem| ctx.layout_into_fragment(&elem, $style_chain)) + .transpose() + }; + } + + // Layout the top and bottom attachments early so we can measure their + // widths, in order to calculate what the stretch size is relative to. + let t = layout!(t, sup_style_chain)?; + let b = layout!(b, sub_style_chain)?; + if let Some(stretch) = stretch { + let relative_to_width = measure!(t, width).max(measure!(b, width)); + stretch_fragment( + ctx, + styles, + &mut base, + Some(Axis::X), + Some(relative_to_width), + stretch, + Abs::zero(), + ); + } + + let fragments = [ + layout!(tl, sup_style_chain)?, + t, + layout!(tr, sup_style_chain)?, + layout!(bl, sub_style_chain)?, + b, + layout!(br, sub_style_chain)?, + ]; + + layout_attachments(ctx, styles, base, fragments) +} + +/// Lays out a [`PrimeElem`]. +#[typst_macros::time(name = "math.primes", span = elem.span())] +pub fn layout_primes( + elem: &Packed<PrimesElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + match *elem.count() { + count @ 1..=4 => { + let c = match count { + 1 => '′', + 2 => '″', + 3 => '‴', + 4 => '⁗', + _ => unreachable!(), + }; + let f = ctx.layout_into_fragment(&TextElem::packed(c), styles)?; + ctx.push(f); + } + count => { + // Custom amount of primes + let prime = + ctx.layout_into_fragment(&TextElem::packed('′'), styles)?.into_frame(); + let width = prime.width() * (count + 1) as f64 / 2.0; + let mut frame = Frame::soft(Size::new(width, prime.height())); + frame.set_baseline(prime.ascent()); + + for i in 0..count { + frame.push_frame( + Point::new(prime.width() * (i as f64 / 2.0), Abs::zero()), + prime.clone(), + ) + } + ctx.push(FrameFragment::new(ctx, styles, frame).with_text_like(true)); + } + } + Ok(()) +} + +/// Lays out a [`ScriptsElem`]. +#[typst_macros::time(name = "math.scripts", span = elem.span())] +pub fn layout_scripts( + elem: &Packed<ScriptsElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let mut fragment = ctx.layout_into_fragment(elem.body(), styles)?; + fragment.set_limits(Limits::Never); + ctx.push(fragment); + Ok(()) +} + +/// Lays out a [`LimitsElem`]. +#[typst_macros::time(name = "math.limits", span = elem.span())] +pub fn layout_limits( + elem: &Packed<LimitsElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let limits = if elem.inline(styles) { Limits::Always } else { Limits::Display }; + let mut fragment = ctx.layout_into_fragment(elem.body(), styles)?; + fragment.set_limits(limits); + ctx.push(fragment); + Ok(()) +} + +/// Get the size to stretch the base to, if the attach argument is true. +fn stretch_size( + styles: StyleChain, + elem: &Packed<AttachElem>, +) -> Option<Smart<Rel<Length>>> { + // Extract from an EquationElem. + let mut base = elem.base(); + if let Some(equation) = base.to_packed::<EquationElem>() { + base = equation.body(); + } + + base.to_packed::<StretchElem>().map(|stretch| stretch.size(styles)) +} + +/// Lay out the attachments. +fn layout_attachments( + ctx: &mut MathContext, + styles: StyleChain, + base: MathFragment, + [tl, t, tr, bl, b, br]: [Option<MathFragment>; 6], +) -> SourceResult<()> { + let base_class = base.class(); + + // 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]) + }; + + // 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()]); + + // Calculate the final frame height. + let ascent = base + .ascent() + .max(tx_shift + measure!(tr, ascent)) + .max(tx_shift + measure!(tl, ascent)) + .max(t_shift + measure!(t, ascent)); + let descent = base + .descent() + .max(bx_shift + measure!(br, descent)) + .max(bx_shift + measure!(bl, descent)) + .max(b_shift + measure!(b, descent)); + let height = ascent + descent; + + // Calculate the vertical position of each element in the final frame. + let base_y = ascent - base.ascent(); + let tx_y = |tx: &MathFragment| ascent - tx_shift - tx.ascent(); + let bx_y = |bx: &MathFragment| ascent + bx_shift - bx.ascent(); + let t_y = |t: &MathFragment| ascent - t_shift - t.ascent(); + let b_y = |b: &MathFragment| ascent + b_shift - b.ascent(); + + // Calculate the distance each limit extends to the left and right of the + // base's width. + let ((t_pre_width, t_post_width), (b_pre_width, b_post_width)) = + compute_limit_widths(&base, [t.as_ref(), b.as_ref()]); + + // `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); + + // Calculate the distance each pre-script extends to the left of the base's + // width. + let (tl_pre_width, bl_pre_width) = compute_pre_script_widths( + ctx, + &base, + [tl.as_ref(), bl.as_ref()], + (tx_shift, bx_shift), + space_after_script, + ); + + // Calculate the distance each post-script extends to the right of the + // base's width. Also calculate each post-script's kerning (we need this for + // its position later). + let ((tr_post_width, tr_kern), (br_post_width, br_kern)) = compute_post_script_widths( + ctx, + &base, + [tr.as_ref(), br.as_ref()], + (tx_shift, bx_shift), + space_after_script, + ); + + // Calculate the final frame width. + let pre_width = t_pre_width.max(b_pre_width).max(tl_pre_width).max(bl_pre_width); + let base_width = base.width(); + let post_width = t_post_width.max(b_post_width).max(tr_post_width).max(br_post_width); + let width = pre_width + base_width + post_width; + + // Calculate the horizontal position of each element in the final frame. + let base_x = pre_width; + let tl_x = pre_width - tl_pre_width + space_after_script; + let bl_x = pre_width - bl_pre_width + space_after_script; + let tr_x = pre_width + base_width + tr_kern; + let br_x = pre_width + base_width + br_kern; + let t_x = pre_width - t_pre_width; + let b_x = pre_width - b_pre_width; + + // Create the final frame. + let mut frame = Frame::soft(Size::new(width, height)); + frame.set_baseline(ascent); + frame.push_frame(Point::new(base_x, base_y), base.into_frame()); + + macro_rules! layout { + ($e: ident, $x: ident, $y: ident) => { + if let Some($e) = $e { + frame.push_frame(Point::new($x, $y(&$e)), $e.into_frame()); + } + }; + } + + layout!(tl, tl_x, tx_y); // pre-superscript + layout!(bl, bl_x, bx_y); // pre-subscript + layout!(tr, tr_x, tx_y); // post-superscript + layout!(br, br_x, bx_y); // post-subscript + layout!(t, t_x, t_y); // upper-limit + layout!(b, b_x, b_y); // lower-limit + + // Done! Note that we retain the class of the base. + ctx.push(FrameFragment::new(ctx, styles, frame).with_class(base_class)); + + Ok(()) +} + +/// Calculate the distance each post-script extends to the right of the base's +/// width, as well as its kerning value. Requires the distance from the base's +/// baseline to each post-script's baseline to obtain the correct kerning value. +/// Returns 2 tuples of two lengths, each first containing the distance the +/// post-script extends left of the base's width and second containing the +/// post-script's kerning value. The first tuple is for the post-superscript, +/// and the second is for the post-subscript. +fn compute_post_script_widths( + ctx: &MathContext, + base: &MathFragment, + [tr, br]: [Option<&MathFragment>; 2], + (tr_shift, br_shift): (Abs, Abs), + space_after_post_script: Abs, +) -> ((Abs, Abs), (Abs, Abs)) { + let tr_values = tr.map_or_default(|tr| { + let kern = math_kern(ctx, base, tr, tr_shift, Corner::TopRight); + (space_after_post_script + tr.width() + kern, kern) + }); + + // The base's bounding box already accounts for its italic correction, so we + // need to shift the post-subscript left by the base's italic correction + // (see the kerning algorithm as described in the OpenType MATH spec). + let br_values = br.map_or_default(|br| { + let kern = math_kern(ctx, base, br, br_shift, Corner::BottomRight) + - base.italics_correction(); + (space_after_post_script + br.width() + kern, kern) + }); + + (tr_values, br_values) +} + +/// Calculate the distance each pre-script extends to the left of the base's +/// width. Requires the distance from the base's baseline to each pre-script's +/// baseline to obtain the correct kerning value. +/// Returns two lengths, the first being the distance the pre-superscript +/// extends left of the base's width and the second being the distance the +/// pre-subscript extends left of the base's width. +fn compute_pre_script_widths( + ctx: &MathContext, + base: &MathFragment, + [tl, bl]: [Option<&MathFragment>; 2], + (tl_shift, bl_shift): (Abs, Abs), + space_before_pre_script: Abs, +) -> (Abs, Abs) { + let tl_pre_width = tl.map_or_default(|tl| { + let kern = math_kern(ctx, base, tl, tl_shift, Corner::TopLeft); + space_before_pre_script + tl.width() + kern + }); + + let bl_pre_width = bl.map_or_default(|bl| { + let kern = math_kern(ctx, base, bl, bl_shift, Corner::BottomLeft); + space_before_pre_script + bl.width() + kern + }); + + (tl_pre_width, bl_pre_width) +} + +/// Calculate the distance each limit extends beyond the base's width, in each +/// direction. Can be a negative value if the limit does not extend beyond the +/// base's width, indicating how far into the base's width the limit extends. +/// Returns 2 tuples of two lengths, each first containing the distance the +/// limit extends leftward beyond the base's width and second containing the +/// distance the limit extends rightward beyond the base's width. The first +/// tuple is for the upper-limit, and the second is for the lower-limit. +fn compute_limit_widths( + base: &MathFragment, + [t, b]: [Option<&MathFragment>; 2], +) -> ((Abs, Abs), (Abs, Abs)) { + // The upper- (lower-) limit is shifted to the right (left) of the base's + // center by half the base's italic correction. + let delta = base.italics_correction() / 2.0; + + let t_widths = t.map_or_default(|t| { + let half = (t.width() - base.width()) / 2.0; + (half - delta, half + delta) + }); + + let b_widths = b.map_or_default(|b| { + let half = (b.width() - base.width()) / 2.0; + (half + delta, half - delta) + }); + + (t_widths, b_widths) +} + +/// Calculate the distance from the base's baseline to each limit's baseline. +/// 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, + base: &MathFragment, + [t, b]: [Option<&MathFragment>; 2], +) -> (Abs, Abs) { + // `upper_gap_min` and `lower_gap_min` give gaps to the descender and + // 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); + 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); + base.descent() + lower_drop_min.max(lower_gap_min + b.ascent()) + }); + + (t_shift, b_shift) +} + +/// Calculate the distance from the base's baseline to each script's baseline. +/// 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, + base: &MathFragment, + [tl, tr, bl, br]: [&Option<MathFragment>; 4], +) -> (Abs, Abs) { + let sup_shift_up = if EquationElem::cramped_in(styles) { + scaled!(ctx, styles, superscript_shift_up_cramped) + } else { + scaled!(ctx, styles, superscript_shift_up) + }; + + let sup_bottom_min = scaled!(ctx, styles, superscript_bottom_min); + 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); + + let mut shift_up = Abs::zero(); + let mut shift_down = Abs::zero(); + let is_text_like = base.is_text_like(); + + if tl.is_some() || tr.is_some() { + let ascent = match &base { + MathFragment::Frame(frame) => frame.base_ascent, + _ => base.ascent(), + }; + shift_up = shift_up + .max(sup_shift_up) + .max(if is_text_like { Abs::zero() } else { ascent - sup_drop_max }) + .max(sup_bottom_min + measure!(tl, descent)) + .max(sup_bottom_min + measure!(tr, descent)); + } + + if bl.is_some() || br.is_some() { + shift_down = shift_down + .max(sub_shift_down) + .max(if is_text_like { Abs::zero() } else { base.descent() + sub_drop_min }) + .max(measure!(bl, ascent) - sub_top_max) + .max(measure!(br, ascent) - sub_top_max); + } + + for (sup, sub) in [(tl, bl), (tr, br)] { + if let (Some(sup), Some(sub)) = (&sup, &sub) { + let sup_bottom = shift_up - sup.descent(); + let sub_top = sub.ascent() - shift_down; + let gap = sup_bottom - sub_top; + if gap >= gap_min { + continue; + } + + let increase = gap_min - gap; + let sup_only = + (sup_bottom_max_with_sub - sup_bottom).clamp(Abs::zero(), increase); + let rest = (increase - sup_only) / 2.0; + shift_up += sup_only + rest; + shift_down += rest; + } + } + + (shift_up, shift_down) +} + +/// Calculate the kerning value for a script with respect to the base. A +/// positive value means shifting the script further away from the base, whereas +/// a negative value means shifting the script closer to the base. Requires the +/// distance from the base's baseline to the script's baseline, as well as the +/// script's corner (tl, tr, bl, br). +fn math_kern( + ctx: &MathContext, + base: &MathFragment, + script: &MathFragment, + shift: Abs, + pos: Corner, +) -> Abs { + // This process is described under the MathKernInfo table in the OpenType + // MATH spec. + + let (corr_height_top, corr_height_bot) = match pos { + // Calculate two correction heights for superscripts: + // - The distance from the superscript's baseline to the top of the + // base's bounding box. + // - The distance from the base's baseline to the bottom of the + // superscript's bounding box. + Corner::TopLeft | Corner::TopRight => { + (base.ascent() - shift, shift - script.descent()) + } + // Calculate two correction heights for subscripts: + // - The distance from the base's baseline to the top of the + // subscript's bounding box. + // - The distance from the subscript's baseline to the bottom of the + // base's bounding box. + Corner::BottomLeft | Corner::BottomRight => { + (script.ascent() - shift, shift - base.descent()) + } + }; + + // Calculate the sum of kerning values for each correction height. + let summed_kern = |height| { + let base_kern = base.kern_at_height(ctx, pos, height); + let attach_kern = script.kern_at_height(ctx, pos.inv(), height); + base_kern + attach_kern + }; + + // Take the smaller kerning amount (and so the larger value). Note that + // there is a bug in the spec (as of 2024-08-15): it says to take the + // minimum of the two sums, but as the kerning value is usually negative it + // really means the smaller kern. The current wording of the spec could + // result in glyphs colliding. + summed_kern(corr_height_top).max(summed_kern(corr_height_bot)) +} diff --git a/crates/typst-layout/src/math/cancel.rs b/crates/typst-layout/src/math/cancel.rs new file mode 100644 index 00000000..994e0e2f --- /dev/null +++ b/crates/typst-layout/src/math/cancel.rs @@ -0,0 +1,144 @@ +use comemo::Track; +use typst_library::diag::{At, SourceResult}; +use typst_library::foundations::{Context, Packed, Smart, StyleChain}; +use typst_library::layout::{Abs, Angle, Frame, FrameItem, Point, Rel, Size, Transform}; +use typst_library::math::{CancelAngle, CancelElem}; +use typst_library::text::TextElem; +use typst_library::visualize::{FixedStroke, Geometry}; +use typst_syntax::Span; + +use super::{scaled_font_size, FrameFragment, MathContext}; + +/// Lays out a [`CancelElem`]. +#[typst_macros::time(name = "math.cancel", span = elem.span())] +pub fn layout_cancel( + elem: &Packed<CancelElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let body = ctx.layout_into_fragment(elem.body(), styles)?; + + // Preserve properties of body. + let body_class = body.class(); + let body_italics = body.italics_correction(); + let body_attach = body.accent_attach(); + let body_text_like = body.is_text_like(); + + let mut body = body.into_frame(); + let body_size = body.size(); + let span = elem.span(); + let length = elem.length(styles).at(scaled_font_size(ctx, styles)); + + let stroke = elem.stroke(styles).unwrap_or(FixedStroke { + paint: TextElem::fill_in(styles).as_decoration(), + ..Default::default() + }); + + let invert = elem.inverted(styles); + let cross = elem.cross(styles); + let angle = elem.angle(styles); + + let invert_first_line = !cross && invert; + let first_line = draw_cancel_line( + ctx, + length, + stroke.clone(), + invert_first_line, + &angle, + body_size, + styles, + span, + )?; + + // The origin of our line is the very middle of the element. + let center = body_size.to_point() / 2.0; + body.push_frame(center, first_line); + + if cross { + // Draw the second line. + let second_line = + draw_cancel_line(ctx, length, stroke, true, &angle, body_size, styles, span)?; + + body.push_frame(center, second_line); + } + + ctx.push( + FrameFragment::new(ctx, styles, body) + .with_class(body_class) + .with_italics_correction(body_italics) + .with_accent_attach(body_attach) + .with_text_like(body_text_like), + ); + + Ok(()) +} + +/// Draws a cancel line. +#[allow(clippy::too_many_arguments)] +fn draw_cancel_line( + ctx: &mut MathContext, + length_scale: Rel<Abs>, + stroke: FixedStroke, + invert: bool, + angle: &Smart<CancelAngle>, + body_size: Size, + styles: StyleChain, + span: Span, +) -> SourceResult<Frame> { + let default = default_angle(body_size); + let mut angle = match angle { + // Non specified angle defaults to the diagonal + Smart::Auto => default, + Smart::Custom(angle) => match angle { + // This specifies the absolute angle w.r.t y-axis clockwise. + CancelAngle::Angle(v) => *v, + // This specifies a function that takes the default angle as input. + CancelAngle::Func(func) => func + .call(ctx.engine, Context::new(None, Some(styles)).track(), [default])? + .cast() + .at(span)?, + }, + }; + + // invert means flipping along the y-axis + if invert { + angle *= -1.0; + } + + // same as above, the default length is the diagonal of the body box. + let default_length = body_size.to_point().hypot(); + let length = length_scale.relative_to(default_length); + + // Draw a vertical line of length and rotate it by angle + let start = Point::new(Abs::zero(), length / 2.0); + let delta = Point::new(Abs::zero(), -length); + + let mut frame = Frame::soft(body_size); + frame.push(start, FrameItem::Shape(Geometry::Line(delta).stroked(stroke), span)); + + // Having the middle of the line at the origin is convenient here. + frame.transform(Transform::rotate(angle)); + Ok(frame) +} + +/// The default line angle for a body of the given size. +fn default_angle(body: Size) -> Angle { + // The default cancel line is the diagonal. + // We infer the default angle from + // the diagonal w.r.t to the body box. + // + // The returned angle is in the range of [0, Pi/2] + // + // Note that the angle is computed w.r.t to the y-axis + // + // B + // /| + // diagonal / | height + // / | + // / | + // O ---- + // width + let (width, height) = (body.x, body.y); + let default_angle = (width / height).atan(); // arctangent (in the range [0, Pi/2]) + Angle::rad(default_angle) +} diff --git a/crates/typst-layout/src/math/frac.rs b/crates/typst-layout/src/math/frac.rs new file mode 100644 index 00000000..50686333 --- /dev/null +++ b/crates/typst-layout/src/math/frac.rs @@ -0,0 +1,136 @@ +use typst_library::diag::SourceResult; +use typst_library::foundations::{Content, Packed, StyleChain}; +use typst_library::layout::{Em, Frame, FrameItem, Point, Size}; +use typst_library::math::{BinomElem, FracElem}; +use typst_library::text::TextElem; +use typst_library::visualize::{FixedStroke, Geometry}; +use typst_syntax::Span; + +use super::{ + scaled_font_size, style_for_denominator, style_for_numerator, FrameFragment, + GlyphFragment, MathContext, DELIM_SHORT_FALL, +}; + +const FRAC_AROUND: Em = Em::new(0.1); + +/// Lays out a [`FracElem`]. +#[typst_macros::time(name = "math.frac", span = elem.span())] +pub fn layout_frac( + elem: &Packed<FracElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_frac_like( + ctx, + styles, + elem.num(), + std::slice::from_ref(elem.denom()), + false, + elem.span(), + ) +} + +/// Lays out a [`BinomElem`]. +#[typst_macros::time(name = "math.binom", span = elem.span())] +pub fn layout_binom( + elem: &Packed<BinomElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_frac_like(ctx, styles, elem.upper(), elem.lower(), true, elem.span()) +} + +/// Layout a fraction or binomial. +fn layout_frac_like( + ctx: &mut MathContext, + styles: StyleChain, + num: &Content, + denom: &[Content], + binom: bool, + span: Span, +) -> SourceResult<()> { + let font_size = scaled_font_size(ctx, styles); + let short_fall = DELIM_SHORT_FALL.at(font_size); + 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 num_style = style_for_numerator(styles); + let num = ctx.layout_into_frame(num, styles.chain(&num_style))?; + + let denom_style = style_for_denominator(styles); + let denom = ctx.layout_into_frame( + &Content::sequence( + // Add a comma between each element. + denom.iter().flat_map(|a| [TextElem::packed(','), a.clone()]).skip(1), + ), + styles.chain(&denom_style), + )?; + + let around = FRAC_AROUND.at(font_size); + let num_gap = (shift_up - (axis + thickness / 2.0) - num.descent()).max(num_min); + let denom_gap = + (shift_down + (axis - thickness / 2.0) - denom.ascent()).max(denom_min); + + let line_width = num.width().max(denom.width()); + let width = line_width + 2.0 * around; + let height = num.height() + num_gap + thickness + denom_gap + denom.height(); + let size = Size::new(width, height); + let num_pos = Point::with_x((width - num.width()) / 2.0); + let line_pos = + Point::new((width - line_width) / 2.0, num.height() + num_gap + thickness / 2.0); + let denom_pos = Point::new((width - denom.width()) / 2.0, height - denom.height()); + let baseline = line_pos.y + axis; + + let mut frame = Frame::soft(size); + frame.set_baseline(baseline); + frame.push_frame(num_pos, num); + frame.push_frame(denom_pos, denom); + + if binom { + let mut left = GlyphFragment::new(ctx, styles, '(', span) + .stretch_vertical(ctx, height, short_fall); + left.center_on_axis(ctx); + ctx.push(left); + ctx.push(FrameFragment::new(ctx, styles, frame)); + let mut right = GlyphFragment::new(ctx, styles, ')', span) + .stretch_vertical(ctx, height, short_fall); + right.center_on_axis(ctx); + ctx.push(right); + } else { + frame.push( + line_pos, + FrameItem::Shape( + Geometry::Line(Point::with_x(line_width)).stroked( + FixedStroke::from_pair( + TextElem::fill_in(styles).as_decoration(), + thickness, + ), + ), + span, + ), + ); + ctx.push(FrameFragment::new(ctx, styles, frame)); + } + + Ok(()) +} diff --git a/crates/typst-layout/src/math/fragment.rs b/crates/typst-layout/src/math/fragment.rs new file mode 100644 index 00000000..19a4494e --- /dev/null +++ b/crates/typst-layout/src/math/fragment.rs @@ -0,0 +1,704 @@ +use std::fmt::{self, Debug, Formatter}; + +use rustybuzz::Feature; +use smallvec::SmallVec; +use ttf_parser::gsub::{ + AlternateSet, AlternateSubstitution, SingleSubstitution, SubstitutionSubtable, +}; +use ttf_parser::opentype_layout::LayoutTable; +use ttf_parser::{GlyphId, Rect}; +use typst_library::foundations::StyleChain; +use typst_library::introspection::Tag; +use typst_library::layout::{ + Abs, Axis, Corner, Em, Frame, FrameItem, HideElem, Point, Size, VAlignment, +}; +use typst_library::math::{EquationElem, MathSize}; +use typst_library::model::{Destination, LinkElem}; +use typst_library::text::{Font, Glyph, Lang, Region, TextElem, TextItem}; +use typst_library::visualize::Paint; +use typst_syntax::Span; +use unicode_math_class::MathClass; + +use super::{scaled_font_size, stretch_glyph, MathContext, Scaled}; + +#[derive(Debug, Clone)] +pub enum MathFragment { + Glyph(GlyphFragment), + Variant(VariantFragment), + Frame(FrameFragment), + Spacing(Abs, bool), + Space(Abs), + Linebreak, + Align, + Tag(Tag), +} + +impl MathFragment { + pub fn size(&self) -> Size { + Size::new(self.width(), self.height()) + } + + pub fn width(&self) -> Abs { + match self { + Self::Glyph(glyph) => glyph.width, + Self::Variant(variant) => variant.frame.width(), + Self::Frame(fragment) => fragment.frame.width(), + Self::Spacing(amount, _) => *amount, + Self::Space(amount) => *amount, + _ => Abs::zero(), + } + } + + pub fn height(&self) -> Abs { + match self { + Self::Glyph(glyph) => glyph.height(), + Self::Variant(variant) => variant.frame.height(), + Self::Frame(fragment) => fragment.frame.height(), + _ => Abs::zero(), + } + } + + pub fn ascent(&self) -> Abs { + match self { + Self::Glyph(glyph) => glyph.ascent, + Self::Variant(variant) => variant.frame.ascent(), + Self::Frame(fragment) => fragment.frame.baseline(), + _ => Abs::zero(), + } + } + + pub fn descent(&self) -> Abs { + match self { + Self::Glyph(glyph) => glyph.descent, + Self::Variant(variant) => variant.frame.descent(), + Self::Frame(fragment) => fragment.frame.descent(), + _ => Abs::zero(), + } + } + + pub fn is_ignorant(&self) -> bool { + match self { + Self::Frame(fragment) => fragment.ignorant, + Self::Tag(_) => true, + _ => false, + } + } + + pub fn class(&self) -> MathClass { + match self { + Self::Glyph(glyph) => glyph.class, + Self::Variant(variant) => variant.class, + Self::Frame(fragment) => fragment.class, + Self::Spacing(_, _) => MathClass::Space, + Self::Space(_) => MathClass::Space, + Self::Linebreak => MathClass::Space, + Self::Align => MathClass::Special, + Self::Tag(_) => MathClass::Special, + } + } + + pub fn math_size(&self) -> Option<MathSize> { + match self { + Self::Glyph(glyph) => Some(glyph.math_size), + Self::Variant(variant) => Some(variant.math_size), + Self::Frame(fragment) => Some(fragment.math_size), + _ => None, + } + } + + pub fn font_size(&self) -> Option<Abs> { + match self { + Self::Glyph(glyph) => Some(glyph.font_size), + Self::Variant(variant) => Some(variant.font_size), + Self::Frame(fragment) => Some(fragment.font_size), + _ => None, + } + } + + pub fn set_class(&mut self, class: MathClass) { + match self { + Self::Glyph(glyph) => glyph.class = class, + Self::Variant(variant) => variant.class = class, + Self::Frame(fragment) => fragment.class = class, + _ => {} + } + } + + pub fn set_limits(&mut self, limits: Limits) { + match self { + Self::Glyph(glyph) => glyph.limits = limits, + Self::Variant(variant) => variant.limits = limits, + Self::Frame(fragment) => fragment.limits = limits, + _ => {} + } + } + + pub fn is_spaced(&self) -> bool { + if self.class() == MathClass::Fence { + return true; + } + + matches!( + self, + MathFragment::Frame(FrameFragment { + spaced: true, + class: MathClass::Normal | MathClass::Alphabetic, + .. + }) + ) + } + + pub fn is_text_like(&self) -> bool { + match self { + Self::Glyph(_) | Self::Variant(_) => self.class() != MathClass::Large, + MathFragment::Frame(frame) => frame.text_like, + _ => false, + } + } + + pub fn italics_correction(&self) -> Abs { + match self { + Self::Glyph(glyph) => glyph.italics_correction, + Self::Variant(variant) => variant.italics_correction, + Self::Frame(fragment) => fragment.italics_correction, + _ => Abs::zero(), + } + } + + pub fn accent_attach(&self) -> Abs { + match self { + Self::Glyph(glyph) => glyph.accent_attach, + Self::Variant(variant) => variant.accent_attach, + Self::Frame(fragment) => fragment.accent_attach, + _ => self.width() / 2.0, + } + } + + pub fn into_frame(self) -> Frame { + match self { + Self::Glyph(glyph) => glyph.into_frame(), + Self::Variant(variant) => variant.frame, + Self::Frame(fragment) => fragment.frame, + Self::Tag(tag) => { + let mut frame = Frame::soft(Size::zero()); + frame.push(Point::zero(), FrameItem::Tag(tag)); + frame + } + _ => Frame::soft(self.size()), + } + } + + pub fn limits(&self) -> Limits { + match self { + MathFragment::Glyph(glyph) => glyph.limits, + MathFragment::Variant(variant) => variant.limits, + MathFragment::Frame(fragment) => fragment.limits, + _ => Limits::Never, + } + } + + /// If no kern table is provided for a corner, a kerning amount of zero is + /// assumed. + pub fn kern_at_height(&self, ctx: &MathContext, corner: Corner, height: Abs) -> Abs { + match self { + Self::Glyph(glyph) => { + kern_at_height(ctx, glyph.font_size, glyph.id, corner, height) + .unwrap_or_default() + } + _ => Abs::zero(), + } + } +} + +impl From<GlyphFragment> for MathFragment { + fn from(glyph: GlyphFragment) -> Self { + Self::Glyph(glyph) + } +} + +impl From<VariantFragment> for MathFragment { + fn from(variant: VariantFragment) -> Self { + Self::Variant(variant) + } +} + +impl From<FrameFragment> for MathFragment { + fn from(fragment: FrameFragment) -> Self { + Self::Frame(fragment) + } +} + +#[derive(Clone)] +pub struct GlyphFragment { + pub id: GlyphId, + pub c: char, + pub font: Font, + pub lang: Lang, + pub region: Option<Region>, + pub fill: Paint, + pub shift: Abs, + pub width: Abs, + pub ascent: Abs, + pub descent: Abs, + pub italics_correction: Abs, + pub accent_attach: Abs, + pub font_size: Abs, + pub class: MathClass, + pub math_size: MathSize, + pub span: Span, + pub dests: SmallVec<[Destination; 1]>, + pub hidden: bool, + pub limits: Limits, +} + +impl GlyphFragment { + pub fn new(ctx: &MathContext, styles: StyleChain, c: char, span: Span) -> Self { + let id = ctx.ttf.glyph_index(c).unwrap_or_default(); + let id = Self::adjust_glyph_index(ctx, id); + Self::with_id(ctx, styles, c, id, span) + } + + pub fn try_new( + ctx: &MathContext, + styles: StyleChain, + c: char, + span: Span, + ) -> Option<Self> { + let id = ctx.ttf.glyph_index(c)?; + let id = Self::adjust_glyph_index(ctx, id); + Some(Self::with_id(ctx, styles, c, id, span)) + } + + pub fn with_id( + ctx: &MathContext, + styles: StyleChain, + c: char, + id: GlyphId, + span: Span, + ) -> Self { + let class = EquationElem::class_in(styles) + .or_else(|| match c { + ':' => Some(MathClass::Relation), + '.' | '/' | '⋯' | '⋱' | '⋰' | '⋮' => Some(MathClass::Normal), + _ => unicode_math_class::class(c), + }) + .unwrap_or(MathClass::Normal); + + let mut fragment = Self { + id, + c, + font: ctx.font.clone(), + lang: TextElem::lang_in(styles), + region: TextElem::region_in(styles), + fill: TextElem::fill_in(styles).as_decoration(), + shift: TextElem::baseline_in(styles), + font_size: scaled_font_size(ctx, styles), + math_size: EquationElem::size_in(styles), + width: Abs::zero(), + ascent: Abs::zero(), + descent: Abs::zero(), + limits: Limits::for_char(c), + italics_correction: Abs::zero(), + accent_attach: Abs::zero(), + class, + span, + dests: LinkElem::dests_in(styles), + hidden: HideElem::hidden_in(styles), + }; + fragment.set_id(ctx, id); + fragment + } + + /// Apply GSUB substitutions. + fn adjust_glyph_index(ctx: &MathContext, id: GlyphId) -> GlyphId { + if let Some(glyphwise_tables) = &ctx.glyphwise_tables { + glyphwise_tables.iter().fold(id, |id, table| table.apply(id)) + } else { + id + } + } + + /// Sets element id and boxes in appropriate way without changing other + /// styles. This is used to replace the glyph with a stretch variant. + pub fn set_id(&mut self, ctx: &MathContext, id: GlyphId) { + let advance = ctx.ttf.glyph_hor_advance(id).unwrap_or_default(); + let italics = italics_correction(ctx, id, self.font_size).unwrap_or_default(); + let bbox = ctx.ttf.glyph_bounding_box(id).unwrap_or(Rect { + x_min: 0, + y_min: 0, + x_max: 0, + y_max: 0, + }); + + let mut width = advance.scaled(ctx, self.font_size); + let accent_attach = + accent_attach(ctx, id, self.font_size).unwrap_or((width + italics) / 2.0); + + if !is_extended_shape(ctx, id) { + width += italics; + } + + self.id = id; + self.width = width; + self.ascent = bbox.y_max.scaled(ctx, self.font_size); + self.descent = -bbox.y_min.scaled(ctx, self.font_size); + self.italics_correction = italics; + self.accent_attach = accent_attach; + } + + pub fn height(&self) -> Abs { + self.ascent + self.descent + } + + pub fn into_variant(self) -> VariantFragment { + VariantFragment { + c: self.c, + font_size: self.font_size, + italics_correction: self.italics_correction, + accent_attach: self.accent_attach, + class: self.class, + math_size: self.math_size, + span: self.span, + limits: self.limits, + frame: self.into_frame(), + mid_stretched: None, + } + } + + pub fn into_frame(self) -> Frame { + let item = TextItem { + font: self.font.clone(), + size: self.font_size, + fill: self.fill, + lang: self.lang, + region: self.region, + text: self.c.into(), + stroke: None, + glyphs: vec![Glyph { + id: self.id.0, + x_advance: Em::from_length(self.width, self.font_size), + x_offset: Em::zero(), + range: 0..self.c.len_utf8() as u16, + span: (self.span, 0), + }], + }; + let size = Size::new(self.width, self.ascent + self.descent); + let mut frame = Frame::soft(size); + frame.set_baseline(self.ascent); + frame.push(Point::with_y(self.ascent + self.shift), FrameItem::Text(item)); + frame.post_process_raw(self.dests, self.hidden); + frame + } + + pub fn make_scriptsize(&mut self, ctx: &MathContext) { + let alt_id = + script_alternatives(ctx, self.id).and_then(|alts| alts.alternates.get(0)); + + if let Some(alt_id) = alt_id { + self.set_id(ctx, alt_id); + } + } + + pub fn make_scriptscriptsize(&mut self, ctx: &MathContext) { + let alts = script_alternatives(ctx, self.id); + let alt_id = alts + .and_then(|alts| alts.alternates.get(1).or_else(|| alts.alternates.get(0))); + + if let Some(alt_id) = alt_id { + self.set_id(ctx, alt_id); + } + } + + /// Try to stretch a glyph to a desired height. + pub fn stretch_vertical( + self, + ctx: &MathContext, + height: Abs, + short_fall: Abs, + ) -> VariantFragment { + stretch_glyph(ctx, self, height, short_fall, Axis::Y) + } + + /// Try to stretch a glyph to a desired width. + pub fn stretch_horizontal( + self, + ctx: &MathContext, + width: Abs, + short_fall: Abs, + ) -> VariantFragment { + stretch_glyph(ctx, self, width, short_fall, Axis::X) + } +} + +impl Debug for GlyphFragment { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "GlyphFragment({:?})", self.c) + } +} + +#[derive(Clone)] +pub struct VariantFragment { + pub c: char, + pub italics_correction: Abs, + pub accent_attach: Abs, + pub frame: Frame, + pub font_size: Abs, + pub class: MathClass, + pub math_size: MathSize, + pub span: Span, + pub limits: Limits, + pub mid_stretched: Option<bool>, +} + +impl VariantFragment { + /// Vertically adjust the fragment's frame so that it is centered + /// on the axis. + pub fn center_on_axis(&mut self, ctx: &MathContext) { + self.align_on_axis(ctx, VAlignment::Horizon) + } + + /// Vertically adjust the fragment's frame so that it is aligned + /// to the given alignment on the axis. + pub fn align_on_axis(&mut self, ctx: &MathContext, align: VAlignment) { + let h = self.frame.height(); + let axis = ctx.constants.axis_height().scaled(ctx, self.font_size); + self.frame.set_baseline(align.inv().position(h + axis * 2.0)); + } +} + +impl Debug for VariantFragment { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "VariantFragment({:?})", self.c) + } +} + +#[derive(Debug, Clone)] +pub struct FrameFragment { + pub frame: Frame, + pub font_size: Abs, + pub class: MathClass, + pub math_size: MathSize, + pub limits: Limits, + pub spaced: bool, + pub base_ascent: Abs, + pub italics_correction: Abs, + pub accent_attach: Abs, + pub text_like: bool, + pub ignorant: bool, +} + +impl FrameFragment { + pub fn new(ctx: &MathContext, styles: StyleChain, frame: Frame) -> Self { + let base_ascent = frame.ascent(); + let accent_attach = frame.width() / 2.0; + Self { + frame: frame.post_processed(styles), + font_size: scaled_font_size(ctx, styles), + class: EquationElem::class_in(styles).unwrap_or(MathClass::Normal), + math_size: EquationElem::size_in(styles), + limits: Limits::Never, + spaced: false, + base_ascent, + italics_correction: Abs::zero(), + accent_attach, + text_like: false, + ignorant: false, + } + } + + pub fn with_class(self, class: MathClass) -> Self { + Self { class, ..self } + } + + pub fn with_limits(self, limits: Limits) -> Self { + Self { limits, ..self } + } + + pub fn with_spaced(self, spaced: bool) -> Self { + Self { spaced, ..self } + } + + pub fn with_base_ascent(self, base_ascent: Abs) -> Self { + Self { base_ascent, ..self } + } + + pub fn with_italics_correction(self, italics_correction: Abs) -> Self { + Self { italics_correction, ..self } + } + + pub fn with_accent_attach(self, accent_attach: Abs) -> Self { + Self { accent_attach, ..self } + } + + pub fn with_text_like(self, text_like: bool) -> Self { + Self { text_like, ..self } + } + + pub fn with_ignorant(self, ignorant: bool) -> Self { + Self { ignorant, ..self } + } +} + +/// Look up the italics correction for a glyph. +fn italics_correction(ctx: &MathContext, id: GlyphId, font_size: Abs) -> Option<Abs> { + Some( + ctx.table + .glyph_info? + .italic_corrections? + .get(id)? + .scaled(ctx, font_size), + ) +} + +/// Loop up the top accent attachment position for a glyph. +fn accent_attach(ctx: &MathContext, id: GlyphId, font_size: Abs) -> Option<Abs> { + Some( + ctx.table + .glyph_info? + .top_accent_attachments? + .get(id)? + .scaled(ctx, font_size), + ) +} + +/// Look up the script/scriptscript alternates for a glyph +fn script_alternatives<'a>( + ctx: &MathContext<'a, '_, '_>, + id: GlyphId, +) -> Option<AlternateSet<'a>> { + ctx.ssty_table.and_then(|ssty| { + ssty.coverage.get(id).and_then(|index| ssty.alternate_sets.get(index)) + }) +} + +/// Look up whether a glyph is an extended shape. +fn is_extended_shape(ctx: &MathContext, id: GlyphId) -> bool { + ctx.table + .glyph_info + .and_then(|info| info.extended_shapes) + .and_then(|info| info.get(id)) + .is_some() +} + +/// Look up a kerning value at a specific corner and height. +fn kern_at_height( + ctx: &MathContext, + font_size: Abs, + id: GlyphId, + corner: Corner, + height: Abs, +) -> Option<Abs> { + let kerns = ctx.table.glyph_info?.kern_infos?.get(id)?; + let kern = match corner { + Corner::TopLeft => kerns.top_left, + Corner::TopRight => kerns.top_right, + Corner::BottomRight => kerns.bottom_right, + Corner::BottomLeft => kerns.bottom_left, + }?; + + let mut i = 0; + while i < kern.count() && height > kern.height(i)?.scaled(ctx, font_size) { + i += 1; + } + + Some(kern.kern(i)?.scaled(ctx, font_size)) +} + +/// Describes in which situation a frame should use limits for attachments. +#[derive(Debug, Copy, Clone)] +pub enum Limits { + /// Always scripts. + Never, + /// Display limits only in `display` math. + Display, + /// Always limits. + Always, +} + +impl Limits { + /// The default limit configuration if the given character is the base. + pub fn for_char(c: char) -> Self { + match unicode_math_class::class(c) { + Some(MathClass::Large) => { + if is_integral_char(c) { + Limits::Never + } else { + Limits::Display + } + } + Some(MathClass::Relation) => Limits::Always, + _ => Limits::Never, + } + } + + /// The default limit configuration for a math class. + pub fn for_class(class: MathClass) -> Self { + match class { + MathClass::Large => Self::Display, + MathClass::Relation => Self::Always, + _ => Self::Never, + } + } + + /// Whether limits should be displayed in this context. + pub fn active(&self, styles: StyleChain) -> bool { + match self { + Self::Always => true, + Self::Display => EquationElem::size_in(styles) == MathSize::Display, + Self::Never => false, + } + } +} + +/// Determines if the character is one of a variety of integral signs. +fn is_integral_char(c: char) -> bool { + ('∫'..='∳').contains(&c) || ('⨋'..='⨜').contains(&c) +} + +/// An OpenType substitution table that is applicable to glyph-wise substitutions. +pub enum GlyphwiseSubsts<'a> { + Single(SingleSubstitution<'a>), + Alternate(AlternateSubstitution<'a>, u32), +} + +impl<'a> GlyphwiseSubsts<'a> { + pub fn new(gsub: LayoutTable<'a>, feature: Feature) -> Option<Self> { + let table = gsub + .features + .find(ttf_parser::Tag(feature.tag.0)) + .and_then(|feature| feature.lookup_indices.get(0)) + .and_then(|index| gsub.lookups.get(index))?; + let table = table.subtables.get::<SubstitutionSubtable>(0)?; + match table { + SubstitutionSubtable::Single(single_glyphs) => { + Some(Self::Single(single_glyphs)) + } + SubstitutionSubtable::Alternate(alt_glyphs) => { + Some(Self::Alternate(alt_glyphs, feature.value)) + } + _ => None, + } + } + + pub fn try_apply(&self, glyph_id: GlyphId) -> Option<GlyphId> { + match self { + Self::Single(single) => match single { + SingleSubstitution::Format1 { coverage, delta } => coverage + .get(glyph_id) + .map(|_| GlyphId(glyph_id.0.wrapping_add(*delta as u16))), + SingleSubstitution::Format2 { coverage, substitutes } => { + coverage.get(glyph_id).and_then(|idx| substitutes.get(idx)) + } + }, + Self::Alternate(alternate, value) => alternate + .coverage + .get(glyph_id) + .and_then(|idx| alternate.alternate_sets.get(idx)) + .and_then(|set| set.alternates.get(*value as u16)), + } + } + + pub fn apply(&self, glyph_id: GlyphId) -> GlyphId { + self.try_apply(glyph_id).unwrap_or(glyph_id) + } +} diff --git a/crates/typst-layout/src/math/lr.rs b/crates/typst-layout/src/math/lr.rs new file mode 100644 index 00000000..aba9012f --- /dev/null +++ b/crates/typst-layout/src/math/lr.rs @@ -0,0 +1,135 @@ +use typst_library::diag::SourceResult; +use typst_library::foundations::{Packed, Smart, StyleChain}; +use typst_library::layout::{Abs, Axis, Length, Rel}; +use typst_library::math::{EquationElem, LrElem, MidElem}; +use unicode_math_class::MathClass; + +use super::{stretch_fragment, MathContext, MathFragment, DELIM_SHORT_FALL}; + +/// Lays out an [`LrElem`]. +#[typst_macros::time(name = "math.lr", span = elem.span())] +pub fn layout_lr( + elem: &Packed<LrElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let mut body = elem.body(); + + // Extract from an EquationElem. + if let Some(equation) = body.to_packed::<EquationElem>() { + body = equation.body(); + } + + // Extract implicit LrElem. + if let Some(lr) = body.to_packed::<LrElem>() { + if lr.size(styles).is_auto() { + body = lr.body(); + } + } + + let mut fragments = ctx.layout_into_fragments(body, styles)?; + let axis = scaled!(ctx, styles, axis_height); + let max_extent = fragments + .iter() + .map(|fragment| (fragment.ascent() - axis).max(fragment.descent() + axis)) + .max() + .unwrap_or_default(); + + let relative_to = 2.0 * max_extent; + let height = elem.size(styles); + + // Scale up fragments at both ends. + match fragments.as_mut_slice() { + [one] => scale(ctx, styles, one, relative_to, height, None), + [first, .., last] => { + scale(ctx, styles, first, relative_to, height, Some(MathClass::Opening)); + scale(ctx, styles, last, relative_to, height, Some(MathClass::Closing)); + } + _ => {} + } + + // Handle MathFragment::Variant fragments that should be scaled up. + for fragment in &mut fragments { + if let MathFragment::Variant(ref mut variant) = fragment { + if variant.mid_stretched == Some(false) { + variant.mid_stretched = Some(true); + scale(ctx, styles, fragment, relative_to, height, Some(MathClass::Large)); + } + } + } + + // Remove weak SpacingFragment immediately after the opening or immediately + // before the closing. + let original_len = fragments.len(); + let mut index = 0; + fragments.retain(|fragment| { + index += 1; + (index != 2 && index + 1 != original_len) + || !matches!(fragment, MathFragment::Spacing(_, true)) + }); + + ctx.extend(fragments); + + Ok(()) +} + +/// Lays out a [`MidElem`]. +#[typst_macros::time(name = "math.mid", span = elem.span())] +pub fn layout_mid( + elem: &Packed<MidElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let mut fragments = ctx.layout_into_fragments(elem.body(), styles)?; + + for fragment in &mut fragments { + match fragment { + MathFragment::Glyph(glyph) => { + let mut new = glyph.clone().into_variant(); + new.mid_stretched = Some(false); + new.class = MathClass::Fence; + *fragment = MathFragment::Variant(new); + } + MathFragment::Variant(variant) => { + variant.mid_stretched = Some(false); + variant.class = MathClass::Fence; + } + _ => {} + } + } + + ctx.extend(fragments); + Ok(()) +} + +/// Scale a math fragment to a height. +fn scale( + ctx: &mut MathContext, + styles: StyleChain, + fragment: &mut MathFragment, + relative_to: Abs, + height: Smart<Rel<Length>>, + apply: Option<MathClass>, +) { + if matches!( + fragment.class(), + MathClass::Opening | MathClass::Closing | MathClass::Fence + ) { + // This unwrap doesn't really matter. If it is None, then the fragment + // won't be stretchable anyways. + let short_fall = DELIM_SHORT_FALL.at(fragment.font_size().unwrap_or_default()); + stretch_fragment( + ctx, + styles, + fragment, + Some(Axis::Y), + Some(relative_to), + height, + short_fall, + ); + + if let Some(class) = apply { + fragment.set_class(class); + } + } +} diff --git a/crates/typst-layout/src/math/mat.rs b/crates/typst-layout/src/math/mat.rs new file mode 100644 index 00000000..6c8b0455 --- /dev/null +++ b/crates/typst-layout/src/math/mat.rs @@ -0,0 +1,333 @@ +use typst_library::diag::{bail, SourceResult}; +use typst_library::foundations::{Content, Packed, StyleChain}; +use typst_library::layout::{ + Abs, Axes, Em, FixedAlignment, Frame, FrameItem, Point, Ratio, Rel, Size, +}; +use typst_library::math::{Augment, AugmentOffsets, CasesElem, MatElem, VecElem}; +use typst_library::text::TextElem; +use typst_library::visualize::{FillRule, FixedStroke, Geometry, LineCap, Shape}; +use typst_syntax::Span; + +use super::{ + alignments, delimiter_alignment, scaled_font_size, stack, style_for_denominator, + AlignmentResult, FrameFragment, GlyphFragment, LeftRightAlternator, MathContext, + Scaled, DELIM_SHORT_FALL, +}; + +const VERTICAL_PADDING: Ratio = Ratio::new(0.1); +const DEFAULT_STROKE_THICKNESS: Em = Em::new(0.05); + +/// Lays out a [`VecElem`]. +#[typst_macros::time(name = "math.vec", span = elem.span())] +pub fn layout_vec( + elem: &Packed<VecElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let delim = elem.delim(styles); + let frame = layout_vec_body( + ctx, + styles, + elem.children(), + elem.align(styles), + elem.gap(styles).at(scaled_font_size(ctx, styles)), + LeftRightAlternator::Right, + )?; + + layout_delimiters(ctx, styles, frame, delim.open(), delim.close(), elem.span()) +} + +/// Lays out a [`MatElem`]. +#[typst_macros::time(name = "math.mat", span = elem.span())] +pub fn layout_mat( + elem: &Packed<MatElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let augment = elem.augment(styles); + let rows = elem.rows(); + + if let Some(aug) = &augment { + for &offset in &aug.hline.0 { + if offset == 0 || offset.unsigned_abs() >= rows.len() { + bail!( + elem.span(), + "cannot draw a horizontal line after row {} of a matrix with {} rows", + if offset < 0 { rows.len() as isize + offset } else { offset }, + rows.len() + ); + } + } + + let ncols = elem.rows().first().map_or(0, |row| row.len()); + + for &offset in &aug.vline.0 { + if offset == 0 || offset.unsigned_abs() >= ncols { + bail!( + elem.span(), + "cannot draw a vertical line after column {} of a matrix with {} columns", + if offset < 0 { ncols as isize + offset } else { offset }, + ncols + ); + } + } + } + + let font_size = scaled_font_size(ctx, styles); + let column_gap = elem.column_gap(styles).at(font_size); + let row_gap = elem.row_gap(styles).at(font_size); + let delim = elem.delim(styles); + let frame = layout_mat_body( + ctx, + styles, + rows, + elem.align(styles), + augment, + Axes::new(column_gap, row_gap), + elem.span(), + )?; + + layout_delimiters(ctx, styles, frame, delim.open(), delim.close(), elem.span()) +} + +/// Lays out a [`CasesElem`]. +#[typst_macros::time(name = "math.cases", span = elem.span())] +pub fn layout_cases( + elem: &Packed<CasesElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let delim = elem.delim(styles); + let frame = layout_vec_body( + ctx, + styles, + elem.children(), + FixedAlignment::Start, + elem.gap(styles).at(scaled_font_size(ctx, styles)), + LeftRightAlternator::None, + )?; + + let (open, close) = + if elem.reverse(styles) { (None, delim.close()) } else { (delim.open(), None) }; + + layout_delimiters(ctx, styles, frame, open, close, elem.span()) +} + +/// Layout the inner contents of a vector. +fn layout_vec_body( + ctx: &mut MathContext, + styles: StyleChain, + column: &[Content], + align: FixedAlignment, + row_gap: Rel<Abs>, + alternator: LeftRightAlternator, +) -> SourceResult<Frame> { + let gap = row_gap.relative_to(ctx.region.size.y); + + let denom_style = style_for_denominator(styles); + let mut flat = vec![]; + for child in column { + flat.push(ctx.layout_into_run(child, styles.chain(&denom_style))?); + } + // We pad ascent and descent with the ascent and descent of the paren + // to ensure that normal vectors are aligned with others unless they are + // way too big. + let paren = + GlyphFragment::new(ctx, styles.chain(&denom_style), '(', Span::detached()); + Ok(stack(flat, align, gap, 0, alternator, Some((paren.ascent, paren.descent)))) +} + +/// Layout the inner contents of a matrix. +fn layout_mat_body( + ctx: &mut MathContext, + styles: StyleChain, + rows: &[Vec<Content>], + align: FixedAlignment, + augment: Option<Augment<Abs>>, + gap: Axes<Rel<Abs>>, + span: Span, +) -> SourceResult<Frame> { + let ncols = rows.first().map_or(0, |row| row.len()); + let nrows = rows.len(); + if ncols == 0 || nrows == 0 { + return Ok(Frame::soft(Size::zero())); + } + + let gap = gap.zip_map(ctx.region.size, Rel::relative_to); + let half_gap = gap * 0.5; + + // We provide a default stroke thickness that scales + // with font size to ensure that augmentation lines + // look correct by default at all matrix sizes. + // The line cap is also set to square because it looks more "correct". + let font_size = scaled_font_size(ctx, styles); + let default_stroke_thickness = DEFAULT_STROKE_THICKNESS.at(font_size); + let default_stroke = FixedStroke { + thickness: default_stroke_thickness, + paint: TextElem::fill_in(styles).as_decoration(), + cap: LineCap::Square, + ..Default::default() + }; + + let (hline, vline, stroke) = match augment { + Some(augment) => { + // We need to get stroke here for ownership. + let stroke = augment.stroke.unwrap_or_default().unwrap_or(default_stroke); + (augment.hline, augment.vline, stroke) + } + _ => (AugmentOffsets::default(), AugmentOffsets::default(), default_stroke), + }; + + // Before the full matrix body can be laid out, the + // individual cells must first be independently laid out + // so we can ensure alignment across rows and columns. + + // This variable stores the maximum ascent and descent for each row. + let mut heights = vec![(Abs::zero(), Abs::zero()); nrows]; + + // We want to transpose our data layout to columns + // before final layout. For efficiency, the columns + // variable is set up here and newly generated + // individual cells are then added to it. + let mut cols = vec![vec![]; ncols]; + + let denom_style = style_for_denominator(styles); + // 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(ctx, styles.chain(&denom_style), '(', Span::detached()); + + for (row, (ascent, descent)) in rows.iter().zip(&mut heights) { + for (cell, col) in row.iter().zip(&mut cols) { + let cell = ctx.layout_into_run(cell, styles.chain(&denom_style))?; + + ascent.set_max(cell.ascent().max(paren.ascent)); + descent.set_max(cell.descent().max(paren.descent)); + + col.push(cell); + } + } + + // For each row, combine maximum ascent and descent into a row height. + // Sum the row heights, then add the total height of the gaps between rows. + let total_height = + heights.iter().map(|&(a, b)| a + b).sum::<Abs>() + gap.y * (nrows - 1) as f64; + + // Width starts at zero because it can't be calculated until later + let mut frame = Frame::soft(Size::new(Abs::zero(), total_height)); + + let mut x = Abs::zero(); + + for (index, col) in cols.into_iter().enumerate() { + let AlignmentResult { points, width: rcol } = alignments(&col); + + let mut y = Abs::zero(); + + for (cell, &(ascent, descent)) in col.into_iter().zip(&heights) { + let cell = cell.into_line_frame(&points, LeftRightAlternator::Right); + let pos = Point::new( + if points.is_empty() { + x + align.position(rcol - cell.width()) + } else { + x + }, + y + ascent - cell.ascent(), + ); + + frame.push_frame(pos, cell); + + y += ascent + descent + gap.y; + } + + // Advance to the end of the column + x += rcol; + + // If a vertical line should be inserted after this column + if vline.0.contains(&(index as isize + 1)) + || vline.0.contains(&(1 - ((ncols - index) as isize))) + { + frame.push( + Point::with_x(x + half_gap.x), + line_item(total_height, true, stroke.clone(), span), + ); + } + + // Advance to the start of the next column + x += gap.x; + } + + // Once all the columns are laid out, the total width can be calculated + let total_width = x - gap.x; + + // This allows the horizontal lines to be laid out + for line in hline.0 { + let real_line = + if line < 0 { nrows - line.unsigned_abs() } else { line as usize }; + let offset = (heights[0..real_line].iter().map(|&(a, b)| a + b).sum::<Abs>() + + gap.y * (real_line - 1) as f64) + + half_gap.y; + + frame.push( + Point::with_y(offset), + line_item(total_width, false, stroke.clone(), span), + ); + } + + frame.size_mut().x = total_width; + + Ok(frame) +} + +fn line_item(length: Abs, vertical: bool, stroke: FixedStroke, span: Span) -> FrameItem { + let line_geom = if vertical { + Geometry::Line(Point::with_y(length)) + } else { + Geometry::Line(Point::with_x(length)) + }; + + FrameItem::Shape( + Shape { + geometry: line_geom, + fill: None, + fill_rule: FillRule::default(), + stroke: Some(stroke), + }, + span, + ) +} + +/// Layout the outer wrapper around the body of a vector or matrix. +fn layout_delimiters( + ctx: &mut MathContext, + styles: StyleChain, + mut frame: Frame, + left: Option<char>, + right: Option<char>, + span: Span, +) -> SourceResult<()> { + let font_size = scaled_font_size(ctx, styles); + let short_fall = DELIM_SHORT_FALL.at(font_size); + let axis = ctx.constants.axis_height().scaled(ctx, font_size); + let height = frame.height(); + let target = height + VERTICAL_PADDING.of(height); + frame.set_baseline(height / 2.0 + axis); + + if let Some(left) = left { + let mut left = GlyphFragment::new(ctx, styles, left, span) + .stretch_vertical(ctx, target, short_fall); + left.align_on_axis(ctx, delimiter_alignment(left.c)); + ctx.push(left); + } + + ctx.push(FrameFragment::new(ctx, styles, frame)); + + if let Some(right) = right { + let mut right = GlyphFragment::new(ctx, styles, right, span) + .stretch_vertical(ctx, target, short_fall); + right.align_on_axis(ctx, delimiter_alignment(right.c)); + ctx.push(right); + } + + Ok(()) +} diff --git a/crates/typst-layout/src/math/mod.rs b/crates/typst-layout/src/math/mod.rs new file mode 100644 index 00000000..b3dde977 --- /dev/null +++ b/crates/typst-layout/src/math/mod.rs @@ -0,0 +1,703 @@ +#[macro_use] +mod shared; +mod accent; +mod attach; +mod cancel; +mod frac; +mod fragment; +mod lr; +mod mat; +mod root; +mod run; +mod stretch; +mod text; +mod underover; + +use ttf_parser::gsub::SubstitutionSubtable; +use typst_library::diag::{bail, SourceResult}; +use typst_library::engine::Engine; +use typst_library::foundations::{Content, NativeElement, Packed, Resolve, StyleChain}; +use typst_library::introspection::{Counter, Locator, SplitLocator, TagElem}; +use typst_library::layout::{ + Abs, AlignElem, Axes, BlockElem, BoxElem, Em, FixedAlignment, Fragment, Frame, HElem, + InlineItem, OuterHAlignment, PlaceElem, Point, Region, Regions, Size, Spacing, + SpecificAlignment, VAlignment, +}; +use typst_library::math::*; +use typst_library::model::ParElem; +use typst_library::routines::{Arenas, RealizationKind}; +use typst_library::text::{ + families, features, variant, Font, LinebreakElem, SpaceElem, TextEdgeBounds, + TextElem, TextSize, +}; +use typst_library::World; +use typst_syntax::Span; +use typst_utils::Numeric; +use unicode_math_class::MathClass; + +use self::fragment::{ + FrameFragment, GlyphFragment, GlyphwiseSubsts, Limits, MathFragment, VariantFragment, +}; +use self::run::{LeftRightAlternator, MathRun, MathRunFrameBuilder}; +use self::shared::*; +use self::stretch::{stretch_fragment, stretch_glyph}; + +/// Layout an inline equation (in a paragraph). +#[typst_macros::time(span = elem.span())] +pub fn layout_equation_inline( + elem: &Packed<EquationElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + region: Size, +) -> SourceResult<Vec<InlineItem>> { + assert!(!elem.block(styles)); + + let font = find_math_font(engine, styles, elem.span())?; + + let mut locator = locator.split(); + let mut ctx = MathContext::new(engine, &mut locator, styles, region, &font); + let run = ctx.layout_into_run(&elem.body, styles)?; + + let mut items = if run.row_count() == 1 { + run.into_par_items() + } else { + vec![InlineItem::Frame(run.into_fragment(&ctx, styles).into_frame())] + }; + + // An empty equation should have a height, so we still create a frame + // (which is then resized in the loop). + if items.is_empty() { + items.push(InlineItem::Frame(Frame::soft(Size::zero()))); + } + + for item in &mut items { + let InlineItem::Frame(frame) = item else { continue }; + + let font_size = scaled_font_size(&ctx, styles); + let slack = ParElem::leading_in(styles) * 0.7; + + let (t, b) = font.edges( + TextElem::top_edge_in(styles), + TextElem::bottom_edge_in(styles), + font_size, + TextEdgeBounds::Frame(frame), + ); + + let ascent = t.max(frame.ascent() - slack); + let descent = b.max(frame.descent() - slack); + frame.translate(Point::with_y(ascent - frame.baseline())); + frame.size_mut().y = ascent + descent; + } + + Ok(items) +} + +/// Layout a block-level equation (in a flow). +#[typst_macros::time(span = elem.span())] +pub fn layout_equation_block( + elem: &Packed<EquationElem>, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + regions: Regions, +) -> SourceResult<Fragment> { + assert!(elem.block(styles)); + + let span = elem.span(); + let font = find_math_font(engine, styles, span)?; + + let mut locator = locator.split(); + let mut ctx = MathContext::new(engine, &mut locator, styles, regions.base(), &font); + let full_equation_builder = ctx + .layout_into_run(&elem.body, styles)? + .multiline_frame_builder(&ctx, styles); + let width = full_equation_builder.size.x; + + let equation_builders = if BlockElem::breakable_in(styles) { + let mut rows = full_equation_builder.frames.into_iter().peekable(); + let mut equation_builders = vec![]; + let mut last_first_pos = Point::zero(); + let mut regions = regions; + + loop { + // Keep track of the position of the first row in this region, + // so that the offset can be reverted later. + let Some(&(_, first_pos)) = rows.peek() else { break }; + last_first_pos = first_pos; + + let mut frames = vec![]; + let mut height = Abs::zero(); + while let Some((sub, pos)) = rows.peek() { + let mut pos = *pos; + pos.y -= first_pos.y; + + // Finish this region if the line doesn't fit. Only do it if + // we placed at least one line _or_ we still have non-last + // regions. Crucially, we don't want to infinitely create + // new regions which are too small. + if !regions.size.y.fits(sub.height() + pos.y) + && (regions.may_progress() + || (regions.may_break() && !frames.is_empty())) + { + break; + } + + let (sub, _) = rows.next().unwrap(); + height = height.max(pos.y + sub.height()); + frames.push((sub, pos)); + } + + equation_builders + .push(MathRunFrameBuilder { frames, size: Size::new(width, height) }); + regions.next(); + } + + // Append remaining rows to the equation builder of the last region. + if let Some(equation_builder) = equation_builders.last_mut() { + equation_builder.frames.extend(rows.map(|(frame, mut pos)| { + pos.y -= last_first_pos.y; + (frame, pos) + })); + + let height = equation_builder + .frames + .iter() + .map(|(frame, pos)| frame.height() + pos.y) + .max() + .unwrap_or(equation_builder.size.y); + + equation_builder.size.y = height; + } + + // Ensure that there is at least one frame, even for empty equations. + if equation_builders.is_empty() { + equation_builders + .push(MathRunFrameBuilder { frames: vec![], size: Size::zero() }); + } + + equation_builders + } else { + vec![full_equation_builder] + }; + + let Some(numbering) = (**elem).numbering(styles) else { + let frames = equation_builders + .into_iter() + .map(MathRunFrameBuilder::build) + .collect(); + return Ok(Fragment::frames(frames)); + }; + + let pod = Region::new(regions.base(), Axes::splat(false)); + let counter = Counter::of(EquationElem::elem()) + .display_at_loc(engine, elem.location().unwrap(), styles, numbering)? + .spanned(span); + let number = + (engine.routines.layout_frame)(engine, &counter, locator.next(&()), styles, pod)?; + + static NUMBER_GUTTER: Em = Em::new(0.5); + let full_number_width = number.width() + NUMBER_GUTTER.resolve(styles); + + let number_align = match elem.number_align(styles) { + SpecificAlignment::H(h) => SpecificAlignment::Both(h, VAlignment::Horizon), + SpecificAlignment::V(v) => SpecificAlignment::Both(OuterHAlignment::End, v), + SpecificAlignment::Both(h, v) => SpecificAlignment::Both(h, v), + }; + + // Add equation numbers to each equation region. + let region_count = equation_builders.len(); + let frames = equation_builders + .into_iter() + .map(|builder| { + if builder.frames.is_empty() && region_count > 1 { + // Don't number empty regions, but do number empty equations. + return builder.build(); + } + add_equation_number( + builder, + number.clone(), + number_align.resolve(styles), + AlignElem::alignment_in(styles).resolve(styles).x, + regions.size.x, + full_number_width, + ) + }) + .collect(); + + Ok(Fragment::frames(frames)) +} + +fn find_math_font( + engine: &mut Engine<'_>, + styles: StyleChain, + span: Span, +) -> SourceResult<Font> { + let variant = variant(styles); + let world = engine.world; + let Some(font) = families(styles).find_map(|family| { + let id = world.book().select(family, 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, + number_align: Axes<FixedAlignment>, + equation_align: FixedAlignment, + region_size_x: Abs, + full_number_width: Abs, +) -> Frame { + let first = + equation_builder.frames.first().map_or( + (equation_builder.size, Point::zero(), Abs::zero()), + |(frame, pos)| (frame.size(), *pos, frame.baseline()), + ); + let last = + equation_builder.frames.last().map_or( + (equation_builder.size, Point::zero(), Abs::zero()), + |(frame, pos)| (frame.size(), *pos, frame.baseline()), + ); + let line_count = equation_builder.frames.len(); + let mut equation = equation_builder.build(); + + let width = if region_size_x.is_finite() { + region_size_x + } else { + equation.width() + 2.0 * full_number_width + }; + + let is_multiline = line_count >= 2; + let resizing_offset = resize_equation( + &mut equation, + &number, + number_align, + equation_align, + width, + is_multiline, + [first, last], + ); + equation.translate(Point::with_x(match (equation_align, number_align.x) { + (FixedAlignment::Start, FixedAlignment::Start) => full_number_width, + (FixedAlignment::End, FixedAlignment::End) => -full_number_width, + _ => Abs::zero(), + })); + + let x = match number_align.x { + FixedAlignment::Start => Abs::zero(), + FixedAlignment::End => equation.width() - number.width(), + _ => unreachable!(), + }; + let y = { + let align_baselines = |(_, pos, baseline): (_, Point, Abs), number: &Frame| { + resizing_offset.y + pos.y + baseline - number.baseline() + }; + match number_align.y { + FixedAlignment::Start => align_baselines(first, &number), + FixedAlignment::Center if !is_multiline => align_baselines(first, &number), + // In this case, the center lines (not baselines) of the number frame + // and the equation frame shall be aligned. + FixedAlignment::Center => (equation.height() - number.height()) / 2.0, + FixedAlignment::End => align_baselines(last, &number), + } + }; + + equation.push_frame(Point::new(x, y), number); + equation +} + +/// Resize the equation's frame accordingly so that it encompasses the number. +fn resize_equation( + equation: &mut Frame, + number: &Frame, + number_align: Axes<FixedAlignment>, + equation_align: FixedAlignment, + width: Abs, + is_multiline: bool, + [first, last]: [(Axes<Abs>, Point, Abs); 2], +) -> Point { + if matches!(number_align.y, FixedAlignment::Center if is_multiline) { + // In this case, the center lines (not baselines) of the number frame + // and the equation frame shall be aligned. + return equation.resize( + Size::new(width, equation.height().max(number.height())), + Axes::<FixedAlignment>::new(equation_align, FixedAlignment::Center), + ); + } + + let excess_above = Abs::zero().max({ + if !is_multiline || matches!(number_align.y, FixedAlignment::Start) { + let (.., baseline) = first; + number.baseline() - baseline + } else { + Abs::zero() + } + }); + let excess_below = Abs::zero().max({ + if !is_multiline || matches!(number_align.y, FixedAlignment::End) { + let (size, .., baseline) = last; + (number.height() - number.baseline()) - (size.y - baseline) + } else { + Abs::zero() + } + }); + + // The vertical expansion is asymmetric on the top and bottom edges, so we + // first align at the top then translate the content downward later. + let resizing_offset = equation.resize( + Size::new(width, equation.height() + excess_above + excess_below), + Axes::<FixedAlignment>::new(equation_align, FixedAlignment::Start), + ); + equation.translate(Point::with_y(excess_above)); + resizing_offset + Point::with_y(excess_above) +} + +/// The context for math layout. +struct MathContext<'a, 'v, 'e> { + // External. + engine: &'v mut Engine<'e>, + locator: &'v mut SplitLocator<'a>, + region: Region, + // Font-related. + font: &'a Font, + ttf: &'a ttf_parser::Face<'a>, + table: ttf_parser::math::Table<'a>, + constants: ttf_parser::math::Constants<'a>, + ssty_table: Option<ttf_parser::gsub::AlternateSubstitution<'a>>, + glyphwise_tables: Option<Vec<GlyphwiseSubsts<'a>>>, + space_width: Em, + // Mutable. + fragments: Vec<MathFragment>, +} + +impl<'a, 'v, 'e> MathContext<'a, 'v, 'e> { + /// Create a new math context. + fn new( + engine: &'v mut Engine<'e>, + locator: &'v mut SplitLocator<'a>, + styles: StyleChain<'a>, + base: Size, + font: &'a Font, + ) -> Self { + let math_table = font.ttf().tables().math.unwrap(); + let gsub_table = font.ttf().tables().gsub; + let constants = math_table.constants.unwrap(); + + let ssty_table = gsub_table + .and_then(|gsub| { + gsub.features + .find(ttf_parser::Tag::from_bytes(b"ssty")) + .and_then(|feature| feature.lookup_indices.get(0)) + .and_then(|index| gsub.lookups.get(index)) + }) + .and_then(|ssty| ssty.subtables.get::<SubstitutionSubtable>(0)) + .and_then(|ssty| match ssty { + SubstitutionSubtable::Alternate(alt_glyphs) => Some(alt_glyphs), + _ => None, + }); + + let features = features(styles); + let glyphwise_tables = gsub_table.map(|gsub| { + features + .into_iter() + .filter_map(|feature| GlyphwiseSubsts::new(gsub, feature)) + .collect() + }); + + let ttf = font.ttf(); + let space_width = ttf + .glyph_index(' ') + .and_then(|id| ttf.glyph_hor_advance(id)) + .map(|advance| font.to_em(advance)) + .unwrap_or(THICK); + + Self { + engine, + locator, + region: Region::new(base, Axes::splat(false)), + font, + ttf: font.ttf(), + table: math_table, + constants, + ssty_table, + glyphwise_tables, + space_width, + fragments: vec![], + } + } + + /// Push a fragment. + fn push(&mut self, fragment: impl Into<MathFragment>) { + self.fragments.push(fragment.into()); + } + + /// Push multiple fragments. + fn extend(&mut self, fragments: impl IntoIterator<Item = MathFragment>) { + self.fragments.extend(fragments); + } + + /// Layout the given element and return the result as a [`MathRun`]. + fn layout_into_run( + &mut self, + elem: &Content, + styles: StyleChain, + ) -> SourceResult<MathRun> { + Ok(MathRun::new(self.layout_into_fragments(elem, styles)?)) + } + + /// Layout the given element and return the resulting [`MathFragment`]s. + fn layout_into_fragments( + &mut self, + elem: &Content, + styles: StyleChain, + ) -> SourceResult<Vec<MathFragment>> { + // The element's layout_math() changes the fragments held in this + // MathContext object, but for convenience this function shouldn't change + // them, so we restore the MathContext's fragments after obtaining the + // layout result. + let prev = std::mem::take(&mut self.fragments); + self.layout_into_self(elem, styles)?; + Ok(std::mem::replace(&mut self.fragments, prev)) + } + + /// Layout the given element and return the result as a + /// unified [`MathFragment`]. + fn layout_into_fragment( + &mut self, + elem: &Content, + styles: StyleChain, + ) -> SourceResult<MathFragment> { + Ok(self.layout_into_run(elem, styles)?.into_fragment(self, styles)) + } + + /// Layout the given element and return the result as a [`Frame`]. + fn layout_into_frame( + &mut self, + elem: &Content, + styles: StyleChain, + ) -> SourceResult<Frame> { + Ok(self.layout_into_fragment(elem, styles)?.into_frame()) + } + + /// Layout arbitrary content. + fn layout_into_self( + &mut self, + content: &Content, + styles: StyleChain, + ) -> SourceResult<()> { + let arenas = Arenas::default(); + let pairs = (self.engine.routines.realize)( + RealizationKind::Math, + self.engine, + self.locator, + &arenas, + content, + styles, + )?; + + let outer = styles; + for (elem, styles) in pairs { + // Hack because the font is fixed in math. + if styles != outer && TextElem::font_in(styles) != TextElem::font_in(outer) { + let frame = layout_external(elem, self, styles)?; + self.push(FrameFragment::new(self, styles, frame).with_spaced(true)); + continue; + } + + layout_realized(elem, self, styles)?; + } + + Ok(()) + } +} + +/// Lays out a leaf element resulting from realization. +fn layout_realized( + elem: &Content, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + if let Some(elem) = elem.to_packed::<TagElem>() { + ctx.push(MathFragment::Tag(elem.tag.clone())); + } else if elem.is::<SpaceElem>() { + let font_size = scaled_font_size(ctx, styles); + ctx.push(MathFragment::Space(ctx.space_width.at(font_size))); + } else if elem.is::<LinebreakElem>() { + ctx.push(MathFragment::Linebreak); + } else if let Some(elem) = elem.to_packed::<HElem>() { + layout_h(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::<TextElem>() { + self::text::layout_text(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::<BoxElem>() { + layout_box(elem, ctx, styles)?; + } else if elem.is::<AlignPointElem>() { + ctx.push(MathFragment::Align); + } else if let Some(elem) = elem.to_packed::<ClassElem>() { + layout_class(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::<AccentElem>() { + self::accent::layout_accent(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::<AttachElem>() { + self::attach::layout_attach(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::<PrimesElem>() { + self::attach::layout_primes(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::<ScriptsElem>() { + self::attach::layout_scripts(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::<LimitsElem>() { + self::attach::layout_limits(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::<CancelElem>() { + self::cancel::layout_cancel(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<FracElem>() { + self::frac::layout_frac(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::<BinomElem>() { + self::frac::layout_binom(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::<LrElem>() { + self::lr::layout_lr(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<MidElem>() { + self::lr::layout_mid(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<VecElem>() { + self::mat::layout_vec(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<MatElem>() { + self::mat::layout_mat(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<CasesElem>() { + self::mat::layout_cases(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<OpElem>() { + layout_op(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<RootElem>() { + self::root::layout_root(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<StretchElem>() { + self::stretch::layout_stretch(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<UnderlineElem>() { + self::underover::layout_underline(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<OverlineElem>() { + self::underover::layout_overline(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<UnderbraceElem>() { + self::underover::layout_underbrace(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<OverbraceElem>() { + self::underover::layout_overbrace(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<UnderbracketElem>() { + self::underover::layout_underbracket(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<OverbracketElem>() { + self::underover::layout_overbracket(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<UnderparenElem>() { + self::underover::layout_underparen(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<OverparenElem>() { + self::underover::layout_overparen(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<UndershellElem>() { + self::underover::layout_undershell(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::<OvershellElem>() { + self::underover::layout_overshell(elem, ctx, styles)? + } else { + let mut frame = layout_external(elem, ctx, styles)?; + if !frame.has_baseline() { + let axis = scaled!(ctx, styles, axis_height); + frame.set_baseline(frame.height() / 2.0 + axis); + } + ctx.push( + FrameFragment::new(ctx, styles, frame) + .with_spaced(true) + .with_ignorant(elem.is::<PlaceElem>()), + ); + } + + Ok(()) +} + +/// Lays out an [`BoxElem`]. +fn layout_box( + elem: &Packed<BoxElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let local = TextElem::set_size(TextSize(scaled_font_size(ctx, styles).into())).wrap(); + let frame = (ctx.engine.routines.layout_box)( + elem, + ctx.engine, + ctx.locator.next(&elem.span()), + styles.chain(&local), + ctx.region.size, + )?; + ctx.push(FrameFragment::new(ctx, styles, frame).with_spaced(true)); + Ok(()) +} + +/// Lays out an [`HElem`]. +fn layout_h( + elem: &Packed<HElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + if let Spacing::Rel(rel) = elem.amount() { + if rel.rel.is_zero() { + ctx.push(MathFragment::Spacing( + rel.abs.at(scaled_font_size(ctx, styles)), + elem.weak(styles), + )); + } + } + Ok(()) +} + +/// Lays out a [`ClassElem`]. +#[typst_macros::time(name = "math.op", span = elem.span())] +fn layout_class( + elem: &Packed<ClassElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let class = *elem.class(); + let style = EquationElem::set_class(Some(class)).wrap(); + let mut fragment = ctx.layout_into_fragment(elem.body(), styles.chain(&style))?; + fragment.set_class(class); + fragment.set_limits(Limits::for_class(class)); + ctx.push(fragment); + Ok(()) +} + +/// Lays out an [`OpElem`]. +#[typst_macros::time(name = "math.op", span = elem.span())] +fn layout_op( + elem: &Packed<OpElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let fragment = ctx.layout_into_fragment(elem.text(), styles)?; + let italics = fragment.italics_correction(); + let accent_attach = fragment.accent_attach(); + let text_like = fragment.is_text_like(); + + ctx.push( + FrameFragment::new(ctx, styles, fragment.into_frame()) + .with_class(MathClass::Large) + .with_italics_correction(italics) + .with_accent_attach(accent_attach) + .with_text_like(text_like) + .with_limits(if elem.limits(styles) { + Limits::Display + } else { + Limits::Never + }), + ); + Ok(()) +} + +/// Layout into a frame with normal layout. +fn layout_external( + content: &Content, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<Frame> { + let local = TextElem::set_size(TextSize(scaled_font_size(ctx, styles).into())).wrap(); + (ctx.engine.routines.layout_frame)( + ctx.engine, + content, + ctx.locator.next(&content.span()), + styles.chain(&local), + ctx.region, + ) +} diff --git a/crates/typst-layout/src/math/root.rs b/crates/typst-layout/src/math/root.rs new file mode 100644 index 00000000..0bb2f539 --- /dev/null +++ b/crates/typst-layout/src/math/root.rs @@ -0,0 +1,126 @@ +use typst_library::diag::SourceResult; +use typst_library::foundations::{Packed, StyleChain}; +use typst_library::layout::{Abs, Frame, FrameItem, Point, Size}; +use typst_library::math::{EquationElem, MathSize, RootElem}; +use typst_library::text::TextElem; +use typst_library::visualize::{FixedStroke, Geometry}; + +use super::{style_cramped, FrameFragment, GlyphFragment, MathContext}; + +/// Lays out a [`RootElem`]. +/// +/// TeXbook page 443, page 360 +/// See also: <https://www.w3.org/TR/mathml-core/#radicals-msqrt-mroot> +#[typst_macros::time(name = "math.root", span = elem.span())] +pub fn layout_root( + elem: &Packed<RootElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let index = elem.index(styles); + let radicand = elem.radicand(); + let span = elem.span(); + + let gap = scaled!( + ctx, styles, + text: radical_vertical_gap, + display: radical_display_style_vertical_gap, + ); + let thickness = scaled!(ctx, styles, radical_rule_thickness); + let extra_ascender = scaled!(ctx, styles, radical_extra_ascender); + let kern_before = scaled!(ctx, styles, radical_kern_before_degree); + let kern_after = scaled!(ctx, styles, radical_kern_after_degree); + let raise_factor = percent!(ctx, radical_degree_bottom_raise_percent); + + // Layout radicand. + let radicand = { + let cramped = style_cramped(); + let styles = styles.chain(&cramped); + let run = ctx.layout_into_run(radicand, styles)?; + let multiline = run.is_multiline(); + let mut radicand = run.into_fragment(ctx, styles).into_frame(); + if multiline { + // Align the frame center line with the math axis. + radicand.set_baseline( + radicand.height() / 2.0 + scaled!(ctx, styles, axis_height), + ); + } + radicand + }; + + // Layout root symbol. + let target = radicand.height() + thickness + gap; + let sqrt = GlyphFragment::new(ctx, styles, '√', span) + .stretch_vertical(ctx, target, Abs::zero()) + .frame; + + // Layout the index. + let sscript = EquationElem::set_size(MathSize::ScriptScript).wrap(); + let index = index + .as_ref() + .map(|elem| ctx.layout_into_frame(elem, styles.chain(&sscript))) + .transpose()?; + + // TeXbook, page 443, item 11 + // Keep original gap, and then distribute any remaining free space + // equally above and below. + let gap = gap.max((sqrt.height() - thickness - radicand.height() + gap) / 2.0); + + let sqrt_ascent = radicand.ascent() + gap + thickness; + let descent = sqrt.height() - sqrt_ascent; + let inner_ascent = sqrt_ascent + extra_ascender; + + let mut sqrt_offset = Abs::zero(); + let mut shift_up = Abs::zero(); + let mut ascent = inner_ascent; + + if let Some(index) = &index { + sqrt_offset = kern_before + index.width() + kern_after; + // The formula below for how much raise the index by comes from + // the TeXbook, page 360, in the definition of `\root`. + // However, the `+ index.descent()` part is different from TeX. + // Without it, descenders can collide with the surd, a rarity + // in practice, but possible. MS Word also adjusts index positions + // for descenders. + shift_up = raise_factor * (inner_ascent - descent) + index.descent(); + ascent.set_max(shift_up + index.ascent()); + } + + let radicand_x = sqrt_offset + sqrt.width(); + let radicand_y = ascent - radicand.ascent(); + let width = radicand_x + radicand.width(); + let size = Size::new(width, ascent + descent); + + // The extra "- thickness" comes from the fact that the sqrt is placed + // in `push_frame` with respect to its top, not its baseline. + let sqrt_pos = Point::new(sqrt_offset, radicand_y - gap - thickness); + let line_pos = Point::new(radicand_x, radicand_y - gap - (thickness / 2.0)); + let radicand_pos = Point::new(radicand_x, radicand_y); + + let mut frame = Frame::soft(size); + frame.set_baseline(ascent); + + if let Some(index) = index { + let index_pos = Point::new(kern_before, ascent - index.ascent() - shift_up); + frame.push_frame(index_pos, index); + } + + frame.push_frame(sqrt_pos, sqrt); + frame.push( + line_pos, + FrameItem::Shape( + Geometry::Line(Point::with_x(radicand.width())).stroked( + FixedStroke::from_pair( + TextElem::fill_in(styles).as_decoration(), + thickness, + ), + ), + span, + ), + ); + + frame.push_frame(radicand_pos, radicand); + ctx.push(FrameFragment::new(ctx, styles, frame)); + + Ok(()) +} diff --git a/crates/typst-layout/src/math/run.rs b/crates/typst-layout/src/math/run.rs new file mode 100644 index 00000000..8f12c509 --- /dev/null +++ b/crates/typst-layout/src/math/run.rs @@ -0,0 +1,469 @@ +use std::iter::once; + +use typst_library::foundations::{Resolve, StyleChain}; +use typst_library::layout::{Abs, AlignElem, Em, Frame, InlineItem, Point, Size}; +use typst_library::math::{EquationElem, MathSize, MEDIUM, THICK, THIN}; +use typst_library::model::ParElem; +use unicode_math_class::MathClass; + +use super::{alignments, scaled_font_size, FrameFragment, MathContext, MathFragment}; + +const TIGHT_LEADING: Em = Em::new(0.25); + +/// A linear collection of [`MathFragment`]s. +#[derive(Debug, Default, Clone)] +pub struct MathRun(Vec<MathFragment>); + +impl MathRun { + /// Takes the given [`MathFragment`]s and do some basic processing. + pub fn new(fragments: Vec<MathFragment>) -> Self { + let iter = fragments.into_iter().peekable(); + let mut last: Option<usize> = None; + let mut space: Option<MathFragment> = None; + let mut resolved: Vec<MathFragment> = vec![]; + + for mut fragment in iter { + match fragment { + // Keep space only if supported by spaced fragments. + MathFragment::Space(_) => { + if last.is_some() { + space = Some(fragment); + } + continue; + } + + // Explicit spacing disables automatic spacing. + MathFragment::Spacing(width, weak) => { + last = None; + space = None; + + if weak { + match resolved.last_mut() { + None => continue, + Some(MathFragment::Spacing(prev, true)) => { + *prev = (*prev).max(width); + continue; + } + Some(_) => {} + } + } + + resolved.push(fragment); + continue; + } + + // Alignment points are resolved later. + MathFragment::Align => { + resolved.push(fragment); + continue; + } + + // New line, new things. + MathFragment::Linebreak => { + resolved.push(fragment); + space = None; + last = None; + continue; + } + + _ => {} + } + + // Convert variable operators into binary operators if something + // precedes them and they are not preceded by a operator or comparator. + if fragment.class() == MathClass::Vary + && matches!( + last.map(|i| resolved[i].class()), + Some( + MathClass::Normal + | MathClass::Alphabetic + | MathClass::Closing + | MathClass::Fence + ) + ) + { + fragment.set_class(MathClass::Binary); + } + + // Insert spacing between the last and this non-ignorant item. + if !fragment.is_ignorant() { + if let Some(i) = last { + if let Some(s) = spacing(&resolved[i], space.take(), &fragment) { + resolved.insert(i + 1, s); + } + } + + last = Some(resolved.len()); + } + + resolved.push(fragment); + } + + if let Some(MathFragment::Spacing(_, true)) = resolved.last() { + resolved.pop(); + } + + Self(resolved) + } + + pub fn iter(&self) -> std::slice::Iter<'_, MathFragment> { + self.0.iter() + } + + /// Split by linebreaks, and copy [`MathFragment`]s into rows. + pub fn rows(&self) -> Vec<Self> { + self.0 + .split(|frag| matches!(frag, MathFragment::Linebreak)) + .map(|slice| Self(slice.to_vec())) + .collect() + } + + pub fn row_count(&self) -> usize { + let mut count = + 1 + self.0.iter().filter(|f| matches!(f, MathFragment::Linebreak)).count(); + + // A linebreak at the very end does not introduce an extra row. + if let Some(f) = self.0.last() { + if matches!(f, MathFragment::Linebreak) { + count -= 1 + } + } + count + } + + pub fn ascent(&self) -> Abs { + self.iter() + .filter(|e| affects_row_height(e)) + .map(|e| e.ascent()) + .max() + .unwrap_or_default() + } + + pub fn descent(&self) -> Abs { + self.iter() + .filter(|e| affects_row_height(e)) + .map(|e| e.descent()) + .max() + .unwrap_or_default() + } + + pub fn class(&self) -> MathClass { + // Predict the class of the output of 'into_fragment' + if self.0.len() == 1 { + self.0 + .first() + .map(|fragment| fragment.class()) + .unwrap_or(MathClass::Normal) + } else { + // FrameFragment::new() (inside 'into_fragment' in this branch) defaults + // to MathClass::Normal for its class. + MathClass::Normal + } + } + + pub fn into_frame(self, ctx: &MathContext, styles: StyleChain) -> Frame { + if !self.is_multiline() { + self.into_line_frame(&[], LeftRightAlternator::Right) + } else { + self.multiline_frame_builder(ctx, styles).build() + } + } + + pub fn into_fragment(self, ctx: &MathContext, styles: StyleChain) -> MathFragment { + if self.0.len() == 1 { + return self.0.into_iter().next().unwrap(); + } + + // Fragments without a math_size are ignored: the notion of size do not + // apply to them, so their text-likeness is meaningless. + let text_like = self + .iter() + .filter(|e| e.math_size().is_some()) + .all(|e| e.is_text_like()); + + FrameFragment::new(ctx, styles, self.into_frame(ctx, styles)) + .with_text_like(text_like) + .into() + } + + /// Returns a builder that lays out the [`MathFragment`]s into a possibly + /// multi-row [`Frame`]. The rows are aligned using the same set of alignment + /// points computed from them as a whole. + pub fn multiline_frame_builder( + self, + ctx: &MathContext, + styles: StyleChain, + ) -> MathRunFrameBuilder { + let rows: Vec<_> = self.rows(); + let row_count = rows.len(); + let alignments = alignments(&rows); + + let leading = if EquationElem::size_in(styles) >= MathSize::Text { + ParElem::leading_in(styles) + } else { + let font_size = scaled_font_size(ctx, styles); + TIGHT_LEADING.at(font_size) + }; + + let align = AlignElem::alignment_in(styles).resolve(styles).x; + let mut frames: Vec<(Frame, Point)> = vec![]; + let mut size = Size::zero(); + for (i, row) in rows.into_iter().enumerate() { + if i == row_count - 1 && row.0.is_empty() { + continue; + } + + let sub = row.into_line_frame(&alignments.points, LeftRightAlternator::Right); + if i > 0 { + size.y += leading; + } + + let mut pos = Point::with_y(size.y); + if alignments.points.is_empty() { + pos.x = align.position(alignments.width - sub.width()); + } + size.x.set_max(sub.width()); + size.y += sub.height(); + frames.push((sub, pos)); + } + + MathRunFrameBuilder { size, frames } + } + + /// Lay out [`MathFragment`]s into a one-row [`Frame`], using the + /// caller-provided alignment points. + pub fn into_line_frame( + self, + points: &[Abs], + mut alternator: LeftRightAlternator, + ) -> Frame { + let ascent = self.ascent(); + let mut frame = Frame::soft(Size::new(Abs::zero(), ascent + self.descent())); + frame.set_baseline(ascent); + + let mut next_x = { + let widths: Vec<Abs> = if points.is_empty() { + vec![] + } else { + self.iter() + .as_slice() + .split(|e| matches!(e, MathFragment::Align)) + .map(|chunk| chunk.iter().map(|e| e.width()).sum()) + .collect() + }; + + let mut prev_points = once(Abs::zero()).chain(points.iter().copied()); + let mut point_widths = points.iter().copied().zip(widths); + move || { + point_widths + .next() + .zip(prev_points.next()) + .zip(alternator.next()) + .map(|(((point, width), prev_point), alternator)| match alternator { + LeftRightAlternator::Right => point - width, + _ => prev_point, + }) + } + }; + let mut x = next_x().unwrap_or_default(); + + for fragment in self.0.into_iter() { + if matches!(fragment, MathFragment::Align) { + x = next_x().unwrap_or(x); + continue; + } + + let y = ascent - fragment.ascent(); + let pos = Point::new(x, y); + x += fragment.width(); + frame.push_frame(pos, fragment.into_frame()); + } + + frame.size_mut().x = x; + frame + } + + pub fn into_par_items(self) -> Vec<InlineItem> { + let mut items = vec![]; + + let mut x = Abs::zero(); + let mut ascent = Abs::zero(); + let mut descent = Abs::zero(); + let mut frame = Frame::soft(Size::zero()); + let mut empty = true; + + let finalize_frame = |frame: &mut Frame, x, ascent, descent| { + frame.set_size(Size::new(x, ascent + descent)); + frame.set_baseline(Abs::zero()); + frame.translate(Point::with_y(ascent)); + }; + + let mut space_is_visible = false; + + let is_relation = |f: &MathFragment| matches!(f.class(), MathClass::Relation); + let is_space = |f: &MathFragment| { + matches!(f, MathFragment::Space(_) | MathFragment::Spacing(_, _)) + }; + + let mut iter = self.0.into_iter().peekable(); + while let Some(fragment) = iter.next() { + if space_is_visible { + match fragment { + MathFragment::Space(width) | MathFragment::Spacing(width, _) => { + items.push(InlineItem::Space(width, true)); + continue; + } + _ => {} + } + } + + let class = fragment.class(); + let y = fragment.ascent(); + + ascent.set_max(y); + descent.set_max(fragment.descent()); + + let pos = Point::new(x, -y); + x += fragment.width(); + frame.push_frame(pos, fragment.into_frame()); + empty = false; + + if class == MathClass::Binary + || (class == MathClass::Relation + && !iter.peek().map(is_relation).unwrap_or_default()) + { + let mut frame_prev = + std::mem::replace(&mut frame, Frame::soft(Size::zero())); + + finalize_frame(&mut frame_prev, x, ascent, descent); + items.push(InlineItem::Frame(frame_prev)); + empty = true; + + x = Abs::zero(); + ascent = Abs::zero(); + descent = Abs::zero(); + + space_is_visible = true; + if let Some(f_next) = iter.peek() { + if !is_space(f_next) { + items.push(InlineItem::Space(Abs::zero(), true)); + } + } + } else { + space_is_visible = false; + } + } + + // Don't use `frame.is_empty()` because even an empty frame can + // contribute width (if it had hidden content). + if !empty { + finalize_frame(&mut frame, x, ascent, descent); + items.push(InlineItem::Frame(frame)); + } + + items + } + + pub fn is_multiline(&self) -> bool { + self.iter().any(|frag| matches!(frag, MathFragment::Linebreak)) + } +} + +impl<T: Into<MathFragment>> From<T> for MathRun { + fn from(fragment: T) -> Self { + Self(vec![fragment.into()]) + } +} + +/// An iterator that alternates between the `Left` and `Right` values, if the +/// initial value is not `None`. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum LeftRightAlternator { + None, + Left, + Right, +} + +impl Iterator for LeftRightAlternator { + type Item = LeftRightAlternator; + + fn next(&mut self) -> Option<Self::Item> { + let r = Some(*self); + match self { + Self::None => {} + Self::Left => *self = Self::Right, + Self::Right => *self = Self::Left, + } + r + } +} + +/// How the rows from the [`MathRun`] should be aligned and merged into a [`Frame`]. +pub struct MathRunFrameBuilder { + /// The size of the resulting frame. + pub size: Size, + /// Each row's frame, and the position where the frame should + /// be pushed into the resulting frame. + pub frames: Vec<(Frame, Point)>, +} + +impl MathRunFrameBuilder { + /// Consumes the builder and returns a [`Frame`]. + pub fn build(self) -> Frame { + let mut frame = Frame::soft(self.size); + for (sub, pos) in self.frames.into_iter() { + frame.push_frame(pos, sub); + } + frame + } +} + +fn affects_row_height(fragment: &MathFragment) -> bool { + !matches!(fragment, MathFragment::Align | MathFragment::Linebreak) +} + +/// Create the spacing between two fragments in a given style. +fn spacing( + l: &MathFragment, + space: Option<MathFragment>, + r: &MathFragment, +) -> Option<MathFragment> { + use MathClass::*; + + let resolve = |v: Em, size_ref: &MathFragment| -> Option<MathFragment> { + let width = size_ref.font_size().map_or(Abs::zero(), |size| v.at(size)); + Some(MathFragment::Spacing(width, false)) + }; + let script = |f: &MathFragment| f.math_size().is_some_and(|s| s <= MathSize::Script); + + match (l.class(), r.class()) { + // No spacing before punctuation; thin spacing after punctuation, unless + // in script size. + (_, Punctuation) => None, + (Punctuation, _) if !script(l) => resolve(THIN, l), + + // No spacing after opening delimiters and before closing delimiters. + (Opening, _) | (_, Closing) => None, + + // Thick spacing around relations, unless followed by a another relation + // or in script size. + (Relation, Relation) => None, + (Relation, _) if !script(l) => resolve(THICK, l), + (_, Relation) if !script(r) => resolve(THICK, r), + + // Medium spacing around binary operators, unless in script size. + (Binary, _) if !script(l) => resolve(MEDIUM, l), + (_, Binary) if !script(r) => resolve(MEDIUM, r), + + // Thin spacing around large operators, unless to the left of + // an opening delimiter. TeXBook, p170 + (Large, Opening | Fence) => None, + (Large, _) => resolve(THIN, l), + (_, Large) => resolve(THIN, r), + + // Spacing around spaced frames. + _ if (l.is_spaced() || r.is_spaced()) => space, + + _ => None, + } +} diff --git a/crates/typst-layout/src/math/shared.rs b/crates/typst-layout/src/math/shared.rs new file mode 100644 index 00000000..13477c10 --- /dev/null +++ b/crates/typst-layout/src/math/shared.rs @@ -0,0 +1,207 @@ +use ttf_parser::math::MathValue; +use typst_library::foundations::{Style, StyleChain}; +use typst_library::layout::{Abs, Em, FixedAlignment, Frame, Point, Size, VAlignment}; +use typst_library::math::{EquationElem, MathSize}; +use typst_library::text::TextElem; +use typst_utils::LazyHash; + +use super::{LeftRightAlternator, MathContext, MathFragment, MathRun}; + +macro_rules! scaled { + ($ctx:expr, $styles:expr, text: $text:ident, display: $display:ident $(,)?) => { + match typst_library::math::EquationElem::size_in($styles) { + typst_library::math::MathSize::Display => scaled!($ctx, $styles, $display), + _ => scaled!($ctx, $styles, $text), + } + }; + ($ctx:expr, $styles:expr, $name:ident) => { + $crate::math::Scaled::scaled( + $ctx.constants.$name(), + $ctx, + $crate::math::scaled_font_size($ctx, $styles), + ) + }; +} + +macro_rules! percent { + ($ctx:expr, $name:ident) => { + $ctx.constants.$name() as f64 / 100.0 + }; +} + +/// How much less high scaled delimiters can be than what they wrap. +pub const DELIM_SHORT_FALL: Em = Em::new(0.1); + +/// Converts some unit to an absolute length with the current font & font size. +pub trait Scaled { + fn scaled(self, ctx: &MathContext, font_size: Abs) -> Abs; +} + +impl Scaled for i16 { + fn scaled(self, ctx: &MathContext, font_size: Abs) -> Abs { + ctx.font.to_em(self).at(font_size) + } +} + +impl Scaled for u16 { + fn scaled(self, ctx: &MathContext, font_size: Abs) -> Abs { + ctx.font.to_em(self).at(font_size) + } +} + +impl Scaled for MathValue<'_> { + fn scaled(self, ctx: &MathContext, font_size: Abs) -> Abs { + self.value.scaled(ctx, font_size) + } +} + +/// Get the font size scaled with the `MathSize`. +pub fn scaled_font_size(ctx: &MathContext, styles: StyleChain) -> Abs { + let factor = match EquationElem::size_in(styles) { + MathSize::Display | MathSize::Text => 1.0, + MathSize::Script => percent!(ctx, script_percent_scale_down), + MathSize::ScriptScript => percent!(ctx, script_script_percent_scale_down), + }; + factor * TextElem::size_in(styles) +} + +/// Styles something as cramped. +pub fn style_cramped() -> LazyHash<Style> { + EquationElem::set_cramped(true).wrap() +} + +/// The style for subscripts in the current style. +pub fn style_for_subscript(styles: StyleChain) -> [LazyHash<Style>; 2] { + [style_for_superscript(styles), EquationElem::set_cramped(true).wrap()] +} + +/// The style for superscripts in the current style. +pub fn style_for_superscript(styles: StyleChain) -> LazyHash<Style> { + EquationElem::set_size(match EquationElem::size_in(styles) { + MathSize::Display | MathSize::Text => MathSize::Script, + MathSize::Script | MathSize::ScriptScript => MathSize::ScriptScript, + }) + .wrap() +} + +/// The style for numerators in the current style. +pub fn style_for_numerator(styles: StyleChain) -> LazyHash<Style> { + EquationElem::set_size(match EquationElem::size_in(styles) { + MathSize::Display => MathSize::Text, + MathSize::Text => MathSize::Script, + MathSize::Script | MathSize::ScriptScript => MathSize::ScriptScript, + }) + .wrap() +} + +/// The style for denominators in the current style. +pub fn style_for_denominator(styles: StyleChain) -> [LazyHash<Style>; 2] { + [style_for_numerator(styles), EquationElem::set_cramped(true).wrap()] +} + +/// How a delimieter should be aligned when scaling. +pub fn delimiter_alignment(delimiter: char) -> VAlignment { + match delimiter { + '⌜' | '⌝' => VAlignment::Top, + '⌞' | '⌟' => VAlignment::Bottom, + _ => VAlignment::Horizon, + } +} + +/// Stack rows on top of each other. +/// +/// Add a `gap` between each row and uses the baseline of the `baseline`-th +/// row for the whole frame. `alternator` controls the left/right alternating +/// alignment behavior of `AlignPointElem` in the rows. +pub fn stack( + rows: Vec<MathRun>, + align: FixedAlignment, + gap: Abs, + baseline: usize, + alternator: LeftRightAlternator, + minimum_ascent_descent: Option<(Abs, Abs)>, +) -> Frame { + let rows: Vec<_> = rows.into_iter().flat_map(|r| r.rows()).collect(); + let AlignmentResult { points, width } = alignments(&rows); + let rows: Vec<_> = rows + .into_iter() + .map(|row| row.into_line_frame(&points, alternator)) + .collect(); + + let padded_height = |height: Abs| { + height.max(minimum_ascent_descent.map_or(Abs::zero(), |(a, d)| a + d)) + }; + + let mut frame = Frame::soft(Size::new( + width, + rows.iter().map(|row| padded_height(row.height())).sum::<Abs>() + + rows.len().saturating_sub(1) as f64 * gap, + )); + + let mut y = Abs::zero(); + for (i, row) in rows.into_iter().enumerate() { + let x = if points.is_empty() { + align.position(width - row.width()) + } else { + Abs::zero() + }; + let ascent_padded_part = minimum_ascent_descent + .map_or(Abs::zero(), |(a, _)| (a - row.ascent())) + .max(Abs::zero()); + let pos = Point::new(x, y + ascent_padded_part); + if i == baseline { + frame.set_baseline(y + row.baseline() + ascent_padded_part); + } + y += padded_height(row.height()) + gap; + frame.push_frame(pos, row); + } + + frame +} + +/// Determine the positions of the alignment points, according to the input rows combined. +pub fn alignments(rows: &[MathRun]) -> AlignmentResult { + let mut widths = Vec::<Abs>::new(); + + let mut pending_width = Abs::zero(); + for row in rows { + let mut width = Abs::zero(); + let mut alignment_index = 0; + + for fragment in row.iter() { + if matches!(fragment, MathFragment::Align) { + if alignment_index < widths.len() { + widths[alignment_index].set_max(width); + } else { + widths.push(width.max(pending_width)); + } + width = Abs::zero(); + alignment_index += 1; + } else { + width += fragment.width(); + } + } + if widths.is_empty() { + pending_width.set_max(width); + } else if alignment_index < widths.len() { + widths[alignment_index].set_max(width); + } else { + widths.push(width.max(pending_width)); + } + } + + let mut points = widths; + for i in 1..points.len() { + let prev = points[i - 1]; + points[i] += prev; + } + AlignmentResult { + width: points.last().copied().unwrap_or(pending_width), + points, + } +} + +pub struct AlignmentResult { + pub points: Vec<Abs>, + pub width: Abs, +} diff --git a/crates/typst-layout/src/math/stretch.rs b/crates/typst-layout/src/math/stretch.rs new file mode 100644 index 00000000..6dc82014 --- /dev/null +++ b/crates/typst-layout/src/math/stretch.rs @@ -0,0 +1,291 @@ +use ttf_parser::math::{GlyphAssembly, GlyphConstruction, GlyphPart}; +use ttf_parser::LazyArray16; +use typst_library::diag::SourceResult; +use typst_library::foundations::{Packed, Smart, StyleChain}; +use typst_library::layout::{Abs, Axis, Frame, Length, Point, Rel, Size}; +use typst_library::math::StretchElem; +use typst_utils::Get; + +use super::{ + delimiter_alignment, scaled_font_size, GlyphFragment, MathContext, MathFragment, + Scaled, VariantFragment, +}; + +/// Maximum number of times extenders can be repeated. +const MAX_REPEATS: usize = 1024; + +/// Lays out a [`StretchElem`]. +#[typst_macros::time(name = "math.stretch", span = elem.span())] +pub fn layout_stretch( + elem: &Packed<StretchElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let mut fragment = ctx.layout_into_fragment(elem.body(), styles)?; + stretch_fragment( + ctx, + styles, + &mut fragment, + None, + None, + elem.size(styles), + Abs::zero(), + ); + ctx.push(fragment); + Ok(()) +} + +/// Attempts to stretch the given fragment by/to the amount given in stretch. +pub fn stretch_fragment( + ctx: &mut MathContext, + styles: StyleChain, + fragment: &mut MathFragment, + axis: Option<Axis>, + relative_to: Option<Abs>, + stretch: Smart<Rel<Length>>, + short_fall: Abs, +) { + let glyph = match fragment { + MathFragment::Glyph(glyph) => glyph.clone(), + MathFragment::Variant(variant) => { + GlyphFragment::new(ctx, styles, variant.c, variant.span) + } + _ => return, + }; + + // Return if we attempt to stretch along an axis which isn't stretchable, + // so that the original fragment isn't modified. + let Some(stretch_axis) = stretch_axis(ctx, &glyph) else { return }; + let axis = axis.unwrap_or(stretch_axis); + if axis != stretch_axis { + return; + } + + let relative_to_size = relative_to.unwrap_or_else(|| fragment.size().get(axis)); + + let mut variant = stretch_glyph( + ctx, + glyph, + stretch + .unwrap_or(Rel::one()) + .at(scaled_font_size(ctx, styles)) + .relative_to(relative_to_size), + short_fall, + axis, + ); + + if axis == Axis::Y { + variant.align_on_axis(ctx, delimiter_alignment(variant.c)); + } + + *fragment = MathFragment::Variant(variant); +} + +/// Try to stretch a glyph to a desired width or height. +/// +/// The resulting frame may not have the exact desired width. +pub fn stretch_glyph( + ctx: &MathContext, + mut base: GlyphFragment, + target: Abs, + short_fall: Abs, + axis: Axis, +) -> VariantFragment { + // If the base glyph is good enough, use it. + let advance = match axis { + Axis::X => base.width, + Axis::Y => base.height(), + }; + let short_target = target - short_fall; + if short_target <= advance { + return base.into_variant(); + } + + let mut min_overlap = Abs::zero(); + let construction = ctx + .table + .variants + .and_then(|variants| { + min_overlap = variants.min_connector_overlap.scaled(ctx, base.font_size); + match axis { + Axis::X => variants.horizontal_constructions, + Axis::Y => variants.vertical_constructions, + } + .get(base.id) + }) + .unwrap_or(GlyphConstruction { assembly: None, variants: LazyArray16::new(&[]) }); + + // Search for a pre-made variant with a good advance. + let mut best_id = base.id; + let mut best_advance = base.width; + for variant in construction.variants { + best_id = variant.variant_glyph; + best_advance = base.font.to_em(variant.advance_measurement).at(base.font_size); + if short_target <= best_advance { + break; + } + } + + // This is either good or the best we've got. + if short_target <= best_advance || construction.assembly.is_none() { + base.set_id(ctx, best_id); + return base.into_variant(); + } + + // Assemble from parts. + let assembly = construction.assembly.unwrap(); + assemble(ctx, base, assembly, min_overlap, target, axis) +} + +/// Return whether the glyph is stretchable and if it is, along which axis it +/// can be stretched. +fn stretch_axis(ctx: &MathContext, base: &GlyphFragment) -> Option<Axis> { + let base_id = base.id; + let vertical = ctx + .table + .variants + .and_then(|variants| variants.vertical_constructions.get(base_id)) + .map(|_| Axis::Y); + let horizontal = ctx + .table + .variants + .and_then(|variants| variants.horizontal_constructions.get(base_id)) + .map(|_| Axis::X); + + match (vertical, horizontal) { + (vertical, None) => vertical, + (None, horizontal) => horizontal, + _ => { + // As far as we know, there aren't any glyphs that have both + // vertical and horizontal constructions. So for the time being, we + // will assume that a glyph cannot have both. + panic!("glyph {:?} has both vertical and horizontal constructions", base.c); + } + } +} + +/// Assemble a glyph from parts. +fn assemble( + ctx: &MathContext, + base: GlyphFragment, + assembly: GlyphAssembly, + min_overlap: Abs, + target: Abs, + axis: Axis, +) -> VariantFragment { + // Determine the number of times the extenders need to be repeated as well + // as a ratio specifying how much to spread the parts apart + // (0 = maximal overlap, 1 = minimal overlap). + let mut full; + let mut ratio; + let mut repeat = 0; + loop { + full = Abs::zero(); + ratio = 0.0; + + let mut parts = parts(assembly, repeat).peekable(); + let mut growable = Abs::zero(); + + while let Some(part) = parts.next() { + let mut advance = part.full_advance.scaled(ctx, base.font_size); + if let Some(next) = parts.peek() { + let max_overlap = part + .end_connector_length + .min(next.start_connector_length) + .scaled(ctx, base.font_size); + + advance -= max_overlap; + growable += max_overlap - min_overlap; + } + + full += advance; + } + + if full < target { + let delta = target - full; + ratio = (delta / growable).min(1.0); + full += ratio * growable; + } + + if target <= full || repeat >= MAX_REPEATS { + break; + } + + repeat += 1; + } + + let mut selected = vec![]; + let mut parts = parts(assembly, repeat).peekable(); + while let Some(part) = parts.next() { + let mut advance = part.full_advance.scaled(ctx, base.font_size); + if let Some(next) = parts.peek() { + let max_overlap = part + .end_connector_length + .min(next.start_connector_length) + .scaled(ctx, base.font_size); + advance -= max_overlap; + advance += ratio * (max_overlap - min_overlap); + } + + let mut fragment = base.clone(); + fragment.set_id(ctx, part.glyph_id); + selected.push((fragment, advance)); + } + + let size; + let baseline; + match axis { + Axis::X => { + let height = base.ascent + base.descent; + size = Size::new(full, height); + baseline = base.ascent; + } + Axis::Y => { + let axis = ctx.constants.axis_height().scaled(ctx, base.font_size); + let width = selected.iter().map(|(f, _)| f.width).max().unwrap_or_default(); + size = Size::new(width, full); + baseline = full / 2.0 + axis; + } + } + + let mut frame = Frame::soft(size); + let mut offset = Abs::zero(); + frame.set_baseline(baseline); + frame.post_process_raw(base.dests, base.hidden); + + for (fragment, advance) in selected { + let pos = match axis { + Axis::X => Point::new(offset, frame.baseline() - fragment.ascent), + Axis::Y => Point::with_y(full - offset - fragment.height()), + }; + frame.push_frame(pos, fragment.into_frame()); + offset += advance; + } + + let accent_attach = match axis { + Axis::X => frame.width() / 2.0, + Axis::Y => base.accent_attach, + }; + + VariantFragment { + c: base.c, + frame, + font_size: base.font_size, + italics_correction: Abs::zero(), + accent_attach, + class: base.class, + math_size: base.math_size, + span: base.span, + limits: base.limits, + mid_stretched: None, + } +} + +/// Return an iterator over the assembly's parts with extenders repeated the +/// specified number of times. +fn parts(assembly: GlyphAssembly, repeat: usize) -> impl Iterator<Item = GlyphPart> + '_ { + assembly.parts.into_iter().flat_map(move |part| { + let count = if part.part_flags.extender() { repeat } else { 1 }; + std::iter::repeat(part).take(count) + }) +} diff --git a/crates/typst-layout/src/math/text.rs b/crates/typst-layout/src/math/text.rs new file mode 100644 index 00000000..df80b45a --- /dev/null +++ b/crates/typst-layout/src/math/text.rs @@ -0,0 +1,344 @@ +use std::f64::consts::SQRT_2; + +use ecow::{eco_vec, EcoString}; +use typst_library::diag::SourceResult; +use typst_library::foundations::{Packed, StyleChain, StyleVec}; +use typst_library::layout::{Abs, Size}; +use typst_library::math::{EquationElem, MathSize, MathVariant}; +use typst_library::text::{ + BottomEdge, BottomEdgeMetric, TextElem, TextSize, TopEdge, TopEdgeMetric, +}; +use typst_syntax::{is_newline, Span}; +use unicode_math_class::MathClass; +use unicode_segmentation::UnicodeSegmentation; + +use super::{ + scaled_font_size, FrameFragment, GlyphFragment, MathContext, MathFragment, MathRun, +}; + +/// Lays out a [`TextElem`]. +pub fn layout_text( + elem: &Packed<TextElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let text = elem.text(); + let span = elem.span(); + let mut chars = text.chars(); + let math_size = EquationElem::size_in(styles); + + let fragment: MathFragment = if let Some(mut glyph) = chars + .next() + .filter(|_| chars.next().is_none()) + .map(|c| styled_char(styles, c, true)) + .and_then(|c| GlyphFragment::try_new(ctx, styles, c, span)) + { + // A single letter that is available in the math font. + match math_size { + MathSize::Script => { + glyph.make_scriptsize(ctx); + } + MathSize::ScriptScript => { + glyph.make_scriptscriptsize(ctx); + } + _ => (), + } + + if glyph.class == MathClass::Large { + let mut variant = if math_size == MathSize::Display { + let height = scaled!(ctx, styles, display_operator_min_height) + .max(SQRT_2 * glyph.height()); + glyph.stretch_vertical(ctx, height, Abs::zero()) + } else { + glyph.into_variant() + }; + // TeXbook p 155. Large operators are always vertically centered on the axis. + variant.center_on_axis(ctx); + variant.into() + } else { + glyph.into() + } + } else if text.chars().all(|c| c.is_ascii_digit() || c == '.') { + // Numbers aren't that difficult. + let mut fragments = vec![]; + for c in text.chars() { + let c = styled_char(styles, c, false); + fragments.push(GlyphFragment::new(ctx, styles, c, span).into()); + } + let frame = MathRun::new(fragments).into_frame(ctx, styles); + FrameFragment::new(ctx, styles, frame).with_text_like(true).into() + } else { + let local = [ + TextElem::set_top_edge(TopEdge::Metric(TopEdgeMetric::Bounds)), + TextElem::set_bottom_edge(BottomEdge::Metric(BottomEdgeMetric::Bounds)), + TextElem::set_size(TextSize(scaled_font_size(ctx, styles).into())), + ] + .map(|p| p.wrap()); + + // Anything else is handled by Typst's standard text layout. + let styles = styles.chain(&local); + let text: EcoString = + text.chars().map(|c| styled_char(styles, c, false)).collect(); + if text.contains(is_newline) { + let mut fragments = vec![]; + for (i, piece) in text.split(is_newline).enumerate() { + if i != 0 { + fragments.push(MathFragment::Linebreak); + } + if !piece.is_empty() { + fragments.push(layout_complex_text(piece, ctx, span, styles)?.into()); + } + } + let mut frame = MathRun::new(fragments).into_frame(ctx, styles); + let axis = scaled!(ctx, styles, axis_height); + frame.set_baseline(frame.height() / 2.0 + axis); + FrameFragment::new(ctx, styles, frame).into() + } else { + layout_complex_text(&text, ctx, span, styles)?.into() + } + }; + + ctx.push(fragment); + Ok(()) +} + +/// Layout the given text string into a [`FrameFragment`]. +fn layout_complex_text( + text: &str, + ctx: &mut MathContext, + span: Span, + styles: StyleChain, +) -> SourceResult<FrameFragment> { + // There isn't a natural width for a paragraph in a math environment; + // because it will be placed somewhere probably not at the left margin + // it will overflow. So emulate an `hbox` instead and allow the paragraph + // to extend as far as needed. + let spaced = text.graphemes(true).nth(1).is_some(); + let elem = TextElem::packed(text).spanned(span); + let frame = (ctx.engine.routines.layout_inline)( + ctx.engine, + &StyleVec::wrap(eco_vec![elem]), + ctx.locator.next(&span), + styles, + false, + Size::splat(Abs::inf()), + false, + )? + .into_frame(); + + Ok(FrameFragment::new(ctx, styles, frame) + .with_class(MathClass::Alphabetic) + .with_text_like(true) + .with_spaced(spaced)) +} + +/// Select the correct styled math letter. +/// +/// <https://www.w3.org/TR/mathml-core/#new-text-transform-mappings> +/// <https://en.wikipedia.org/wiki/Mathematical_Alphanumeric_Symbols> +fn styled_char(styles: StyleChain, c: char, auto_italic: bool) -> char { + use MathVariant::*; + + let variant = EquationElem::variant_in(styles); + let bold = EquationElem::bold_in(styles); + let italic = EquationElem::italic_in(styles).unwrap_or( + auto_italic + && matches!( + c, + 'a'..='z' | 'ı' | 'ȷ' | 'A'..='Z' | 'α'..='ω' | + '∂' | 'ϵ' | 'ϑ' | 'ϰ' | 'ϕ' | 'ϱ' | 'ϖ' + ) + && matches!(variant, Sans | Serif), + ); + + if let Some(c) = basic_exception(c) { + return c; + } + + if let Some(c) = latin_exception(c, variant, bold, italic) { + return c; + } + + if let Some(c) = greek_exception(c, variant, bold, italic) { + return c; + } + + let base = match c { + 'A'..='Z' => 'A', + 'a'..='z' => 'a', + 'Α'..='Ω' => 'Α', + 'α'..='ω' => 'α', + '0'..='9' => '0', + // Hebrew Alef -> Dalet. + '\u{05D0}'..='\u{05D3}' => '\u{05D0}', + _ => return c, + }; + + let tuple = (variant, bold, italic); + let start = match c { + // Latin upper. + 'A'..='Z' => match tuple { + (Serif, false, false) => 0x0041, + (Serif, true, false) => 0x1D400, + (Serif, false, true) => 0x1D434, + (Serif, true, true) => 0x1D468, + (Sans, false, false) => 0x1D5A0, + (Sans, true, false) => 0x1D5D4, + (Sans, false, true) => 0x1D608, + (Sans, true, true) => 0x1D63C, + (Cal, false, _) => 0x1D49C, + (Cal, true, _) => 0x1D4D0, + (Frak, false, _) => 0x1D504, + (Frak, true, _) => 0x1D56C, + (Mono, _, _) => 0x1D670, + (Bb, _, _) => 0x1D538, + }, + + // Latin lower. + 'a'..='z' => match tuple { + (Serif, false, false) => 0x0061, + (Serif, true, false) => 0x1D41A, + (Serif, false, true) => 0x1D44E, + (Serif, true, true) => 0x1D482, + (Sans, false, false) => 0x1D5BA, + (Sans, true, false) => 0x1D5EE, + (Sans, false, true) => 0x1D622, + (Sans, true, true) => 0x1D656, + (Cal, false, _) => 0x1D4B6, + (Cal, true, _) => 0x1D4EA, + (Frak, false, _) => 0x1D51E, + (Frak, true, _) => 0x1D586, + (Mono, _, _) => 0x1D68A, + (Bb, _, _) => 0x1D552, + }, + + // Greek upper. + 'Α'..='Ω' => match tuple { + (Serif, false, false) => 0x0391, + (Serif, true, false) => 0x1D6A8, + (Serif, false, true) => 0x1D6E2, + (Serif, true, true) => 0x1D71C, + (Sans, _, false) => 0x1D756, + (Sans, _, true) => 0x1D790, + (Cal | Frak | Mono | Bb, _, _) => return c, + }, + + // Greek lower. + 'α'..='ω' => match tuple { + (Serif, false, false) => 0x03B1, + (Serif, true, false) => 0x1D6C2, + (Serif, false, true) => 0x1D6FC, + (Serif, true, true) => 0x1D736, + (Sans, _, false) => 0x1D770, + (Sans, _, true) => 0x1D7AA, + (Cal | Frak | Mono | Bb, _, _) => return c, + }, + + // Hebrew Alef -> Dalet. + '\u{05D0}'..='\u{05D3}' => 0x2135, + + // Numbers. + '0'..='9' => match tuple { + (Serif, false, _) => 0x0030, + (Serif, true, _) => 0x1D7CE, + (Bb, _, _) => 0x1D7D8, + (Sans, false, _) => 0x1D7E2, + (Sans, true, _) => 0x1D7EC, + (Mono, _, _) => 0x1D7F6, + (Cal | Frak, _, _) => return c, + }, + + _ => unreachable!(), + }; + + std::char::from_u32(start + (c as u32 - base as u32)).unwrap() +} + +fn basic_exception(c: char) -> Option<char> { + Some(match c { + '〈' => '⟨', + '〉' => '⟩', + '《' => '⟪', + '》' => '⟫', + _ => return None, + }) +} + +fn latin_exception( + c: char, + variant: MathVariant, + bold: bool, + italic: bool, +) -> Option<char> { + use MathVariant::*; + Some(match (c, variant, bold, italic) { + ('B', Cal, false, _) => 'ℬ', + ('E', Cal, false, _) => 'ℰ', + ('F', Cal, false, _) => 'ℱ', + ('H', Cal, false, _) => 'ℋ', + ('I', Cal, false, _) => 'ℐ', + ('L', Cal, false, _) => 'ℒ', + ('M', Cal, false, _) => 'ℳ', + ('R', Cal, false, _) => 'ℛ', + ('C', Frak, false, _) => 'ℭ', + ('H', Frak, false, _) => 'ℌ', + ('I', Frak, false, _) => 'ℑ', + ('R', Frak, false, _) => 'ℜ', + ('Z', Frak, false, _) => 'ℨ', + ('C', Bb, ..) => 'ℂ', + ('H', Bb, ..) => 'ℍ', + ('N', Bb, ..) => 'ℕ', + ('P', Bb, ..) => 'ℙ', + ('Q', Bb, ..) => 'ℚ', + ('R', Bb, ..) => 'ℝ', + ('Z', Bb, ..) => 'ℤ', + ('D', Bb, _, true) => 'ⅅ', + ('d', Bb, _, true) => 'ⅆ', + ('e', Bb, _, true) => 'ⅇ', + ('i', Bb, _, true) => 'ⅈ', + ('j', Bb, _, true) => 'ⅉ', + ('h', Serif, false, true) => 'ℎ', + ('e', Cal, false, _) => 'ℯ', + ('g', Cal, false, _) => 'ℊ', + ('o', Cal, false, _) => 'ℴ', + ('ı', Serif, .., true) => '𝚤', + ('ȷ', Serif, .., true) => '𝚥', + _ => return None, + }) +} + +fn greek_exception( + c: char, + variant: MathVariant, + bold: bool, + italic: bool, +) -> Option<char> { + use MathVariant::*; + let list = match c { + 'ϴ' => ['𝚹', '𝛳', '𝜭', '𝝧', '𝞡', 'ϴ'], + '∇' => ['𝛁', '𝛻', '𝜵', '𝝯', '𝞩', '∇'], + '∂' => ['𝛛', '𝜕', '𝝏', '𝞉', '𝟃', '∂'], + 'ϵ' => ['𝛜', '𝜖', '𝝐', '𝞊', '𝟄', 'ϵ'], + 'ϑ' => ['𝛝', '𝜗', '𝝑', '𝞋', '𝟅', 'ϑ'], + 'ϰ' => ['𝛞', '𝜘', '𝝒', '𝞌', '𝟆', 'ϰ'], + 'ϕ' => ['𝛟', '𝜙', '𝝓', '𝞍', '𝟇', 'ϕ'], + 'ϱ' => ['𝛠', '𝜚', '𝝔', '𝞎', '𝟈', 'ϱ'], + 'ϖ' => ['𝛡', '𝜛', '𝝕', '𝞏', '𝟉', 'ϖ'], + 'Γ' => ['𝚪', '𝛤', '𝜞', '𝝘', '𝞒', 'ℾ'], + 'γ' => ['𝛄', '𝛾', '𝜸', '𝝲', '𝞬', 'ℽ'], + 'Π' => ['𝚷', '𝛱', '𝜫', '𝝥', '𝞟', 'ℿ'], + 'π' => ['𝛑', '𝜋', '𝝅', '𝝿', '𝞹', 'ℼ'], + '∑' => ['∑', '∑', '∑', '∑', '∑', '⅀'], + _ => return None, + }; + + Some(match (variant, bold, italic) { + (Serif, true, false) => list[0], + (Serif, false, true) => list[1], + (Serif, true, true) => list[2], + (Sans, _, false) => list[3], + (Sans, _, true) => list[4], + (Bb, ..) => list[5], + _ => return None, + }) +} diff --git a/crates/typst-layout/src/math/underover.rs b/crates/typst-layout/src/math/underover.rs new file mode 100644 index 00000000..b1d4825b --- /dev/null +++ b/crates/typst-layout/src/math/underover.rs @@ -0,0 +1,327 @@ +use typst_library::diag::SourceResult; +use typst_library::foundations::{Content, Packed, StyleChain}; +use typst_library::layout::{Abs, Em, FixedAlignment, Frame, FrameItem, Point, Size}; +use typst_library::math::{ + OverbraceElem, OverbracketElem, OverlineElem, OverparenElem, OvershellElem, + UnderbraceElem, UnderbracketElem, UnderlineElem, UnderparenElem, UndershellElem, +}; +use typst_library::text::TextElem; +use typst_library::visualize::{FixedStroke, Geometry}; +use typst_syntax::Span; + +use super::{ + scaled_font_size, stack, style_cramped, style_for_subscript, style_for_superscript, + FrameFragment, GlyphFragment, LeftRightAlternator, MathContext, MathRun, +}; + +const BRACE_GAP: Em = Em::new(0.25); +const BRACKET_GAP: Em = Em::new(0.25); +const PAREN_GAP: Em = Em::new(0.25); +const SHELL_GAP: Em = Em::new(0.25); + +/// A marker to distinguish under- and overlines. +enum Position { + Under, + Over, +} + +/// Lays out an [`UnderlineElem`]. +#[typst_macros::time(name = "math.underline", span = elem.span())] +pub fn layout_underline( + elem: &Packed<UnderlineElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_underoverline(ctx, styles, elem.body(), elem.span(), Position::Under) +} + +/// Lays out an [`OverlineElem`]. +#[typst_macros::time(name = "math.overline", span = elem.span())] +pub fn layout_overline( + elem: &Packed<OverlineElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_underoverline(ctx, styles, elem.body(), elem.span(), Position::Over) +} + +/// Lays out an [`UnderbraceElem`]. +#[typst_macros::time(name = "math.underbrace", span = elem.span())] +pub fn layout_underbrace( + elem: &Packed<UnderbraceElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_underoverspreader( + ctx, + styles, + elem.body(), + &elem.annotation(styles), + '⏟', + BRACE_GAP, + Position::Under, + elem.span(), + ) +} + +/// Lays out an [`OverbraceElem`]. +#[typst_macros::time(name = "math.overbrace", span = elem.span())] +pub fn layout_overbrace( + elem: &Packed<OverbraceElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_underoverspreader( + ctx, + styles, + elem.body(), + &elem.annotation(styles), + '⏞', + BRACE_GAP, + Position::Over, + elem.span(), + ) +} + +/// Lays out an [`UnderbracketElem`]. +#[typst_macros::time(name = "math.underbracket", span = elem.span())] +pub fn layout_underbracket( + elem: &Packed<UnderbracketElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_underoverspreader( + ctx, + styles, + elem.body(), + &elem.annotation(styles), + '⎵', + BRACKET_GAP, + Position::Under, + elem.span(), + ) +} + +/// Lays out an [`OverbracketElem`]. +#[typst_macros::time(name = "math.overbracket", span = elem.span())] +pub fn layout_overbracket( + elem: &Packed<OverbracketElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_underoverspreader( + ctx, + styles, + elem.body(), + &elem.annotation(styles), + '⎴', + BRACKET_GAP, + Position::Over, + elem.span(), + ) +} + +/// Lays out an [`UnderparenElem`]. +#[typst_macros::time(name = "math.underparen", span = elem.span())] +pub fn layout_underparen( + elem: &Packed<UnderparenElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_underoverspreader( + ctx, + styles, + elem.body(), + &elem.annotation(styles), + '⏝', + PAREN_GAP, + Position::Under, + elem.span(), + ) +} + +/// Lays out an [`OverparenElem`]. +#[typst_macros::time(name = "math.overparen", span = elem.span())] +pub fn layout_overparen( + elem: &Packed<OverparenElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_underoverspreader( + ctx, + styles, + elem.body(), + &elem.annotation(styles), + '⏜', + PAREN_GAP, + Position::Over, + elem.span(), + ) +} + +/// Lays out an [`UndershellElem`]. +#[typst_macros::time(name = "math.undershell", span = elem.span())] +pub fn layout_undershell( + elem: &Packed<UndershellElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_underoverspreader( + ctx, + styles, + elem.body(), + &elem.annotation(styles), + '⏡', + SHELL_GAP, + Position::Under, + elem.span(), + ) +} + +/// Lays out an [`OvershellElem`]. +#[typst_macros::time(name = "math.overshell", span = elem.span())] +pub fn layout_overshell( + elem: &Packed<OvershellElem>, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + layout_underoverspreader( + ctx, + styles, + elem.body(), + &elem.annotation(styles), + '⏠', + SHELL_GAP, + Position::Over, + elem.span(), + ) +} + +/// layout under- or overlined content. +fn layout_underoverline( + ctx: &mut MathContext, + styles: StyleChain, + body: &Content, + span: Span, + position: Position, +) -> SourceResult<()> { + let (extra_height, content, line_pos, content_pos, baseline, bar_height, line_adjust); + match position { + Position::Under => { + let sep = scaled!(ctx, styles, underbar_extra_descender); + bar_height = scaled!(ctx, styles, underbar_rule_thickness); + let gap = scaled!(ctx, styles, underbar_vertical_gap); + extra_height = sep + bar_height + gap; + + content = ctx.layout_into_fragment(body, styles)?; + + line_pos = Point::with_y(content.height() + gap + bar_height / 2.0); + content_pos = Point::zero(); + baseline = content.ascent(); + line_adjust = -content.italics_correction(); + } + Position::Over => { + let sep = scaled!(ctx, styles, overbar_extra_ascender); + bar_height = scaled!(ctx, styles, overbar_rule_thickness); + let gap = scaled!(ctx, styles, overbar_vertical_gap); + extra_height = sep + bar_height + gap; + + let cramped = style_cramped(); + content = ctx.layout_into_fragment(body, styles.chain(&cramped))?; + + line_pos = Point::with_y(sep + bar_height / 2.0); + content_pos = Point::with_y(extra_height); + baseline = content.ascent() + extra_height; + line_adjust = Abs::zero(); + } + } + + let width = content.width(); + let height = content.height() + extra_height; + let size = Size::new(width, height); + let line_width = width + line_adjust; + + let content_class = content.class(); + let content_is_text_like = content.is_text_like(); + let content_italics_correction = content.italics_correction(); + let mut frame = Frame::soft(size); + frame.set_baseline(baseline); + frame.push_frame(content_pos, content.into_frame()); + frame.push( + line_pos, + FrameItem::Shape( + Geometry::Line(Point::with_x(line_width)).stroked(FixedStroke { + paint: TextElem::fill_in(styles).as_decoration(), + thickness: bar_height, + ..FixedStroke::default() + }), + span, + ), + ); + + ctx.push( + FrameFragment::new(ctx, styles, frame) + .with_class(content_class) + .with_text_like(content_is_text_like) + .with_italics_correction(content_italics_correction), + ); + + Ok(()) +} + +/// Layout an over- or underbrace-like object. +#[allow(clippy::too_many_arguments)] +fn layout_underoverspreader( + ctx: &mut MathContext, + styles: StyleChain, + body: &Content, + annotation: &Option<Content>, + c: char, + gap: Em, + position: Position, + span: Span, +) -> SourceResult<()> { + let font_size = scaled_font_size(ctx, styles); + let gap = gap.at(font_size); + let body = ctx.layout_into_run(body, styles)?; + let body_class = body.class(); + let body = body.into_fragment(ctx, styles); + let glyph = GlyphFragment::new(ctx, styles, c, span); + let stretched = glyph.stretch_horizontal(ctx, body.width(), Abs::zero()); + + let mut rows = vec![]; + let baseline = match position { + Position::Under => { + rows.push(MathRun::new(vec![body])); + rows.push(stretched.into()); + if let Some(annotation) = annotation { + let under_style = style_for_subscript(styles); + let annotation_styles = styles.chain(&under_style); + rows.push(ctx.layout_into_run(annotation, annotation_styles)?); + } + 0 + } + Position::Over => { + if let Some(annotation) = annotation { + let over_style = style_for_superscript(styles); + let annotation_styles = styles.chain(&over_style); + rows.push(ctx.layout_into_run(annotation, annotation_styles)?); + } + rows.push(stretched.into()); + rows.push(MathRun::new(vec![body])); + rows.len() - 1 + } + }; + + let frame = stack( + rows, + FixedAlignment::Center, + gap, + baseline, + LeftRightAlternator::Right, + None, + ); + ctx.push(FrameFragment::new(ctx, styles, frame).with_class(body_class)); + + Ok(()) +} |
