Inline equations linebreak at appropriate places (#2938)

Co-authored-by: David Maxwell <damaxwell@alaska.edu>
Co-authored-by: Laurenz <laurmaedje@gmail.com>
This commit is contained in:
Myriad-Dreamin 2024-01-03 20:04:36 +08:00 committed by GitHub
parent 3aeb150c95
commit 34e3bd52aa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 298 additions and 80 deletions

View File

@ -19,7 +19,7 @@ use crate::layout::{
Abs, AlignElem, Axes, BoxElem, Dir, Em, FixedAlign, Fr, Fragment, Frame, HElem,
Layout, Point, Regions, Size, Sizing, Spacing,
};
use crate::math::EquationElem;
use crate::math::{EquationElem, MathParItem};
use crate::model::{Linebreaks, ParElem};
use crate::syntax::Span;
use crate::text::{
@ -61,7 +61,8 @@ pub(crate) fn layout_inline(
};
// Collect all text into one string for BiDi analysis.
let (text, segments, spans) = collect(children, &styles, consecutive)?;
let (text, segments, spans) =
collect(children, &mut engine, &styles, region, consecutive)?;
// Perform BiDi analysis and then prepare paragraph layout by building a
// representation on which we can do line breaking without layouting
@ -180,7 +181,7 @@ impl<'a> Preparation<'a> {
}
/// A segment of one or multiple collapsed children.
#[derive(Debug, Copy, Clone)]
#[derive(Debug, Clone)]
enum Segment<'a> {
/// One or multiple collapsed text or text-equivalent children. Stores how
/// long the segment is (in bytes of the full text string).
@ -188,7 +189,7 @@ enum Segment<'a> {
/// Horizontal spacing between other segments.
Spacing(Spacing),
/// A mathematical equation.
Equation(&'a EquationElem),
Equation(&'a EquationElem, Vec<MathParItem>),
/// A box with arbitrary content.
Box(&'a BoxElem, bool),
/// Metadata.
@ -201,8 +202,12 @@ impl Segment<'_> {
match *self {
Self::Text(len) => len,
Self::Spacing(_) => SPACING_REPLACE.len_utf8(),
Self::Box(_, true) => SPACING_REPLACE.len_utf8(),
Self::Equation(_) | Self::Box(_, _) => OBJ_REPLACE.len_utf8(),
Self::Box(_, frac) => {
(if frac { SPACING_REPLACE } else { OBJ_REPLACE }).len_utf8()
}
Self::Equation(_, ref par_items) => {
par_items.iter().map(MathParItem::text).map(char::len_utf8).sum()
}
Self::Meta => 0,
}
}
@ -395,12 +400,14 @@ impl<'a> Line<'a> {
}
}
/// Collect all text of the paragraph into one string. This also performs
/// string-level preprocessing like case transformations.
/// Collect all text of the paragraph into one string and layout equations. This
/// also performs string-level preprocessing like case transformations.
#[allow(clippy::type_complexity)]
fn collect<'a>(
children: &'a [Prehashed<Content>],
engine: &mut Engine<'_>,
styles: &'a StyleChain<'a>,
region: Size,
consecutive: bool,
) -> SourceResult<(String, Vec<(Segment<'a>, StyleChain<'a>)>, SpanMapper)> {
let mut full = String::new();
@ -493,8 +500,10 @@ fn collect<'a>(
}
Segment::Text(full.len() - prev)
} else if let Some(elem) = child.to::<EquationElem>() {
full.push(OBJ_REPLACE);
Segment::Equation(elem)
let pod = Regions::one(region, Axes::splat(false));
let items = elem.layout_inline(engine, styles, pod)?;
full.extend(items.iter().map(MathParItem::text));
Segment::Equation(elem, items)
} else if let Some(elem) = child.to::<BoxElem>() {
let frac = elem.width(styles).is_fractional();
full.push(if frac { SPACING_REPLACE } else { OBJ_REPLACE });
@ -512,7 +521,7 @@ fn collect<'a>(
spans.push(segment.len(), child.span());
if let (Some((Segment::Text(last_len), last_styles)), Segment::Text(len)) =
(segments.last_mut(), segment)
(segments.last_mut(), &segment)
{
if *last_styles == styles {
*last_len += len;
@ -526,8 +535,7 @@ fn collect<'a>(
Ok((full, segments, spans))
}
/// Prepare paragraph layout by shaping the whole paragraph and layouting all
/// contained inline-level content.
/// Prepare paragraph layout by shaping the whole paragraph.
fn prepare<'a>(
engine: &mut Engine,
children: &'a [Prehashed<Content>],
@ -566,11 +574,16 @@ fn prepare<'a>(
items.push(Item::Fractional(v, None));
}
},
Segment::Equation(equation) => {
let pod = Regions::one(region, Axes::splat(false));
let mut frame = equation.layout(engine, styles, pod)?.into_frame();
frame.translate(Point::with_y(TextElem::baseline_in(styles)));
items.push(Item::Frame(frame));
Segment::Equation(_, par_items) => {
for item in par_items {
match item {
MathParItem::Space(s) => items.push(Item::Absolute(s)),
MathParItem::Frame(mut frame) => {
frame.translate(Point::with_y(TextElem::baseline_in(styles)));
items.push(Item::Frame(frame));
}
}
}
}
Segment::Box(elem, _) => {
if let Sizing::Fr(v) = elem.width(styles) {

View File

@ -140,6 +140,11 @@ impl<'a, 'b, 'v> MathContext<'a, 'b, 'v> {
self.fragments.extend(fragments);
}
pub fn layout_root(&mut self, elem: &dyn LayoutMath) -> SourceResult<MathRow> {
let row = self.layout_fragments(elem)?;
Ok(MathRow::new(row))
}
pub fn layout_fragment(
&mut self,
elem: &dyn LayoutMath,

View File

@ -8,13 +8,14 @@ use crate::foundations::{
};
use crate::introspection::{Count, Counter, CounterUpdate, Locatable};
use crate::layout::{
Abs, Align, AlignElem, Axes, Dir, Em, FixedAlign, Fragment, Layout, Point, Regions,
Size,
Abs, Align, AlignElem, Axes, Dir, Em, FixedAlign, Fragment, Frame, Layout, Point,
Regions, Size,
};
use crate::math::{LayoutMath, MathContext};
use crate::model::{Numbering, Outlinable, ParElem, Refable, Supplement};
use crate::syntax::Span;
use crate::text::{
families, variant, FontFamily, FontList, FontWeight, Lang, LocalName, Region,
families, variant, Font, FontFamily, FontList, FontWeight, Lang, LocalName, Region,
TextElem,
};
use crate::util::{option_eq, NonZeroExt, Numeric};
@ -136,70 +137,47 @@ impl Finalize for EquationElem {
}
}
impl Layout for EquationElem {
#[typst_macros::time(name = "math.equation", span = self.span())]
fn layout(
/// Layouted items suitable for placing in a paragraph.
#[derive(Debug, Clone)]
pub enum MathParItem {
Space(Abs),
Frame(Frame),
}
impl MathParItem {
/// The text representation of this item.
pub fn text(&self) -> char {
match self {
MathParItem::Space(_) => ' ', // Space
MathParItem::Frame(_) => '\u{FFFC}', // Object Replacement Character
}
}
}
impl EquationElem {
pub fn layout_inline(
&self,
engine: &mut Engine,
engine: &mut Engine<'_>,
styles: StyleChain,
regions: Regions,
) -> SourceResult<Fragment> {
const NUMBER_GUTTER: Em = Em::new(0.5);
let block = self.block(styles);
) -> SourceResult<Vec<MathParItem>> {
assert!(!self.block(styles));
// Find a math font.
let variant = variant(styles);
let world = engine.world;
let Some(font) = families(styles).find_map(|family| {
let id = world.book().select(family, variant)?;
let font = world.font(id)?;
let _ = font.ttf().tables().math?.constants?;
Some(font)
}) else {
bail!(self.span(), "current font does not support math");
let font = find_math_font(engine, styles, self.span())?;
let mut ctx = MathContext::new(engine, styles, regions, &font, false);
let rows = ctx.layout_root(self)?;
let mut items = if rows.row_count() == 1 {
rows.into_par_items()
} else {
vec![MathParItem::Frame(rows.into_fragment(&ctx).into_frame())]
};
let mut ctx = MathContext::new(engine, styles, regions, &font, block);
let mut frame = ctx.layout_frame(self)?;
for item in &mut items {
let MathParItem::Frame(frame) = item else { continue };
if block {
if let Some(numbering) = self.numbering(styles) {
let pod = Regions::one(regions.base(), Axes::splat(false));
let counter = Counter::of(Self::elem())
.display(self.span(), Some(numbering), false)
.layout(engine, styles, pod)?
.into_frame();
let full_counter_width = counter.width() + NUMBER_GUTTER.resolve(styles);
let width = if regions.size.x.is_finite() {
regions.size.x
} else {
frame.width() + 2.0 * full_counter_width
};
let height = frame.height().max(counter.height());
let align = AlignElem::alignment_in(styles).resolve(styles).x;
frame.resize(Size::new(width, height), Axes::splat(align));
let dir = TextElem::dir_in(styles);
let offset = match (align, dir) {
(FixedAlign::Start, Dir::RTL) => full_counter_width,
(FixedAlign::End, Dir::LTR) => -full_counter_width,
_ => Abs::zero(),
};
frame.translate(Point::with_x(offset));
let x = if dir.is_positive() {
frame.width() - counter.width()
} else {
Abs::zero()
};
let y = (frame.height() - counter.height()) / 2.0;
frame.push_frame(Point::new(x, y), counter)
}
} else {
let font_size = TextElem::size_in(styles);
let slack = ParElem::leading_in(styles) * 0.7;
let top_edge = TextElem::top_edge_in(styles).resolve(font_size, &font, None);
@ -210,6 +188,67 @@ impl Layout for EquationElem {
let descent = bottom_edge.max(frame.descent() - slack);
frame.translate(Point::with_y(ascent - frame.baseline()));
frame.size_mut().y = ascent + descent;
// Apply metadata.
frame.meta(styles, false);
}
Ok(items)
}
}
impl Layout for EquationElem {
#[typst_macros::time(name = "math.equation", span = self.span())]
fn layout(
&self,
engine: &mut Engine,
styles: StyleChain,
regions: Regions,
) -> SourceResult<Fragment> {
const NUMBER_GUTTER: Em = Em::new(0.5);
assert!(self.block(styles));
// Find a math font.
let font = find_math_font(engine, styles, self.span())?;
let mut ctx = MathContext::new(engine, styles, regions, &font, true);
let mut frame = ctx.layout_frame(self)?;
if let Some(numbering) = self.numbering(styles) {
let pod = Regions::one(regions.base(), Axes::splat(false));
let counter = Counter::of(Self::elem())
.display(self.span(), Some(numbering), false)
.layout(engine, styles, pod)?
.into_frame();
let full_counter_width = counter.width() + NUMBER_GUTTER.resolve(styles);
let width = if regions.size.x.is_finite() {
regions.size.x
} else {
frame.width() + 2.0 * full_counter_width
};
let height = frame.height().max(counter.height());
let align = AlignElem::alignment_in(styles).resolve(styles).x;
frame.resize(Size::new(width, height), Axes::splat(align));
let dir = TextElem::dir_in(styles);
let offset = match (align, dir) {
(FixedAlign::Start, Dir::RTL) => full_counter_width,
(FixedAlign::End, Dir::LTR) => -full_counter_width,
_ => Abs::zero(),
};
frame.translate(Point::with_x(offset));
let x = if dir.is_positive() {
frame.width() - counter.width()
} else {
Abs::zero()
};
let y = (frame.height() - counter.height()) / 2.0;
frame.push_frame(Point::new(x, y), counter)
}
// Apply metadata.
@ -316,3 +355,21 @@ impl LayoutMath for EquationElem {
self.body().layout_math(ctx)
}
}
fn find_math_font(
engine: &mut Engine<'_>,
styles: StyleChain,
span: Span,
) -> SourceResult<Font> {
let variant = variant(styles);
let world = engine.world;
let Some(font) = families(styles).find_map(|family| {
let id = world.book().select(family, variant)?;
let font = world.font(id)?;
let _ = font.ttf().tables().math?.constants?;
Some(font)
}) else {
bail!(span, "current font does not support math");
};
Ok(font)
}

View File

@ -3,10 +3,10 @@ use std::iter::once;
use unicode_math_class::MathClass;
use crate::foundations::Resolve;
use crate::layout::{Abs, AlignElem, Em, FixedAlign, Frame, Point, Size};
use crate::layout::{Abs, AlignElem, Em, FixedAlign, Frame, FrameKind, Point, Size};
use crate::math::{
alignments, spacing, AlignmentResult, FrameFragment, MathContext, MathFragment,
MathSize, Scaled,
MathParItem, MathSize, Scaled,
};
use crate::model::ParElem;
@ -103,6 +103,19 @@ impl MathRow {
.collect()
}
pub fn row_count(&self) -> usize {
let mut count =
1 + self.0.iter().filter(|f| matches!(f, MathFragment::Linebreak)).count();
// A linebreak at the very end does not introduce an extra row.
if let Some(f) = self.0.last() {
if matches!(f, MathFragment::Linebreak) {
count -= 1
}
}
count
}
pub fn ascent(&self) -> Abs {
self.iter().map(MathFragment::ascent).max().unwrap_or_default()
}
@ -239,6 +252,85 @@ impl MathRow {
frame.size_mut().x = x;
frame
}
pub fn into_par_items(self) -> Vec<MathParItem> {
let mut items = vec![];
let mut x = Abs::zero();
let mut ascent = Abs::zero();
let mut descent = Abs::zero();
let mut frame = Frame::new(Size::zero(), FrameKind::Soft);
let finalize_frame = |frame: &mut Frame, x, ascent, descent| {
frame.set_size(Size::new(x, ascent + descent));
frame.set_baseline(Abs::zero());
frame.translate(Point::with_y(ascent));
};
let mut space_is_visible = false;
let is_relation =
|f: &MathFragment| matches!(f.class(), Some(MathClass::Relation));
let is_space = |f: &MathFragment| {
matches!(f, MathFragment::Space(_) | MathFragment::Spacing(_))
};
let mut iter = self.0.into_iter().peekable();
while let Some(fragment) = iter.next() {
if space_is_visible {
match fragment {
MathFragment::Space(s) | MathFragment::Spacing(s) => {
items.push(MathParItem::Space(s));
continue;
}
_ => {}
}
}
let class = fragment.class();
let y = fragment.ascent();
ascent.set_max(y);
descent.set_max(fragment.descent());
let pos = Point::new(x, -y);
x += fragment.width();
frame.push_frame(pos, fragment.into_frame());
if class == Some(MathClass::Binary)
|| (class == Some(MathClass::Relation)
&& !iter.peek().map(is_relation).unwrap_or_default())
{
let mut frame_prev = std::mem::replace(
&mut frame,
Frame::new(Size::zero(), FrameKind::Soft),
);
finalize_frame(&mut frame_prev, x, ascent, descent);
items.push(MathParItem::Frame(frame_prev));
x = Abs::zero();
ascent = Abs::zero();
descent = Abs::zero();
space_is_visible = true;
if let Some(f_next) = iter.peek() {
if !is_space(f_next) {
items.push(MathParItem::Space(Abs::zero()));
}
}
} else {
space_is_visible = false;
}
}
if !frame.is_empty() {
finalize_frame(&mut frame, x, ascent, descent);
items.push(MathParItem::Frame(frame));
}
items
}
}
impl<T: Into<MathFragment>> From<T> for MathRow {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

View File

@ -2,6 +2,7 @@
---
// Test automatic matching.
#set page(width:122pt)
$ (a) + {b/2} + abs(a)/2 + (b) $
$f(x/2) < zeta(c^2 + abs(a + b/2))$

View File

@ -0,0 +1,50 @@
// Test inline equation line breaking.
---
// Basic breaking after binop, rel
#let hrule(x) = box(line(length: x))
#hrule(45pt)$e^(pi i)+1 = 0$\
#hrule(55pt)$e^(pi i)+1 = 0$\
#hrule(70pt)$e^(pi i)+1 = 0$
---
// LR groups prevent linbreaking.
#let hrule(x) = box(line(length: x))
#hrule(76pt)$a+b$\
#hrule(74pt)$(a+b)$\
#hrule(74pt)$paren.l a+b paren.r$
---
// Multiline yet inline does not linebreak
#let hrule(x) = box(line(length: x))
#hrule(80pt)$a + b \ c + d$\
---
// A single linebreak at the end still counts as one line.
#let hrule(x) = box(line(length: x))
#hrule(60pt)$e^(pi i)+1 = 0\ $
---
// Inline, in a box, doesn't linebreak.
#let hrule(x) = box(line(length: x))
#hrule(80pt)#box($a+b$)
---
// A relation followed by a relation doesn't linebreak
#let hrule(x) = box(line(length: x))
#hrule(70pt)$a < = b$\
#hrule(74pt)$a < = b$
---
// Page breaks can happen after a relation even if there is no
// explicit space.
#let hrule(x) = box(line(length: x))
#hrule(90pt)$<;$\
#hrule(95pt)$<;$\
#hrule(90pt)$<)$\
#hrule(95pt)$<)$
---
// Verify empty rows are handled ok.
$ $\
Nothing: $ $, just empty.

View File

@ -3,7 +3,7 @@
---
// Test spacing cases.
$ä, +, c, (, )$ \
$=), (+), {times}$
$=), (+), {times}$ \
$<, abs(-), [=$ \
$a=b, a==b$ \
$-a, +a$ \