diff --git a/crates/typst/src/layout/inline/mod.rs b/crates/typst/src/layout/inline/mod.rs index 39af8ef18..20e6dcbc6 100644 --- a/crates/typst/src/layout/inline/mod.rs +++ b/crates/typst/src/layout/inline/mod.rs @@ -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), /// 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], + 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::() { - 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::() { 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], @@ -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) { diff --git a/crates/typst/src/math/ctx.rs b/crates/typst/src/math/ctx.rs index 9352f08b1..73cb87071 100644 --- a/crates/typst/src/math/ctx.rs +++ b/crates/typst/src/math/ctx.rs @@ -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 { + let row = self.layout_fragments(elem)?; + Ok(MathRow::new(row)) + } + pub fn layout_fragment( &mut self, elem: &dyn LayoutMath, diff --git a/crates/typst/src/math/equation.rs b/crates/typst/src/math/equation.rs index dee7fef34..04e408040 100644 --- a/crates/typst/src/math/equation.rs +++ b/crates/typst/src/math/equation.rs @@ -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 { - const NUMBER_GUTTER: Em = Em::new(0.5); - - let block = self.block(styles); + ) -> SourceResult> { + 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 { + 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 { + 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) +} diff --git a/crates/typst/src/math/row.rs b/crates/typst/src/math/row.rs index cd75e7c32..48b339342 100644 --- a/crates/typst/src/math/row.rs +++ b/crates/typst/src/math/row.rs @@ -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 { + 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> From for MathRow { diff --git a/tests/ref/math/delimited.png b/tests/ref/math/delimited.png index 727f2d13a..5c827c48c 100644 Binary files a/tests/ref/math/delimited.png and b/tests/ref/math/delimited.png differ diff --git a/tests/ref/math/linebreak.png b/tests/ref/math/linebreak.png new file mode 100644 index 000000000..f3212a4a1 Binary files /dev/null and b/tests/ref/math/linebreak.png differ diff --git a/tests/ref/text/linebreak-obj.png b/tests/ref/text/linebreak-obj.png index 3c21377c4..b13ced1af 100644 Binary files a/tests/ref/text/linebreak-obj.png and b/tests/ref/text/linebreak-obj.png differ diff --git a/tests/typ/math/delimited.typ b/tests/typ/math/delimited.typ index 6607c3028..ba623b34d 100644 --- a/tests/typ/math/delimited.typ +++ b/tests/typ/math/delimited.typ @@ -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))$ diff --git a/tests/typ/math/linebreak.typ b/tests/typ/math/linebreak.typ new file mode 100644 index 000000000..88ce69d24 --- /dev/null +++ b/tests/typ/math/linebreak.typ @@ -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. diff --git a/tests/typ/math/spacing.typ b/tests/typ/math/spacing.typ index e62d2eb3a..63a60ae15 100644 --- a/tests/typ/math/spacing.typ +++ b/tests/typ/math/spacing.typ @@ -3,7 +3,7 @@ --- // Test spacing cases. $ä, +, c, (, )$ \ -$=), (+), {times}$ +$=), (+), {times}$ \ $⟧<⟦, abs(-), [=$ \ $a=b, a==b$ \ $-a, +a$ \