Implement fraction styles: vertical, skewed, and horizontal. (#6672)

This commit is contained in:
Théophile Cailliau 2025-08-17 12:48:26 +02:00 committed by GitHub
parent 5ef94692ca
commit f985d15a94
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 251 additions and 18 deletions

View File

@ -123,9 +123,20 @@ impl Eval for ast::MathFrac<'_> {
type Output = Content;
fn eval(self, vm: &mut Vm) -> SourceResult<Self::Output> {
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())
}
}

View File

@ -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(())
}

View File

@ -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.

View File

@ -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,

View File

@ -840,6 +840,16 @@ impl<'a> Math<'a> {
pub fn exprs(self) -> impl DoubleEndedIterator<Item = Expr<'a>> {
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! {

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

View File

@ -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")$