From f985d15a9418bcdd7b0d5299f6942a067a2b9996 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9ophile=20Cailliau?= Date: Sun, 17 Aug 2025 12:48:26 +0200 Subject: [PATCH] Implement fraction styles: vertical, skewed, and horizontal. (#6672) --- crates/typst-eval/src/math.rs | 17 +- crates/typst-layout/src/math/frac.rs | 162 ++++++++++++++++-- crates/typst-library/src/math/frac.rs | 39 ++++- crates/typst-library/src/text/font/mod.rs | 10 ++ crates/typst-syntax/src/ast.rs | 10 ++ tests/ref/math-frac-horizontal-explicit.png | Bin 0 -> 458 bytes tests/ref/math-frac-horizontal-lr-paren.png | Bin 0 -> 320 bytes ...math-frac-horizontal-nonparen-brackets.png | Bin 0 -> 346 bytes tests/ref/math-frac-horizontal.png | Bin 0 -> 515 bytes tests/ref/math-frac-skewed.png | Bin 0 -> 413 bytes tests/ref/math-frac-styles-inline.png | Bin 0 -> 539 bytes tests/suite/math/frac.typ | 31 ++++ 12 files changed, 251 insertions(+), 18 deletions(-) create mode 100644 tests/ref/math-frac-horizontal-explicit.png create mode 100644 tests/ref/math-frac-horizontal-lr-paren.png create mode 100644 tests/ref/math-frac-horizontal-nonparen-brackets.png create mode 100644 tests/ref/math-frac-horizontal.png create mode 100644 tests/ref/math-frac-skewed.png create mode 100644 tests/ref/math-frac-styles-inline.png 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 0000000000000000000000000000000000000000..09a12d93ab5e5ab6b954badb8b2ad4fe603bff29 GIT binary patch literal 458 zcmV;*0X6=KP)Z9#N4Yncv6#$# zzA|UhIb{|zMoQz_lNMV8EU{*g`6|xmrOQwIWI^?~2*z@SKHV0*)of2rjn5n-Fb7LU`ado0+?~;PX6;5-eF|I0va(37d-nqOeg%;RCdK za2L=#sD$;+@mB4q2xYGBQ3sK#D=4^ER&8%&-~L(kp%o@2N9t^ldhk_xb3MwcO%qsd z;(}Qz$1E_&R=^Q1DWo0`uTt}S{m|LE@HhuOeC^}kpoc3pp!TVUj{rE9 z!}TYV=eioswM<$$6aHjyGBSyhCe4Zr-dpS4LIn>p@yHB-u*l-WcYXzMFB^|+52*g| z{(A18P1JA!##b);h~eoya1eGc+M9<*>3F0^@YaFlA^(?O;=lSf-XQ`w6_^Am@XE@Q zEAN`PdFT(7aHC{WYRGS=lL&}Tk;7F7aK8001rM(XdN_EY9N6crg)MC1or701WBfV` SUzH310000RM=Pszv)vlG618}@}&#Bx0I)a4LUC_@d s<^d@dJ8D1{0ewL1R$JJ@7XGL3H*&totDwHm9RL6T07*qoM6N<$g66=YfB*mh literal 0 HcmV?d00001 diff --git a/tests/ref/math-frac-horizontal.png b/tests/ref/math-frac-horizontal.png new file mode 100644 index 0000000000000000000000000000000000000000..2d139df78087203b08db79109e8e36c2f0ede43f GIT binary patch literal 515 zcmV+e0{s1nP)O@KCkd-hHYC4gmnW2^irY04BAkl|K zmO^Ee7?h6Rj`IY6hFP|lA>91V4Loo-=jE-iLsJpp00%h0{{^0$29Pq)&(KeBEoZv} zV6mC;8GOCDf)w~Z!t`XR@M;TyH(iF$q7hV)SpsPOBHISrd$##?1GqO&%W8>mdJe$l z4GleLe~;0A`3nFNG%P^&Fw#GOjU;pF65$wnTdq7MU(J!Wh6Pi#Nal=RXT7eYiEmBB zaJRX#4hgdLy@SLMD-n+K>6oe^2A{E4>n*uTwl2~!z^Rd?3|IdU=iLeCCitA;=va3j zal}i9lNkrssAlH0GY=!UZz6(oyYi4gob#RbZ&9~wZTG9J^57Va!?XMDTd!rI=J%m| zwCV)Ws*hXj@wyu^Qlrxcx~RfQ^H0iKb$ZT9&-3=TI;Svvdoen$Y(3F&eSAE)>C&d? zxRxKhyfbC&5kB+}4%P);9Kdn^k-M3zi#a;DxiK*&TGN(aD-D~rsF@D2wBe9pW5v*-XKk1c+OW(-qOhe@ zp589~TK8{Vp3m`pdF}h%=kRHwLDQtb3ar2i+!**P13+M}&hVmZ(h8tX355%eH7|gv z6hd8?JV0&`q44l|q8-SNmmj=J=Zg&P1EKIwGhQRGeNieWj$rEs&@T&x6Wt6SNuaKz z&|UZyKD7viS$A73DH4X-zH*s~037FpZnf>6Sd{ANCD{X@M<_f3^SbKrXySS(2ya%Q z@WV399aa~^6XvtoI7{zBVdDpM>}(dCGnJo4gQ|cYq3{UEE!hR1d|C`aJ6t*agk3^m zX$x*f>GM9{7K`Q^08D9!`3j<7-7iSCOk2Jb{!{?K1O%p~s3=$q$aZt29QJns!0!Q7 z99a~cuMGpmG9`oL^(5|l(9foipZi%eBs62(*wZ4MIR3l7amP+~2WwiKhO zf{I9~#>ydDiidYC_jP!Z%O%{vIY0vUS$@Nl+&91Bo~Ma@(&UHzupd5r z1tC?a0V?j+_5TT1Zo!aAN0gmr-ijG7MwXx&!&UZP*DJh-`!&8O&^?7PfJ3(!Ap#}1 zTf$Yyy0GRY{C#9%1&F})PjKR?3IGy9SxG{N_~OgVa1kQ7Q!?#S9>TR|GyE9lN8yY` z{fK_;oiki&vD!@J6t;4l;v(Smch$kzUAVWGQ|r*P1wqJ%38Y|Jl8#GK3^+FmFrLJA z<2O$p0`I}LIOy@Yg#g700002ovPDHLkV1giE^yL5m literal 0 HcmV?d00001 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")$