Add math.stretch element function (#5030)

This commit is contained in:
Max 2024-09-26 14:46:26 +00:00 committed by GitHub
parent a40e068590
commit e00e3e4fbf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 312 additions and 80 deletions

View File

@ -1,15 +1,21 @@
use unicode_math_class::MathClass; use unicode_math_class::MathClass;
use crate::diag::SourceResult; use crate::diag::SourceResult;
use crate::foundations::{elem, Content, Packed, StyleChain}; use crate::foundations::{elem, Content, Packed, Smart, StyleChain};
use crate::layout::{Abs, Corner, Frame, Point, Size}; use crate::layout::{Abs, Axis, Corner, Frame, Length, Point, Rel, Size};
use crate::math::{ use crate::math::{
style_for_subscript, style_for_superscript, EquationElem, FrameFragment, LayoutMath, stretch_fragment, style_for_subscript, style_for_superscript, EquationElem,
MathContext, MathFragment, MathSize, Scaled, FrameFragment, LayoutMath, MathContext, MathFragment, MathSize, Scaled, StretchElem,
}; };
use crate::text::TextElem; use crate::text::TextElem;
use crate::utils::OptionExt; use crate::utils::OptionExt;
macro_rules! measure {
($e: ident, $attr: ident) => {
$e.as_ref().map(|e| e.$attr()).unwrap_or_default()
};
}
/// A base with optional attachments. /// A base with optional attachments.
/// ///
/// ```example /// ```example
@ -55,8 +61,9 @@ impl LayoutMath for Packed<AttachElem> {
fn layout_math(&self, ctx: &mut MathContext, styles: StyleChain) -> SourceResult<()> { fn layout_math(&self, ctx: &mut MathContext, styles: StyleChain) -> SourceResult<()> {
let new_elem = merge_base(self); let new_elem = merge_base(self);
let elem = new_elem.as_ref().unwrap_or(self); let elem = new_elem.as_ref().unwrap_or(self);
let stretch = stretch_size(styles, elem);
let base = ctx.layout_into_fragment(elem.base(), styles)?; let mut base = ctx.layout_into_fragment(elem.base(), styles)?;
let sup_style = style_for_superscript(styles); let sup_style = style_for_superscript(styles);
let sup_style_chain = styles.chain(&sup_style); let sup_style_chain = styles.chain(&sup_style);
let tl = elem.tl(sup_style_chain); let tl = elem.tl(sup_style_chain);
@ -86,12 +93,29 @@ impl LayoutMath for Packed<AttachElem> {
}; };
} }
// 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 = [ let fragments = [
layout!(tl, sup_style_chain)?, layout!(tl, sup_style_chain)?,
layout!(t, sup_style_chain)?, t,
layout!(tr, sup_style_chain)?, layout!(tr, sup_style_chain)?,
layout!(bl, sub_style_chain)?, layout!(bl, sub_style_chain)?,
layout!(b, sub_style_chain)?, b,
layout!(br, sub_style_chain)?, layout!(br, sub_style_chain)?,
]; ];
@ -288,10 +312,18 @@ fn merge_base(elem: &Packed<AttachElem>) -> Option<Packed<AttachElem>> {
None None
} }
macro_rules! measure { /// Get the size to stretch the base to, if the attach argument is true.
($e: ident, $attr: ident) => { fn stretch_size(
$e.as_ref().map(|e| e.$attr()).unwrap_or_default() 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))
} }
/// Layout the attachments. /// Layout the attachments.

View File

