From be7cfc85d08c545abfac08098b7b33b4bd71f37e Mon Sep 17 00:00:00 2001 From: Laurenz Date: Sun, 27 Oct 2024 19:04:55 +0100 Subject: Split out four new crates (#5302) --- crates/typst-layout/src/math/accent.rs | 75 ++++ crates/typst-layout/src/math/attach.rs | 515 ++++++++++++++++++++++ crates/typst-layout/src/math/cancel.rs | 144 ++++++ crates/typst-layout/src/math/frac.rs | 136 ++++++ crates/typst-layout/src/math/fragment.rs | 704 ++++++++++++++++++++++++++++++ crates/typst-layout/src/math/lr.rs | 135 ++++++ crates/typst-layout/src/math/mat.rs | 333 ++++++++++++++ crates/typst-layout/src/math/mod.rs | 703 +++++++++++++++++++++++++++++ crates/typst-layout/src/math/root.rs | 126 ++++++ crates/typst-layout/src/math/run.rs | 469 ++++++++++++++++++++ crates/typst-layout/src/math/shared.rs | 207 +++++++++ crates/typst-layout/src/math/stretch.rs | 291 ++++++++++++ crates/typst-layout/src/math/text.rs | 344 +++++++++++++++ crates/typst-layout/src/math/underover.rs | 327 ++++++++++++++ 14 files changed, 4509 insertions(+) create mode 100644 crates/typst-layout/src/math/accent.rs create mode 100644 crates/typst-layout/src/math/attach.rs create mode 100644 crates/typst-layout/src/math/cancel.rs create mode 100644 crates/typst-layout/src/math/frac.rs create mode 100644 crates/typst-layout/src/math/fragment.rs create mode 100644 crates/typst-layout/src/math/lr.rs create mode 100644 crates/typst-layout/src/math/mat.rs create mode 100644 crates/typst-layout/src/math/mod.rs create mode 100644 crates/typst-layout/src/math/root.rs create mode 100644 crates/typst-layout/src/math/run.rs create mode 100644 crates/typst-layout/src/math/shared.rs create mode 100644 crates/typst-layout/src/math/stretch.rs create mode 100644 crates/typst-layout/src/math/text.rs create mode 100644 crates/typst-layout/src/math/underover.rs (limited to 'crates/typst-layout/src/math') 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, + 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, + 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::()); + 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, + 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, + 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, + 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, +) -> Option>> { + // Extract from an EquationElem. + let mut base = elem.base(); + if let Some(equation) = base.to_packed::() { + base = equation.body(); + } + + base.to_packed::().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; 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; 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, + 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, + stroke: FixedStroke, + invert: bool, + angle: &Smart, + body_size: Size, + styles: StyleChain, + span: Span, +) -> SourceResult { + 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, + 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, + 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 { + 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 { + 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 for MathFragment { + fn from(glyph: GlyphFragment) -> Self { + Self::Glyph(glyph) + } +} + +impl From for MathFragment { + fn from(variant: VariantFragment) -> Self { + Self::Variant(variant) + } +} + +impl From 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, + 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 { + 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, +} + +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 { + 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 { + 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> { + 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 { + 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 { + 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::(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 { + 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, + ctx: &mut MathContext, + styles: StyleChain, +) -> SourceResult<()> { + let mut body = elem.body(); + + // Extract from an EquationElem. + if let Some(equation) = body.to_packed::() { + body = equation.body(); + } + + // Extract implicit LrElem. + if let Some(lr) = body.to_packed::() { + 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, + 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>, + apply: Option, +) { + 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, + 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, + 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, + 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, + alternator: LeftRightAlternator, +) -> SourceResult { + 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], + align: FixedAlignment, + augment: Option>, + gap: Axes>, + span: Span, +) -> SourceResult { + 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::() + 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::() + + 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, + right: Option, + 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, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + region: Size, +) -> SourceResult> { + 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, + engine: &mut Engine, + locator: Locator, + styles: StyleChain, + regions: Regions, +) -> SourceResult { + 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 { + 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, + 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, + equation_align: FixedAlignment, + width: Abs, + is_multiline: bool, + [first, last]: [(Axes, 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::::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::::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>, + glyphwise_tables: Option>>, + space_width: Em, + // Mutable. + fragments: Vec, +} + +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::(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) { + self.fragments.push(fragment.into()); + } + + /// Push multiple fragments. + fn extend(&mut self, fragments: impl IntoIterator) { + 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 { + 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> { + // 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 { + 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 { + 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::() { + ctx.push(MathFragment::Tag(elem.tag.clone())); + } else if elem.is::() { + let font_size = scaled_font_size(ctx, styles); + ctx.push(MathFragment::Space(ctx.space_width.at(font_size))); + } else if elem.is::() { + ctx.push(MathFragment::Linebreak); + } else if let Some(elem) = elem.to_packed::() { + layout_h(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::() { + self::text::layout_text(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::() { + layout_box(elem, ctx, styles)?; + } else if elem.is::() { + ctx.push(MathFragment::Align); + } else if let Some(elem) = elem.to_packed::() { + layout_class(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::() { + self::accent::layout_accent(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::() { + self::attach::layout_attach(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::() { + self::attach::layout_primes(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::() { + self::attach::layout_scripts(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::() { + self::attach::layout_limits(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::() { + self::cancel::layout_cancel(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::() { + self::frac::layout_frac(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::() { + self::frac::layout_binom(elem, ctx, styles)?; + } else if let Some(elem) = elem.to_packed::() { + self::lr::layout_lr(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::() { + self::lr::layout_mid(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::() { + self::mat::layout_vec(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::() { + self::mat::layout_mat(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::() { + self::mat::layout_cases(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::() { + layout_op(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::() { + self::root::layout_root(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::() { + self::stretch::layout_stretch(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::() { + self::underover::layout_underline(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::() { + self::underover::layout_overline(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::() { + self::underover::layout_underbrace(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::() { + self::underover::layout_overbrace(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::() { + self::underover::layout_underbracket(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::() { + self::underover::layout_overbracket(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::() { + self::underover::layout_underparen(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::() { + self::underover::layout_overparen(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::() { + self::underover::layout_undershell(elem, ctx, styles)? + } else if let Some(elem) = elem.to_packed::() { + 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::()), + ); + } + + Ok(()) +} + +/// Lays out an [`BoxElem`]. +fn layout_box( + elem: &Packed, + 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, + 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, + 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, + 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 { + 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: +#[typst_macros::time(name = "math.root", span = elem.span())] +pub fn layout_root( + elem: &Packed, + 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); + +impl MathRun { + /// Takes the given [`MathFragment`]s and do some basic processing. + pub fn new(fragments: Vec) -> Self { + let iter = fragments.into_iter().peekable(); + let mut last: Option = None; + let mut space: Option = None; + let mut resolved: Vec = 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.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 = 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 { + 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> From 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 { + 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, + r: &MathFragment, +) -> Option { + use MathClass::*; + + let resolve = |v: Em, size_ref: &MathFragment| -> Option { + 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