mirror of
https://github.com/typst/typst
synced 2025-05-20 03:55:29 +08:00
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:
parent
3aeb150c95
commit
34e3bd52aa
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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 |
BIN
tests/ref/math/linebreak.png
Normal file
BIN
tests/ref/math/linebreak.png
Normal file
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 |
@ -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))$
|
||||
|
||||
|
50
tests/typ/math/linebreak.typ
Normal file
50
tests/typ/math/linebreak.typ
Normal 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.
|
@ -3,7 +3,7 @@
|
||||
---
|
||||
// Test spacing cases.
|
||||
$ä, +, c, (, )$ \
|
||||
$=), (+), {times}$
|
||||
$=), (+), {times}$ \
|
||||
$⟧<⟦, abs(-), [=$ \
|
||||
$a=b, a==b$ \
|
||||
$-a, +a$ \
|
||||
|
Loading…
x
Reference in New Issue
Block a user