diff --git a/crates/typst/src/math/attach.rs b/crates/typst/src/math/attach.rs index 2d491dfd3..9eb0c824c 100644 --- a/crates/typst/src/math/attach.rs +++ b/crates/typst/src/math/attach.rs @@ -1,15 +1,21 @@ use unicode_math_class::MathClass; use crate::diag::SourceResult; -use crate::foundations::{elem, Content, Packed, StyleChain}; -use crate::layout::{Abs, Corner, Frame, Point, Size}; +use crate::foundations::{elem, Content, Packed, Smart, StyleChain}; +use crate::layout::{Abs, Axis, Corner, Frame, Length, Point, Rel, Size}; use crate::math::{ - style_for_subscript, style_for_superscript, EquationElem, FrameFragment, LayoutMath, - MathContext, MathFragment, MathSize, Scaled, + stretch_fragment, style_for_subscript, style_for_superscript, EquationElem, + FrameFragment, LayoutMath, MathContext, MathFragment, MathSize, Scaled, StretchElem, }; use crate::text::TextElem; 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. /// /// ```example @@ -55,8 +61,9 @@ impl LayoutMath for Packed { fn layout_math(&self, ctx: &mut MathContext, styles: StyleChain) -> SourceResult<()> { let new_elem = merge_base(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_chain = styles.chain(&sup_style); let tl = elem.tl(sup_style_chain); @@ -86,12 +93,29 @@ impl LayoutMath for Packed { }; } + // 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)?, - layout!(t, sup_style_chain)?, + t, layout!(tr, sup_style_chain)?, layout!(bl, sub_style_chain)?, - layout!(b, sub_style_chain)?, + b, layout!(br, sub_style_chain)?, ]; @@ -288,10 +312,18 @@ fn merge_base(elem: &Packed) -> Option> { None } -macro_rules! measure { - ($e: ident, $attr: ident) => { - $e.as_ref().map(|e| e.$attr()).unwrap_or_default() - }; +/// Get the size to stretch the base to, if the attach argument is true. +fn stretch_size( + styles: StyleChain, + elem: &Packed, +) -> Option>> { + // Extract from an EquationElem. + let mut base = elem.base(); + if let Some(equation) = base.to_packed::() { + base = equation.body(); + } + + base.to_packed::().map(|stretch| stretch.size(styles)) } /// Layout the attachments. diff --git a/crates/typst/src/math/lr.rs b/crates/typst/src/math/lr.rs index 70fab12df..80ce55eb3 100644 --- a/crates/typst/src/math/lr.rs +++ b/crates/typst/src/math/lr.rs @@ -1,15 +1,11 @@ use unicode_math_class::MathClass; use crate::diag::SourceResult; -use crate::foundations::{ - elem, func, Content, NativeElement, Packed, Resolve, Smart, StyleChain, -}; -use crate::layout::{Abs, Em, Length, Rel}; -use crate::math::{GlyphFragment, LayoutMath, MathContext, MathFragment, Scaled}; +use crate::foundations::{elem, func, Content, NativeElement, Packed, Smart, StyleChain}; +use crate::layout::{Abs, Axis, Em, Length, Rel}; +use crate::math::{stretch_fragment, LayoutMath, MathContext, MathFragment, Scaled}; use crate::text::TextElem; -use super::delimiter_alignment; - /// How much less high scaled delimiters can be than what they wrap. pub(super) const DELIM_SHORT_FALL: Em = Em::new(0.1); @@ -55,18 +51,15 @@ impl LayoutMath for Packed { .max() .unwrap_or_default(); - let height = self - .size(styles) - .unwrap_or(Rel::one()) - .resolve(styles) - .relative_to(2.0 * max_extent); + let relative_to = 2.0 * max_extent; + let height = self.size(styles); // Scale up fragments at both ends. match fragments.as_mut_slice() { - [one] => scale(ctx, styles, one, height, None), + [one] => scale(ctx, styles, one, relative_to, height, None), [first, .., last] => { - scale(ctx, styles, first, height, Some(MathClass::Opening)); - scale(ctx, styles, last, height, Some(MathClass::Closing)); + scale(ctx, styles, first, relative_to, height, Some(MathClass::Opening)); + scale(ctx, styles, last, relative_to, height, Some(MathClass::Closing)); } _ => {} } @@ -76,7 +69,14 @@ impl LayoutMath for Packed { if let MathFragment::Variant(ref mut variant) = fragment { if variant.mid_stretched == Some(false) { 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, styles: StyleChain, fragment: &mut MathFragment, - height: Abs, + relative_to: Abs, + height: Smart>, apply: Option, ) { if matches!( fragment.class(), MathClass::Opening | MathClass::Closing | MathClass::Fence ) { - let glyph = match fragment { - MathFragment::Glyph(glyph) => glyph.clone(), - MathFragment::Variant(variant) => { - GlyphFragment::new(ctx, styles, variant.c, variant.span) - } - _ => return, - }; + // 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, + ); - 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 { fragment.set_class(class); } diff --git a/crates/typst/src/math/matrix.rs b/crates/typst/src/math/matrix.rs index 5b9b17a7d..345c66050 100644 --- a/crates/typst/src/math/matrix.rs +++ b/crates/typst/src/math/matrix.rs @@ -11,9 +11,9 @@ use crate::layout::{ Rel, Size, }; use crate::math::{ - alignments, scaled_font_size, stack, style_for_denominator, AlignmentResult, - FrameFragment, GlyphFragment, LayoutMath, LeftRightAlternator, MathContext, Scaled, - DELIM_SHORT_FALL, + alignments, delimiter_alignment, scaled_font_size, stack, style_for_denominator, + AlignmentResult, FrameFragment, GlyphFragment, LayoutMath, LeftRightAlternator, + MathContext, Scaled, DELIM_SHORT_FALL, }; use crate::symbols::Symbol; use crate::syntax::{Span, Spanned}; @@ -21,8 +21,6 @@ use crate::text::TextElem; use crate::utils::Numeric; use crate::visualize::{FillRule, FixedStroke, Geometry, LineCap, Shape, Stroke}; -use super::delimiter_alignment; - const DEFAULT_ROW_GAP: Em = Em::new(0.2); const DEFAULT_COL_GAP: Em = Em::new(0.5); const VERTICAL_PADDING: Ratio = Ratio::new(0.1); diff --git a/crates/typst/src/math/mod.rs b/crates/typst/src/math/mod.rs index ab9989758..2c5c32cbf 100644 --- a/crates/typst/src/math/mod.rs +++ b/crates/typst/src/math/mod.rs @@ -34,6 +34,7 @@ pub use self::lr::*; pub use self::matrix::*; pub use self::op::*; pub use self::root::*; +pub use self::stretch::*; pub use self::style::*; pub use self::underover::*; @@ -44,7 +45,6 @@ use self::spacing::*; use crate::diag::SourceResult; use crate::foundations::{category, Category, Module, Scope, StyleChain}; -use crate::layout::VAlignment; use crate::text::TextElem; /// Typst has special [syntax]($syntax/#math) and library functions to typeset @@ -161,6 +161,7 @@ pub fn module() -> Module { math.define_elem::(); math.define_elem::(); math.define_elem::(); + math.define_elem::(); math.define_elem::(); math.define_elem::(); math.define_elem::(); @@ -217,11 +218,3 @@ pub trait LayoutMath { /// Layout the element, producing fragment in the context. 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, - } -} diff --git a/crates/typst/src/math/stretch.rs b/crates/typst/src/math/stretch.rs index 749773e3c..d316fa9a2 100644 --- a/crates/typst/src/math/stretch.rs +++ b/crates/typst/src/math/stretch.rs @@ -1,12 +1,137 @@ use ttf_parser::math::{GlyphAssembly, GlyphConstruction, GlyphPart}; use ttf_parser::LazyArray16; -use crate::layout::{Abs, Frame, Point, Size}; -use crate::math::{GlyphFragment, MathContext, Scaled, VariantFragment}; +use crate::diag::SourceResult; +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. 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>, +} + +impl LayoutMath for Packed { + #[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, + relative_to: Option, + stretch: Smart>, + 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 { + 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 { /// Try to stretch a glyph to a desired height. pub fn stretch_vertical( @@ -15,7 +140,7 @@ impl GlyphFragment { height: Abs, short_fall: Abs, ) -> 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. @@ -25,7 +150,7 @@ impl GlyphFragment { width: Abs, short_fall: Abs, ) -> 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, target: Abs, short_fall: Abs, - horizontal: bool, + axis: Axis, ) -> VariantFragment { // 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; if short_target <= advance { return base.into_variant(); @@ -52,10 +180,9 @@ fn stretch_glyph( .variants .and_then(|variants| { min_overlap = variants.min_connector_overlap.scaled(ctx, base.font_size); - if horizontal { - variants.horizontal_constructions - } else { - variants.vertical_constructions + match axis { + Axis::X => variants.horizontal_constructions, + Axis::Y => variants.vertical_constructions, } .get(base.id) }) @@ -80,7 +207,7 @@ fn stretch_glyph( // Assemble from parts. 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. @@ -90,7 +217,7 @@ fn assemble( assembly: GlyphAssembly, min_overlap: Abs, target: Abs, - horizontal: bool, + 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 @@ -153,15 +280,18 @@ fn assemble( let size; let baseline; - if horizontal { - let height = base.ascent + base.descent; - size = Size::new(full, height); - baseline = base.ascent; - } else { - 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; + 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); @@ -170,16 +300,18 @@ fn assemble( frame.post_process_raw(base.dests, base.hidden); for (fragment, advance) in selected { - let pos = if horizontal { - Point::new(offset, frame.baseline() - fragment.ascent) - } else { - Point::with_y(full - offset - fragment.height()) + 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 = 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 { c: base.c, diff --git a/docs/reference/groups.yml b/docs/reference/groups.yml index 0be0fd5c3..3f2bef23e 100644 --- a/docs/reference/groups.yml +++ b/docs/reference/groups.yml @@ -83,6 +83,10 @@ automatically decides which is more suitable depending on the base, but you 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 $ sum_(i=0)^n a_i = 2^(1+i) $ diff --git a/tests/ref/math-stretch-attach-nested-equation.png b/tests/ref/math-stretch-attach-nested-equation.png new file mode 100644 index 000000000..5ab4fbb51 Binary files /dev/null and b/tests/ref/math-stretch-attach-nested-equation.png differ diff --git a/tests/ref/math-stretch-attach.png b/tests/ref/math-stretch-attach.png new file mode 100644 index 000000000..179281a5a Binary files /dev/null and b/tests/ref/math-stretch-attach.png differ diff --git a/tests/ref/math-stretch-basic.png b/tests/ref/math-stretch-basic.png new file mode 100644 index 000000000..3144b11f2 Binary files /dev/null and b/tests/ref/math-stretch-basic.png differ diff --git a/tests/ref/math-stretch-complex.png b/tests/ref/math-stretch-complex.png new file mode 100644 index 000000000..be694b71b Binary files /dev/null and b/tests/ref/math-stretch-complex.png differ diff --git a/tests/ref/math-stretch-horizontal.png b/tests/ref/math-stretch-horizontal.png new file mode 100644 index 000000000..9b30be594 Binary files /dev/null and b/tests/ref/math-stretch-horizontal.png differ diff --git a/tests/ref/math-stretch-nested.png b/tests/ref/math-stretch-nested.png new file mode 100644 index 000000000..4583cab9d Binary files /dev/null and b/tests/ref/math-stretch-nested.png differ diff --git a/tests/ref/math-stretch-shorthand.png b/tests/ref/math-stretch-shorthand.png new file mode 100644 index 000000000..59db832ad Binary files /dev/null and b/tests/ref/math-stretch-shorthand.png differ diff --git a/tests/ref/math-stretch-vertical.png b/tests/ref/math-stretch-vertical.png new file mode 100644 index 000000000..1f60506d7 Binary files /dev/null and b/tests/ref/math-stretch-vertical.png differ diff --git a/tests/suite/math/stretch.typ b/tests/suite/math/stretch.typ new file mode 100644 index 000000000..e6817ee5e --- /dev/null +++ b/tests/suite/math/stretch.typ @@ -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" $