@ -1,15 +1,11 @@
use unicode_math_class::MathClass; use unicode_math_class::MathClass;
use crate::diag::SourceResult; use crate::diag::SourceResult;
use crate::foundations::{ use crate::foundations::{elem, func, Content, NativeElement, Packed, Smart, StyleChain};
elem, func, Content, NativeElement, Packed, Resolve, Smart, StyleChain, use crate::layout::{Abs, Axis, Em, Length, Rel};
}; use crate::math::{stretch_fragment, LayoutMath, MathContext, MathFragment, Scaled};
use crate::layout::{Abs, Em, Length, Rel};
use crate::math::{GlyphFragment, LayoutMath, MathContext, MathFragment, Scaled};
use crate::text::TextElem; use crate::text::TextElem;
use super::delimiter_alignment;
/// How much less high scaled delimiters can be than what they wrap. /// How much less high scaled delimiters can be than what they wrap.
pub(super) const DELIM_SHORT_FALL: Em = Em::new(0.1); pub(super) const DELIM_SHORT_FALL: Em = Em::new(0.1);
@ -55,18 +51,15 @@ impl LayoutMath for Packed<LrElem> {
.max() .max()
.unwrap_or_default(); .unwrap_or_default();
let height = self let relative_to = 2.0 * max_extent;
.size(styles) let height = self.size(styles);
.unwrap_or(Rel::one())
.resolve(styles)
.relative_to(2.0 * max_extent);
// Scale up fragments at both ends. // Scale up fragments at both ends.
match fragments.as_mut_slice() { match fragments.as_mut_slice() {
[one] => scale(ctx, styles, one, height, None), [one] => scale(ctx, styles, one, relative_to, height, None),
[first, .., last] => { [first, .., last] => {
scale(ctx, styles, first, height, Some(MathClass::Opening)); scale(ctx, styles, first, relative_to, height, Some(MathClass::Opening));
scale(ctx, styles, last, height, Some(MathClass::Closing)); scale(ctx, styles, last, relative_to, height, Some(MathClass::Closing));
} }
_ => {} _ => {}
} }
@ -76,7 +69,14 @@ impl LayoutMath for Packed<LrElem> {
if let MathFragment::Variant(ref mut variant) = fragment { if let MathFragment::Variant(ref mut variant) = fragment {
if variant.mid_stretched == Some(false) { if variant.mid_stretched == Some(false) {
variant.mid_stretched = Some(true); variant.mid_stretched = Some(true);
scale(ctx, styles, fragment, height, Some(MathClass::Large)); scale(
ctx,
styles,
fragment,
relative_to,
height,
Some(MathClass::Large),
);
} }
} }
} }
@ -140,26 +140,27 @@ fn scale(
ctx: &mut MathContext, ctx: &mut MathContext,
styles: StyleChain, styles: StyleChain,
fragment: &mut MathFragment, fragment: &mut MathFragment,
height: Abs, relative_to: Abs,
height: Smart<Rel<Length>>,
apply: Option<MathClass>, apply: Option<MathClass>,
) { ) {
if matches!( if matches!(
fragment.class(), fragment.class(),
MathClass::Opening | MathClass::Closing | MathClass::Fence MathClass::Opening | MathClass::Closing | MathClass::Fence
) { ) {
let glyph = match fragment { // This unwrap doesn't really matter. If it is None, then the fragment
MathFragment::Glyph(glyph) => glyph.clone(), // won't be stretchable anyways.
MathFragment::Variant(variant) => { let short_fall = DELIM_SHORT_FALL.at(fragment.font_size().unwrap_or_default());
GlyphFragment::new(ctx, styles, variant.c, variant.span) stretch_fragment(
} ctx,
_ => return, styles,
}; fragment,
Some(Axis::Y),
Some(relative_to),
height,
short_fall,
);
let short_fall = DELIM_SHORT_FALL.at(glyph.font_size);
let mut stretched = glyph.stretch_vertical(ctx, height, short_fall);
stretched.align_on_axis(ctx, delimiter_alignment(stretched.c));
*fragment = MathFragment::Variant(stretched);
if let Some(class) = apply { if let Some(class) = apply {
fragment.set_class(class); fragment.set_class(class);
} }

View File

@ -11,9 +11,9 @@ use crate::layout::{
Rel, Size, Rel, Size,
}; };
use crate::math::{ use crate::math::{
alignments, scaled_font_size, stack, style_for_denominator, AlignmentResult, alignments, delimiter_alignment, scaled_font_size, stack, style_for_denominator,
FrameFragment, GlyphFragment, LayoutMath, LeftRightAlternator, MathContext, Scaled, AlignmentResult, FrameFragment, GlyphFragment, LayoutMath, LeftRightAlternator,
DELIM_SHORT_FALL, MathContext, Scaled, DELIM_SHORT_FALL,
}; };
use crate::symbols::Symbol; use crate::symbols::Symbol;
use crate::syntax::{Span, Spanned}; use crate::syntax::{Span, Spanned};
@ -21,8 +21,6 @@ use crate::text::TextElem;
use crate::utils::Numeric; use crate::utils::Numeric;
use crate::visualize::{FillRule, FixedStroke, Geometry, LineCap, Shape, Stroke}; use crate::visualize::{FillRule, FixedStroke, Geometry, LineCap, Shape, Stroke};
use super::delimiter_alignment;
const DEFAULT_ROW_GAP: Em = Em::new(0.2); const DEFAULT_ROW_GAP: Em = Em::new(0.2);
const DEFAULT_COL_GAP: Em = Em::new(0.5); const DEFAULT_COL_GAP: Em = Em::new(0.5);
const VERTICAL_PADDING: Ratio = Ratio::new(0.1); const VERTICAL_PADDING: Ratio = Ratio::new(0.1);

View File

@ -34,6 +34,7 @@ pub use self::lr::*;
pub use self::matrix::*; pub use self::matrix::*;
pub use self::op::*; pub use self::op::*;
pub use self::root::*; pub use self::root::*;
pub use self::stretch::*;
pub use self::style::*; pub use self::style::*;
pub use self::underover::*; pub use self::underover::*;
@ -44,7 +45,6 @@ use self::spacing::*;
use crate::diag::SourceResult; use crate::diag::SourceResult;
use crate::foundations::{category, Category, Module, Scope, StyleChain}; use crate::foundations::{category, Category, Module, Scope, StyleChain};
use crate::layout::VAlignment;
use crate::text::TextElem; use crate::text::TextElem;
/// Typst has special [syntax]($syntax/#math) and library functions to typeset /// Typst has special [syntax]($syntax/#math) and library functions to typeset
@ -161,6 +161,7 @@ pub fn module() -> Module {
math.define_elem::<LrElem>(); math.define_elem::<LrElem>();
math.define_elem::<MidElem>(); math.define_elem::<MidElem>();
math.define_elem::<AttachElem>(); math.define_elem::<AttachElem>();
math.define_elem::<StretchElem>();
math.define_elem::<ScriptsElem>(); math.define_elem::<ScriptsElem>();
math.define_elem::<LimitsElem>(); math.define_elem::<LimitsElem>();
math.define_elem::<AccentElem>(); math.define_elem::<AccentElem>();
@ -217,11 +218,3 @@ pub trait LayoutMath {
/// Layout the element, producing fragment in the context. /// Layout the element, producing fragment in the context.
fn layout_math(&self, ctx: &mut MathContext, styles: StyleChain) -> SourceResult<()>; fn layout_math(&self, ctx: &mut MathContext, styles: StyleChain) -> SourceResult<()>;
} }
fn delimiter_alignment(delimiter: char) -> VAlignment {
match delimiter {
'\u{231c}' | '\u{231d}' => VAlignment::Top,
'\u{231e}' | '\u{231f}' => VAlignment::Bottom,
_ => VAlignment::Horizon,
}
}

View File

@ -1,12 +1,137 @@
use ttf_parser::math::{GlyphAssembly, GlyphConstruction, GlyphPart}; use ttf_parser::math::{GlyphAssembly, GlyphConstruction, GlyphPart};
use ttf_parser::LazyArray16; use ttf_parser::LazyArray16;
use crate::layout::{Abs, Frame, Point, Size}; use crate::diag::SourceResult;
use crate::math::{GlyphFragment, MathContext, Scaled, VariantFragment}; use crate::foundations::{elem, Content, Packed, Resolve, Smart, StyleChain};
use crate::layout::{Abs, Axis, Frame, Length, Point, Rel, Size, VAlignment};
use crate::math::{
GlyphFragment, LayoutMath, MathContext, MathFragment, Scaled, VariantFragment,
};
use crate::utils::Get;
/// Maximum number of times extenders can be repeated. /// Maximum number of times extenders can be repeated.
const MAX_REPEATS: usize = 1024; const MAX_REPEATS: usize = 1024;
/// Stretches a glyph.
///
/// This function can also be used to automatically stretch the base of an
/// attachment, so that it fits the top and bottom attachments.
///
/// Note that only some glyphs can be stretched, and which ones can depend on
/// the math font being used. However, most math fonts are the same in this
/// regard.
///
/// ```example
/// $ H stretch(=)^"define" U + p V $
/// $ f : X stretch(->>, size: #150%)_"surjective" Y $
/// $ x stretch(harpoons.ltrb, size: #3em) y
/// stretch(\[, size: #150%) z $
/// ```
#[elem(LayoutMath)]
pub struct StretchElem {
/// The glyph to stretch.
#[required]
pub body: Content,
/// The size to stretch to, relative to the glyph's current size.
pub size: Smart<Rel<Length>>,
}
impl LayoutMath for Packed<StretchElem> {
#[typst_macros::time(name = "math.stretch", span = self.span())]
fn layout_math(&self, ctx: &mut MathContext, styles: StyleChain) -> SourceResult<()> {
let mut fragment = ctx.layout_into_fragment(self.body(), styles)?;
stretch_fragment(
ctx,
styles,
&mut fragment,
None,
None,
self.size(styles),
Abs::zero(),
);
ctx.push(fragment);
Ok(())
}
}
/// Attempts to stretch the given fragment by/to the amount given in stretch.
pub(super) 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,
};
let Some(axis) = axis.or_else(|| stretch_axis(ctx, &glyph)) else {
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())
.resolve(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);
}
pub(super) fn delimiter_alignment(delimiter: char) -> VAlignment {
match delimiter {
'\u{231c}' | '\u{231d}' => VAlignment::Top,
'\u{231e}' | '\u{231f}' => VAlignment::Bottom,
_ => VAlignment::Horizon,
}
}
/// 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);
}
}
}
impl GlyphFragment { impl GlyphFragment {
/// Try to stretch a glyph to a desired height. /// Try to stretch a glyph to a desired height.
pub fn stretch_vertical( pub fn stretch_vertical(
@ -15,7 +140,7 @@ impl GlyphFragment {
height: Abs, height: Abs,
short_fall: Abs, short_fall: Abs,
) -> VariantFragment { ) -> VariantFragment {
stretch_glyph(ctx, self, height, short_fall, false) stretch_glyph(ctx, self, height, short_fall, Axis::Y)
} }
/// Try to stretch a glyph to a desired width. /// Try to stretch a glyph to a desired width.
@ -25,7 +150,7 @@ impl GlyphFragment {
width: Abs, width: Abs,
short_fall: Abs, short_fall: Abs,
) -> VariantFragment { ) -> VariantFragment {
stretch_glyph(ctx, self, width, short_fall, true) stretch_glyph(ctx, self, width, short_fall, Axis::X)
} }
} }
@ -37,10 +162,13 @@ fn stretch_glyph(
mut base: GlyphFragment, mut base: GlyphFragment,
target: Abs, target: Abs,
short_fall: Abs, short_fall: Abs,
horizontal: bool, axis: Axis,
) -> VariantFragment { ) -> VariantFragment {
// If the base glyph is good enough, use it. // If the base glyph is good enough, use it.
let advance = if horizontal { base.width } else { base.height() }; let advance = match axis {
Axis::X => base.width,
Axis::Y => base.height(),
};
let short_target = target - short_fall; let short_target = target - short_fall;
if short_target <= advance { if short_target <= advance {
return base.into_variant(); return base.into_variant();
@ -52,10 +180,9 @@ fn stretch_glyph(
.variants .variants
.and_then(|variants| { .and_then(|variants| {
min_overlap = variants.min_connector_overlap.scaled(ctx, base.font_size); min_overlap = variants.min_connector_overlap.scaled(ctx, base.font_size);
if horizontal { match axis {
variants.horizontal_constructions Axis::X => variants.horizontal_constructions,
} else { Axis::Y => variants.vertical_constructions,
variants.vertical_constructions
} }
.get(base.id) .get(base.id)
}) })
@ -80,7 +207,7 @@ fn stretch_glyph(
// Assemble from parts. // Assemble from parts.
let assembly = construction.assembly.unwrap(); let assembly = construction.assembly.unwrap();
assemble(ctx, base, assembly, min_overlap, target, horizontal) assemble(ctx, base, assembly, min_overlap, target, axis)
} }
/// Assemble a glyph from parts. /// Assemble a glyph from parts.
@ -90,7 +217,7 @@ fn assemble(
assembly: GlyphAssembly, assembly: GlyphAssembly,
min_overlap: Abs, min_overlap: Abs,
target: Abs, target: Abs,
horizontal: bool, axis: Axis,
) -> VariantFragment { ) -> VariantFragment {
// Determine the number of times the extenders need to be repeated as well // Determine the number of times the extenders need to be repeated as well
// as a ratio specifying how much to spread the parts apart // as a ratio specifying how much to spread the parts apart
@ -153,15 +280,18 @@ fn assemble(
let size; let size;
let baseline; let baseline;
if horizontal { match axis {
let height = base.ascent + base.descent; Axis::X => {
size = Size::new(full, height); let height = base.ascent + base.descent;
baseline = base.ascent; size = Size::new(full, height);
} else { baseline = base.ascent;
let axis = ctx.constants.axis_height().scaled(ctx, base.font_size); }
let width = selected.iter().map(|(f, _)| f.width).max().unwrap_or_default(); Axis::Y => {
size = Size::new(width, full); let axis = ctx.constants.axis_height().scaled(ctx, base.font_size);
baseline = full / 2.0 + axis; 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 frame = Frame::soft(size);
@ -170,16 +300,18 @@ fn assemble(
frame.post_process_raw(base.dests, base.hidden); frame.post_process_raw(base.dests, base.hidden);
for (fragment, advance) in selected { for (fragment, advance) in selected {
let pos = if horizontal { let pos = match axis {
Point::new(offset, frame.baseline() - fragment.ascent) Axis::X => Point::new(offset, frame.baseline() - fragment.ascent),
} else { Axis::Y => Point::with_y(full - offset - fragment.height()),
Point::with_y(full - offset - fragment.height())
}; };
frame.push_frame(pos, fragment.into_frame()); frame.push_frame(pos, fragment.into_frame());
offset += advance; offset += advance;
} }
let accent_attach = if horizontal { frame.width() / 2.0 } else { base.accent_attach }; let accent_attach = match axis {
Axis::X => frame.width() / 2.0,
Axis::Y => base.accent_attach,
};
VariantFragment { VariantFragment {
c: base.c, c: base.c,

View File

@ -83,6 +83,10 @@
automatically decides which is more suitable depending on the base, but you automatically decides which is more suitable depending on the base, but you
can also control this manually with the `scripts` and `limits` functions. can also control this manually with the `scripts` and `limits` functions.
If you want the base to stretch to fit long top and bottom attachments (for
example, an arrow with text above it), use the [`stretch`]($math.stretch)
function.
# Example # Example
```example ```example
$ sum_(i=0)^n a_i = 2^(1+i) $ $ sum_(i=0)^n a_i = 2^(1+i) $

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 B

View File

@ -0,0 +1,72 @@
// Test math stretch.
--- math-stretch-basic ---
// Test basic stretch.
$ P -> Q stretch(->, size: #200%) R \
R stretch(->) S stretch(->, size: #50%)^"epimorphism" T $
--- math-stretch-complex ---
// Test complex stretch.
$ H stretch(=)^"define" U + p V \
x stretch(harpoons.ltrb, size: #3em) y
stretch(\[, size: #150%) z \
f : X stretch(arrow.hook, size: #150%)_"injective" Y \
V stretch(->, size: #(100% + 1.5em))^("surjection") ZZ $
--- math-stretch-attach ---
// Test stretch interactions with attachments.
#set page(width: auto)
$stretch(stretch(=, size: #4em))_A$
$stretch(arrow.hook, size: #5em)^"injective map"$
$stretch(arrow.hook, size: #200%)^"injective map"$
$ P = Q
stretch(=)^(k = 0)_(forall i) R
stretch(=, size: #150%)^(k = 0)_(forall i) S
stretch(=, size: #2mm)^(k = 0)_(forall i) T \
U stretch(equiv)^(forall i)_"Chern-Weil" V
stretch(equiv, size: #(120% + 2mm))^(forall i)_"Chern-Weil" W $
--- math-stretch-horizontal ---
// Test stretching along horizontal axis.
#let ext(sym) = math.stretch(sym, size: 2em)
$ ext(arrow.r) quad ext(arrow.l.double.bar) \
ext(harpoon.rb) quad ext(harpoons.ltrb) \
ext(paren.t) quad ext(shell.b) \
ext(eq) quad ext(equiv) $
--- math-stretch-vertical ---
// Test stretching along vertical axis.
#let ext(sym) = math.stretch(sym, size: 2em)
$ ext(bar.v) quad ext(bar.v.double) quad
ext(angle.l) quad ext(angle.r) quad
ext(paren.l) quad ext(paren.r) \
ext(bracket.l.double) quad ext(bracket.r.double) quad
ext(brace.l) quad ext(brace.r) quad
ext(bracket.l) quad ext(bracket.r) $
--- math-stretch-shorthand ---
// Test stretch when base is given with shorthand.
$stretch(||, size: #2em)$
$stretch(\(, size: #2em)$
$stretch("⟧", size: #2em)$
$stretch("|", size: #2em)$
$stretch(->, size: #2em)$
$stretch(, size: #2em)$
--- math-stretch-nested ---
// Test nested stretch calls.
$ stretch(=, size: #2em) \
stretch(stretch(=, size: #4em), size: #50%) $
#let base = math.stretch($=$, size: 4em)
$ stretch(base, size: #50%) $
#let base = $stretch(=, size: #4em) $
$ stretch(base, size: #50%) $
--- math-stretch-attach-nested-equation ---
// Test stretching with attachments when nested in an equation.
#let body = $stretch(=)$
$ body^"text" $