diff --git a/Cargo.lock b/Cargo.lock index 7efcd441f..e1716f4dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2704,7 +2704,7 @@ dependencies = [ [[package]] name = "typst-dev-assets" version = "0.11.0" -source = "git+https://github.com/typst/typst-dev-assets?rev=48a924d#48a924d9de82b631bc775124a69384c8d860db04" +source = "git+https://github.com/typst/typst-dev-assets?rev=e9f8127#e9f81271547c0d7003770b4fa1e59343e51f7ae8" [[package]] name = "typst-docs" diff --git a/Cargo.toml b/Cargo.toml index 18b670f0c..cb0ef9400 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ typst-syntax = { path = "crates/typst-syntax", version = "0.11.0" } typst-timing = { path = "crates/typst-timing", version = "0.11.0" } typst-utils = { path = "crates/typst-utils", version = "0.11.0" } typst-assets = { git = "https://github.com/typst/typst-assets", rev = "4ee794c" } -typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "48a924d" } +typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "e9f8127" } arrayvec = "0.7.4" az = "1.2" base64 = "0.22" diff --git a/crates/typst-utils/src/lib.rs b/crates/typst-utils/src/lib.rs index 4d5f8e0c3..831b2374c 100644 --- a/crates/typst-utils/src/lib.rs +++ b/crates/typst-utils/src/lib.rs @@ -83,6 +83,27 @@ impl ArcExt for Arc { } } +/// Extra methods for [`Option`]. +pub trait OptionExt { + /// Maps an `Option` to `U` by applying a function to a contained value + /// (if `Some`) or returns a default (if `None`). + fn map_or_default(self, f: F) -> U + where + F: FnOnce(T) -> U; +} + +impl OptionExt for Option { + fn map_or_default(self, f: F) -> U + where + F: FnOnce(T) -> U, + { + match self { + Some(x) => f(x), + None => U::default(), + } + } +} + /// Extra methods for [`[T]`](slice). pub trait SliceExt { /// Split a slice into consecutive runs with the same key and yield for diff --git a/crates/typst/src/layout/corners.rs b/crates/typst/src/layout/corners.rs index a3b6ba93e..cfd6b7acf 100644 --- a/crates/typst/src/layout/corners.rs +++ b/crates/typst/src/layout/corners.rs @@ -262,6 +262,16 @@ pub enum Corner { } impl Corner { + /// The opposite corner. + pub fn inv(self) -> Self { + match self { + Self::TopLeft => Self::BottomRight, + Self::TopRight => Self::BottomLeft, + Self::BottomRight => Self::TopLeft, + Self::BottomLeft => Self::TopRight, + } + } + /// The next corner, clockwise. pub fn next_cw(self) -> Self { match self { diff --git a/crates/typst/src/math/attach.rs b/crates/typst/src/math/attach.rs index 70bf2e5e9..2d491dfd3 100644 --- a/crates/typst/src/math/attach.rs +++ b/crates/typst/src/math/attach.rs @@ -2,12 +2,13 @@ use unicode_math_class::MathClass; use crate::diag::SourceResult; use crate::foundations::{elem, Content, Packed, StyleChain}; -use crate::layout::{Abs, Frame, Point, Size}; +use crate::layout::{Abs, Corner, Frame, Point, Size}; use crate::math::{ style_for_subscript, style_for_superscript, EquationElem, FrameFragment, LayoutMath, MathContext, MathFragment, MathSize, Scaled, }; use crate::text::TextElem; +use crate::utils::OptionExt; /// A base with optional attachments. /// @@ -240,7 +241,7 @@ impl Limits { } } - /// Whether limits should be displayed in this context + /// Whether limits should be displayed in this context. pub fn active(&self, styles: StyleChain) -> bool { match self { Self::Always => true, @@ -300,145 +301,231 @@ fn layout_attachments( base: MathFragment, [tl, t, tr, bl, b, br]: [Option; 6], ) -> SourceResult<()> { - let (shift_up, shift_down) = if [&tl, &tr, &bl, &br].iter().all(|e| e.is_none()) { - (Abs::zero(), Abs::zero()) - } else { - compute_shifts_up_and_down(ctx, styles, &base, [&tl, &tr, &bl, &br]) - }; - - let sup_delta = Abs::zero(); - let sub_delta = -base.italics_correction(); - let (base_width, base_ascent, base_descent) = - (base.width(), base.ascent(), base.descent()); let base_class = base.class(); - let mut ascent = base_ascent - .max(shift_up + measure!(tr, ascent)) - .max(shift_up + measure!(tl, ascent)) - .max(shift_up + measure!(t, height)); - - let mut descent = base_descent - .max(shift_down + measure!(br, descent)) - .max(shift_down + measure!(bl, descent)) - .max(shift_down + measure!(b, height)); - - let pre_sup_width = measure!(tl, width); - let pre_sub_width = measure!(bl, width); - let pre_width_dif = pre_sup_width - pre_sub_width; // Could be negative. - let pre_width_max = pre_sup_width.max(pre_sub_width); - let post_width_max = - (sup_delta + measure!(tr, width)).max(sub_delta + measure!(br, width)); - - let (center_frame, base_offset) = if t.is_none() && b.is_none() { - (base.into_frame(), Abs::zero()) + // 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 { - attach_top_and_bottom(ctx, styles, base, t, b) + compute_script_shifts(ctx, styles, &base, [&tl, &tr, &bl, &br]) }; - if [&tl, &bl, &tr, &br].iter().all(|&e| e.is_none()) { - ctx.push(FrameFragment::new(ctx, styles, center_frame).with_class(base_class)); - return Ok(()); - } - ascent.set_max(center_frame.ascent()); - descent.set_max(center_frame.descent()); + // 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()]); - let mut frame = Frame::soft(Size::new( - pre_width_max - + base_width - + post_width_max - + scaled!(ctx, styles, space_after_script), - ascent + descent, - )); - frame.set_baseline(ascent); - frame.push_frame( - Point::new(sup_delta + pre_width_max, frame.ascent() - base_ascent - base_offset), - center_frame, + // 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, ); - if let Some(tl) = tl { - let pos = - Point::new(-pre_width_dif.min(Abs::zero()), ascent - shift_up - tl.ascent()); - frame.push_frame(pos, tl.into_frame()); + // 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()); + } + }; } - if let Some(bl) = bl { - let pos = - Point::new(pre_width_dif.max(Abs::zero()), ascent + shift_down - bl.ascent()); - frame.push_frame(pos, bl.into_frame()); - } - - if let Some(tr) = tr { - let pos = Point::new( - sup_delta + pre_width_max + base_width, - ascent - shift_up - tr.ascent(), - ); - frame.push_frame(pos, tr.into_frame()); - } - - if let Some(br) = br { - let pos = Point::new( - sub_delta + pre_width_max + base_width, - ascent + shift_down - br.ascent(), - ); - frame.push_frame(pos, br.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(()) } -fn attach_top_and_bottom( +/// 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, - styles: StyleChain, - base: MathFragment, - t: Option, - b: Option, -) -> (Frame, Abs) { - let upper_gap_min = scaled!(ctx, styles, upper_limit_gap_min); - let upper_rise_min = scaled!(ctx, styles, upper_limit_baseline_rise_min); - let lower_gap_min = scaled!(ctx, styles, lower_limit_gap_min); - let lower_drop_min = scaled!(ctx, styles, lower_limit_baseline_drop_min); + 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) + }); - let mut base_offset = Abs::zero(); - let mut width = base.width(); - let mut height = base.height(); + // 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) + }); - if let Some(t) = &t { - let top_gap = upper_gap_min.max(upper_rise_min - t.descent()); - width.set_max(t.width()); - height += t.height() + top_gap; - base_offset = top_gap + t.height(); - } - - if let Some(b) = &b { - let bottom_gap = lower_gap_min.max(lower_drop_min - b.ascent()); - width.set_max(b.width()); - height += b.height() + bottom_gap; - } - - let base_pos = Point::new((width - base.width()) / 2.0, base_offset); - let delta = base.italics_correction() / 2.0; - - let mut frame = Frame::soft(Size::new(width, height)); - frame.set_baseline(base_pos.y + base.ascent()); - frame.push_frame(base_pos, base.into_frame()); - - if let Some(t) = t { - let top_pos = Point::with_x((width - t.width()) / 2.0 + delta); - frame.push_frame(top_pos, t.into_frame()); - } - - if let Some(b) = b { - let bottom_pos = - Point::new((width - b.width()) / 2.0 - delta, height - b.height()); - frame.push_frame(bottom_pos, b.into_frame()); - } - - (frame, base_offset) + (tr_values, br_values) } -fn compute_shifts_up_and_down( +/// 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, @@ -504,7 +591,56 @@ fn compute_shifts_up_and_down( (shift_up, shift_down) } -/// Determines if the character is one of a variety of integral signs +/// 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)) +} + +/// Determines if the character is one of a variety of integral signs. fn is_integral_char(c: char) -> bool { ('∫'..='∳').contains(&c) || ('⨋'..='⨜').contains(&c) } diff --git a/crates/typst/src/math/fragment.rs b/crates/typst/src/math/fragment.rs index c6de24863..2adf2d9d0 100644 --- a/crates/typst/src/math/fragment.rs +++ b/crates/typst/src/math/fragment.rs @@ -184,6 +184,18 @@ impl MathFragment { _ => Limits::Never, } } + + /// If no kern table is provided for a corner, a kerning amount of zero is + /// assumed. + pub fn kern_at_height(&self, ctx: &MathContext, corner: Corner, height: Abs) -> Abs { + match self { + Self::Glyph(glyph) => { + kern_at_height(ctx, glyph.font_size, glyph.id, corner, height) + .unwrap_or_default() + } + _ => Abs::zero(), + } + } } impl From for MathFragment { @@ -552,10 +564,6 @@ fn is_extended_shape(ctx: &MathContext, id: GlyphId) -> bool { } /// Look up a kerning value at a specific corner and height. -/// -/// This can be integrated once we've found a font that actually provides this -/// data. -#[allow(unused)] fn kern_at_height( ctx: &MathContext, font_size: Abs, diff --git a/tests/ref/gradient-math-misc.png b/tests/ref/gradient-math-misc.png index b9b55d4d9..acf14c6fe 100644 Binary files a/tests/ref/gradient-math-misc.png and b/tests/ref/gradient-math-misc.png differ diff --git a/tests/ref/math-attach-high.png b/tests/ref/math-attach-high.png index 4bb6cb29f..0452c5eac 100644 Binary files a/tests/ref/math-attach-high.png and b/tests/ref/math-attach-high.png differ diff --git a/tests/ref/math-attach-horizontal-align.png b/tests/ref/math-attach-horizontal-align.png index 8aa2a3767..409a30d28 100644 Binary files a/tests/ref/math-attach-horizontal-align.png and b/tests/ref/math-attach-horizontal-align.png differ diff --git a/tests/ref/math-attach-integral.png b/tests/ref/math-attach-integral.png index baebf44c6..64688b93f 100644 Binary files a/tests/ref/math-attach-integral.png and b/tests/ref/math-attach-integral.png differ diff --git a/tests/ref/math-attach-kerning-mixed.png b/tests/ref/math-attach-kerning-mixed.png new file mode 100644 index 000000000..9d0bea27a Binary files /dev/null and b/tests/ref/math-attach-kerning-mixed.png differ diff --git a/tests/ref/math-attach-kerning.png b/tests/ref/math-attach-kerning.png new file mode 100644 index 000000000..d3de7a227 Binary files /dev/null and b/tests/ref/math-attach-kerning.png differ diff --git a/tests/ref/math-attach-limit-long.png b/tests/ref/math-attach-limit-long.png new file mode 100644 index 000000000..b79e6ed4a Binary files /dev/null and b/tests/ref/math-attach-limit-long.png differ diff --git a/tests/ref/math-attach-mixed.png b/tests/ref/math-attach-mixed.png index 4be327e3e..e1268b43b 100644 Binary files a/tests/ref/math-attach-mixed.png and b/tests/ref/math-attach-mixed.png differ diff --git a/tests/ref/math-attach-nested-base.png b/tests/ref/math-attach-nested-base.png index 657cf46f6..54c623872 100644 Binary files a/tests/ref/math-attach-nested-base.png and b/tests/ref/math-attach-nested-base.png differ diff --git a/tests/ref/math-attach-nested.png b/tests/ref/math-attach-nested.png index 8b4309cf8..c6cc99b08 100644 Binary files a/tests/ref/math-attach-nested.png and b/tests/ref/math-attach-nested.png differ diff --git a/tests/ref/math-attach-prescripts.png b/tests/ref/math-attach-prescripts.png index cd105e9df..f0d21cb8a 100644 Binary files a/tests/ref/math-attach-prescripts.png and b/tests/ref/math-attach-prescripts.png differ diff --git a/tests/ref/math-spacing-decorated.png b/tests/ref/math-spacing-decorated.png index 4a785e715..b8846ff05 100644 Binary files a/tests/ref/math-spacing-decorated.png and b/tests/ref/math-spacing-decorated.png differ diff --git a/tests/suite/math/attach.typ b/tests/suite/math/attach.typ index ff859c0f1..9c92c62e3 100644 --- a/tests/suite/math/attach.typ +++ b/tests/suite/math/attach.typ @@ -129,6 +129,37 @@ $integral.sect_a^b quad \u{2a1b}_a^b quad limits(\u{2a1b})_a^b$ $ tack.t.big_0^1 quad \u{02A0A}_0^1 quad join_0^1 $ $tack.t.big_0^1 quad \u{02A0A}_0^1 quad join_0^1$ +--- math-attach-limit-long --- +// Test long limit attachments. +$ attach(product, t: 123456789) attach(product, t: 123456789, bl: x) \ + attach(product, b: 123456789) attach(product, b: 123456789, tr: x) $ +$attach(limits(product), t: 123456789) attach(limits(product), t: 123456789, bl: x)$ + +$attach(limits(product), b: 123456789) attach(limits(product), b: 123456789, tr: x)$ + +--- math-attach-kerning --- +// Test math kerning. +#show math.equation: set text(font: "STIX Two Math") + +$ L^A Y^c R^2 delta^y omega^f a^2 t^w gamma^V p^+ \ + b_lambda f_k p_i x_1 x_j x_A y_l y_y beta_s theta_k \ + J_0 Y_0 T_1 T_f V_a V_A F_j cal(F)_j lambda_y \ + attach(W, tl: l) attach(A, tl: 2) attach(cal(V), tl: beta) + attach(cal(P), tl: iota) attach(f, bl: i) attach(A, bl: x) + attach(cal(J), bl: xi) attach(cal(A), bl: m) $ + +--- math-attach-kerning-mixed --- +// Test mixtures of math kerning. +#show math.equation: set text(font: "STIX Two Math") + +$ x_1^i x_2^lambda x_2^(2alpha) x_2^(k+1) x_2^(-p_(-1)) x_j^gamma \ + f_2^2 v_0^2 z_0^2 beta_s^2 xi_i^k J_1^2 N_(k y)^(-1) V_pi^x \ + attach(J, tl: 1, br: i) attach(P, tl: i, br: 2) B_i_0 phi.alt_i_(n-1) + attach(A, tr: x, bl: x, br: x, tl: x) attach(F, tl: i, tr: f) \ + attach(cal(A), tl: 2, bl: o) attach(cal(J), bl: l, br: A) + attach(cal(y), tr: p, bl: n t) attach(cal(O), tl: 16, tr: +, br: sigma) + attach(italic(Upsilon), tr: s, br: Psi, bl: d) $ + --- math-attach-nested-base --- // Test attachments when the base has attachments. $ attach(a^b, b: c) quad