diff --git a/crates/typst-eval/src/math.rs b/crates/typst-eval/src/math.rs index 994ac9808..a2979e289 100644 --- a/crates/typst-eval/src/math.rs +++ b/crates/typst-eval/src/math.rs @@ -123,9 +123,20 @@ impl Eval for ast::MathFrac<'_> { type Output = Content; fn eval(self, vm: &mut Vm) -> SourceResult { - let num = self.num().eval_display(vm)?; - let denom = self.denom().eval_display(vm)?; - Ok(FracElem::new(num, denom).pack()) + let num_expr = self.num(); + let num = num_expr.eval_display(vm)?; + let denom_expr = self.denom(); + let denom = denom_expr.eval_display(vm)?; + + let num_depar = + matches!(num_expr, ast::Expr::Math(math) if math.was_deparenthesized()); + let denom_depar = + matches!(denom_expr, ast::Expr::Math(math) if math.was_deparenthesized()); + + Ok(FracElem::new(num, denom) + .with_num_deparenthesized(num_depar) + .with_denom_deparenthesized(denom_depar) + .pack()) } } diff --git a/crates/typst-layout/src/math/frac.rs b/crates/typst-layout/src/math/frac.rs index bfc912362..22c8186fb 100644 --- a/crates/typst-layout/src/math/frac.rs +++ b/crates/typst-layout/src/math/frac.rs @@ -1,7 +1,11 @@ use typst_library::diag::SourceResult; -use typst_library::foundations::{Content, Packed, Resolve, StyleChain, SymbolElem}; -use typst_library::layout::{Em, Frame, FrameItem, Point, Size}; -use typst_library::math::{BinomElem, EquationElem, FracElem, MathSize}; +use typst_library::foundations::{ + Content, NativeElement, Packed, Resolve, StyleChain, SymbolElem, +}; +use typst_library::layout::{Abs, Em, Frame, FrameItem, Point, Size}; +use typst_library::math::{ + BinomElem, EquationElem, FracElem, FracStyle, LrElem, MathSize, +}; use typst_library::text::TextElem; use typst_library::visualize::{FixedStroke, Geometry}; use typst_syntax::Span; @@ -20,14 +24,28 @@ pub fn layout_frac( ctx: &mut MathContext, styles: StyleChain, ) -> SourceResult<()> { - layout_frac_like( - ctx, - styles, - &elem.num, - std::slice::from_ref(&elem.denom), - false, - elem.span(), - ) + match elem.style.get(styles) { + FracStyle::Skewed => { + layout_skewed_frac(ctx, styles, &elem.num, &elem.denom, elem.span()) + } + FracStyle::Horizontal => layout_horizontal_frac( + ctx, + styles, + &elem.num, + &elem.denom, + elem.span(), + elem.num_deparenthesized.get(styles), + elem.denom_deparenthesized.get(styles), + ), + FracStyle::Vertical => layout_vertical_frac_like( + ctx, + styles, + &elem.num, + std::slice::from_ref(&elem.denom), + false, + elem.span(), + ), + } } /// Lays out a [`BinomElem`]. @@ -37,11 +55,11 @@ pub fn layout_binom( ctx: &mut MathContext, styles: StyleChain, ) -> SourceResult<()> { - layout_frac_like(ctx, styles, &elem.upper, &elem.lower, true, elem.span()) + layout_vertical_frac_like(ctx, styles, &elem.upper, &elem.lower, true, elem.span()) } -/// Layout a fraction or binomial. -fn layout_frac_like( +/// Layout a vertical fraction or binomial. +fn layout_vertical_frac_like( ctx: &mut MathContext, styles: StyleChain, num: &Content, @@ -143,3 +161,119 @@ fn layout_frac_like( Ok(()) } + +// Lays out a horizontal fraction +fn layout_horizontal_frac( + ctx: &mut MathContext, + styles: StyleChain, + num: &Content, + denom: &Content, + span: Span, + num_deparen: bool, + denom_deparen: bool, +) -> SourceResult<()> { + let num = if num_deparen { + &LrElem::new(Content::sequence(vec![ + SymbolElem::packed('('), + num.clone(), + SymbolElem::packed(')'), + ])) + .pack() + } else { + num + }; + let num_frame = ctx.layout_into_fragment(num, styles)?; + ctx.push(num_frame); + + let mut slash = + ctx.layout_into_fragment(&SymbolElem::packed('/').spanned(span), styles)?; + slash.center_on_axis(); + ctx.push(slash); + + let denom = if denom_deparen { + &LrElem::new(Content::sequence(vec![ + SymbolElem::packed('('), + denom.clone(), + SymbolElem::packed(')'), + ])) + .pack() + } else { + denom + }; + let denom_frame = ctx.layout_into_fragment(denom, styles)?; + ctx.push(denom_frame); + + Ok(()) +} + +/// Lay out a skewed fraction. +fn layout_skewed_frac( + ctx: &mut MathContext, + styles: StyleChain, + num: &Content, + denom: &Content, + span: Span, +) -> SourceResult<()> { + // Font-derived constants + let constants = ctx.font().math(); + let vgap = constants.skewed_fraction_vertical_gap.resolve(styles); + let hgap = constants.skewed_fraction_horizontal_gap.resolve(styles); + let axis = constants.axis_height.resolve(styles); + + let num_style = style_for_numerator(styles); + let num_frame = ctx.layout_into_frame(num, styles.chain(&num_style))?; + let num_size = num_frame.size(); + let denom_style = style_for_denominator(styles); + let denom_frame = ctx.layout_into_frame(denom, styles.chain(&denom_style))?; + let denom_size = denom_frame.size(); + + let short_fall = DELIM_SHORT_FALL.resolve(styles); + + // Height of the fraction frame + // We recalculate this value below if the slash glyph overflows + let mut fraction_height = num_size.y + denom_size.y + vgap; + + // Build the slash glyph to calculate its size + let mut slash_frag = + ctx.layout_into_fragment(&SymbolElem::packed('\u{2044}').spanned(span), styles)?; + slash_frag.stretch_vertical(ctx, fraction_height - short_fall); + slash_frag.center_on_axis(); + let slash_frame = slash_frag.into_frame(); + + // Adjust the fraction height if the slash overflows + let slash_size = slash_frame.size(); + let vertical_offset = Abs::zero().max(slash_size.y - fraction_height) / 2.0; + fraction_height.set_max(slash_size.y); + + // Reference points for all three objects, used to place them in the frame. + let mut slash_up_left = Point::new(num_size.x + hgap / 2.0, fraction_height / 2.0) + - slash_size.to_point() / 2.0; + let mut num_up_left = Point::with_y(vertical_offset); + let mut denom_up_left = num_up_left + num_size.to_point() + Point::new(hgap, vgap); + + // Fraction width + let fraction_width = (denom_up_left.x + denom_size.x) + .max(slash_up_left.x + slash_size.x) + + Abs::zero().max(-slash_up_left.x); + // We have to shift everything right to avoid going in the negatives for + // the x coordinate + let horizontal_offset = Point::with_x(Abs::zero().max(-slash_up_left.x)); + slash_up_left += horizontal_offset; + num_up_left += horizontal_offset; + denom_up_left += horizontal_offset; + + // Build the final frame + let mut fraction_frame = Frame::soft(Size::new(fraction_width, fraction_height)); + + // Baseline (use axis height to center slash on the axis) + fraction_frame.set_baseline(fraction_height / 2.0 + axis); + + // Numerator, Denominator, Slash + fraction_frame.push_frame(num_up_left, num_frame); + fraction_frame.push_frame(denom_up_left, denom_frame); + fraction_frame.push_frame(slash_up_left, slash_frame); + + ctx.push(FrameFragment::new(styles, fraction_frame)); + + Ok(()) +} diff --git a/crates/typst-library/src/math/frac.rs b/crates/typst-library/src/math/frac.rs index b10b5b183..384e1c337 100644 --- a/crates/typst-library/src/math/frac.rs +++ b/crates/typst-library/src/math/frac.rs @@ -1,7 +1,7 @@ use typst_syntax::Spanned; use crate::diag::bail; -use crate::foundations::{Content, Value, elem}; +use crate::foundations::{Cast, Content, Value, elem}; use crate::math::Mathy; /// A mathematical fraction. @@ -26,6 +26,43 @@ pub struct FracElem { /// The fraction's denominator. #[required] pub denom: Content, + + /// How the fraction shoud be laid out. + /// + /// ```example + /// #set math.frac(style: "skewed") + /// $ a / b $ + /// $ frac(x, y, style: "vertical") $ + /// ``` + #[default(FracStyle::Vertical)] + pub style: FracStyle, + + /// Whether the numerator was originally surrounded by parentheses + /// that were stripped by the parser. + #[internal] + #[parse(None)] + #[default(false)] + pub num_deparenthesized: bool, + + /// Whether the denominator was originally surrounded by parentheses + /// that were stripped by the parser. + #[internal] + #[parse(None)] + #[default(false)] + pub denom_deparenthesized: bool, +} + +/// Fraction style +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum FracStyle { + /// Stacked numerator and denominator with a bar. + #[default] + Vertical, + /// Numerator and denominator separated by a slash. + Skewed, + /// Numerator and denominator placed inline and parentheses are not + /// absorbed. + Horizontal, } /// A binomial expression. diff --git a/crates/typst-library/src/text/font/mod.rs b/crates/typst-library/src/text/font/mod.rs index 49ed6c683..665cc08ca 100644 --- a/crates/typst-library/src/text/font/mod.rs +++ b/crates/typst-library/src/text/font/mod.rs @@ -379,6 +379,10 @@ impl FontMetrics { .to_em(constants.fraction_denominator_gap_min().value), fraction_denom_display_style_gap_min: font .to_em(constants.fraction_denom_display_style_gap_min().value), + skewed_fraction_vertical_gap: font + .to_em(constants.skewed_fraction_vertical_gap().value), + skewed_fraction_horizontal_gap: font + .to_em(constants.skewed_fraction_horizontal_gap().value), overbar_vertical_gap: font .to_em(constants.overbar_vertical_gap().value), overbar_rule_thickness: font @@ -413,6 +417,8 @@ impl FontMetrics { // - `flattened_accent_base_height` from Building Math Fonts // - `overbar_rule_thickness` and `underbar_rule_thickness` // from our best guess + // - `skewed_fraction_vertical_gap` and `skewed_fraction_horizontal_gap` + // from our best guess // - `script_percent_scale_down` and // `script_script_percent_scale_down` from Building Math // Fonts as the defaults given in MathML Core have more @@ -458,6 +464,8 @@ impl FontMetrics { fraction_denominator_gap_min: metrics.underline.thickness, fraction_denom_display_style_gap_min: 3.0 * metrics.underline.thickness, + skewed_fraction_vertical_gap: Em::zero(), + skewed_fraction_horizontal_gap: Em::new(0.5), overbar_vertical_gap: 3.0 * metrics.underline.thickness, overbar_rule_thickness: metrics.underline.thickness, overbar_extra_ascender: metrics.underline.thickness, @@ -553,6 +561,8 @@ pub struct MathConstants { pub fraction_rule_thickness: Em, pub fraction_denominator_gap_min: Em, pub fraction_denom_display_style_gap_min: Em, + pub skewed_fraction_vertical_gap: Em, + pub skewed_fraction_horizontal_gap: Em, pub overbar_vertical_gap: Em, pub overbar_rule_thickness: Em, pub overbar_extra_ascender: Em, diff --git a/crates/typst-syntax/src/ast.rs b/crates/typst-syntax/src/ast.rs index d629f24d7..1a8761140 100644 --- a/crates/typst-syntax/src/ast.rs +++ b/crates/typst-syntax/src/ast.rs @@ -840,6 +840,16 @@ impl<'a> Math<'a> { pub fn exprs(self) -> impl DoubleEndedIterator> { self.0.children().filter_map(Expr::cast_with_space) } + + /// Whether this `Math` node was originally parenthesized. + pub fn was_deparenthesized(self) -> bool { + let mut iter = self.0.children(); + matches!(iter.next().map(SyntaxNode::kind), Some(SyntaxKind::LeftParen)) + && matches!( + iter.next_back().map(SyntaxNode::kind), + Some(SyntaxKind::RightParen) + ) + } } node! { diff --git a/tests/ref/math-frac-horizontal-explicit.png b/tests/ref/math-frac-horizontal-explicit.png new file mode 100644 index 000000000..09a12d93a Binary files /dev/null and b/tests/ref/math-frac-horizontal-explicit.png differ diff --git a/tests/ref/math-frac-horizontal-lr-paren.png b/tests/ref/math-frac-horizontal-lr-paren.png new file mode 100644 index 000000000..4489460dd Binary files /dev/null and b/tests/ref/math-frac-horizontal-lr-paren.png differ diff --git a/tests/ref/math-frac-horizontal-nonparen-brackets.png b/tests/ref/math-frac-horizontal-nonparen-brackets.png new file mode 100644 index 000000000..64c31dbc9 Binary files /dev/null and b/tests/ref/math-frac-horizontal-nonparen-brackets.png differ diff --git a/tests/ref/math-frac-horizontal.png b/tests/ref/math-frac-horizontal.png new file mode 100644 index 000000000..2d139df78 Binary files /dev/null and b/tests/ref/math-frac-horizontal.png differ diff --git a/tests/ref/math-frac-skewed.png b/tests/ref/math-frac-skewed.png new file mode 100644 index 000000000..40215c06c Binary files /dev/null and b/tests/ref/math-frac-skewed.png differ diff --git a/tests/ref/math-frac-styles-inline.png b/tests/ref/math-frac-styles-inline.png new file mode 100644 index 000000000..1042e945a Binary files /dev/null and b/tests/ref/math-frac-styles-inline.png differ diff --git a/tests/suite/math/frac.typ b/tests/suite/math/frac.typ index 3bd00eab2..7dcabbf53 100644 --- a/tests/suite/math/frac.typ +++ b/tests/suite/math/frac.typ @@ -45,3 +45,34 @@ $ a_1/b_2, 1/f(x), zeta(x)/2, "foo"[|x|]/2 \ --- math-frac-gap --- // Test that the gap above and below the fraction rule is correct. $ sqrt(n^(2/3)) $ + +--- math-frac-horizontal --- +// Test that horizontal fractions look identical to inline math with `slash` +#set math.frac(style: "horizontal") +$ (a / b) / (c / (d / e)) $ +$ (a slash b) slash (c slash (d slash e)) $ + +--- math-frac-horizontal-lr-paren --- +// Test that parentheses are in a left-right pair even when rebuilt by a horizontal fraction +#set math.frac(style: "horizontal") +$ (#v(2em)) / n $ + +--- math-frac-skewed --- +// Test skewed fractions +#set math.frac(style: "skewed") +$ a / b, a / (b / c) $ + +--- math-frac-horizontal-explicit --- +// Test that explicit fractions don't change parentheses +#set math.frac(style: "horizontal") +$ frac(a, (b + c)), frac(a, b + c) $ + +--- math-frac-horizontal-nonparen-brackets --- +// Test that non-parentheses left-right pairs remain untouched +#set math.frac(style: "horizontal") +$ [x+y] / {z} $ + +--- math-frac-styles-inline --- +// Test inline layout of styled fractions +#set math.frac(style: "horizontal") +$a/(b+c), frac(a, b+c, style: "skewed"), frac(a, b+c, style: "vertical")$