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