diff --git a/crates/typst-layout/src/math/mat.rs b/crates/typst-layout/src/math/mat.rs index d678f8658..f478675b0 100644 --- a/crates/typst-layout/src/math/mat.rs +++ b/crates/typst-layout/src/math/mat.rs @@ -220,7 +220,7 @@ fn layout_body( let mut x = Abs::zero(); for (index, col) in cols.into_iter().enumerate() { - let AlignmentResult { points, width: rcol } = alignments(&col); + let AlignmentResult { points, width: rcol, .. } = alignments(&col, None); let mut y = Abs::zero(); diff --git a/crates/typst-layout/src/math/mod.rs b/crates/typst-layout/src/math/mod.rs index 708a4443d..b4791f26a 100644 --- a/crates/typst-layout/src/math/mod.rs +++ b/crates/typst-layout/src/math/mod.rs @@ -98,6 +98,12 @@ pub fn layout_equation_inline( Ok(items) } +pub struct EquationSizings<'a> { + region_size_x: Abs, + gaps: &'a [GapSizing], + padding: &'a [GapSizing], +} + /// Layout a block-level equation (in a flow). #[typst_macros::time(span = elem.span())] pub fn layout_equation_block( @@ -118,9 +124,17 @@ pub fn layout_equation_block( let scale_style = style_for_script_scale(&ctx); let styles = styles.chain(&scale_style); + let gaps = elem.column_gap(styles).resolve(styles); + let padding = elem.column_padding(styles).resolve(styles); + let sizings = EquationSizings { + region_size_x: regions.size.x, + gaps: gaps.0.as_slice(), + padding: padding.0.as_slice(), + }; + let full_equation_builder = ctx .layout_into_run(&elem.body, styles)? - .multiline_frame_builder(styles); + .multiline_frame_builder(styles, Some(sizings)); let width = full_equation_builder.size.x; let equation_builders = if BlockElem::breakable_in(styles) { diff --git a/crates/typst-layout/src/math/run.rs b/crates/typst-layout/src/math/run.rs index ae64368d6..e542b6385 100644 --- a/crates/typst-layout/src/math/run.rs +++ b/crates/typst-layout/src/math/run.rs @@ -6,7 +6,7 @@ use typst_library::math::{EquationElem, MathSize, MEDIUM, THICK, THIN}; use typst_library::model::ParElem; use unicode_math_class::MathClass; -use super::{alignments, FrameFragment, MathFragment}; +use super::{alignments, EquationSizings, FrameFragment, MathFragment}; const TIGHT_LEADING: Em = Em::new(0.25); @@ -165,7 +165,7 @@ impl MathRun { if !self.is_multiline() { self.into_line_frame(&[], LeftRightAlternator::Right) } else { - self.multiline_frame_builder(styles).build() + self.multiline_frame_builder(styles, None).build() } } @@ -189,10 +189,15 @@ impl MathRun { /// Returns a builder that lays out the [`MathFragment`]s into a possibly /// multi-row [`Frame`]. The rows are aligned using the same set of alignment /// points computed from them as a whole. - pub fn multiline_frame_builder(self, styles: StyleChain) -> MathRunFrameBuilder { + pub fn multiline_frame_builder( + self, + styles: StyleChain, + sizings: Option, + ) -> MathRunFrameBuilder { let rows: Vec<_> = self.rows(); let row_count = rows.len(); - let alignments = alignments(&rows); + + let alignments = alignments(&rows, sizings); let leading = if EquationElem::size_in(styles) >= MathSize::Text { ParElem::leading_in(styles) @@ -213,15 +218,17 @@ impl MathRun { size.y += leading; } - let mut pos = Point::with_y(size.y); + let mut pos = Point::new(alignments.padding.0, size.y); if alignments.points.is_empty() { - pos.x = align.position(alignments.width - sub.width()); + pos.x += align.position(alignments.width - sub.width()); } - size.x.set_max(sub.width()); + size.x.set_max(sub.width() + alignments.padding.0); size.y += sub.height(); frames.push((sub, pos)); } + size.x += alignments.padding.1; + MathRunFrameBuilder { size, frames } } diff --git a/crates/typst-layout/src/math/shared.rs b/crates/typst-layout/src/math/shared.rs index 600c130d4..d3c1cbc04 100644 --- a/crates/typst-layout/src/math/shared.rs +++ b/crates/typst-layout/src/math/shared.rs @@ -1,10 +1,12 @@ use ttf_parser::math::MathValue; use typst_library::foundations::{Style, StyleChain}; -use typst_library::layout::{Abs, Em, FixedAlignment, Frame, Point, Size, VAlignment}; -use typst_library::math::{EquationElem, MathSize}; +use typst_library::layout::{ + Abs, Em, FixedAlignment, Fr, Frame, Point, Size, VAlignment, +}; +use typst_library::math::{EquationElem, GapSizing, MathSize}; use typst_utils::LazyHash; -use super::{LeftRightAlternator, MathContext, MathFragment, MathRun}; +use super::{EquationSizings, LeftRightAlternator, MathContext, MathFragment, MathRun}; macro_rules! scaled { ($ctx:expr, $styles:expr, text: $text:ident, display: $display:ident $(,)?) => { @@ -118,7 +120,7 @@ pub fn stack( baseline: usize, alternator: LeftRightAlternator, ) -> Frame { - let AlignmentResult { points, width } = alignments(&rows); + let AlignmentResult { points, width, .. } = alignments(&rows, None); let rows: Vec<_> = rows .into_iter() .map(|row| row.into_line_frame(&points, alternator)) @@ -148,8 +150,15 @@ pub fn stack( frame } -/// Determine the positions of the alignment points, according to the input rows combined. -pub fn alignments(rows: &[MathRun]) -> AlignmentResult { +pub struct AlignmentResult { + pub points: Vec, + pub width: Abs, + pub padding: (Abs, Abs), +} + +/// Determine the positions of the alignment points, according to the input +/// rows combined. +pub fn alignments(rows: &[MathRun], sizings: Option) -> AlignmentResult { let mut widths = Vec::::new(); let mut pending_width = Abs::zero(); @@ -179,18 +188,80 @@ pub fn alignments(rows: &[MathRun]) -> AlignmentResult { } } + if widths.is_empty() { + widths.push(pending_width); + let padding = add_gaps(&mut widths, sizings); + return AlignmentResult { width: pending_width, points: vec![], padding }; + } + + let padding = add_gaps(&mut widths, sizings); let mut points = widths; for i in 1..points.len() { let prev = points[i - 1]; points[i] += prev; } AlignmentResult { - width: points.last().copied().unwrap_or(pending_width), + width: points.last().copied().unwrap(), points, + padding, } } -pub struct AlignmentResult { - pub points: Vec, - pub width: Abs, +/// Inserts gaps between columns given by the alignments. +fn add_gaps(widths: &mut [Abs], sizings: Option) -> (Abs, Abs) { + let Some(sizings) = sizings else { + return (Abs::zero(), Abs::zero()); + }; + + // Padding to be returned. + let mut padding = [Abs::zero(), Abs::zero()]; + + // Number of gaps between columns. + let len = widths.len(); + let ngaps = len.div_ceil(2).saturating_sub(1); + + // Discard excess gaps or repeat the last gap to match the number of gaps. + let mut gaps = sizings.gaps.to_vec(); + gaps.truncate(ngaps); + if let Some(last_gap) = gaps.last().copied() { + gaps.extend(std::iter::repeat_n(last_gap, ngaps.saturating_sub(gaps.len()))); + } + + // Sum of fractions of all fractional gaps. + let mut fr = Fr::zero(); + + // Resolve the size of all relative gaps and compute the sum of all + // fractional gaps. + let region_width = sizings.region_size_x; + for (i, gap) in gaps.iter().enumerate() { + match gap { + GapSizing::Rel(v) => widths[1 + i * 2] += v.relative_to(region_width), + GapSizing::Fr(v) => fr += *v, + } + } + for (i, gap) in sizings.padding.iter().enumerate() { + match gap { + GapSizing::Rel(v) => padding[i] = v.relative_to(region_width), + GapSizing::Fr(v) => fr += *v, + } + } + + // Size that is not used by fixed-size gaps. + let remaining = region_width - (widths.iter().sum::() + padding.iter().sum()); + + // Distribute remaining space to fractional gaps. + if !remaining.approx_empty() { + for (i, gap) in gaps.iter().enumerate() { + if let GapSizing::Fr(v) = gap { + widths[1 + i * 2] += v.share(fr, remaining); + } + } + for (i, gap) in sizings.padding.iter().enumerate() { + if let GapSizing::Fr(v) = gap { + padding[i] = v.share(fr, remaining); + } + } + } + + (padding[0], padding[1]) } diff --git a/crates/typst-library/src/math/equation.rs b/crates/typst-library/src/math/equation.rs index 32be216a4..e91822fb2 100644 --- a/crates/typst-library/src/math/equation.rs +++ b/crates/typst-library/src/math/equation.rs @@ -1,18 +1,19 @@ use std::num::NonZeroUsize; -use typst_utils::NonZeroExt; +use smallvec::{smallvec, SmallVec}; +use typst_utils::{NonZeroExt, Numeric}; use unicode_math_class::MathClass; -use crate::diag::SourceResult; +use crate::diag::{bail, HintedStrResult, SourceResult}; use crate::engine::Engine; use crate::foundations::{ - elem, Content, NativeElement, Packed, Show, ShowSet, Smart, StyleChain, Styles, - Synthesize, + cast, elem, Array, Content, NativeElement, Packed, Resolve, Show, ShowSet, Smart, + StyleChain, Styles, Synthesize, Value, }; use crate::introspection::{Count, Counter, CounterUpdate, Locatable}; use crate::layout::{ - AlignElem, Alignment, BlockElem, InlineElem, OuterHAlignment, SpecificAlignment, - VAlignment, + Abs, AlignElem, Alignment, BlockElem, Fr, InlineElem, Length, OuterHAlignment, Rel, + Spacing, SpecificAlignment, VAlignment, }; use crate::math::{MathSize, MathVariant}; use crate::model::{Numbering, Outlinable, ParLine, Refable, Supplement}; @@ -101,6 +102,23 @@ pub struct EquationElem { /// ``` pub supplement: Smart>, + /// The gap between columns. + /// + /// ```example + /// #set math.equation(column-gap: 3em) + /// $ 4 &= 4 & &"yes" \ + /// 0 &= 0 & &"no" \ + /// 1+1 &= 2 & &"maybe" $ + /// ``` + #[default(Fr::one().into())] + #[borrowed] + pub column_gap: GapSizings, + + /// + #[default(Fr::one().into())] + #[borrowed] + pub column_padding: PaddingSizings, + /// The contents of the equation. #[required] pub body: Content, @@ -248,3 +266,101 @@ impl Outlinable for Packed { Content::empty() } } + +/// Gap sizing definitions. +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct GapSizings(pub SmallVec<[GapSizing; 1]>); + +impl> From for GapSizings { + fn from(spacing: T) -> Self { + Self(smallvec![GapSizing::from(spacing)]) + } +} + +impl Resolve for &GapSizings { + type Output = GapSizings; + + fn resolve(self, styles: StyleChain) -> Self::Output { + Self::Output { + 0: self.0.iter().map(|v| v.resolve(styles)).collect(), + } + } +} + +cast! { + GapSizings, + self => self.0.into_value(), + v: GapSizing => Self(smallvec![v]), + v: Array => Self(v.into_iter().map(Value::cast).collect::>()?), +} + +/// Padding sizing definitions. +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct PaddingSizings(pub SmallVec<[GapSizing; 2]>); + +impl> From for PaddingSizings { + fn from(spacing: T) -> Self { + let spacing = spacing.into(); + Self(smallvec![GapSizing::from(spacing), GapSizing::from(spacing)]) + } +} + +impl Resolve for &PaddingSizings { + type Output = PaddingSizings; + + fn resolve(self, styles: StyleChain) -> Self::Output { + Self::Output { + 0: self.0.iter().map(|v| v.resolve(styles)).collect(), + } + } +} + +cast! { + PaddingSizings, + self => self.0.into_value(), + v: GapSizing => Self(smallvec![v, v]), + v: Array => match v.as_slice() { + [start, end] => Self(smallvec![start.clone().cast()?, end.clone().cast()?]), + _ => bail!("expected 2 sizings, found {}", v.len()), + }, +} + +/// Defines how to size a gap along an axis. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum GapSizing { + /// A size specified in absolute terms and relative to the parent's size. + Rel(Rel), + /// A size specified as a fraction of the remaining free space in the + /// parent. + Fr(Fr), +} + +impl Resolve for GapSizing { + type Output = GapSizing; + + fn resolve(self, styles: StyleChain) -> Self::Output { + match self { + Self::Rel(rel) => Self::Output::Rel(rel.resolve(styles)), + Self::Fr(fr) => Self::Output::Fr(fr), + } + } +} + +impl> From for GapSizing { + fn from(spacing: T) -> Self { + match spacing.into() { + Spacing::Rel(rel) => Self::Rel(rel), + Spacing::Fr(fr) => Self::Fr(fr), + } + } +} + +cast! { + GapSizing, + self => match self { + Self::Rel(rel) => rel.into_value(), + Self::Fr(fr) => fr.into_value(), + }, + v: Rel => Self::Rel(v), + v: Fr => Self::Fr(v), +} diff --git a/docs/reference/library/math.md b/docs/reference/library/math.md index 61f2bb58f..c632b286f 100644 --- a/docs/reference/library/math.md +++ b/docs/reference/library/math.md @@ -78,6 +78,9 @@ alternating the alignment twice. `& &` and `&&` behave exactly the same way. Meanwhile, "multiply by 7" is right-aligned because just one `&` precedes it. Each alignment point simply alternates between right-aligned/left-aligned. +By default, there is a gap of `1em` added between columns. You can modify this +with the [`column-gap`]($math.equation.column-gap) parameter. + ```example $ (3x + y) / 7 &= 9 && "given" \ 3x + y &= 63 & "multiply by 7" \ diff --git a/tests/ref/math-align-columns.png b/tests/ref/math-align-columns.png new file mode 100644 index 000000000..fabfba8a5 Binary files /dev/null and b/tests/ref/math-align-columns.png differ diff --git a/tests/ref/math-equation-align-column-gap.png b/tests/ref/math-equation-align-column-gap.png new file mode 100644 index 000000000..696793129 Binary files /dev/null and b/tests/ref/math-equation-align-column-gap.png differ diff --git a/tests/ref/math-equation-align-columns-1.png b/tests/ref/math-equation-align-columns-1.png new file mode 100644 index 000000000..c9d211796 Binary files /dev/null and b/tests/ref/math-equation-align-columns-1.png differ diff --git a/tests/ref/math-equation-align-columns-2.png b/tests/ref/math-equation-align-columns-2.png new file mode 100644 index 000000000..edf986285 Binary files /dev/null and b/tests/ref/math-equation-align-columns-2.png differ diff --git a/tests/ref/math-multiline-line-spacing.png b/tests/ref/math-multiline-line-spacing.png new file mode 100644 index 000000000..7d4447893 Binary files /dev/null and b/tests/ref/math-multiline-line-spacing.png differ diff --git a/tests/suite/math/alignment.typ b/tests/suite/math/alignment.typ index 941c20556..521886cc9 100644 --- a/tests/suite/math/alignment.typ +++ b/tests/suite/math/alignment.typ @@ -28,6 +28,7 @@ $ --- math-align-toggle --- // Test #460 equations. +#set math.equation(column-gap: 0em) $ a &=b & quad c&=d \ e &=f & g&=h diff --git a/tests/suite/math/call.typ b/tests/suite/math/call.typ index 5caacfac6..116168a11 100644 --- a/tests/suite/math/call.typ +++ b/tests/suite/math/call.typ @@ -205,6 +205,7 @@ $ sin(#1) $ // attach, etc.) // // This is not good, so this test should fail and be updated once it is fixed. +#set math.equation(column-gap: 0em) #let id(body) = body #let bx(body) = box(body, stroke: blue+0.5pt, inset: (x:2pt, y:3pt)) #let eq(body) = math.equation(body) diff --git a/tests/suite/math/equation.typ b/tests/suite/math/equation.typ index 148a49d02..c379e5287 100644 --- a/tests/suite/math/equation.typ +++ b/tests/suite/math/equation.typ @@ -54,6 +54,32 @@ This is big: $sum_(i=0)^n$ #eq(start) #eq(end) +--- math-equation-align-columns-1 --- +// Test columns in equations. +#set page(width: auto) +$ sum a &<= & sum b &<= & &sum c \ + log sum a &<= & log sum b &<= & log &sum c $ + +#math.equation(block: true, column-gap: 0em, $ + sum a &<= & sum b &<= & &sum c \ + log sum a &<= & log sum b &<= & log &sum c +$) + +--- math-equation-align-columns-2 --- +// Test columns in equations. +#set page(width: auto) +#set math.equation(column-gap: 1em) +#block(stroke: black + 1pt, $ + && x & = y && & a & = b + c && && && \ + && -4 + 5x & = -2 && & a b & = c b && && && +$) + +--- math-equation-align-column-gap --- +// Test column-gap in equations. +#set math.equation(column-gap: 4em) +$ a &=b & c&=d \ + e &=f & g&=h $ + --- math-equation-number-align --- #set math.equation(numbering: "(1)") diff --git a/tests/suite/math/multiline.typ b/tests/suite/math/multiline.typ index 34e66b99c..ea5157219 100644 --- a/tests/suite/math/multiline.typ +++ b/tests/suite/math/multiline.typ @@ -17,6 +17,15 @@ $ x + 1 &= a^2 + b^2 \ $ a + b &= 2 + 3 &= 5 \ b &= c &= 3 $ +--- math-align-columns --- +// Test columns created with alignment points. +$ A &= B &= C \ + D &= E &= F $ +$ A &= B B B B &= C \ + D &= E &= F $ +$ A &= B & &= C \ + D &= E & &= F $ + --- math-align-cases --- // Test in case distinction. $ f := cases( @@ -55,6 +64,12 @@ $ $ Multiple trailing line breaks. +--- math-multiline-line-spacing --- +// Test modifying spacing between lines. +#set par(leading: 2em) +$ a &=b & c&=d \ + e &=f & g&=h $ + --- math-linebreaking-after-binop-and-rel --- // Basic breaking after binop, rel #let hrule(x) = box(line(length: x)) @@ -110,6 +125,7 @@ Nothing: $ $, just empty. --- math-pagebreaking --- // Test breaking of equations at page boundaries. #set page(height: 5em) +#set math.equation(column-gap: 0em) #show math.equation: set block(breakable: true) $ a &+ b + & c \ @@ -121,7 +137,7 @@ $ a &+ b + & c \ --- math-pagebreaking-numbered --- // Test breaking of equations with numbering. #set page(height: 5em) -#set math.equation(numbering: "1") +#set math.equation(column-gap: 0em, numbering: "1") #show math.equation: set block(breakable: true) $ a &+ b + & c \