Implement math kerning and fix various math.attach bugs (#4762)

This commit is contained in:
Max 2024-08-26 17:04:02 +00:00 committed by GitHub
parent c38d01e4c5
commit 373163dba4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 333 additions and 127 deletions

2
Cargo.lock generated
View file

@ -2704,7 +2704,7 @@ dependencies = [
[[package]]
name = "typst-dev-assets"
version = "0.11.0"
source = "git+https://github.com/typst/typst-dev-assets?rev=48a924d#48a924d9de82b631bc775124a69384c8d860db04"
source = "git+https://github.com/typst/typst-dev-assets?rev=e9f8127#e9f81271547c0d7003770b4fa1e59343e51f7ae8"
[[package]]
name = "typst-docs"

View file

@ -28,7 +28,7 @@ typst-syntax = { path = "crates/typst-syntax", version = "0.11.0" }
typst-timing = { path = "crates/typst-timing", version = "0.11.0" }
typst-utils = { path = "crates/typst-utils", version = "0.11.0" }
typst-assets = { git = "https://github.com/typst/typst-assets", rev = "4ee794c" }
typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "48a924d" }
typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "e9f8127" }
arrayvec = "0.7.4"
az = "1.2"
base64 = "0.22"

View file

@ -83,6 +83,27 @@ impl<T: Clone> ArcExt<T> for Arc<T> {
}
}
/// Extra methods for [`Option`].
pub trait OptionExt<T> {
/// Maps an `Option<T>` to `U` by applying a function to a contained value
/// (if `Some`) or returns a default (if `None`).
fn map_or_default<U: Default, F>(self, f: F) -> U
where
F: FnOnce(T) -> U;
}
impl<T> OptionExt<T> for Option<T> {
fn map_or_default<U: Default, F>(self, f: F) -> U
where
F: FnOnce(T) -> U,
{
match self {
Some(x) => f(x),
None => U::default(),
}
}
}
/// Extra methods for [`[T]`](slice).
pub trait SliceExt<T> {
/// Split a slice into consecutive runs with the same key and yield for

View file

@ -262,6 +262,16 @@ pub enum Corner {
}
impl Corner {
/// The opposite corner.
pub fn inv(self) -> Self {
match self {
Self::TopLeft => Self::BottomRight,
Self::TopRight => Self::BottomLeft,
Self::BottomRight => Self::TopLeft,
Self::BottomLeft => Self::TopRight,
}
}
/// The next corner, clockwise.
pub fn next_cw(self) -> Self {
match self {

View file

@ -2,12 +2,13 @@ use unicode_math_class::MathClass;
use crate::diag::SourceResult;
use crate::foundations::{elem, Content, Packed, StyleChain};
use crate::layout::{Abs, Frame, Point, Size};
use crate::layout::{Abs, Corner, Frame, Point, Size};
use crate::math::{
style_for_subscript, style_for_superscript, EquationElem, FrameFragment, LayoutMath,
MathContext, MathFragment, MathSize, Scaled,
};
use crate::text::TextElem;
use crate::utils::OptionExt;
/// A base with optional attachments.
///
@ -240,7 +241,7 @@ impl Limits {
}
}
/// Whether limits should be displayed in this context
/// Whether limits should be displayed in this context.
pub fn active(&self, styles: StyleChain) -> bool {
match self {
Self::Always => true,
@ -300,145 +301,231 @@ fn layout_attachments(
base: MathFragment,
[tl, t, tr, bl, b, br]: [Option<MathFragment>; 6],
) -> SourceResult<()> {
let (shift_up, shift_down) = if [&tl, &tr, &bl, &br].iter().all(|e| e.is_none()) {
(Abs::zero(), Abs::zero())
} else {
compute_shifts_up_and_down(ctx, styles, &base, [&tl, &tr, &bl, &br])
};
let sup_delta = Abs::zero();
let sub_delta = -base.italics_correction();
let (base_width, base_ascent, base_descent) =
(base.width(), base.ascent(), base.descent());
let base_class = base.class();
let mut ascent = base_ascent
.max(shift_up + measure!(tr, ascent))
.max(shift_up + measure!(tl, ascent))
.max(shift_up + measure!(t, height));
let mut descent = base_descent
.max(shift_down + measure!(br, descent))
.max(shift_down + measure!(bl, descent))
.max(shift_down + measure!(b, height));
let pre_sup_width = measure!(tl, width);
let pre_sub_width = measure!(bl, width);
let pre_width_dif = pre_sup_width - pre_sub_width; // Could be negative.
let pre_width_max = pre_sup_width.max(pre_sub_width);
let post_width_max =
(sup_delta + measure!(tr, width)).max(sub_delta + measure!(br, width));
let (center_frame, base_offset) = if t.is_none() && b.is_none() {
(base.into_frame(), Abs::zero())
// Calculate the distance from the base's baseline to the superscripts' and
// subscripts' baseline.
let (tx_shift, bx_shift) = if [&tl, &tr, &bl, &br].iter().all(|e| e.is_none()) {
(Abs::zero(), Abs::zero())
} else {
attach_top_and_bottom(ctx, styles, base, t, b)
compute_script_shifts(ctx, styles, &base, [&tl, &tr, &bl, &br])
};
if [&tl, &bl, &tr, &br].iter().all(|&e| e.is_none()) {
ctx.push(FrameFragment::new(ctx, styles, center_frame).with_class(base_class));
return Ok(());
}
ascent.set_max(center_frame.ascent());
descent.set_max(center_frame.descent());
// Calculate the distance from the base's baseline to the top attachment's
// and bottom attachment's baseline.
let (t_shift, b_shift) =
compute_limit_shifts(ctx, styles, &base, [t.as_ref(), b.as_ref()]);
let mut frame = Frame::soft(Size::new(
pre_width_max
+ base_width
+ post_width_max
+ scaled!(ctx, styles, space_after_script),
ascent + descent,
));
frame.set_baseline(ascent);
frame.push_frame(
Point::new(sup_delta + pre_width_max, frame.ascent() - base_ascent - base_offset),
center_frame,
// Calculate the final frame height.
let ascent = base
.ascent()
.max(tx_shift + measure!(tr, ascent))
.max(tx_shift + measure!(tl, ascent))
.max(t_shift + measure!(t, ascent));
let descent = base
.descent()
.max(bx_shift + measure!(br, descent))
.max(bx_shift + measure!(bl, descent))
.max(b_shift + measure!(b, descent));
let height = ascent + descent;
// Calculate the vertical position of each element in the final frame.
let base_y = ascent - base.ascent();
let tx_y = |tx: &MathFragment| ascent - tx_shift - tx.ascent();
let bx_y = |bx: &MathFragment| ascent + bx_shift - bx.ascent();
let t_y = |t: &MathFragment| ascent - t_shift - t.ascent();
let b_y = |b: &MathFragment| ascent + b_shift - b.ascent();
// Calculate the distance each limit extends to the left and right of the
// base's width.
let ((t_pre_width, t_post_width), (b_pre_width, b_post_width)) =
compute_limit_widths(&base, [t.as_ref(), b.as_ref()]);
// `space_after_script` is extra spacing that is at the start before each
// pre-script, and at the end after each post-script (see the MathConstants
// table in the OpenType MATH spec).
let space_after_script = scaled!(ctx, styles, space_after_script);
// Calculate the distance each pre-script extends to the left of the base's
// width.
let (tl_pre_width, bl_pre_width) = compute_pre_script_widths(
ctx,
&base,
[tl.as_ref(), bl.as_ref()],
(tx_shift, bx_shift),
space_after_script,
);
if let Some(tl) = tl {
let pos =
Point::new(-pre_width_dif.min(Abs::zero()), ascent - shift_up - tl.ascent());
frame.push_frame(pos, tl.into_frame());
// Calculate the distance each post-script extends to the right of the
// base's width. Also calculate each post-script's kerning (we need this for
// its position later).
let ((tr_post_width, tr_kern), (br_post_width, br_kern)) = compute_post_script_widths(
ctx,
&base,
[tr.as_ref(), br.as_ref()],
(tx_shift, bx_shift),
space_after_script,
);
// Calculate the final frame width.
let pre_width = t_pre_width.max(b_pre_width).max(tl_pre_width).max(bl_pre_width);
let base_width = base.width();
let post_width = t_post_width.max(b_post_width).max(tr_post_width).max(br_post_width);
let width = pre_width + base_width + post_width;
// Calculate the horizontal position of each element in the final frame.
let base_x = pre_width;
let tl_x = pre_width - tl_pre_width + space_after_script;
let bl_x = pre_width - bl_pre_width + space_after_script;
let tr_x = pre_width + base_width + tr_kern;
let br_x = pre_width + base_width + br_kern;
let t_x = pre_width - t_pre_width;
let b_x = pre_width - b_pre_width;
// Create the final frame.
let mut frame = Frame::soft(Size::new(width, height));
frame.set_baseline(ascent);
frame.push_frame(Point::new(base_x, base_y), base.into_frame());
macro_rules! layout {
($e: ident, $x: ident, $y: ident) => {
if let Some($e) = $e {
frame.push_frame(Point::new($x, $y(&$e)), $e.into_frame());
}
};
}
if let Some(bl) = bl {
let pos =
Point::new(pre_width_dif.max(Abs::zero()), ascent + shift_down - bl.ascent());
frame.push_frame(pos, bl.into_frame());
}
if let Some(tr) = tr {
let pos = Point::new(
sup_delta + pre_width_max + base_width,
ascent - shift_up - tr.ascent(),
);
frame.push_frame(pos, tr.into_frame());
}
if let Some(br) = br {
let pos = Point::new(
sub_delta + pre_width_max + base_width,
ascent + shift_down - br.ascent(),
);
frame.push_frame(pos, br.into_frame());
}
layout!(tl, tl_x, tx_y); // pre-superscript
layout!(bl, bl_x, bx_y); // pre-subscript
layout!(tr, tr_x, tx_y); // post-superscript
layout!(br, br_x, bx_y); // post-subscript
layout!(t, t_x, t_y); // upper-limit
layout!(b, b_x, b_y); // lower-limit
// Done! Note that we retain the class of the base.
ctx.push(FrameFragment::new(ctx, styles, frame).with_class(base_class));
Ok(())
}
fn attach_top_and_bottom(
/// Calculate the distance each post-script extends to the right of the base's
/// width, as well as its kerning value. Requires the distance from the base's
/// baseline to each post-script's baseline to obtain the correct kerning value.
/// Returns 2 tuples of two lengths, each first containing the distance the
/// post-script extends left of the base's width and second containing the
/// post-script's kerning value. The first tuple is for the post-superscript,
/// and the second is for the post-subscript.
fn compute_post_script_widths(
ctx: &MathContext,
styles: StyleChain,
base: MathFragment,
t: Option<MathFragment>,
b: Option<MathFragment>,
) -> (Frame, Abs) {
let upper_gap_min = scaled!(ctx, styles, upper_limit_gap_min);
let upper_rise_min = scaled!(ctx, styles, upper_limit_baseline_rise_min);
let lower_gap_min = scaled!(ctx, styles, lower_limit_gap_min);
let lower_drop_min = scaled!(ctx, styles, lower_limit_baseline_drop_min);
base: &MathFragment,
[tr, br]: [Option<&MathFragment>; 2],
(tr_shift, br_shift): (Abs, Abs),
space_after_post_script: Abs,
) -> ((Abs, Abs), (Abs, Abs)) {
let tr_values = tr.map_or_default(|tr| {
let kern = math_kern(ctx, base, tr, tr_shift, Corner::TopRight);
(space_after_post_script + tr.width() + kern, kern)
});
let mut base_offset = Abs::zero();
let mut width = base.width();
let mut height = base.height();
// The base's bounding box already accounts for its italic correction, so we
// need to shift the post-subscript left by the base's italic correction
// (see the kerning algorithm as described in the OpenType MATH spec).
let br_values = br.map_or_default(|br| {
let kern = math_kern(ctx, base, br, br_shift, Corner::BottomRight)
- base.italics_correction();
(space_after_post_script + br.width() + kern, kern)
});
if let Some(t) = &t {
let top_gap = upper_gap_min.max(upper_rise_min - t.descent());
width.set_max(t.width());
height += t.height() + top_gap;
base_offset = top_gap + t.height();
}
if let Some(b) = &b {
let bottom_gap = lower_gap_min.max(lower_drop_min - b.ascent());
width.set_max(b.width());
height += b.height() + bottom_gap;
}
let base_pos = Point::new((width - base.width()) / 2.0, base_offset);
let delta = base.italics_correction() / 2.0;
let mut frame = Frame::soft(Size::new(width, height));
frame.set_baseline(base_pos.y + base.ascent());
frame.push_frame(base_pos, base.into_frame());
if let Some(t) = t {
let top_pos = Point::with_x((width - t.width()) / 2.0 + delta);
frame.push_frame(top_pos, t.into_frame());
}
if let Some(b) = b {
let bottom_pos =
Point::new((width - b.width()) / 2.0 - delta, height - b.height());
frame.push_frame(bottom_pos, b.into_frame());
}
(frame, base_offset)
(tr_values, br_values)
}
fn compute_shifts_up_and_down(
/// Calculate the distance each pre-script extends to the left of the base's
/// width. Requires the distance from the base's baseline to each pre-script's
/// baseline to obtain the correct kerning value.
/// Returns two lengths, the first being the distance the pre-superscript
/// extends left of the base's width and the second being the distance the
/// pre-subscript extends left of the base's width.
fn compute_pre_script_widths(
ctx: &MathContext,
base: &MathFragment,
[tl, bl]: [Option<&MathFragment>; 2],
(tl_shift, bl_shift): (Abs, Abs),
space_before_pre_script: Abs,
) -> (Abs, Abs) {
let tl_pre_width = tl.map_or_default(|tl| {
let kern = math_kern(ctx, base, tl, tl_shift, Corner::TopLeft);
space_before_pre_script + tl.width() + kern
});
let bl_pre_width = bl.map_or_default(|bl| {
let kern = math_kern(ctx, base, bl, bl_shift, Corner::BottomLeft);
space_before_pre_script + bl.width() + kern
});
(tl_pre_width, bl_pre_width)
}
/// Calculate the distance each limit extends beyond the base's width, in each
/// direction. Can be a negative value if the limit does not extend beyond the
/// base's width, indicating how far into the base's width the limit extends.
/// Returns 2 tuples of two lengths, each first containing the distance the
/// limit extends leftward beyond the base's width and second containing the
/// distance the limit extends rightward beyond the base's width. The first
/// tuple is for the upper-limit, and the second is for the lower-limit.
fn compute_limit_widths(
base: &MathFragment,
[t, b]: [Option<&MathFragment>; 2],
) -> ((Abs, Abs), (Abs, Abs)) {
// The upper- (lower-) limit is shifted to the right (left) of the base's
// center by half the base's italic correction.
let delta = base.italics_correction() / 2.0;
let t_widths = t.map_or_default(|t| {
let half = (t.width() - base.width()) / 2.0;
(half - delta, half + delta)
});
let b_widths = b.map_or_default(|b| {
let half = (b.width() - base.width()) / 2.0;
(half + delta, half - delta)
});
(t_widths, b_widths)
}
/// Calculate the distance from the base's baseline to each limit's baseline.
/// Returns two lengths, the first being the distance to the upper-limit's
/// baseline and the second being the distance to the lower-limit's baseline.
fn compute_limit_shifts(
ctx: &MathContext,
styles: StyleChain,
base: &MathFragment,
[t, b]: [Option<&MathFragment>; 2],
) -> (Abs, Abs) {
// `upper_gap_min` and `lower_gap_min` give gaps to the descender and
// ascender of the limits respectively, whereas `upper_rise_min` and
// `lower_drop_min` give gaps to each limit's baseline (see the
// MathConstants table in the OpenType MATH spec).
let t_shift = t.map_or_default(|t| {
let upper_gap_min = scaled!(ctx, styles, upper_limit_gap_min);
let upper_rise_min = scaled!(ctx, styles, upper_limit_baseline_rise_min);
base.ascent() + upper_rise_min.max(upper_gap_min + t.descent())
});
let b_shift = b.map_or_default(|b| {
let lower_gap_min = scaled!(ctx, styles, lower_limit_gap_min);
let lower_drop_min = scaled!(ctx, styles, lower_limit_baseline_drop_min);
base.descent() + lower_drop_min.max(lower_gap_min + b.ascent())
});
(t_shift, b_shift)
}
/// Calculate the distance from the base's baseline to each script's baseline.
/// Returns two lengths, the first being the distance to the superscripts'
/// baseline and the second being the distance to the subscripts' baseline.
fn compute_script_shifts(
ctx: &MathContext,
styles: StyleChain,
base: &MathFragment,
@ -504,7 +591,56 @@ fn compute_shifts_up_and_down(
(shift_up, shift_down)
}
/// Determines if the character is one of a variety of integral signs
/// Calculate the kerning value for a script with respect to the base. A
/// positive value means shifting the script further away from the base, whereas
/// a negative value means shifting the script closer to the base. Requires the
/// distance from the base's baseline to the script's baseline, as well as the
/// script's corner (tl, tr, bl, br).
fn math_kern(
ctx: &MathContext,
base: &MathFragment,
script: &MathFragment,
shift: Abs,
pos: Corner,
) -> Abs {
// This process is described under the MathKernInfo table in the OpenType
// MATH spec.
let (corr_height_top, corr_height_bot) = match pos {
// Calculate two correction heights for superscripts:
// - The distance from the superscript's baseline to the top of the
// base's bounding box.
// - The distance from the base's baseline to the bottom of the
// superscript's bounding box.
Corner::TopLeft | Corner::TopRight => {
(base.ascent() - shift, shift - script.descent())
}
// Calculate two correction heights for subscripts:
// - The distance from the base's baseline to the top of the
// subscript's bounding box.
// - The distance from the subscript's baseline to the bottom of the
// base's bounding box.
Corner::BottomLeft | Corner::BottomRight => {
(script.ascent() - shift, shift - base.descent())
}
};
// Calculate the sum of kerning values for each correction height.
let summed_kern = |height| {
let base_kern = base.kern_at_height(ctx, pos, height);
let attach_kern = script.kern_at_height(ctx, pos.inv(), height);
base_kern + attach_kern
};
// Take the smaller kerning amount (and so the larger value). Note that
// there is a bug in the spec (as of 2024-08-15): it says to take the
// minimum of the two sums, but as the kerning value is usually negative it
// really means the smaller kern. The current wording of the spec could
// result in glyphs colliding.
summed_kern(corr_height_top).max(summed_kern(corr_height_bot))
}
/// Determines if the character is one of a variety of integral signs.
fn is_integral_char(c: char) -> bool {
('∫'..='∳').contains(&c) || ('⨋'..='⨜').contains(&c)
}

View file

@ -184,6 +184,18 @@ impl MathFragment {
_ => Limits::Never,
}
}
/// If no kern table is provided for a corner, a kerning amount of zero is
/// assumed.
pub fn kern_at_height(&self, ctx: &MathContext, corner: Corner, height: Abs) -> Abs {
match self {
Self::Glyph(glyph) => {
kern_at_height(ctx, glyph.font_size, glyph.id, corner, height)
.unwrap_or_default()
}
_ => Abs::zero(),
}
}
}
impl From<GlyphFragment> for MathFragment {
@ -552,10 +564,6 @@ fn is_extended_shape(ctx: &MathContext, id: GlyphId) -> bool {
}
/// Look up a kerning value at a specific corner and height.
///
/// This can be integrated once we've found a font that actually provides this
/// data.
#[allow(unused)]
fn kern_at_height(
ctx: &MathContext,
font_size: Abs,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 965 B

After

Width:  |  Height:  |  Size: 964 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 952 B

After

Width:  |  Height:  |  Size: 957 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 675 B

After

Width:  |  Height:  |  Size: 670 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -129,6 +129,37 @@ $integral.sect_a^b quad \u{2a1b}_a^b quad limits(\u{2a1b})_a^b$
$ tack.t.big_0^1 quad \u{02A0A}_0^1 quad join_0^1 $
$tack.t.big_0^1 quad \u{02A0A}_0^1 quad join_0^1$
--- math-attach-limit-long ---
// Test long limit attachments.
$ attach(product, t: 123456789) attach(product, t: 123456789, bl: x) \
attach(product, b: 123456789) attach(product, b: 123456789, tr: x) $
$attach(limits(product), t: 123456789) attach(limits(product), t: 123456789, bl: x)$
$attach(limits(product), b: 123456789) attach(limits(product), b: 123456789, tr: x)$
--- math-attach-kerning ---
// Test math kerning.
#show math.equation: set text(font: "STIX Two Math")
$ L^A Y^c R^2 delta^y omega^f a^2 t^w gamma^V p^+ \
b_lambda f_k p_i x_1 x_j x_A y_l y_y beta_s theta_k \
J_0 Y_0 T_1 T_f V_a V_A F_j cal(F)_j lambda_y \
attach(W, tl: l) attach(A, tl: 2) attach(cal(V), tl: beta)
attach(cal(P), tl: iota) attach(f, bl: i) attach(A, bl: x)
attach(cal(J), bl: xi) attach(cal(A), bl: m) $
--- math-attach-kerning-mixed ---
// Test mixtures of math kerning.
#show math.equation: set text(font: "STIX Two Math")
$ x_1^i x_2^lambda x_2^(2alpha) x_2^(k+1) x_2^(-p_(-1)) x_j^gamma \
f_2^2 v_0^2 z_0^2 beta_s^2 xi_i^k J_1^2 N_(k y)^(-1) V_pi^x \
attach(J, tl: 1, br: i) attach(P, tl: i, br: 2) B_i_0 phi.alt_i_(n-1)
attach(A, tr: x, bl: x, br: x, tl: x) attach(F, tl: i, tr: f) \
attach(cal(A), tl: 2, bl: o) attach(cal(J), bl: l, br: A)
attach(cal(y), tr: p, bl: n t) attach(cal(O), tl: 16, tr: +, br: sigma)
attach(italic(Upsilon), tr: s, br: Psi, bl: d) $
--- math-attach-nested-base ---
// Test attachments when the base has attachments.
$ attach(a^b, b: c) quad